6 · Testes e validação#
«Compila» não é «funciona», e «o comando devolveu 0» não é «fez o que devia». Este capítulo mostra como se prova um runtime — e sobretudo o que a prova encontrou que a leitura do código não encontrou.
O resultado#
$ cargo test --workspace # (linhas relevantes) Running unittests src/lib.rs test ch01_essencial::tests::borrowing_keeps_ownership ... ok test ch01_essencial::tests::match_describes_every_state ... ok test ch01_essencial::tests::traits_allow_static_and_dynamic_dispatch ... ok test ch02_idiomatico::tests::builder_validates_in_one_place ... ok test ch01_essencial::tests::parse_size_handles_units_and_hostile_input ... ok test ch02_idiomatico::tests::ids_are_validated_once_at_the_boundary ... ok test ch02_idiomatico::tests::lifecycle_goes_forward_and_can_restart ... ok test ch06_distribuido::tests::apply_is_idempotent ... ok test ch06_distribuido::tests::backoff_grows_and_is_capped_without_overflow ... ok test ch06_distribuido::tests::exit_codes_are_a_stable_contract ... ok test ch06_distribuido::tests::only_our_own_resources_are_pruned ... ok test ch06_distribuido::tests::plan_is_create_update_or_noop ... ok test ch06_distribuido::tests::resume_rejects_a_206_for_the_wrong_offset ... ok test ch06_distribuido::tests::retries_only_transient_failures ... ok test ch06_distribuido::tests::three_way_diff_separates_our_removals_from_foreign_edits ... ok test ch06_distribuido::tests::atomic_write_never_exposes_a_half_file ... ok test result: ok. 16 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; Running unittests src/bin/ns_demo.rs test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; Running tests/namespaces.rs test a_new_uts_and_pid_namespace_isolate_the_child_and_leave_the_host_alone ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; Running unittests src/lib.rs test cgroup::tests::negative_means_unlimited_and_no_limits_means_no_files ... ok test cgroup::tests::translates_limits_to_v2_files ... ok test fsutil::tests::ids_are_allow_listed ... ok test image::tests::digest_format_is_validated_before_touching_disk ... ok test fsutil::tests::creates_missing_directories ... ok test spec::tests::refuses_a_bundle_that_wants_less_isolation ... ok test spec::tests::parses_a_real_runc_style_document ... ok test spec::tests::default_spec_is_valid_and_round_trips ... ok test fsutil::tests::refuses_parent_dir_and_symlinks ... ok test state::tests::a_dead_pid_is_reported_stopped ... ok test spec::tests::refuses_what_it_cannot_honour ... ok test image::tests::a_tampered_blob_is_refused ... ok test state::tests::create_twice_is_a_conflict_and_missing_is_not_found ... ok test fsutil::tests::atomic_write_replaces_whole_file ... ok test state::tests::concurrent_updates_do_not_lose_writes ... ok test result: ok. 15 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; Running unittests src/main.rs test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; Running tests/e2e.rs test a_bundle_asking_for_seccomp_is_refused_not_ignored ... ok test propagates_the_exit_code_of_the_workload ... ok test runs_as_pid_1_with_its_own_hostname ... ok test readonly_root_is_enforced_but_tmpfs_is_writable ... ok test a_missing_binary_exits_127_like_a_shell ... ok test drops_capabilities_to_the_oci_default_set ... ok test lifecycle_create_start_kill_delete_with_stable_error_classes ... ok test result: ok. 7 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; Doc-tests examples test examples/src/ch02_idiomatico.rs - ch02_idiomatico::Container (line 65) ... ok test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; test examples/src/ch02_idiomatico.rs - ch02_idiomatico::Container (line 82) - compile fail ... ok test examples/src/ch02_idiomatico.rs - ch02_idiomatico::Container (line 74) - compile fail ... ok test result: ok. 2 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; Doc-tests minicontainer test result: ok. 0 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; $ cargo clippy --workspace --all-targets -- -D warnings Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.78s $ cargo fmt --all --check && echo formatação-ok formatação-ok $ cargo deny check licenses bans sources bans ok, licenses ok, sources ok
22 testes no minicontainer (15 unidade + 7 integração), mais os 16 + 1 + 3 dos exemplos. clippy -D warnings, fmt e cargo deny limpos. Isto é o que corre em CI a cada PR.
Testes de integração: o binário a sério#
Os 7 testes de tests/e2e.rs correm o binário (env!("CARGO_BIN_EXE_mc")) com o kernel real. O que cada um prova:
| Teste | Prova |
|---|---|
runs_as_pid_1_with_its_own_hostname |
pid=1 host=minicontainer — PID e UTS namespaces |
propagates_the_exit_code_of_the_workload |
exit 7 → o mc sai com 7 |
a_missing_binary_exits_127_like_a_shell |
binário inexistente → 127, como uma shell |
drops_capabilities_to_the_oci_default_set |
CapBnd == 00000000a80425fb |
readonly_root_is_enforced_but_tmpfs_is_writable |
root-ro / tmp-rw |
lifecycle_…_with_stable_error_classes |
create→start→kill→delete, saídas 4 e 5 |
a_bundle_asking_for_seccomp_is_refused_not_ignored |
saída 2 e a palavra seccomp no erro |
O setup constrói um rootfs mínimo a partir do busybox do host, e salta com aviso se não há user namespaces:
fn setup() -> Option<Env> {
let Some(bb) = which("busybox") else {
skip("busybox não encontrado");
return None;
};
let userns_ok = Command::new("unshare").args(["-Ur", "true"]).status().is_ok_and(|s| s.success());
if !userns_ok {
skip("user namespaces indisponíveis");
return None;
}
let tmp = tempfile::tempdir().unwrap();
let bundle = tmp.path().join("bundle");
let rootfs = bundle.join("rootfs");
for d in ["bin", "proc", "dev", "tmp", "etc"] {
std::fs::create_dir_all(rootfs.join(d)).unwrap();
}
std::fs::copy(&bb, rootfs.join("bin/busybox")).unwrap();
for a in ["sh", "cat", "hostname", "sleep", "touch", "echo"] {
std::os::unix::fs::symlink("busybox", rootfs.join("bin").join(a)).unwrap();
}
Some(Env { root: tmp.path().join("state"), _tmp: tmp, bundle })
}Ver a checklist de projecto: um teste que salta em silêncio lê-se como verde.
A contra-prova: o mesmo bundle no runc#
Um teste escrito por quem escreveu o código herda os mesmos pressupostos que o código. A prova de conformidade que não herda é outro implementador:
$ runc --version | head -1 runc version 1.5.1 $ runc run rc # o MESMO bundle que o mc acabou de correr (+ mapeamentos de user ns) vem da layer 2 motd passwd cwd=/tmp MSG=ola-da-imagem [exit 0]
O bundle que o mc unpack gerou e o mc run correu foi entregue ao runc 1.5.1, com a mesma saída. (O runc exige os mapeamentos de user namespace explícitos no config.json; o mc sempre os cria — é a diferença de contrato, não de resultado.)
Os bugs que só a execução encontrou#
Seis, por ordem de descoberta. Cada um passou por «o código parece certo» e falhou quando foi corrido — e cada um está fixado por um teste ou por um comentário no código.
1. uid_map: Operation not permitted#
O primeiro mc run falhou a escrever /proc/self/uid_map. O mesmo mapeamento feito à mão em Python funcionava. Causa: getuid() chamado depois do unshare(NEWUSER) devolve 65534 (o uid de overflow), logo o mapa 0 65534 1 é recusado. Cura: ler os ids antes e passá-los como argumentos (passo 2 do capítulo 12).
2. mc run pendurado para sempre#
recv_report usava read_to_string, que espera pelo EOF — e o init mantém o pipe aberto até ao exec. Cura: uma só read (capítulo 14). Foi apanhado por um timeout — sem ele, o teste teria ficado pendurado também.
3. Command::output() bloqueado 3 minutos#
Um mc create num teste de integração deixava o supervisor a segurar o stdout do chamador. Cura: stdio em ficheiro no create. Um teste que só verificasse «devolve 0» nunca teria visto isto — o comando devolvia 0; era o leitor que ficava preso.
4. mc run devolvia 255 em vez de 7#
wait_stopped confiava em «o PID morreu» (reconcile) antes de o supervisor gravar o exit code — uma corrida de milissegundos. Os testes passavam sozinhos e falhavam em paralelo. Cura: esperar pelo estado persistido stopped, e só desistir se o supervisor desaparecer sem gravar, após uma folga de 2 s.
5. Uma limpeza «inofensiva» que partiu 6 testes#
Uma correcção de lint do clippy (or_else(|_| Err(…)) → ?) revelou que o código antigo escondia um erro: resolve_in_root(…, create=false) num ficheiro que ainda não existia devolvia NotFound, e um .unwrap_or_else engolia-o e caía num caminho alternativo por acaso. Com o ? honesto, o teste ficou vermelho. Lição: um unwrap_or_else sobre um erro é um convite para o esconder — e os testes de integração são o que apanha a diferença.
6. O meu próprio demo estava conceptualmente errado#
O primeiro ns_demo afirmava que o hostname do host ficava «inalterado» comparando o do pai antes e depois. Falhava — porque o pai também entrou no UTS namespace novo (o unshare aplica-se a quem chama). A afirmação certa só se pode fazer de fora: o teste de integração corre o binário e compara o hostname do host antes e depois. Lição: verifica a propriedade no sítio onde ela é observável.
O padrão nos seis
Nenhum destes teria sido apanhado por leitura ou por um teste unitário: são interacções — com o kernel, entre processos, entre ordens de operações. É por isto que o delonix tem uma bateria E2E que corre a CLI real e um arnês de caos, e porque a regra da casa é «prova medida, não afirmada».
O que não foi validado#
Dizê-lo é parte do trabalho:
- Uma só máquina, um só kernel (Linux 7.0, cgroup v2 puro,
busyboxestático). Não se testou em kernels antigos, cgroup v1 ou híbrido, nem noutras distros. - A suite oficial do OCI (
runtime-tools) não foi corrida. A contra-prova é um bundle norunc, não conformidade total. - Um só uid mapeado. Imagens com ficheiros de vários donos (e
USERnão-root) não foram testadas. - Sem
seccompnem rede — recusa-se o primeiro; do segundo só há olo. - CI: os testes E2E dependem de user namespaces e
busybox. No GitHub Actions correram de facto (7 testes em 0,5 s, como no host de desenvolvimento) — e a CI defineMC_REQUIRE_E2E=1, que faz o teste falhar em vez de saltar se o runner deixar de os permitir. Sem isso, um verde por ausência de execução seria indistinguível de um verde por sucesso. - Sem fuzzing. O
unpackde arquivos e o parser da spec são superfícies óbvias paracargo fuzz— fica como exercício.
Corre tu#
git clone https://github.com/angolardevops/delonix-rust-tutorial && cd delonix-rust-tutorial
cargo test --workspace # o que a CI corre
scripts/demo.sh # regenera TODAS as saídas deste capítulo
systemd-run --user --scope -p Delegate=yes scripts/demo.sh # inclui cgroups (OOM, pids)