delonix-rust tutorial

Projecto: minicontainer · 25 min de leitura

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#

saída real · medida neste host
$ 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:

minicontainer/tests/e2e.rs · e2e-setupver no GitHub ↗
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:

saída real · medida neste host
$ 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, busybox está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 no runc, não conformidade total.
  • Um só uid mapeado. Imagens com ficheiros de vários donos (e USER não-root) não foram testadas.
  • Sem seccomp nem rede — recusa-se o primeiro; do segundo só há o lo.
  • 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 define MC_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 unpack de arquivos e o parser da spec são superfícies óbvias para cargo 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)