Promessa de estabilidade da CLI

Aplica-se a partir da v0.42.3. Escrita para o 0.x — cada quebra listada abaixo tinha de esperar por um major. A v1.0.0 é esse major: fechou a única quebra pendente (os atalhos de topo), e esta lista passa a valer como a promessa de semver real de resto em diante — um breaking change deixa de caber num 1.x.

Um motor sem contrato não se automatiza. Quem escreve um Makefile, um passo de CI ou um script de deploy precisa de saber o que pode partir num upgrade — e a resposta «é 0.x, tudo pode partir» é verdadeira e inútil: garante que ninguém depende de nada, o que é o mesmo que ninguém adoptar.

Isto listava o que se compromete e o que não se compromete dentro do 0.x, que é o que falta a maior parte dos projectos nessa fase. Desde a v1.0.0 é mais do que isso: é o contrato de semver do projecto.

Estável — não quebra sem um major

Os verbos de ciclo de vida de container, com os nomes e a semântica que Docker e Podman lhes dão:

container run   ps   stop   start   restart   kill   rm   exec   logs
                wait   inspect   port   rename   pause   unpause
image     pull  ls    remove    build (delonix build)

Concretamente, garante-se:

Quebra de contrato na v1.0.0. Até à v0.69.0 os atalhos de topo (ps, run, exec, logs, rm, images) estavam aqui, como reescrita de argv para container <verbo>/image list. Saíram — corte limpo, sem alias: a grafia antiga falha com unrecognized subcommand, nunca em silêncio, a mesma regra que a reorganização da v0.30.0 já seguia. Os grupos continuam: delonix container ps, delonix container run, delonix container exec, delonix container logs, delonix container rm, delonix image list (ver a nota da v2.0.0 abaixo — essa grafia mudou outra vez). Os códigos de saída não mudam nesta versão — ver a secção abaixo.

Quebra de contrato na v2.0.0. O B2 da reestruturação da CLI (v0.67.0) tinha renomeado image ls/image rm para image list/image remove — a única excepção de nomenclatura numa CLI onde as outras 15 folhas do tipo "listar" usam ls (network ls, volume ls, vm ls, pod ls, secret ls, stack ls, …), seguindo o padrão Docker/Podman que este mesmo documento já cita para os verbos de container. Revertido: image list volta a image ls (image remove fica). Corte limpo, sem alias — a grafia list falha com unrecognized subcommand, nunca em silêncio.

Códigos de saída

Alteração de contrato na v0.49.0. Até à v0.48.0 toda a falha do motor era 1 — «não existe» e «rebentou» eram o mesmo número. Continua a valer que 0 é sucesso e não-zero é falha, portanto um if delonix …; then não muda de comportamento; o que muda é para quem testa [ $? -eq 1 ] à espera de uma falha específica. Ver a nota de migração no fim desta secção.

código significado
0 sucesso
1 falha sem classe própria (o default de sempre)
2 uso inválido (o do clap) — e, em stack plan --detailed-exitcode, «há alterações»
3 o recurso existe mas não está a correr
4 não existe esse recurso
5 conflito — o nome já está tomado
69 capacidade que este host não tem — uma ferramenta por instalar, um backend indisponível
74 o sistema de ficheiros disse que não — disco cheio, caminho impossível
77 permissão negada — o remédio é uma permissão, e depois repetir
124 o prazo esgotou-se — stack wait --timeout, e o que vier a ter prazo

3 e 4 não são números inventados: são os códigos de estado do LSB que o systemctl ainda fala (3 = o programa não está a correr, 4 = não há tal unidade). 5 não tem convenção por trás — é o número livre seguinte, abaixo da gama que a shell usa (126/127, e 128+N para sinais).

