Skip to content

Releases: angolardevops/delonix-runtime

v0.45.0 — o que se descobre ao correr aquilo que se acabou de escrever

Choose a tag to compare

@github-actions github-actions released this 09 Aug 12:07

v0.45.0 — o que se descobre ao correr aquilo que se acabou de escrever

A v0.44.0 publicou o VMfile. Esta release é o resultado de o usar como um
utilizador o usaria, num host que não é a máquina de quem o escreveu. Quatro
achados, todos reproduzidos antes de corrigidos, nenhum deles visível a um
cargo test.

O esqueleto do vm init --vmfile não construía

O VMfile gerado dizia, em comentário, «Builds as written». Não construía: o
primeiro RUN era apt-get update && apt-get install nginx, e o vm build
passa sempre --no-network ao virt-customize. A primeira coisa que um
utilizador faz com o comando novo — correr o que ele acabou de escrever — não
podia funcionar.

O --no-network está certo como omissão (um build que vai à internet dá uma
imagem diferente conforme o dia em que correu). Mas a coisa mais comum que se
quer fazer numa imagem é instalar um pacote, e um motor que a torna impossível
não está a oferecer uma escolha: está só a recusar. Por isso:

  • delonix vm build --network — opt-in explícito, com o custo de
    reprodutibilidade dito no --help. Ligado aos três caminhos (vm build,
    image vm build, image --vm build); na receita dourada é recusado com um
    erro que aponta para o --offline, que é quem decide isso lá.
  • O esqueleto passou a construir tal como está escrito, offline, e mostra o
    apt-get como o exemplo comentado do que fazer com --network.

«No such file or directory» quando o que falta é um pacote

Num host sem libguestfs, o vm build descarregou 600 MB, verificou o
SHA256SUMS, redimensionou o disco — e depois disse:

error invalid argument: running virt-customize: No such file or directory (os error 2)

O ENOENT de um Command::status() é a ferramenta não existir, mas a frase
lê-se como um ficheiro em falta e manda o leitor procurar um caminho.
Agora nomeia o pacote, nas duas famílias:

error invalid argument: `virt-customize` is not installed. Install it with
`sudo apt install libguestfs-tools` (Debian/Ubuntu) or
`sudo dnf install guestfs-tools` (Fedora/Rocky).

Vale para virt-customize/virt-sparsify/virt-copy-out, qemu-img,
cloud-localds e virsh. A tabela é pura e testada, porque é ela que carrega
o valor todo.

vm create --url-img — validado de ponta a ponta, e o utilizador errado

Correu inteiro: descarregou o qcow2 de um URL absoluto (com o aviso honesto de
que não há .sha256 publicado ao lado, logo a confiança é só do TLS), montou o
overlay, gerou o seed NoCloud, arrancou em libvirt, e o cloud-init aplicou
hostname e chave. Entrei na VM por SSH e confirmei Ubuntu 24.04.4 LTS,
1 vCPU, ~1 GiB, cloud-init status: done.

O que falhou foi eu adivinhar o utilizador. Numa cloud image de Ubuntu o palpite
óbvio é ubuntu, essa conta existe, e não tem a chave — por isso responde
Permission denied (publickey), que se lê como chave partida e não como nome
errado. A chave vai para delonix, e nada no output o dizia. O bloco de
próximos passos passou a incluir a linha, com o IP quando já é conhecido:

Next steps:
  delonix vm console urltest2    # open the serial console (back to host: Ctrl+])
  ssh delonix@<ip>               # log in with the key you injected
  ...

Só no caminho em que o seed é gerado por nós — quem trouxe o seu próprio
--seed decidiu as contas e nós estaríamos a adivinhar.

O vm build completo continua por validar aqui, e digo porquê

Este host não tem libguestfs-tools e não posso instalá-lo. Fica provado tudo
até à fronteira: download, verificação de SHA256SUMS, achatamento para qcow2,
SIZE aplicado antes de qualquer RUN, e a recusa clara na ferramenta em
falta. O que falta exercitar é o virt-customize em si — um
sudo apt install libguestfs-tools e o delonix vm build -t x:1.0 . do
esqueleto fecha-o.

Validação

669 testes verdes em duas corridas independentes, clippy e fmt limpos. Os
caminhos novos verificados também em --l18n=pt.

v0.44.0 — VMfile, e um diagnóstico de cgroup que estava a mandar editar o /etc por nada

Choose a tag to compare

@github-actions github-actions released this 09 Aug 11:10

v0.44.0 — VMfile, e um diagnóstico de cgroup que estava a mandar editar o /etc por nada

Duas metades. A primeira é funcionalidade nova: construir a imagem qcow2 de uma
microVM a partir de um ficheiro declarativo, ao estilo do Delonixfile. A
segunda começou por ser uma pergunta do utilizador — o que faz sudo delonix system setup --delegate? — e acabou a corrigir o comando, o instalador e a
documentação, porque a resposta que eu tinha dado estava errada.

VMfilevm init e vm build

O caminho para uma imagem VM própria era image --vm build com uma lista de
flags (--extra-package, --extra-run) por cima de uma receita fixa em Rust.
Serve para a golden do k8s, que é nossa; não serve para alguém que queira a
SUA imagem, com o seu cloud-init, publicada no seu repositório.

VMfile é essa forma, com a gramática que toda a gente já sabe ler:

FROM ubuntu:24.04
SIZE 20G
HOSTNAME app
VCPUS 2
MEMORY 4G

RUN apt-get update && apt-get install -y nginx
COPY ./site /var/www/html
ENV APP_ENV=production
USER delonix
SSHKEY @~/.ssh/id_ed25519.pub
CLOUDINIT ./cloud-init/user-data
  • FROM é uma cloud image, não uma imagem OCI — ubuntu:24.04,
    debian:12, rocky:9, ou um URL absoluto para o qcow2 que quiseres. É a
    distinção que dá sentido ao resto: uma cloud image traz cloud-init, e é o
    cloud-init que faz o primeiro boot aplicar hostname, chaves e utilizadores.
  • Multi-stage com COPY --from=<estágio>, como no Delonixfile.
  • delonix vm init escreve o esqueleto completo (VMfile + cloud-init/
    com user-data/meta-data comentados), para não se começar de uma página
    em branco.
  • delonix vm create --url-img https://… cria a microVM directamente de um
    qcow2 publicado — o caminho de quem já construiu a imagem e a pôs no seu
    repositório.

