delonix-rust tutorial

Rust para sistemas · 30 min de leitura

Um projecto Rust completo#

Escrever código que funciona é o primeiro terço. Os outros dois são mantê-lo e deixar outros mexerem-lhe sem medo. Este capítulo é a lista de decisões de um projecto de sistemas em Rust que a comunidade reconhece como saudável — e cada uma está aplicada neste repositório, que podes clonar e usar como template.

Estrutura#

delonix-rust-tutorial/
├── Cargo.toml              # workspace + lints partilhados + perfil de release
├── rustfmt.toml  clippy.toml  deny.toml
├── minicontainer/          # o projecto (biblioteca + binário `mc`)
│   ├── src/  lib.rs  main.rs  error.rs  spec.rs  image.rs  container.rs  cgroup.rs  state.rs  fsutil.rs
│   └── tests/e2e.rs        # testes de integração: correm o binário a sério
├── examples/               # cada exemplo dos capítulos, compilado e testado
├── scripts/                # demo.sh, make-rootfs.sh, extract-snippets.py …
├── content/  site-src/  build.py     # este site
└── .github/workflows/      # ci.yml (qualidade) e pages.yml (publicação)

Três decisões a copiar:

  1. Biblioteca + binário fino. lib.rs tem a lógica (testável, reutilizável); main.rs só faz parse de argumentos e traduz erros em códigos de saída. Se a lógica vivesse no main, só se testaria por subprocesso.
  2. tests/ para o que é do binário, #[cfg(test)] para o que é da função. Os testes de integração usam env!("CARGO_BIN_EXE_mc") — o binário real.
  3. Um crate examples para o código dos capítulos: se um exemplo do tutorial estiver errado, o CI fica vermelho. Documentação que não compila mente.

Lints num só sítio#

Cargo.tomlver no GitHub ↗
[workspace]
resolver = "3"
members = ["minicontainer", "examples"]

[workspace.package]
edition = "2024"
rust-version = "1.90"
license = "Apache-2.0"
repository = "https://github.com/angolardevops/delonix-rust-tutorial"

# Lints partilhados: um sítio só, herdados por cada crate com `[lints] workspace = true`.
[workspace.lints.rust]
unsafe_op_in_unsafe_fn = "deny"
missing_debug_implementations = "warn"

[workspace.lints.clippy]
all = { level = "warn", priority = -1 }
unwrap_used = "warn"          # nunca em código de produção; testes podem (allow local)
dbg_macro = "warn"
todo = "warn"

[profile.release]
lto = "thin"
codegen-units = 1
panic = "abort"   # um container runtime não faz unwinding através de fork/exec
  • [workspace.lints] — definidos uma vez, herdados com [lints] workspace = true em cada crate. Sem isto, cada crate diverge.
  • unwrap_used = "warn" — combinado com clippy.toml (allow-unwrap-in-tests = true): proibido em produção, livre nos testes.
  • panic = "abort" no release — um runtime que faz fork/exec não faz unwinding através deles.
  • unsafe_op_in_unsafe_fn = "deny" — cada operação insegura tem o seu bloco e o seu // SAFETY:.

Edição, MSRV e resolver

edition = "2024" e resolver = "3" são o presente; rust-version (o MSRV, versão mínima suportada) declara-se para o Cargo recusar compilar com um toolchain antigo com uma mensagem clara, em vez de um erro críptico a meio.

A pirâmide de testes#

Nível Onde O que prova Neste repositório
Unidade #[cfg(test)] uma função pura, em microssegundos spec, cgroup::limit_files, reconcile
Documentação /// com ``` que o exemplo compila e o contrato typestate com compile_fail
Propriedade/concorrência #[cfg(test)] invariantes sob entradas/corridas state: 16 threads a actualizar
Integração tests/ o binário, com o kernel a sério e2e.rs: PID 1, exit codes, capabilities
Conformidade fora do repo outro implementador cumpre a mesma norma o mesmo bundle no runc
Caos script sobrevive a falhas injectadas (no delonix: scripts/chaos.sh)

Regras que o delonix aprendeu a pagar, e que os testes deste tutorial seguem:

Um teste que passa com o código apagado não prova nada

Antes de confiar num teste, reverte a correcção e vê-o falhar. O delonix chama a isto «verificado pela regra do repo»: um cenário de caos sobre convergência tinha a asserção «o PID não mudou», que um apply que não faz nada também satisfaz. A asserção certa observa o efeito (o registo mudou e o plano seguinte não tem nada a propor).

«Passou» não é «correu»

Os testes E2E deste repositório saltam com aviso quando o host não permite user namespaces — mas um salto silencioso lê-se como verde. Por isso o salto é dito (SKIP: user namespaces indisponíveis), e no delonix os runners alojados do GitHub bloqueiam userns e o job de caos fica «verde a saltar tudo»: um verde por ausência de execução é indistinguível de um verde por sucesso se só se olhar para o topo.

Um teste de concorrência a sério não pode ser óbvio. Este falha sem o flock:

minicontainer/src/state.rs · concurrent-testver no GitHub ↗
    #[test]
    fn concurrent_updates_do_not_lose_writes() {
        let (_d, s) = store();
        let mut init = st("c");
        init.pid = 0;
        s.create(&init).unwrap();
        let s = std::sync::Arc::new(s);
        let handles: Vec<_> = (0..16)
            .map(|_| {
                let s = s.clone();
                std::thread::spawn(move || {
                    s.update("c", |st| {
                        let v = st.pid;
                        std::thread::sleep(std::time::Duration::from_millis(2));
                        st.pid = v + 1;
                        Ok(())
                    })
                    .unwrap();
                })
            })
            .collect();
        handles.into_iter().for_each(|h| h.join().unwrap());
        assert_eq!(s.load("c").unwrap().pid, 16);
    }