74 e 77 saíram do balde do 1, e não de nenhuma classe publicada. Até à v0.66.1 uma falha de I/O respondia 1, que a linha de cima descreve como «falha sem classe própria» — dar-lhe classe é o que esta tabela existe para fazer, e um script com *) exit 1 continua a cair no mesmo ramo. O 77 é recortado do 74 pelo kind do erro, porque é assim que ele chega: medido contra um state root sem bit de escrita, volumes create e secret create vêm ambos como permissão negada. É a falha mais accionável que existe — corrige a permissão e repete — e respondia o mesmo número que um disco cheio.

69 e 124 também não. 69 é o EX_UNAVAILABLE do sysexits.h; 124 é o que o timeout(1) devolve quando o prazo passa, e está portanto já nos dedos de quem embrulha um comando num. Entraram porque tinham produtores reais mal classificados, não para completar uma tabela: um stack wait que esgotava o tempo respondia 1 — o mesmo número de um apply rebentado, no comando cuja função inteira é ser lido por CI — e um wg/virt-customize/ngrok em falta respondia 1 também, indistinguível de um erro de escrita numa flag. As duas chamadas seguintes de um reconciliador são opostas: esperar mais, ou parar e instalar alguma coisa.

delonix stack wait -f delonix-manifest.yaml --timeout 120
case $? in
  0)   ;;                     # tudo de pé
  124) exit 0 ;;              # ainda a subir — o pipeline seguinte volta a tentar
  69)  echo "falta uma ferramenta neste nó" >&2; exit 1 ;;
  *)   exit 1 ;;              # qualquer outra coisa: pára
esac

O número do dicionário: DX-CDNN

O código de saída e o DX_* dizem a classe. O número DX-CDNN diz qual falha (ADR-0043): o milhar é a classe, a centena o domínio, os dois últimos a falha, e 00 a própria classe. A CLI imprime-o na linha de erro — error[DX-4501] no such VM: dev — e delonix explain DX-4501 diz o que significa e o que fazer (--json para scripts). O dicionário completo está em delonix explain codes e na página Dicionário de códigos, gerada da mesma tabela.

Um número nunca muda de significado nem é reutilizado: pode acrescentar-se, e uma falha que deixe de existir fica com o número retirado. A mensagem ao lado pode ser reescrita e é traduzida; o número é o contrato. Enquanto um sítio do motor ainda não tem entrada própria, responde com a entrada genérica da sua classe (DX-4000, DX-1000, …).

A identidade textual: DX_*

O número serve quem lê $?. Quem lê texto — um cliente HTTP, um consumidor de -o json, um pipeline de logs — tem o código DX_*, que é a mesma classificação noutra grafia:

DX_* código quando
DX_NOT_FOUND 4 não existe esse recurso
DX_NOT_RUNNING 3 existe, mas não está a correr
DX_CONFLICT 5 o nome já está tomado
DX_UNAVAILABLE 69 capacidade que este host não tem
DX_TIMEOUT 124 o prazo esgotou-se
DX_INVALID_ARGUMENT · DX_REGISTRY · DX_SYSCALL_FAILED · DX_INVALID_STATE · DX_IO 1 falhas sem número próprio

Estes nomes são contrato: um código pode ser ACRESCENTADO; um existente nunca muda de grafia nem de significado.

A relação é assimétrica de propósito. Um DX_* mapeia sempre para UM número — se mapeasse para dois, o $? e o texto contradiziam-se para a mesma falha. Mas o 1 carrega VÁRIOS códigos, porque é o balde «sem classe própria» e o texto pode dar-se ao luxo de ser mais fino: cada NÚMERO é uma promessa que tem de valer o resto do 0.x, enquanto DX_REGISTRY ao lado de DX_INVALID_ARGUMENT não custa nada. Há teste a exigir as duas metades desta regra, incluindo que o balde continue a ser um balde.

Hoje o código sai na API de gestão (delonix serve api), como campo acrescentado ao corpo de erro — {"error": "...", "code": "DX_NOT_FOUND"}. O campo error não foi tocado: a regra do ADR-0005 (acrescentar sim, remover ou mudar de tipo não) vale aqui como vale no -o json.