O --delegate que eu recomendei não era a correcção

Ao explicar o comando, afirmei que ele resolvia o cpu em falta neste host.
Medi antes de publicar e a afirmação estava errada nos três pontos:

  1. O Ubuntu 24.04 já traz Delegate=pids memory cpu no user@.service. O
    drop-in que o --delegate escreve pedia exactamente o que já lá estava.
  2. O subtree_control do user@1000.service já era propriedade do utilizador.
    A delegação não estava em falta — estava feita.
  3. O que faltava o cpu era o slice de onde o comando corria (app.slice, o
    scope do editor), que não o passa para baixo. Um drop-in no /etc não lhe
    toca.

E cpuset/io nunca podiam aparecer: o user.slice, que é da root, só
passa cpu memory pids para os descendentes. Pedi-los num drop-in seria pedir
uma coisa que o antepassado não tem para dar.

O que mudou por causa disso

O cpu e o cpuset/io deixaram de ser o mesmo facto. Faltar o cpu é
um nó Kubernetes que não arranca; faltar cpuset/io é o estado normal de um
Ubuntu de fábrica, onde tudo funciona. A lista única tratava os dois como o
mesmo problema e mandava para o /etc por causa do segundo:

controllers: memory pids
missing:  cpu  ← a Kubernetes node CANNOT boot without this
absent:   cpuset io  ← optional; nothing here needs them

A ordem dos remédios inverteu-se. O scope delegado — sem root, sem
reiniciar sessão, efeito imediato — é agora a correcção 1, e o drop-in no
/etc é a 2, apresentada como o que fazer se a primeira ainda disser que
falta o cpu
. A ordem anterior mandava alterar a configuração global da
máquina antes de tentar a coisa gratuita que resolve o caso comum.

O preflight do cluster create diz o mesmo. Recusa antes de descarregar
425 MB, como na v0.43.1, mas o comando que oferece é o scope, não o sudo.

O install.sh não escreve o drop-in num host que já delega o cpu. Lê o
Delegate= efectivo do user@.service e salta quando o cpu já lá está.
Escrever no /etc de toda a máquina para repetir o que a distro já faz não é
inofensivo: dá a impressão de ter resolvido um problema que o utilizador
continua a ter.

Documentação

O README e o site descreviam a ordem antiga. Passaram a explicar as duas
correcções pela ordem certa, e a dizer porque é que cpuset/io aparecem como
absent sem que isso seja um problema a resolver. O laboratório 2 (limites de
recursos) foi reescrito no mesmo sentido — era o sítio onde um leitor novo
aprendia a começar pelo sudo.

Validação

Testes verdes, clippy e fmt limpos. Os dois caminhos do system setup
verificados ao vivo (dentro do scope do editor e dentro de um scope delegado),
e o cluster create confirmado a passar o preflight e a avançar para o pull
dentro do scope delegado. Teste novo a fixar a distinção fatal/opcional, que
falha se a lista voltar a ser uma só.

v0.43.1 — o nó do cluster morria por delegação de cgroup, e ninguém dizia porquê

Choose a tag to compare

@github-actions github-actions released this 08 Aug 20:50

v0.43.1 — o nó do cluster morria por delegação de cgroup, e ninguém dizia porquê

Bug report real, com o ecrã inteiro como prova:

✗ Preparing nodes (1)
error invalid argument: timeout waiting for containerd on node 'kaeso-control-plane' (90s)

O containerd nunca chegou a arrancar porque o nó já tinha morrido. Os logs
do nó tinham a resposta em duas linhas:

INFO: running in a user namespace (experimental)
ERROR: UserNS: cpu controller needs to be delegated

Entre a causa e o sintoma havia um download de 425 MB e 90 segundos de espera
por um serviço que nunca teve hipótese.

O system setup reportava sucesso sobre a limitação

Pior: o comando que existe para diagnosticar isto dizia que estava tudo bem.

limits:   APPLY — --memory/--cpus/--pids-limit take effect
Nothing to do.

E estava certo naquilo que verificava — «consigo criar um filho e escrever o
subtree_control?», sim. Só que nunca olhou para QUAIS controladores existem.
Neste host havia memory pids e mais nada; o cpu, que é o que um nó
Kubernetes exige, não estava delegado. Um diagnóstico que responde à pergunta
ao lado da que interessa é pior do que nenhum, porque é acreditado.

Agora lista-os, nomeia os que faltam, e liga-os à consequência:

controllers: memory pids
missing:  cpu cpuset io  ← a Kubernetes node needs these

Container limits work, but `cpu`, `cpuset`, `io` is not delegated.
`delonix cluster create` (kind mode) will fail: the node's entrypoint refuses
to boot without it (`UserNS: cpu controller needs to be delegated`).

O drop-in que o --delegate escreve passou a nomear os controladores em vez
de Delegate=yes: neste host o yes produziu apenas memory pids, o que passa
em todas as verificações do motor e mata um nó kind na mesma.

Falhar em milissegundos, não em 90 segundos

cluster create (modo kind) ganhou um preflight. Sem o cpu delegado recusa
ANTES de puxar a imagem, com a causa e o remédio:

error invalid argument: the `cpu` cgroup controller is not delegated, and a
Kubernetes node cannot boot without it (its entrypoint exits with `UserNS: cpu
controller needs to be delegated`). Delegated here: memory pids. Run `delonix
system setup` for the diagnosis and `sudo delonix system setup --delegate` for
the fix, then log out and back in.

Só o cpu bloqueia. O cpuset/io faltam em muitos hosts onde o nó arranca
bem, e recusar por causa deles rejeitaria clusters que teriam funcionado.

E o timeout, quando acontece, deixou de ser a mensagem inteira: passa a dizer se
o nó ainda está vivo e a mostrar as últimas linhas do log dele. Era o que faltava
para ligar timeout waiting for containerd a cpu controller needs to be delegated.

vm create não descarregava a imagem oficial

delonix vm create <nome> sem --disk, num host sem imagens VM locais,
respondia:

no local VM images — run delonix image --vm build (or pull) first

O cluster kubeadm já descarregava sozinho nessa mesma situação. A imagem
dourada é publicada como artefacto OCI precisamente para não ter de ser
construída à mão, por isso a mesma imagem em falta era um download prestável num
comando e um beco sem saída no outro.

