Fundamentos
Introdução ao Rust para esta base de código
Antes de leres: Introdução ao cloud native, cujo vocabulário os exemplos usam, e Rust básico (The Rust Programming Language, capítulos 1–10).
Isto não é um tutorial de Rust. É o subconjunto de Rust de que precisas para ler este repositório,
com cada ideia presa a um ficheiro que podes abrir. Se um conceito for novo para ti, os links
«Ler mais» levam à fonte oficial; volta aqui para ver como o motor o usa. Depois dela consegues
abrir qualquer crate do workspace e seguir os seus tipos de erro, os seus traits, os seus blocos
unsafe e os seus testes sem parares na língua.
Os caminhos são relativos à raiz do repositório. Os símbolos são nomeados para os poderes procurar
com grep — os números de linha ficam de fora de propósito, porque mudam a cada PR.
A toolchain fixada está no rust-toolchain.toml (um channel fixo, mais rustfmt e
clippy). O rustup respeita-o automaticamente, por isso compilas com o mesmo compilador que a CI usa.
3.1 O workspace do Cargo#
O repositório é um único workspace do Cargo: um Cargo.toml na raiz com
[workspace] members = [...] e um Cargo.toml por crate. Aqui importam três convenções:
- Cada caminho e cada versão escreve-se uma vez, na raiz. A raiz tem uma tabela
[workspace.dependencies]que lista tanto os crates do próprio motor (porpath) como todos os crates de terceiros (porversion). Um membro nunca escreve uma versão; escreve:
# crates/contexts/delonix-node/Cargo.toml
[dependencies]
delonix-model = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
libc = { workspace = true }
Um membro pode acrescentar features = [...], e mais nada. O default-features = false vive na
raiz porque um membro não consegue desligar defaults que o workspace ligou.
O scripts/arch_fitness.py (função inline_versions) faz falhar o build se um membro escrever
a sua própria versão.
-
O directório é a camada. Os crates vivem em
crates/foundation/,crates/contexts/,crates/adapters/,crates/providers/,crates/interfaces/e os binários embins/(ADR-0040). Oscripts/arch_fitness.pyrecusa um crate cujo directório não corresponda à sua camada declarada, e uma dependência que aponte contra a direcção permitida. As regras de camada são ensinadas mais à frente no curso, em Arquitectura — Camadas e a direcção permitida. -
Os lints são herdados. A raiz declara
[workspace.lints.clippy]comundocumented_unsafe_blocks = "deny", e cada membro adere com[lints] workspace = true. Todos os blocosunsafelevam por isso um comentário// SAFETY:(ver §3.4).
A raiz define também [workspace.package] (a version, a edition e a license partilhadas), que
os membros consomem como version.workspace = true. A versão não é decoração: ver
Alinhamento da versão para a regra e
Os gates que a CI corre para o gate de versão.
Ler mais: Cargo Book —
Workspaces,
[workspace.dependencies],
[lints],
rust-toolchain.toml.
3.2 Erros: um só Error, e códigos de saída derivados do seu tipo#
Quase todas as funções falíveis do motor devolvem delonix_model::Result<T>, um alias sobre o
enum partilhado definido em crates/foundation/delonix-model/src/error.rs. O enum vive no crate de
fundação puro delonix-model, do qual qualquer outro crate do motor pode depender. Alguns adapters
definem o seu próprio erro e convertem-no neste (§5.2 de
Convenções de código). É construído com
thiserror: #[derive(Error)] gera o Display a partir do atributo
#[error("...")], e #[from] gera impls de From para que o ? converta automaticamente um erro de
nível mais baixo:
// crates/foundation/delonix-model/src/error.rs
#[error("I/O error: {0}")]
Io(#[from] std::io::Error),
As variantes dizem respeito ao que quem chama deve fazer a seguir, não a que subsistema falhou:
NotFound, NotRunning, Conflict, Unavailable (uma ferramenta ou funcionalidade do kernel em
falta), Timeout, Invalid, Registry, Runtime { context, message } para uma syscall falhada, e
por aí fora. Os doc comments de cada variante explicam porque é que ela existe; lê-os antes de
acrescentares uma variante.
Essas variantes tornam-se códigos de saída do processo num só sítio:
crates/foundation/delonix-model/src/exitcode.rs, função for_error. A CLI re-exporta o
módulo (pub use delonix_model::exitcode; em bins/delonix-runtime-bin/src/cmd/mod.rs) e o
bins/delonix-runtime-bin/src/main.rs chama std::process::exit(cmd::exitcode::for_error(&e)).
// crates/foundation/delonix-model/src/exitcode.rs
pub fn for_error(e: &Error) -> i32 {
match e {
Error::NotFound(_) | Error::VmNotFound(_) => NOT_FOUND,
Error::NotRunning(_) => NOT_RUNNING,
// ...
Duas coisas a notar:
- O
matché exaustivo, sem braço_ =>. Acrescentar uma variante aoErrorpára o build nofor_error(e noError::codeemerror.rs) até alguém decidir a sua classe. É um uso deliberado do compilador como lista de verificação. - As mensagens são traduzidas para o operador, por isso os scripts têm de ramificar pelo código de
saída (ou pelo código de máquina estável de
Error::code), nunca pelo texto da mensagem.
Ler mais: The Rust Book —
Recoverable errors with Result,
o operador ?;
Rust by Example — Defining an error type;
thiserror no docs.rs.
3.3 Traits como portas: VmBackend e o registo de backends#
O motor fala com os providers através de traits («portas»), e um provider é uma implementação
de uma delas. O exemplo mais claro é o VmBackend em crates/adapters/delonix-vm/src/lib.rs:
pub trait VmBackend {
fn id(&self) -> &'static str;
fn available(&self) -> bool;
fn boot(&self, vmdir: &Path, cfg: &VmConfig, overlay: &str,
on: &dyn Fn(CreateStage)) -> Result<Boot>;
fn is_running(&self, vm: &Vm) -> bool;
fn ip(&self, vm: &Vm) -> Option<String>;
// ... more methods, many with default bodies
}
Implementações: CloudHypervisorBackend e LibvirtBackend no mesmo ficheiro, e
ProxmoxBackend em crates/providers/delonix-proxmox/src/lib.rs. Os métodos com corpo por
omissão no trait (por exemplo auto_selectable) deixam um backend novo herdar um comportamento
sensato e sobrepor só o que difere.
Os backends são escolhidos em tempo de execução, por isso são tratados como trait objects,
Box<dyn VmBackend>. São criados através de um registo de factories:
// crates/adapters/delonix-vm/src/lib.rs
pub type BackendFactory = Box<dyn Fn() -> Result<Box<dyn VmBackend>> + Send + Sync>;
static BACKENDS: std::sync::OnceLock<std::sync::RwLock<Vec<BackendRegistration>>> =
std::sync::OnceLock::new();
- A factory é uma closure (
dyn Fn), porque um backend remoto precisa de configuração capturada (endpoint, nó, credencial) que um simples ponteirofn()não consegue transportar. Send + Syncé exigido porque a tabela é umstaticde todo o processo; o bound restringe a closure, não o traitVmBackend.- O
OnceLockinicializa a tabela de forma preguiçosa (builtin_backends()semeia os dois backends locais), e oRwLockdeixa oregister_backendacrescentar um terceiro no arranque. O binário fá-lo embins/delonix-runtime-bin/src/cmd/vmbackends.rs(register_configured).
A decisão por trás desta forma é o ADR-0008. O mesmo padrão
«trait + implementações + um só sítio que escolhe» aparece noutros lados (por exemplo a porta
VmNetwork, guardada num OnceLock<Box<dyn VmNetwork>> perto do topo do mesmo ficheiro).
Ler mais: The Rust Book —
Traits,
Trait objects,
Closures,
Send e Sync;
documentação da std — OnceLock.
3.4 unsafe, FFI e syscalls do Linux#
Um motor de containers é sobretudo chamadas de sistema. Este repo chega ao kernel através de três crates:
| Crate | Usado para | Exemplo neste repo |
|---|---|---|
nix |
Wrappers mais ou menos seguros: clone, setns, unshare, pivot_root, fork, mount, sinais |
use nix:: em crates/ |
libc |
Chamadas cruas que o nix não embrulha, ou onde a struct exacta importa |
libc:: em crates/ (peer_uid); libc::flock em crates/ |
rustix |
A nova API de mount (fsopen/fsconfig/fsmount/move_mount) |
fsopen_overlay em crates/ |
Todos os blocos unsafe dizem porque são correctos, ao lado deles (o lint do workspace impõe a
presença do comentário; os revisores impõem a sua veracidade):
// crates/contexts/delonix-node/src/peer_cred.rs
// SAFETY: getsockopt on SO_PEERCRED with a correctly-sized ucred buffer.
let r = unsafe { libc::getsockopt(stream.as_raw_fd(), libc::SOL_SOCKET, libc::SO_PEERCRED, ...) };
Onde o container nasce#
A fn spawn em crates/adapters/delonix-linux/src/lib.rs termina em:
// SAFETY: single-threaded; the child mounts the container and does `exec`.
let cloned = unsafe { clone(cb, &mut stack, flags, Some(Signal::SIGCHLD as i32)) };
O filho corre container_init, que chama setup_rootfs (bind mounts, depois pivot_root),
drop_capabilities, instala o seccomp, e por fim faz execvp. O setns é a forma como o exec
entra nos namespaces de um container existente. Os mapas de uid/gid de um user namespace são escritos
a partir do pai (write_userns_maps, que usa os helpers newuidmap/newgidmap quando disponíveis).
Porque é que fork/clone num processo multi-thread é perigoso#
Depois de um fork ou clone, só a thread que chamou existe no filho, mas o filho herda todos os
locks tal como estavam — incluindo locks seguros por threads que já não existem. Se outra thread
segurava o lock do alocador nesse instante, a primeira alocação do filho bloqueia para sempre. O
fork mitiga parte disto com handlers pthread_atfork; o clone cru não os corre.
Este repo tem duas regras concretas por causa disto, ambas explicadas em comentários que deves ler:
- O
serve docker-apinunca chamaspawndentro do processo. O doc comment acima do helper de re-exec embins/delonix-runtime-bin/src/cmd/dockerapi.rsexplica que o servidor é um runtime tokio multi-thread, por isso re-executa o binário (__apirun <spec.json>) para obter um processo novo de uma só thread, onde a pré-condição doclonevolta a ser verdadeira. - Depois de um
forkcru, só trabalho async-signal-safe. Oreexec_mappede oreexec_mapped_holdemcrates/adapters/delonix-linux/src/lib.rspré-calculam todas as alocações antes do fork. O comentário noreexec_mapped_holdregista também porque usamforkcru em vez destd::process::Command+pre_exec: oCommand::spawnespera que o filho chegue aoexec, e um hookpre_execque bloqueie à espera do pai faz deadlock.
A nova API de mount, e porque existe aqui#
O mount_overlay_if_marked monta a raiz overlay de um container através de rustix::mount::fsopen e
de uma chamada fsconfig_set_string(&fs, "lowerdir+", lower) por camada. O mount(2) clássico mete
todas as opções numa única string data do tamanho de uma página, que o kernel trunca em silêncio
para imagens com muitas camadas. A medição e a decisão estão no
ADR-0037. É um bom exemplo do hábito do repo:
o comentário /// acima de uma syscall pouco óbvia explica a falha que a motivou.
Ler mais: The Rust Book — Unsafe Rust;
o Rustonomicon (sobretudo
FFI); man pages
clone(2),
setns(2),
pivot_root(2),
fork(2),
signal-safety(7),
fsopen(2).
3.5 Serialização: serde, manifestos YAML, schema gerado#
O estado em disco é JSON; os manifestos são YAML. Os dois passam por derives do
serde.
Registos retrocompatíveis. Um registo escrito por um binário mais antigo tem de continuar a
carregar. A regra é: um campo novo leva #[serde(default)], e quando «ausente» tem de significar algo
diferente do default do tipo, passa a Option:
// bins/delonix-runtime-bin/src/cmd/vmimage.rs (VmImage)
#[serde(default)]
pub cloud_init: Option<bool>,
Aqui None (todos os registos escritos antes de o campo existir) é lido como «sim»; um simples bool
teria como default false e mudaria em silêncio o comportamento das imagens antigas. Vais ver o
mesmo raciocínio em crates/contexts/delonix-compute/src/record.rs: o Mount::propagation é um
Option (#[serde(default, skip_serializing_if = "Option::is_none")]), e o Mount::optional é um
simples bool com #[serde(default)], porque false é o que todos os registos mais antigos
significavam.
Manifestos. O bins/delonix-runtime-bin/src/cmd/manifest.rs lê YAML multi-documento com
serde_yaml::Deserializer::from_str(text) para ManifestDoc, cujo spec fica como um
serde_yaml::Value cru até o Kind dono o desserializar no seu spec tipado.
Schema gerado. Os tipos de spec derivam schemars::JsonSchema (por exemplo PodSpec em
crates/contexts/delonix-compute/src/pod.rs), e o bins/delonix-runtime-bin/src/cmd/schema.rs
gera a partir deles o JSON Schema publicado (ADR-0007).
Repara no comentário lá: o schemars respeita #[serde(rename)] mas não #[serde(alias)].
Ler mais: serde.rs —
atributos de campo (default, skip_serializing_if, alias);
serde_yaml; schemars.
3.6 A CLI: clap derive e saída traduzida#
O binário delonix é o bins/delonix-runtime-bin. O seu nível de topo é um derive do
clap em src/main.rs: #[derive(Parser)] struct Cli, que contém uma
flag global --l18n e #[command(subcommand)] cmd: Cmd, onde enum Cmd é um
#[derive(Subcommand)]. Cada grupo tem o seu próprio módulo e enum em src/cmd/ — por exemplo
pub enum ContainerCmd em src/cmd/container.rs.
Os doc comments (///) nas variantes e nos campos tornam-se o texto do --help. Vais ver
#[allow(clippy::large_enum_variant)] nos enums de comandos, com um comentário a explicar porquê: são
lidos uma vez por invocação, por isso pôr as variantes em Box não compraria nada.
As strings na fonte são em inglês; o português vem de um catálogo. O src/cmd/po.rs embute
data/pt.po com include_str! e expõe:
po::t("already exists, nothing to do")— uma string fixa;po::tf("port {port} is taken by '{owner}'", &[("port", &hp), ("owner", &ow)])— um template com placeholders nomeados, porque uma tradução pode reordená-los;po::translate_help, que reescreve o texto de ajuda do clap depois de opo::peek_langter decidido a língua antes do parse.
Uma string nova visível ao utilizador é inglês no código mais uma entrada em data/pt.po. A política
de língua (LANG-01) e o seu gate são tratados em Fluxo de contribuição.
Ler mais: tutorial do clap derive;
std — include_str!.
3.7 Async e gRPC — e porque é que a maior parte do motor é síncrona#
O núcleo do motor é síncrono: a CLI arranca, faz o seu trabalho com syscalls bloqueantes e I/O de ficheiros, e sai. Não há daemon nem runtime async ambiente. O código async só aparece nas interfaces que servem um protocolo:
- Servidor CRI —
crates/interfaces/delonix-cri. Os stubs gRPC são gerados em tempo de build: obuild.rschamatonic_build::configure()...compile_protos(&["proto/api.proto"], &["proto"]). Isso precisa doprotocinstalado (a CI instala oprotobuf-compiler); umprotocem falta é a falha mais comum no primeiro build. Oserve_blockingemsrc/lib.rscria o seu próprio runtimetokio::runtime::Builder::new_multi_thread(), e os handlers empurram o trabalho bloqueante do motor para fora dos workers async através do helperblockingemsrc/runtime_svc.rs— senão umcloneou uma chamada aodelonixpela shell bloquearia os workers do tokio. - Reverse proxy L7 — o
bins/delonix-runtime-bin/src/cmd/ingress_proxy.rsusahyperdirectamente (hyper::service::service_fn) sobre o seu próprio runtime tokio. - Servidor MCP — o
crates/interfaces/delonix-mcpusarmcpsobre stdio (rmcp::transport::io::stdio). - Telemetria — o
crates/adapters/delonix-telemetry/src/telemetry.rsexporta spans OpenTelemetry a partir de uma thread dedicada com um cliente HTTP bloqueante, precisamente para que a CLI síncrona não precise de um runtime.
O contrato de nó em proto/delonix/node/v1 é uma API protobuf separada com o seu próprio gate
(scripts/contract_gate.py, buf); ver Compilar e testar.
Ler mais: Asynchronous Programming in Rust;
tutorial do Tokio;
tonic e tonic-build;
instalação do Protocol Buffers.
3.8 Concorrência e estado partilhado#
Não há base de dados. O estado são ficheiros JSON debaixo da raiz de estado, e vários processos
(duas invocações da CLI, o servidor CRI, um supervisor) podem tocar no mesmo registo ao mesmo tempo. O
padrão para qualquer leitura-modificação-escrita é o update com uma closure, sob um flock
exclusivo:
// crates/adapters/delonix-state/src/store.rs (Store::update)
let id = self.load(id_or_name)?.id;
let _lock = FileLock::acquire(&self.lock_path(&id))?;
// Re-read UNDER the lock ...
let mut c = self.load(&id)?;
if !f(&mut c) { return Ok(c); }
self.save(&c)?;
O JsonStore<T>::update no mesmo ficheiro é a versão genérica para outros tipos de registo. Ambos
recusam correr quando o lock não pode ser obtido (Error::Lock), em vez de continuarem sem lock.
(O SecretStore::update em secret.rs é a excepção: o lock dele é best-effort.) Regras que
daí decorrem:
- Nunca faças
load→ mutar →saveà mão para um registo que outro processo possa escrever; vais perder actualizações. Usa oupdate. - Volta a ler dentro do lock. O valor que carregaste antes pode já estar desactualizado.
- O
FileLockliberta o lock noDrop— o padrão RAII.
Dentro de um único processo, o estado partilhado usa os tipos padrão: um
Arc<std::sync::RwLock<Arc<Vec<Route>>>> guarda a tabela de rotas trocável a quente do proxy
(SharedRoutes em cmd/ingress_proxy.rs), e o dashboard partilha a sua amostra lenta através de um
Arc<Mutex<...>> (cmd/dash.rs).
Uma ideia relacionada que vais encontrar em crates/foundation/delonix-model/src/typestate.rs: o
padrão typestate, em que os estados do ciclo de vida são tipos e as transições ilegais não
compilam (os seus doc tests incluem exemplos compile_fail).
Ler mais: The Rust Book —
Shared-state concurrency,
Drop;
flock(2);
std — Arc,
RwLock.
3.9 Testes#
- Os testes unitários vivem ao lado do código, num
#[cfg(test)] mod tests { ... }no fundo do ficheiro. Muitas funções puras existem precisamente para que uma decisão possa ser testada como dados (por exemploreconcile::planemcrates/contexts/delonix-stack/src/reconcile.rs). - Os testes de integração vivem em
crates/<layer>/<crate>/tests/*.rse usam só a API pública do crate. Exemplo: ocrates/interfaces/delonix-cri/tests/grpc_status.rsarranca o servidor CRI num socket Unix e fala com ele através do cliente gRPC gerado (é por isso que obuild.rsdefinebuild_client(true)). Os testes emcrates/providers/*/tests/live.rsprecisam de um provider real e são opt-in. - Os testes de propriedade usam
proptest: vercrates/adapters/delonix-sdn/tests/ip_invariants.rs(proptest! { ... }). - Os doc tests também correm — incluindo blocos
compile_failcomo os dotypestate.rs. - Os benchmarks usam
criterion(crates/adapters/delonix-oci/benches/). - Nomes. Os nomes dos testes descrevem o comportamento que está a ser provado. Muitos testes
existentes têm nomes em português; os identificadores novos são em inglês (LANG-01, imposto como
ratchet pelo
scripts/lang_ratchet.py). - Os testes não podem tocar no estado real do host. Obtém um directório temporário e passa-o (os stores recebem um caminho de raiz) em vez de chamares código que resolve a raiz de estado real. Tudo o que precise de namespaces reais, cgroups ou um holder de rede pertence à validação ao vivo/E2E descrita em Compilar e testar.
Ler mais: The Rust Book — Writing tests, Test organization; rustdoc — Documentation tests.
3.10 Ferramentas que a CI impõe#
| Ferramenta | Comando da CI (de .github/workflows/ci.yml) |
O que apanha |
|---|---|---|
| rustfmt | cargo fmt --all --check |
deriva de formatação |
| clippy | cargo clippy --workspace --all-targets --locked -- -D warnings |
qualquer aviso, incluindo undocumented_unsafe_blocks |
| testes | cargo test --workspace --locked --no-fail-fast |
regressões |
| cargo-deny | EmbarkStudios/ com deny.toml |
avisos RUSTSEC, licenças, fontes de crates |
O repo tem também gates em Python (scripts/arch_fitness.py, scripts/lang_ratchet.py e outros).
A lista completa e a forma de correr cada um localmente estão em Compilar e testar.
Ler mais: Clippy, rustfmt, cargo-deny.
Seguinte: Preparar o teu ambiente — um host que consegue compilar a árvore e correr os caminhos ao vivo, e as armadilhas do host que parecem bugs do motor.