O que continua sem código próprio, e é honesto dizê-lo: permissão negada e falha temporária foram considerados e ficaram de fora — não há hoje uma variante de erro que os produza (as falhas de permissão chegam embrulhadas no errno de uma syscall, e o retry que existe acontece dentro do motor e nunca chega a quem chama). Publicar um número que nada constrói é um número que nunca pode voltar.

O que isto resolve é concreto: um reconciliador — o Makefile, o passo de CI, o ciclo em bash que conduz esta CLI — não conseguia separar «cria, porque falta» de «pára, porque falhou» sem ler a MENSAGEM de erro. E a mensagem é traduzida (--l18n=pt), portanto um script que faz grep 'no such' funciona na máquina onde foi escrito e deixa de classificar num nó com outra locale.

delonix container inspect web >/dev/null 2>&1
case $? in
  0) ;;                       # está lá
  4) delonix container run -d --name web nginx ;;   # falta — cria
  3) delonix container start web ;;                 # existe, parado — arranca
  *) exit 1 ;;                # qualquer outra coisa: pára, não adivinhes
esac

O código de um run/exec continua a ser o do WORKLOAD, não o do motor. run em primeiro plano devolve o código do processo do container, exec o do comando, e healthcheck sai 1 quando não saudável — as três promessas de sempre, inalteradas. A consequência a reter: um container que saia 4 sai 4, logo o $? de um run/exec nunca se lê como uma das classes acima — esse número foi escolhido pelo workload, não pelo motor.

O que fica de fora, e é honesto dizê-lo: uma pré-condição do host por satisfazer (uma sessão sem delegação de cgroup, por exemplo) não tem código próprio — o motor avisa e continua, portanto não há falha para classificar. E os grupos ainda não estáveis (ver a lista mais abaixo) podem responder 1 onde um comando estável já responde 4: o workload describe de um nome que não existe é o caso conhecido.

Migrar

Estável em conteúdo, não em formato

As tabelas de ls/ps. As colunas podem mudar de largura, de ordem ou ganhar irmãs — são feitas para humanos e medem-se pelo conteúdo real. Um script que faça awk '{print $3}' sobre elas parte, e isso não conta como quebra de contrato.

Para automação há -o json, e é ele que é estável: um array JSON por recurso, campos podem ser ACRESCENTADOS mas não removidos nem com o tipo mudado (ADR-0005). Verificado a funcionar (2026-09-25, v4.4.0) em container ps, image ls, volume ls, network ls, vm ls, secret ls, workload ls e no verbo genérico get <plural> (get pods, get volumes, …), que é o caminho de listagem dos Kinds sem ls próprio — o pod ls já não existe, e o grupo storage fundiu-se no volume. Também inspect e -q/--quiet. Excepção medida: backup ls ainda não aceita -o json.

Uma versão anterior deste documento dizia que -o json estava «por fazer e é a lacuna reconhecida aqui». Estava errado — existe desde a ADR-0005. É exactamente o erro que o paridade-docker-podman.md abre por corrigir: inferir ausência a partir de um sintoma, sem ir ver.

O schema dos manifestos — estável, e é o que mais importa

Esta secção substitui a linha que dizia que o schema «é aditivo na prática, mas não é uma promessa». Era o compromisso ao contrário: a CLI estava mais protegida que o formato declarativo, quando é o formato que as pessoas põem em git e revêem em PR. Um delonix-manifest.yaml só se versiona se se souber que não parte sozinho.

Para os Kinds com spec tipado — Container, Pod, Volume, Network — garante-se, dentro do 0.x:

A verdade não é este texto, é o schema: delonix manifest schema emite-o a partir do próprio código (ADR-0007), e o mesmo ficheiro está publicado em schema/v1/delonix.json. Um teste do repositório falha se o publicado deixar de ser o gerado, precisamente para esta página não voltar a poder mentir.