Fechado com um helper partilhado pelos dois: vm create passa a descarregar
a oficial, e quando o download não é possível o erro lista o que existe em
vez de mandar correr outro comando para descobrir:

error invalid argument: could not download 'ghcr.io/angolardevops/delonix-vm-k8s:9.99':
no such image. Published there: 1.34, 1.35 — pick one with
`delonix vm pull ghcr.io/angolardevops/delonix-vm-k8s:<tag>`, or build your own
with `delonix image --vm build`.

O erro de «várias imagens locais» também passou a nomeá-las. A resposta à
pergunta «qual delas?» tem três palavras e estava a um comando de distância sem
razão nenhuma.

Conformidade CRI: 77 → 79

Três specs fechados, e um deles confirma o que a v0.43.0 publicou como por medir
(o perfil seccomp localhost).

  • ReopenContainerLog era um no-op que devolvia sucesso. O kubelet roda o
    log renomeando o ficheiro e chamando isto; responder «feito» sem fazer nada
    mandava cada linha seguinte para um inode que ninguém voltaria a ler — em
    silêncio, e só para contentores que vivem o suficiente para serem rodados.
    Duas metades: o shim passa a SEGUIR O CAMINHO (compara inodes antes de cada
    lote) e a chamada recria o ficheiro, que é o que o chamador verifica.
  • list_container_stats deitava fora o label_selector. Um filtro que
    devolve mais do que foi pedido é pior que um que dá erro: as linhas a mais
    parecem respostas verdadeiras.
  • ContainerStatus devolvia mounts vazio — lê-se como «este contentor não
    tem volumes», o oposto da verdade para tudo o que um kubelet monta.

O motor corre dentro de um user namespace aninhado

TERCEIRO sítio onde geteuid() foi confundido com «root a sério»:
write_userns_maps mapeava um intervalo de 65536 uids quando euid era 0, o que
num userns aninhado — onde possuímos exactamente um uid — falha com EPERM e
nenhum contentor arranca. Com as correcções irmãs em is_rootless e
runtime_dir, o motor corre inteiro sob unshare --user --map-root-user.

Propagação de mounts medida nesse ambiente, com a origem rshared e uma
montagem feita DEPOIS de o contentor arrancar: rslave e rshared vêem-na,
rprivate não. A via normal mantém o intervalo de subuid intacto (--user 101
continua a funcionar).

Documentação

O README estava na 0.38.0 — cinco releases atrás. Alinhado, com a conformidade
CRI publicada e três linhas novas na tabela comparativa que só valem por serem
honestas. O --help do --security-opt ainda descrevia só o que existia antes
da v0.43.0; como a documentação embebe o help real, a lacuna era no código.
Mais 18 entradas no catálogo pt.po.

Correcção a um teste, não ao motor

iotune_das_vms_e_opt_in_e_so_no_disco_raiz falhava por ordem de escalonamento:
o teste irmão faz env::set_var e os testes correm em paralelo no MESMO
processo, por isso o opt-in lia o que o vizinho tinha acabado de pôr. O
comentário do próprio teste avisava disto enquanto o vizinho o fazia. A
composição do <iotune> passou a função pura e nenhum teste toca no ambiente.

Validação

658 testes verdes, clippy e fmt limpos. Conformidade CRI 79/103 num root
limpo. Os dois bugs deste relatório reproduzidos antes e verificados depois, no
host onde foram encontrados.

v0.43.0 — conformidade CRI medida: 65 → 77 de 103

Choose a tag to compare

@github-actions github-actions released this 07 Aug 08:57

v0.43.0 — conformidade CRI medida: 65 → 77 de 103

Esta série começou por uma comparação honesta com o Docker e o Podman
(docs/paridade-docker-podman.md) e acabou a correr a suite de conformidade de
upstream a sério. É o resultado dessa medição que dá o valor à release:
«serve um kubelet» é uma alegação, «77 de 103 specs nomeados» é um facto que
outra pessoa verifica — tests/compat/cri-conformance.sh reproduz.

O detalhe completo, incluindo o que falha e porquê, está em
docs/cri-conformance.md.

O bug que estava escondido há mais tempo

O delonix-cri invocava delonix netns attach. A reorganização da CLI da
v0.30.0 moveu esse comando para delonix net netns attach, com corte limpo
e sem aliases — decisão deliberada e registada — e este chamador nunca foi
actualizado. A criação de pod em rootless estava partida desde essa versão.

Duas coisas o esconderam, e a segunda é a mais grave: o delonix_detached
mandava o stderr para /dev/null e devolvia um bool, por isso a mensagem
que chegava ao kubelet nomeava a vítima e escondia o assassino. Uma linha de
correcção levou a corrida de 19 para 65 passes.

euid 0 não é ser root

is_rootless() era geteuid() != 0. euid 0 é uid 0 nalgum user namespace, e
num aninhado não compra nada no host: nem escrita no cgroup do host, nem /run,
nem montagem privilegiada. O motor tomava o caminho ROOT exactamente onde tem
menos poder, e o mesmo erro estava num segundo sítio independente
(delonix-net::runtime_dir, que resolvia /run/delonix-net e falhava com
Permission denied).

Corrigido na raiz, com o predicado num só sítio
(delonix_runtime_core::in_initial_userns, lido de /proc/self/uid_map) e
testes puros do parsing.

Consequência prática: o motor passa a correr inteiro sob
unshare --user --map-root-user — continua rootless e daemonless, mas com
CAP_SYS_ADMIN sobre os seus PRÓPRIOS namespaces, que é o tecto honesto do
modelo. O holder degrada em vez de pendurar (o --map-auto precisa de
newuidmap, que num userns aninhado é recusado) e o seu stderr deixou de ir
para /dev/null.

O log do CRI dizia que tudo era stdout

O log_shim escrevia format!("{ts} stdout {stream} …") — com stdout
literal
. Os dois fluxos do contentor iam por UM pipe e saíam todos com a mesma
etiqueta; a variável chamada stream era afinal a etiqueta F/P de linha
completa/parcial, confusão que ajudou o bug a durar.

Não é só conformidade: qualquer kubectl logs sobre este motor afirmava que
tudo era stdout.
Corrigido com um segundo pipe (só em modo CRI — o formato
não-CRI não tem etiqueta e separar lá só interlaçaria pior) e um poll() sobre
os dois.

Metade do ContainerConfig não estava ligada