CI: o que corre em cada PR#

.github/workflows/ci.ymlver no GitHub ↗
name: ci
on:
  push: { branches: [main] }
  pull_request:

permissions: { contents: read }
env:
  CARGO_TERM_COLOR: always
  RUSTFLAGS: -D warnings
  MC_REQUIRE_E2E: "1"   # em CI os testes E2E não podem saltar: falham se o runner não os deixar correr

jobs:
  rust:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: dtolnay/rust-toolchain@stable
        with: { components: "rustfmt, clippy" }
      - uses: Swatinem/rust-cache@v2
      - run: sudo apt-get update && sudo apt-get install -y busybox-static jq
      # O Ubuntu 24.04 restringe user namespaces sem privilégio (AppArmor). Os testes E2E do
      # minicontainer precisam deles; se o runner os recusar, os testes SALTAM com aviso.
      - run: sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 || true
      - run: cargo fmt --all --check
      - run: cargo clippy --workspace --all-targets -- -D warnings
      - run: cargo test --workspace
      - run: cargo test --workspace --doc
  deny:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - uses: EmbarkStudios/cargo-deny-action@v2
  site:
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - run: pip install markdown
      - run: python3 build.py --check

Quatro gates independentes: formatação (zero discussões de estilo), clippy com -D warnings (avisos são erros), testes (incluindo doc tests), e cargo deny (licenças, advisories e fontes das dependências). O job site garante que nenhum link interno do tutorial está partido.

deny.tomlver no GitHub ↗
# cargo-deny: licenças, advisories e fontes das dependências.
[graph]
all-features = true

[advisories]
version = 2
yanked = "deny"

[licenses]
version = 2
allow = ["MIT", "Apache-2.0", "Unicode-3.0"]
confidence-threshold = 0.9
unused-allowed-license = "allow"

[bans]
multiple-versions = "warn"
wildcards = "deny"

[sources]
unknown-registry = "deny"
unknown-git = "deny"
allow-registry = ["https://github.com/rust-lang/crates.io-index"]

Fixa o que corre, não só o que compila

Usa cargo deny para recusar dependências com licença inesperada ou vulnerabilidade conhecida, e mantém a árvore pequena: cada dependência é superfície de ataque. O delonix confina dependências pesadas (ratatui, schemars, hyper) ao crate que precisa e verifica com cargo tree -e normal -p <crate> que os crates de mecanismo continuam limpos.

Documentação que se mantém#

  • /// com exemplos — testados por cargo test --doc.
  • //! no topo de cada módulo — porquê o módulo existe, não o que faz (isso lê-se no código). Vê os do minicontainer: quase todos explicam uma decisão ou uma armadilha.
  • ADRs (Architecture Decision Records) — um ficheiro por decisão de fronteira, com contexto, decisão e consequências. Uma decisão sem ADR é uma opinião. O delonix tem dezenas em docs/adr/.
  • Comentários que dizem o que foi medido: «MEASURED 2026-09-15, k8s 1.36.4, …». Um comentário sem evidência envelhece; um com a medição pode ser contestado.

Versões e releases#

  • SemVer: MAJOR.MINOR.PATCH, e no Rust a API pública é o contrato — cargo semver-checks apanha quebras acidentais.
  • A versão no Cargo.toml está sempre alinhada com a última tag publicada (o delonix impõe-o com version_gate.py): «duas builds com a mesma versão não são a mesma build», e uma versão «de trabalho» que já não corresponde a nenhuma tag confunde quem reporta bugs.
  • Perfil de release com lto = "thin", codegen-units = 1 para um binário menor e mais rápido.
  • Reprodutibilidade: Cargo.lock commitado para binários e aplicações.

Segurança: hábitos, não auditorias#

  1. Valida na fronteira com tipos (newtype), não com if espalhados.
  2. Recusa, não ignores: um campo aceite e ignorado é pior que um campo que não existe (é a regra que o Spec::validate do minicontainer cumpre para o seccomp).
  3. unsafe mínimo e comentado.
  4. Verifica o que descarregas (digest, checksum) antes de o usares, e falha fechado.
  5. Sem segredos em argumentos (visíveis no ps): ficheiro com 0600 ou stdin.
  6. Ficheiros temporários com O_EXCL e nome único, nunca um caminho fixo em /tmp (o delonix teve uma escalada de privilégio local exactamente por escrever /tmp/x.o para o bpftool root carregar).

A checklist#

  • [ ] Workspace com lints partilhados e rustfmt.toml/clippy.toml
  • [ ] Biblioteca + binário fino; erros tipados na biblioteca
  • [ ] Newtypes para tudo o que entra em caminhos, comandos ou nomes
  • [ ] unsafe só onde inevitável, sempre com // SAFETY:
  • [ ] Testes: unidade, doc, integração — e pelo menos um que prove o efeito, não o retorno
  • [ ] CI: fmt, clippy -D warnings, test, deny
  • [ ] --help e mensagens de erro que dizem o remédio
  • [ ] ADR para cada decisão de fronteira
  • [ ] Nada aceite e ignorado em silêncio