O que o schema RECUSA, e vale saber porque é o que aparece sublinhado no editor: um campo desconhecido (um typo), um kind que o motor não conhece — um typo no nome, ou um Kind removido — e um apiVersion que não é o grupo daquele Kind. Até à v0.65.x o kind era uma string livre e qualquer nome passava, por isso um kind: Contaner validava limpo e um Egress já removido também; e o apiVersion era fixo no legado, por isso a grafia de grupo — a canónica — era sublinhada como se fosse erro. Um validador que discorda do motor é pior do que nenhum, porque o visto verde é o que as pessoas seguem.

Aponta o editor e escreve manifestos com completação e validação:

# yaml-language-server: $schema=https://angolardevops.github.io/delonix-runtime/schema/v1/delonix.json
apiVersion: compute.delonix.io/v1alpha1
kind: Pod
metadata: { name: web }
spec:
  containers:
    - name: web
      image: nginx:1.27

Há também uma extensão de VS Code que traz isto já ligado, mais um template por Kind: angolardevops/delonix-vscode. Aponta para o MESMO ficheiro publicado acima — buscado ao vivo, não embutido — por isso o editor e o stack apply não podem discordar.

Para saber o que mudou entre duas versões, não há uma página escrita à mão — haveria a segunda fonte de verdade que a ADR-0007 aboliu. Há um comando:

scripts/schema-diff.sh v0.46.0          # dessa tag até à árvore actual
scripts/schema-diff.sh v0.46.0 v0.47.0  # entre duas tags

Compara campo a campo (nome e tipo), não o JSON cru, e sai 1 quando há diferenças — serve directamente como gate de CI. Um campo removido ou com o tipo mudado é assinalado como quebra de contrato, que é o que esta secção promete não acontecer.

Todos os Kinds têm hoje schema gerado. Esta secção dizia que Vm, Cluster, ShareVolume, Image, Secret, Ingress, FirewallPolicy, HTTPRoute, Tunnel, Workload e Stack não o tinham — deixou de ser verdade: delonix explain <Kind> responde para cada Kind que delonix api-resources lista (verificado a 2026-09-25, v4.4.0), com os nomes canónicos (VirtualMachine, KubernetesCluster, NetworkPolicy, Gateway; os nomes antigos Vm/Cluster/FirewallPolicy/Tunnel continuam a resolver como alias). ShareVolume já não existe: é um kind: Volume com bloco share:. Se um Kind futuro chegar sem schema, o delonix explain recusa-o a dizer que Kinds o têm, em vez de o omitir.

Três Kinds deixaram de existir nesta série, fundidos no que já faziam: Egress → FirewallPolicy com direction: egress; Dependency → FirewallPolicy (açúcar, reduzido no load); Storage → kind: Volume com um bloco nfs:/cifs:/webdav:. Os nomes antigos continuam a carregar, com aviso de depreciação — a regra do «corte limpo» aplica-se a comandos, e um manifesto em git merece um degrau em vez de um erro.

O ficheiro de providers do nó — config.delonix.io/v1

O providers.yaml (ADR-0054) diz que providers o nó tem, como o motor chega a cada um e qual serve um pedido que não nomeia nenhum. Desde o catálogo 1.1.0 (ADR-0059 F1) aceita também um appliance OPNsense (type: opnsense) e o bloco networkDefaults, que diz que provider responde a cada papel de rede (segment, gateway; nat, ipam e dns são recusados até existir quem os sirva). O defaultProvider continua a ser só o de computação. Procura-se por esta ordem, e o primeiro ficheiro que existe ganha, sem fusão: DELONIX_PROVIDERS_CONFIG, $XDG_CONFIG_HOME/delonix/providers.yaml (ou ~/.config/…), /etc/delonix/providers.yaml.

É um formato publicado, com a mesma promessa que o schema dos manifestos dentro do 0.x: uma chave nunca é removida nem muda de significado, uma chave nova é sempre opcional, e apiVersion: config.delonix.io/v1 só muda com um v2 que não sai sem o v1 continuar a ser lido.