ContainerConfig.mounts era lido por ninguém. Nem uma linha. Um kubelet a montar
configMaps, secrets, emptyDirs ou hostPaths não punha nada dentro do contentor —
em silêncio, porque nada dava erro. O mesmo padrão apareceu em dns_config e
port_mappings do sandbox.

Vale grepar cada campo do PodSandboxConfig/ContainerConfig antes de assumir
que está ligado.

Uma linha que valeu quatro specs

O exec juntava-se a user/uts/net/pid/mntfaltava o ipc. Um exec
deve parecer o contentor visto de dentro; sem o IPC vê os objectos System V do
HOST e os kernel.shm*/fs.mqueue.* do host, que são precisamente os knobs que
o --sysctl mexe. Um contentor criado com kernel.shm_rmid_forced=1 reportava
0 através do exec, porque o valor é resolvido na ipc-ns de QUEM LÊ. Lia-se
como «o sysctl não foi aplicado» e mandou investigar um caminho que estava
correcto desde o início.

NetworkReady era um impasse permanente

A condição era infra.up, e este motor é daemonless: a netns de infra arranca A
PEDIDO. Num nó acabado de arrancar isso dava NetworkReady: false → o kubelet
marcava-o NotReady → não agendava pod nenhum → nada trazia a infra acima →
NotReady para sempre. A verificação existia para apanhar uma falha real de
SDN e descrevia o estado normal de repouso do motor.

Passou a distinguir pelo ref-count: infra em baixo COM workloads é falha, em
baixo sem nenhum é ócio. O teste de regressão que a guardava passou a codificar
a regra refinada, com a decisão extraída para uma função pura.


Funcionalidades

Perfis seccomp OCI — a última recusa fail-closed do motor

--security-opt seccomp=<perfil.json> e o localhostProfile do CRI. Formato
runc/Docker/containerd. O motor recusava-os com um erro claro — honesto como
marcador, mas era uma lacuna.

362 syscalls resolvidas por arquitectura (de libc::SYS_*, não literais: os
números diferem em aarch64 e uma tabela fixa estaria silenciosamente errada num
dos dois). Fail-closed no que não percebe — regras com args são recusadas,
não alargadas: uma regra que só queria bloquear clone(CLONE_NEWUSER) viraria
«bloquear clone» e partia todo o programa com threads lá dentro.

Health check contínuo, sem daemon

--health-cmd, --health-interval, --health-timeout, --health-retries,
--health-start-period; o STATUS do ps passa a Up 21 seconds (healthy).

Quem monitoriza era a decisão de desenho: é o supervisor do contentor
destacado — já existe, um por contentor, sobrevive à CLI e morre com aquilo que
vigia. Sem processo de frota. O Podman precisa de timers do systemd para o
mesmo; aqui funciona num contentor, num chroot, num host sem systemd.

O probe limita-se a si próprio dentro do contentor: o motor não o consegue
matar de fora sem deixar o processo vivo no pid-namespace, e isso repetir-se-ia
a cada intervalo, para sempre.

run --wait

Bloqueia até o HEALTHCHECK da imagem passar. Medido: 64 ms sem a flag (serviço
ainda a arrancar) contra 6086 ms com ela e o serviço pronto à saída. Substitui o
until curl …; do sleep; done que toda a gente acaba por escrever mal.

Segurança e namespaces

  • --add-host, persistido (o /etc/hosts é reescrito em cada arranque).
  • --masked-path (ficheiro tapado com /dev/null, directório com tmpfs
    vazio read-only — a técnica do runc) e --readonly-path.
  • --group-add, aplicado mesmo com o contentor a correr como root, e também
    no exec.
  • --security-opt no-new-privileges=[true|false]. O default do motor
    continua ON, mais apertado que o Docker e o Podman.
  • -v src:dst:rprivate|rslave|rshared — propagação de mounts. A raiz do
    namespace passa a MS_SLAVE quando alguma montagem o pede.
  • --dns, --dns-search, --dns-option.

Ergonomia e contrato

  • Atalhos de topo: ps, run, exec, logs, rm, images. Por
    reescrita de argv, não por variantes clap duplicadas — são o MESMO comando por
    construção, --help incluído. stop/start ficam de fora de propósito: este
    motor também pára VMs e pods.
  • system setup [--delegate] — diagnostica e corrige a delegação de cgroup,
    com os DOIS remédios: um session-N.scope de login é IRMÃO do
    user@.service e não herda nada dele.
  • serve docker-api --matrix — as 14 rotas implementadas e as 7 ausentes
    com a razão. Um teste lê o código-fonte do próprio dispatch e falha se existir
    um braço sem linha na tabela.
  • docs/cli-stability.md — o que é estável dentro de
    0.x, o que é estável em conteúdo mas não em formato, e o que não promete nada.

Correcções

  • commit_flat_rootfs_from_tar descartava o HEALTHCHECK no caminho
    rootless (o normal) enquanto o caminho overlay o honrava — o mesmo Dockerfile
    dava imagens diferentes conforme o modo do motor.
  • apply_sysctls deitava fora o erro da escrita.
  • Um membro de pod levava dois caminhos de publicação — a netns é do pod, e
    o slirp por-contentor reclamava a mesma porta que o ingress já publicara.
    Pelo caminho, a quinta ocorrência da armadilha antiga: c.ip nunca era
    atribuído a um membro de pod.
  • ALTO, apanhado em revisão: o --add-host armou um primitivo de escrita
    fora do rootfs. O write_etc_files usava format! + fs::write, ambos
    seguindo symlinks, e em rootless a árvore pertence ao uid mapeado — o próprio
    contentor podia plantar etc/hosts -> ~/.ssh/authorized_keys. Já existia como
    truncar-com-conteúdo-fixo; o --add-host tornou o conteúdo escolhido pelo
    atacante. Fechado com safe_bind_target + validação na fronteira.

O que NÃO fecha, e porquê

  • AppArmor (9 specs) — carregar perfis exige CAP_MAC_ADMIN no user
    namespace INICIAL. Medido: um aninhado responde You need policy admin privileges. O Docker e o containerd têm exactamente o mesmo limite.
  • nil profile = unconfined — divergência deliberada: sem perfil
    declarado aplica-se o allowlist embutido. Somos mais restritos do que a
    especificação pede e isso não muda para ganhar um spec.
  • shareProcessNamespace e o chown_tree_once que tira o dono ao binário
    setuid são compromissos de desenho por decidir, não esquecimentos.
  • O port mapping só com container port precisa que o IP do pod seja alcançável
    do host, o que o modelo rootless não dá.

