Releases: angolardevops/delonix-runtime
Release list
v0.45.0 — o que se descobre ao correr aquilo que se acabou de escrever
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-getcomo 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
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.
VMfile — vm 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-dataFROMé 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 noDelonixfile. delonix vm initescreve o esqueleto completo (VMfile +cloud-init/
comuser-data/meta-datacomentados), 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:
- O Ubuntu 24.04 já traz
Delegate=pids memory cpunouser@.service. O
drop-in que o--delegateescreve pedia exactamente o que já lá estava. - O
subtree_controldouser@1000.servicejá era propriedade do utilizador.
A delegação não estava em falta — estava feita. - O que faltava o
cpuera o slice de onde o comando corria (app.slice, o
scope do editor), que não o passa para baixo. Um drop-in no/etcnã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ê
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(orpull) 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).
ReopenContainerLogera 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_statsdeitava fora olabel_selector. Um filtro que
devolve mais do que foi pedido é pior que um que dá erro: as linhas a mais
parecem respostas verdadeiras.ContainerStatusdevolviamountsvazio — 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
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/mnt — faltava 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
noexec.--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 aMS_SLAVEsó 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,--helpincluído.stop/startficam 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: umsession-N.scopede login é IRMÃO do
user@.servicee 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_tardescartava oHEALTHCHECKno caminho
rootless (o normal) enquanto o caminho overlay o honrava — o mesmo Dockerfile
dava imagens diferentes conforme o modo do motor.apply_sysctlsdeitava 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.ipnunca era
atribuído a um membro de pod. - ALTO, apanhado em revisão: o
--add-hostarmou um primitivo de escrita
fora do rootfs. Owrite_etc_filesusavaformat!+fs::write, ambos
seguindo symlinks, e em rootless a árvore pertence ao uid mapeado — o próprio
contentor podia plantaretc/hosts -> ~/.ssh/authorized_keys. Já existia como
truncar-com-conteúdo-fixo; o--add-hosttornou o conteúdo escolhido pelo
atacante. Fechado comsafe_bind_target+ validação na fronteira.
O que NÃO fecha, e porquê
- AppArmor (9 specs) — carregar perfis exige
CAP_MAC_ADMINno user
namespace INICIAL. Medido: um aninhado respondeYou 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.shareProcessNamespacee ochown_tree_onceque 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 —
create → start → inspect → rename → stop → remove, a sequência que
o Testcontainers usa de facto: 14/14.
v0.42.2 — `system info` mentia sobre a delegação de cgroup
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
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
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 ounsharee adormece. Sem sockets, sem threads,
sem estado. Só morre porkillou com a máquina.delonix netns control— corre lá dentro pornsentere é 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 /runmontaria 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 adddevolvemFile existse, sendo propagados com
?, abortariam o arranque do controlo;- reaplicar o ruleset base reacrescenta as regras de dispatch do
fwconta cada
reinício (o ruleset funde-se na tabela existente — não temflush, 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
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 describenum membro de pod não mostrava pod nenhum;container restartde um membro morria comclone failed: EPERMe deixava-o
Dead, sem caminho de volta — ocmd_startreconstró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 restartde 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
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_container → unfirewall).
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
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 busyao 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 upcontinua 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.