A verdade é o schema, gerado dos tipos que o motor lê: delonix provider config schema, publicado em schema/v1/providers.json, com o mesmo teste a falhar se o publicado deixar de ser o gerado. O schema recusa uma chave desconhecida, outra versão, um tipo de provider que não existe e um segredo escrito no próprio ficheiro (tokenSecret, password, secret — só por referência: tokenSecretFile, passwordFile, secretFile, secretRef).

O schema diz se o ficheiro está bem escrito; o delonix provider config validate diz se ele serve: um defaultProvider ou um networkDefaults sem entrada no ficheiro, um ficheiro de token ou de segredo legível por outros utilizadores, uma CA que não existe. Nenhum dos dois contacta um nó.

# yaml-language-server: $schema=https://angolardevops.github.io/delonix-runtime/schema/v1/providers.json
apiVersion: config.delonix.io/v1
defaultProvider: libvirt
providers:
  - type: libvirt
  - type: opnsense
    url: https://fw.example
    auth: { keyFile: /etc/delonix/opnsense.key, secretFile: /etc/delonix/opnsense.secret }
networkDefaults:
  gateway: opnsense

NÃO estável — pode mudar em qualquer versão

Mudou na v0.67.0: delonix backup <kind> <nome> passou a delonix backup create <kind> <nome>, e o delonix restore de raiz passou a delonix backup restore <arquivo> — sem o <kind> posicional, que o arquivo já regista (continua a poder afirmar-se com --kind para que uma discordância seja recusada). O agendamento saiu de flags do backup para delonix backup schedule, com a mesma semântica: instala o temporizador e tira o primeiro arquivo. Corte limpo, sem aliases — a forma antiga falha com «unrecognized subcommand», nunca em silêncio.

delonix system backup não foi tocado nessa data. É outro âmbito — o state root inteiro de um nó — e não uma segunda porta para este grupo.

Mudou depois (Sprint 4 da reestruturação da CLI, 2026-09-12): o próprio system backup/system restore foi renomeado para system snapshot create/system snapshot restore — mesmo vocabulário de vm snapshot/ volume snapshot (captura num instante), só que à escala do nó. O ÂMBITO não mudou nem se fundiu com este grupo — só a PALAVRA partilhada («backup») desapareceu, que era a fonte real da confusão que o parágrafo anterior já andava a explicar por escrito. Corte limpo: a forma antiga falha com «unrecognized subcommand». * Tudo o que começa por net netns — plumbing interno exposto por conveniência de depuração. * O formato dos ficheiros de estado em $DELONIX_ROOT. Lê-se pelo inspect, nunca do disco. * stack history, stack rollback e o conteúdo de $DELONIX_ROOT/stacks/ (ADR-0019). É um registo do que foi aplicado, e o desenho promete explicitamente que nada o lê para decidir o que existe: apagar essa pasta não muda o que o plan, o apply, o prune ou o destroy fazem — só perde o histórico, e há gate na bateria E2E a exigi-lo. Automação que dependa da presença de uma revisão está a apostar contra essa promessa.

Como uma quebra é feita, quando tem de acontecer

Precedente já cumprido pelo projecto: a reorganização da v0.30.0 (netns → net netns, cri → serve cri, …) foi um corte limpo, sem aliases — a forma antiga falha com «unrecognized subcommand», nunca em silêncio.

Isso mantém-se como regra: falhar alto. Um alias de compatibilidade que muda de comportamento é pior que um erro.

E uma lição que custou esta sessão: essa quebra deixou um chamador INTERNO por actualizar (o delonix-cri continuou a invocar delonix netns attach), o que partiu a criação de pod rootless durante meses — ver cri-conformance.md. Um corte limpo obriga a fazer o grep por chamadores em TODO o workspace, não só na documentação.