Validação

658 testes verdes, clippy e fmt limpos. Conformidade CRI 77/103 num root
limpo (correr sempre assim: com estado acumulado, três specs falham por poluição
e não por conformidade). Smoke da API Docker com o SDK oficial de Python —
createstartinspectrenamestopremove, a sequência que
o Testcontainers usa de facto: 14/14.

v0.42.2 — `system info` mentia sobre a delegação de cgroup

Choose a tag to compare

@github-actions github-actions released this 05 Aug 10:09

v0.42.2 — system info mentia sobre a delegação de cgroup

Varredura pedida sobre uma CLASSE de bug, não sobre um caso: cinco defeitos desta
série eram todos a mesma frase — X não é Y. Um ficheiro de socket não é um
listener; /sys/class/net não é a netns do processo; capture() devolver Ok
não é o comando ter passado; uma label não é o estado persistido; None no pid
do controlo não é ausência de controlo.

A varredura encontrou mais um da mesma família, e no pior sítio possível.

delonix system info respondia sempre «yes»

A linha cgroup2 delegated: — a que se lê exactamente para perceber porque é que
-m/--cpus/--pids-limit não estão a pegar — era decidida assim:

Path::new("/sys/fs/cgroup/cgroup.controllers").exists()
    && read_to_string("/sys/fs/cgroup/cgroup.subtree_control").contains("memory")

Os dois ficheiros são do cgroup raiz do host. Em qualquer máquina com systemd
o segundo contém memory (medido aqui: cpuset cpu io memory pids). O comando
respondia portanto yes incondicionalmente — incluindo na sessão SSH onde
este projecto já tinha medido os cinco limites silenciosamente inertes.

É a mesma lição que o install.sh já tinha aprendido («ler cgroup.controllers
não chega») e que o system info nunca recebeu.

A função certa existia e ninguém a chamava

delonix_runtime::cgroup_limits_apply() já fazia a pergunta certa — mas só tinha
um chamador (cluster create) e, pior, só testava o caminho root
(delonix.slice, que num host rootless nem existe). Em rootless o spawn usa o
cgroup actual. É o mesmo erro de base-estática-vs-dinâmica que o
update_limits fez com container.cgroup() em vez de live_cgroup().

Agora é consciente do modo, e o system info chama-a.

Qual é a sonda que discrimina (medido, não deduzido)

Testaram-se os três candidatos no cgroup real desta sessão:

sonda scope delegado sessão SSH
criar um filho OK OK ← não discrimina
cgroup.subtree_control gravável OK recusa
activar +memory falha falha ← falso negativo

Criar um filho é possível nos dois. Activar +memory falha mesmo num scope
genuinamente delegado, porque a regra de no internal processes do kernel a
recusa enquanto o nosso próprio processo estiver no cgroup (o motor contorna-o
movendo-se para um dlx-mgr primeiro — demasiado invasivo para um diagnóstico
só-de-leitura). O que separa os dois casos é a posse do
cgroup.subtree_control
: o systemd faz chown dele para o utilizador num
Delegate=yes, e num session-N.scope fica root:root. Confirmado neste host:
o scope delegado é walter:walter, o session-2.scope real é root.

O que a varredura NÃO encontrou

Nenhum outro capture(...) a ser lido pelo Result em vez da saída, e os
unwrap_or_default() restantes são todos «listar para decidir o que
acrescentar», onde vazio leva a criar (idempotente) e nunca a apagar.

Fica registada uma armadilha de API, sem bug vivo: reap_orphan_hostfwds é
público e falha ABERTO com uma lista vazia (lista vazia ⇒ tudo é órfão ⇒ apaga
tudo). O único chamador deste repo é seguro — propaga o erro do store.list()
em vez de passar um conjunto vazio, e o comentário raciocina sobre isso — mas a
função continua a ser uma armadilha para um consumidor externo, e foi exactamente
assim que as portas publicadas morriam sozinhas.

v0.42.1 — «sem pid de controlo» e «sem plano de controlo» deixam de se ler igual

Choose a tag to compare

@github-actions github-actions released this 05 Aug 08:58

v0.42.1 — «sem pid de controlo» e «sem plano de controlo» deixam de se ler igual

Follow-up directo da v0.42.0, encontrado ao testar o caminho que mais interessa a
quem já tem um nó a correr: um upgrade in-place sobre um holder pré-split.

Esse caminho funciona, e foi validado ao vivo com o binário v0.41.0 real a segurar
a netns: o v0.42.0 reconhece o holder antigo como pin, encontra o socket de
controlo alcançável, e não toca em nada — holder e container mantêm o PID, a
rede fica intacta, e o binário novo cria containers novos contra ele sem
problema.

O que estava mal era o relato. O status mostrava control pid —, porque um
holder pré-split não tem ficheiro de pid do controlo — é um só processo a fazer
os dois trabalhos. Só que um plano de controlo genuinamente morto mostrava
exactamente a mesma coisa. Dois estados opostos, a mesma linha:

ingress UP — pin 98880 · control — · slirp 98900 …   ← tudo bem
ingress UP — pin 100733 · control — · slirp 100755 … ← sem plano de controlo

InfraStatus ganhou control_reachable (decidido por um connect, não por um
pidfile nem pela existência de um ficheiro) e o status passou a nomear os três
estados:

control 100736   → processo de controlo próprio, vivo
control in-pin   → holder pré-split: um só processo faz os dois trabalhos
control DOWN     → não há ninguém a servir o socket

É a mesma lição que esta base de código já pagou três vezes — holder_pid.is_some()
não é «o holder é alcançável», container.userns não é «está num userns
diferente do meu», e um ficheiro de socket não é um listener. Aqui era None a
significar duas coisas contrárias.

v0.42.0 — o holder deixa de ser um ponto único de falha da rede

Choose a tag to compare

@github-actions github-actions released this 05 Aug 08:44

v0.42.0 — o holder deixa de ser um ponto único de falha da rede

Até aqui, um só processo fazia duas coisas sem relação: segurava os
namespaces (userns/netns/mountns) e corria o plano de controlo (socket de
controlo, DNS, RA, servidores de DHCP). Como consequência, reiniciar o plano de
controlo — um crash, um kill, um upgrade in-place — destruía a netns e
desligava permanentemente todos os workloads do nó.

A v0.41.0 tratou o sintoma (recuperar por reinício). Esta trata a causa.

O que a medição mostrou

Antes de escrever código, matei o holder com uma VM Cloud Hypervisor viva e fui
ver o que restava:

processo da VM vivo
netns a mesma (mesmo inode)
delonix0 + o tap da VM presentes, UP
tap0 do slirp presente, 10.0.2.100/24, rota default
ruleset nft intacto, com a chain de isolamento da VM
processo slirp4netns vivo

E entrei nessa netns órfã sem privilégio nenhum, através do processo da VM
(nsenter -t <pid> -U -m -n).

Nada disto estava partido por limitação de kernel. O que matava a rede era o
ensure_up seguinte deitar fora uma netns perfeitamente funcional para construir
outra. Isto corrige, de passagem, uma afirmação demasiado larga que estava
registada: o que é impossível em rootless é ip netns attach a partir do host
(precisa de CAP_SYS_ADMIN sobre o userns do holder morto) — entrar na netns a
partir de um membro vivo é outra operação, e essa funciona.

A separação

  • delonix netns pin — faz o unshare e adormece. Sem sockets, sem threads,
    sem estado. Só morre por kill ou com a máquina.
  • delonix netns control — corre lá dentro por nsenter e é livre de ir e
    vir: socket de controlo, DNS, Router Advertisements e o DHCP por bridge.

O ensure_up passou a ter três casos: pin vivo e controlo alcançável (nada a
fazer); pin vivo e controlo ausente (repõe só o controlo — o caso que esta
versão existe para criar); pin morto (reconstrução completa + recuperação por
reinício, exactamente como antes).

O ficheiro de pid do pin mantém o nome histórico de propósito: é o pid que todos
os nsenter -t <holder> da árvore visam (join_argv, infra_join_argv,
disable_ipv6_live, …) e agora é o pid que nunca muda. Renomeá-lo seria
mexer em todos os consumidores para dizerem o mesmo.

Efeito lateral valioso: o pin não tem comportamento versionado, logo **pin antigo

  • controlo novo é seguro por construção** — a armadilha do upgrade in-place que
    custou a v0.34.2 deixa de existir para o caminho normal.

Três bugs que só a validação ao vivo revelou

/sys/class/net não reflecte a netns do processo. A primeira sonda de «esta
netns já está construída?» lia /sys/class/net/delonix0. O sysfs reporta a netns
de quem o montou, e o pin nunca remonta /sys — de dentro do controlo aquele
directório continua a ser o do host. Resultado: a sonda dizia «vazia» para uma
netns que tinha bridge, e o controlo morria em ip link add delonix0: File exists. Passou a perguntar por netlink (ip link show).

capture() devolve Ok mesmo quando o comando falha — não olha para o exit
status de todo. A segunda versão da sonda usava .is_ok() e era portanto
sempre verdadeira: numa netns virgem o controlo tomava o caminho de reattach,
não construía nada, e o net netns up anunciava ingress UP sobre uma netns sem
bridge nenhuma. Agora lê a saída, que é o sinal que interessa.

Um ficheiro de socket sobrevive ao processo que o criou. wait_for_control_sock
era path.exists() — a terceira aparição nesta base de código do mesmo erro,
depois do status() por pidfile («holder_pid.is_some() não é "o holder é
alcançável"») e do container.userns. Passou a doer no momento em que o split deu
ao plano de controlo uma forma de morrer sozinho: com o ficheiro órfão a passar, o
ensure_up devolvia um alegre ingress UP sobre um nó sem plano de controlo
nenhum
— dataplane bem (é o objectivo do split), mas sem attach, sem publish,
sem DNS, e sem um aviso. Agora faz um connect real. A função que tinha ficado
sem chamadores foi apagada, não deixada à espera do primeiro.

Reattach não repete os passos destrutivos

Re-correr a construção sobre uma netns viva não é só desperdício, é destrutivo, e
cada um destes foi verificado e não assumido:

  • mount -t tmpfs none /run montaria um segundo tmpfs por cima, escondendo
    /run/netns — ou seja, a netns nomeada de cada pod e de cada container
    --net <custom> do nó, inalcançável por nome num instante;
  • ip link add / ip addr add devolvem File exists e, sendo propagados com
    ?, abortariam o arranque do controlo;
  • reaplicar o ruleset base reacrescenta as regras de dispatch do fwcont a cada
    reinício (o ruleset funde-se na tabela existente — não tem flush, que é a
    razão de as firewalls dos containers sobreviverem de todo).

No reattach reconstrói-se só o que é local ao processo: os servidores de DHCP,
que são threads e morreram com o controlo anterior — o ingress default mais cada
rede privada.

Validação

Ao vivo, com um pod, um container em rede custom e uma VM Cloud
Hypervisor
a correr ao mesmo tempo: kill -9 no controlo →

pin      77461 → 77461      (intocado)
control  77464 → 77722      (reposto)
VM       77610 → 77610      pod 77528 → 77528      container 77573 → 77573

Zero reinícios, rede intacta nos três, chains de isolamento preservadas, e o
controlo a aceitar trabalho novo de imediato. Matar o pin continua a cair na
reconstrução completa com recuperação por reinício, como antes.

Cenário de caos novo (control_restart), que compara PIDs e não só
conectividade — uma recuperação por reinício também deixaria a rede a funcionar e
seria indistinguível de outra forma. Arnês completo: 17 PASS · 0 FAIL · 0 SKIP.

Ainda em aberto

Se o pin morrer, os workloads continuam a ser recuperados por reinício — a
adopção da netns por um holder novo continua impossível em rootless, e agora é
uma janela muito mais estreita (o pin não faz nada que possa falhar). As VMs
continuam fora dessa reconciliação: só o tap morre com o pin e nada o repõe.

v0.41.0 — um pod voltava do respawn do holder sem rede, em silêncio

Choose a tag to compare

@github-actions github-actions released this 05 Aug 08:09

v0.41.0 — um pod voltava do respawn do holder sem rede, em silêncio

Continuação directa da v0.40.0, que trouxe pods e VMs para dentro do isolamento
de namespace. Trazê-los para dentro tornou visível a pergunta seguinte: e quando
o holder morre e volta?

A resposta, medida antes de escrever código:

recovered 1 container(s) stranded by the previous holder (restarted)

pa-c0   Up 32 seconds   →  ping: Network unreachable

O container foi recuperado. O pod ficou vivo, sem rede, sem a sua chain de
isolamento, e sem uma linha a dizê-lo. Uma recuperação que reporta sucesso
por cima de um workload que acabou de abandonar é pior do que uma que não faz
nada — porque a primeira é lida como "está tratado".

A raiz: a associação ao pod nunca era persistida

Container tem um campo pod desde sempre. O describe sempre o imprimiu.
Nada alguma vez lho atribuiu — o único traço de pertença em disco era uma
label. É a quarta vez que este repositório paga exactamente esta armadilha (o
-v não persistido, o -p numa rede custom, as redes extra perdidas no
restart), e a regra continua a mesma: estado necessário para RECONSTRUIR o
recurso tem de ser persistido, não só usado uma vez na criação.

Duas consequências, ambas reproduzidas:

  • container describe num membro de pod não mostrava pod nenhum;
  • container restart de um membro morria com clone failed: EPERM e deixava-o
    Dead, sem caminho de volta — o cmd_start reconstrói o spec a partir do
    registo e não tinha como saber que devia re-entrar na netns partilhada.

O que passou a funcionar

  • container restart de um membro de pod volta a entrar na netns do pod,
    com o mesmo IP, sem tocar nos peers. Se a netns já não existir (o holder foi
    substituído), é recriada — com a namespace do membro, portanto o isolamento
    volta com ela.
  • Um respawn do holder recupera pods como já recuperava containers.

Dois bugs apanhados pelo caminho, ambos só visíveis ao vivo

reexec_start ignorava o seu próprio parâmetro netns e usava o id.
Funcionava por coincidência: o único chamador passava um netns igual ao id. Um
membro de pod é o primeiro caso em que diferem — com o código antigo teria
tentado entrar numa netns com o nome do container, que não existe. Mesma família
dos ajudantes públicos, mortos e com defeito à espera do primeiro chamador que
este repo já teve de apagar duas vezes. O caminho de falha ganhou também um
owns_netns: só se desmonta uma netns que seja nossa — a de um pod é partilhada,
e derrubá-la porque um membro não voltou tiraria a rede aos outros.

A guarda de idempotência da recuperação estava a saltar membros. Perguntava,
dentro do ciclo, «o holder serve esta netns?» — e a resposta passa a sim assim
que o primeiro membro recupera, pelo que todos os seguintes eram tratados
como saudáveis enquanto continuavam dentro da netns morta. Foi a primeira versão
desta correcção, e só um pod de dois containers a mostrou:

recovered 2 container(s)     pa-c0 → Network unreachable

A pergunta passou a ser feita uma vez, antes do ciclo. Aí é a pergunta certa:
no início da passagem, ou o holder servia a netns do pod (nada morreu — saltam-se
todos os membros) ou não servia (estão todos encalhados — reiniciam-se todos).
Para um container, cuja netns é só sua, snapshot e consulta ao vivo são
equivalentes.

Validação

Ao vivo, no sandbox isolado: restart de um membro com um peer a correr (o peer
não perde um pacote, o membro volta ao mesmo IP do pod); respawn do holder com um
pod de dois membros + um container em rede custom — os três recuperados, os
dois membros no mesmo IP, e o isolamento reconstruído (cross-namespace bloqueado
nos dois sentidos, mesmo-pod aberto).

Cenário de caos novo (pod_holder_respawn), deliberadamente com dois
containers no pod: com um só, a guarda partida ainda passava. Falha com a
correcção revertida (c0=down c1=down) e passa com ela. Arnês completo:
16 PASS · 0 FAIL · 0 SKIP.

Ainda em aberto

A recuperação continua a ser por reinício, não por adopção: adoptar a netns
viva para dentro do holder novo é impossível no kernel em rootless (ip netns attach precisa de CAP_SYS_ADMIN sobre o userns do holder que morreu), e isso
está medido e documentado desde a v0.39. VMs continuam fora da reconciliação — o
tap morre com o holder e nada o repõe.

v0.40.0 — pods e VMs entram no isolamento de namespace

Choose a tag to compare

@github-actions github-actions released this 05 Aug 06:44

v0.40.0 — pods e VMs entram no isolamento de namespace

O isolamento por --namespace cobria containers simples. Pods e VMs ficavam de
fora — cada um por uma razão diferente, e a dos pods era a pior das duas.

Pods estavam meta-ligados, o que é pior que desligados

pod create já passava a namespace ao attach, por isso o IP do pod entrava
nos sets @dlxall/@dlxns_<ns>: as chains dos outros workloads já recusavam
ligações vindas dele. O que nunca existiu foi chain própria — e as regras de
isolamento vivem na chain de cada workload. Sem chain, nada dropava o tráfego a
entrar no pod. Uma fronteira aberta num sentido só é uma fronteira aberta.

Medido antes da correcção, três pods de um container na bridge default:

podA(teamA) → podB(teamB)    REACHABLE    ← devia estar bloqueado
podA(teamA) → podA2(teamA)   reachable    ← correcto

…com os sets do holder perfeitamente correctos (@dlxall = {.2,.3,.4},
teamA = {.2,.4}, teamB = {.3}) e o @fwmap vazio. A metade da
composição estava lá; a metade que aplica, não.

Corrigido no create_pod, chaveado pelo nome da netns do pod e não pelo id
de um container membro: a netns é que segura o endereço, todos os membros a
partilham, e o verdict map do dataplane é chaveado por IP — uma entrada é tudo
o que caberia de qualquer forma. O teardown já estava coberto (pod rm
detach_containerunfirewall).

VMs não tinham namespace — e havia um obstáculo real

O IP de uma VM vem por DHCP, portanto no momento do attach ainda não se sabe
qual é. A saída veio de o servidor DHCP ser nosso: dhcp_serve corre em Rust
dentro do holder e o lease é determinístico do MAC. infra::dhcp_lease_ip
calcula-o do lado do host, antes de o guest sequer arrancar — e é isso que
permite registar a membership e instalar a chain no momento certo, em vez de
esperar por um lease que ninguém observa.

Essa aritmética estava duplicada em dois sítios (dhcp_serve e
dhcp_ip_for_mac) e esta sessão quase acrescentou uma terceira. Agora há uma só
função, e os três consumidores — o servidor que entrega o lease, o vm ls que o
reporta, e o attach que o isola — passam todos por ela. Duas cópias divergiriam
no dia em que a pool mudasse, e o sintoma seria o pior possível: uma VM com
firewall num endereço que ninguém usa, reportada como isolada.

Novidades de superfície: vm create --namespace <ns>, metadata.namespace no
kind: Vm, e a namespace visível no vm describe. Vm.namespace é persistido
(registos antigos ficam em default, que é exactamente o que eram) e
reconstruído pelo config_from, com teste dedicado — a namespace desaparecer no
primeiro vm start seria a quarta ocorrência de uma armadilha já documentada
neste repo (-v não persistido, -p em rede custom, redes extra perdidas no
restart).

Só o backend cloud-hypervisor — e a recusa é explícita

Uma VM libvirt vive na virbr0, no netns do host: outro L2, que este motor
não programa. --namespace nesse backend é recusado com erro dirigido, nunca
aceite-e-ignorado. Aceitar uma opção de isolamento e não fazer nada é a armadilha
que este projecto já teve de corrigir três vezes (--security-opt seccomp=,
-v …:z, --network-alias), e num campo de segurança custa mais do que não ter
a opção.

Compatibilidade de holder

A linha de controlo vmtap só cresce para 6 tokens quando há mesmo namespace a
aplicar (vmtap_line, pura e testada) — o mesmo idioma que attach e
attach-extra já usavam. Contra um holder de uma build anterior, uma VM
namespaced falha alto (invalid control command) em vez de arrancar sem
isolamento em silêncio. Confirmado ao vivo.

Validação

Pods, ao vivo no sandbox isolado: cross-namespace bloqueado nos dois
sentidos, same-namespace aberto, gateway intacto, uma chain por pod no @fwmap.

VMs, ao vivo: vm create --backend cloud-hypervisor --namespace teamA real,
chain instalada em 10.200.254.20 — o lease previsto no attach, e o mesmo
endereço que o vm ls reporta. Com tráfego verdadeiro contra esse endereço,
através da chain instalada, os contadores do kernel nomeiam a regra que decidiu:

ct state established,related          counter packets 1 bytes 84   accept
ip daddr … ip saddr @dlxnse20c4037    counter packets 1 bytes 84   accept   ← same-ns
ip daddr … ip saddr @dlxall ct new    counter packets 4 bytes 336  drop     ← cross-ns

Cenário de caos novo (pod_namespace_isolation), que falha com a correcção
revertida e passa com ela. Arnês completo: 15 PASS · 0 FAIL · 0 SKIP.

O que não foi provado com um convidado a sério: nenhuma imagem deste host
arranca em Cloud Hypervisor (a golden é libvirt-only e falta o hypervisor-fw),
por isso o alvo no endereço da VM foi um veth real na bridge do holder e não o
guest. O que fica por confirmar é só o troço tap→convidado, que é idêntico ao
de qualquer VM CH sem namespace nenhuma; a chain, o endereço e a decisão do
kernel foram exercitados com pacotes verdadeiros.

Ainda em aberto

O isolamento continua a não ser reconstruído num respawn do holder (os
sets/chains voltam vazios e os workloads vivos não se re-atacham sozinhos;
reiniciar cada um repõe). É agora a última limitação conhecida do modelo.

v0.39.3 — SEGURANÇA: o isolamento por namespace estava desligado desde a v0.39.0

Choose a tag to compare

@github-actions github-actions released this 04 Aug 22:32

v0.39.3 — SEGURANÇA: o isolamento por namespace estava desligado desde a v0.39.0

Actualize imediatamente se corre containers de mais do que um inquilino no mesmo
host.
As versões v0.39.0, v0.39.1 e v0.39.2 aplicam metadata.namespace /
--namespace mas não instalam a firewall que o faz valer. Um container de uma
namespace alcança containers de outra.

O que aconteceu

Tornar o supervisor universal (v0.39.0, para capturar exit codes) fez com que
todos os containers desanexados passassem a sair de cmd_run mais cedo — antes
do bloco que aplica o isolamento por namespace. Enquanto o supervisor estava
condicionado a --restart, isso não tinha efeito prático; deixou de estar.

E nada falhou. Uma firewall que nunca é instalada não devolve erro: o tráfego
simplesmente passa. Foi preciso atravessar a fronteira para o descobrir.

Medido, mesmo cenário, dois binários:

v0.38.2 (supervisor só com --restart)   teamA → teamB   bloqueado
v0.39.0 (supervisor universal)          teamA → teamB   ALCANÇA

A correcção

O isolamento passou a ser aplicado antes do ramo do supervisor, onde o IP já é
conhecido, por isso os dois caminhos o recebem. Verificado nos dois sentidos:
cross-namespace bloqueado, same-namespace aberto — porque um isolamento demasiado
agressivo é outro bug, não uma correcção.

É a terceira ocorrência da mesma armadilha estrutural nesta série: estado
necessário depois da criação vivia depois de um return antecipado. As duas
anteriores foram ip/network (v0.39.0) e o rootfs no rm (v0.39.1). A lição que
fica no código: quando um return novo é acrescentado a cmd_run, tudo o que vem
depois dele deixa de acontecer para esse caminho — e nada avisa.

O que impede a repetição

Cenário novo no arnês de caos, namespace-isolation, que atravessa a fronteira
em vez de verificar configuração: cross-namespace tem de estar bloqueado, e
same-namespace aberto. Uma firewall ausente falha o cenário.

Bateria: 14 PASS · 0 FAIL · 0 SKIP.

Também nesta versão

O último wedge do holder. handle_control saiu da thread do accept para um
worker dedicado que mantém a serialização da fábrica de netns/veth/nft. Um nft/ip
preso deixou de pendurar o holder inteiro:

  • o accept continua a aceitar, e um chamador recebe holder busy ao fim de 60 s em
    vez de nunca receber nada;
  • os verbos de leitura (ping, has-netns, fwstats, egress-show) continuam a
    ser servidos, por isso o nó permanece observável — e a reconciliação do
    net netns up continua a poder perguntar que containers estão servidos.

Dito com todas as letras: isto não torna um handle_control preso inofensivo. O
worker é único por desenho, logo uma mutação encravada continua a bloquear as
mutações seguintes — que agora falham com erro limitado e diagnosticável em vez de
nunca voltarem. Tornar isso progresso real exige a fábrica ser interrompível, que é
outro trabalho.

Validação

clippy --workspace --all-targets a zero avisos; 622 testes; arnês 14/14.