Rust essencial#
Não vais aprender Rust inteiro aqui — vais aprender o suficiente para ler o motor e escrever a tua primeira contribuição. Cada exemplo abaixo vive em examples/src/ch01_essencial.rs e corre em CI (cargo test -p examples).
Porque Rust para um runtime de containers?
Um runtime corre como utilizador privilegiado sobre input hostil (imagens, manifestos, nomes) e fala com o kernel por chamadas de sistema. Rust dá-te segurança de memória sem garbage collector (sem pausas, sem runtime a carregar), erros que têm de ser tratados, e um sistema de tipos capaz de tornar estados inválidos impossíveis de escrever. O delonix escolheu-o por isso — e por ser um único binário estático, sem daemon.
Ownership e empréstimos#
Cada valor tem um dono. Passar por valor move; passar por referência (&) empresta. O compilador prova que nunca há dois donos, nem uma referência a algo já libertado — a classe de bugs (use-after-free, double free) que domina as CVEs de runtimes em C.
/// `&str` empresta: quem chama continua dono da `String`.
pub fn is_valid_name(name: &str) -> bool {
!name.is_empty() && name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
}
/// `String` por valor MOVE: depois da chamada o chamador já não a pode usar.
pub fn into_label(name: String) -> String {
format!("delonix.io/name={name}")
}Boa prática
Recebe &str e &[T], não &String nem &Vec<T>: aceitam mais tipos e não custam nada. Só recebe String por valor quando a função precisa de ficar dona (guardar num struct, mover para uma thread).
Para mutar, &mut T é exclusivo: enquanto existe, mais ninguém pode ler nem escrever. É por isto que, em Rust, «corridas de dados» são erro de compilação.
Enums e match exaustivo#
Um enum de Rust é uma soma de variantes que carregam dados. Modela um estado como este e o compilador obriga-te a tratar todos os casos:
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Status {
Created,
Running { pid: i32 },
Stopped { exit_code: i32 },
}
impl Status {
/// `match` **exaustivo**: se acrescentares uma variante, isto deixa de compilar até
/// decidires o que fazer com ela — é o que substitui os `default:` silenciosos de outras linguagens.
pub fn describe(&self) -> String {
match self {
Self::Created => "created".to_owned(),
Self::Running { pid } => format!("running (pid {pid})"),
Self::Stopped { exit_code: 0 } => "exited cleanly".to_owned(),
Self::Stopped { exit_code } => format!("exited with {exit_code}"),
}
}
}Se acrescentares Paused a Status, describe deixa de compilar até decidires o que fazer. Compara com um switch com default: que engole o caso novo em silêncio.
No delonix
O Status do container é um enum, e o motor vai mais longe: o typestate faz das transições ilegais erros de compilação.
Result, Option e o operador ?#
Rust não tem excepções. Uma função que pode falhar devolve Result<T, E>; uma que pode não ter valor devolve Option<T>. O ? propaga o erro ao chamador:
#[derive(Debug, PartialEq, Eq)]
pub enum ParseError {
Empty,
NotANumber(String),
Negative(i64),
}
/// Converte `"64M"`, `"2G"`, `"512"` em bytes. Cada `?` devolve o erro ao chamador.
pub fn parse_size(s: &str) -> Result<u64, ParseError> {
let s = s.trim();
if s.is_empty() {
return Err(ParseError::Empty);
}
let (digits, mult) = match s.chars().last() {
Some('K') => (&s[..s.len() - 1], 1u64 << 10),
Some('M') => (&s[..s.len() - 1], 1 << 20),
Some('G') => (&s[..s.len() - 1], 1 << 30),
_ => (s, 1),
};
let n: i64 = digits.parse().map_err(|_| ParseError::NotANumber(s.to_owned()))?;
let n = u64::try_from(n).map_err(|_| ParseError::Negative(n))?;
Ok(n.saturating_mul(mult)) // saturating: input hostil não pode dar overflow silencioso
}Repara em três decisões que vais ver por todo o código do motor:
- Erros como valores (
ParseError), não strings — o chamador pode fazermatche decidir. saturating_mulem vez de*: input hostil (9999999999999G) não dá volta ao inteiro em silêncio. O delonix apanhou um bug real desta família —as u64sobre umf64satura em Rust, e uma quota de «99999999999t» tornou-seu64::MAX, ou seja, quota nenhuma, com oinspecta mostrá-la como definida.- Nada de
unwrap()em código de produção — um pânico num runtime derruba a máquina de um cliente. (Nos testes,unwrapé aceitável: o pânico é a falha.)
Traits: o padrão «backend»#
Um trait é uma interface. O motor usa-o para falar com hypervisors sem if provider == "libvirt" espalhado pelo código:
/// Mesma forma que o `VmBackend` do delonix: o motor fala com a *interface*, e cada
/// hypervisor é uma implementação — nunca um `if provider == "libvirt"` espalhado.
pub trait Backend {
fn id(&self) -> &'static str;
fn boot(&self, name: &str) -> Result<u32, String>;
}
#[derive(Debug)]
pub struct Local;
#[derive(Debug)]
pub struct Remote {
pub endpoint: String,
}
impl Backend for Local {
fn id(&self) -> &'static str {
"local"
}
fn boot(&self, _name: &str) -> Result<u32, String> {
Ok(1)
}
}
impl Backend for Remote {
fn id(&self) -> &'static str {
"remote"
}
fn boot(&self, name: &str) -> Result<u32, String> {
if self.endpoint.is_empty() {
return Err(format!("cannot boot {name}: no endpoint"));
}
Ok(2)
}
}
/// Genérico sobre o trait (despacho estático, sem custo) …
pub fn boot_static<B: Backend>(b: &B, name: &str) -> Result<u32, String> {
b.boot(name)
}
/// … ou por `dyn` quando o backend só se conhece em runtime (um registo de backends).
pub fn boot_all(backends: &[Box<dyn Backend>], name: &str) -> Vec<(&'static str, Result<u32, String>)> {
backends.iter().map(|b| (b.id(), b.boot(name))).collect()
}Dois modos de usar, com trade-offs diferentes:
Genérico fn f<B: Backend> |
Objecto Box<dyn Backend> |
|
|---|---|---|
| Despacho | estático (inlined, custo zero) | dinâmico (vtable) |
| Quando | o tipo conhece-se em compilação | escolhido em runtime (um registo de backends) |
| Custo | um binário maior (monomorfização) | uma indirecção por chamada |
E o código real, no motor — o trait VmBackend, que faz o mesmo com Cloud Hypervisor, libvirt e Proxmox:
/// The virtualization mechanism behind a microVM. Allows having Cloud
/// Hypervisor and libvirt/KVM side by side (chosen per VM).
pub trait VmBackend {
/// Stable identifier persisted in the [`Vm`].
fn id(&self) -> &'static str;
/// `true` if the backend has the required tools installed.
fn available(&self) -> bool;
/// Creates the network (if applicable) and boots the VM from the `overlay`. The overlay
/// creation and idempotency are handled by [`create`]. `on` receives the
/// sub-stages (network/define/start) for a progress UI.
fn boot(
&self,
vmdir: &Path,
cfg: &VmConfig,
overlay: &str,
on: &dyn Fn(CreateStage),
) -> delonix_model::Result<Boot>;
/// Is the VM still alive?
fn is_running(&self, vm: &Vm) -> bool;
/// Current IP of the VM (may change/resolve later via DHCP).
fn ip(&self, vm: &Vm) -> Option<String>;
/// Is [`VmBackend::ip`] a PREDICTION rather than an OBSERVATION?
///
/// Default `false`: libvirt reads a real DHCP lease, so an address there is
/// evidence that the guest booted far enough to ask for one. Cloud
/// Hypervisor overrides it — its address is computed from the MAC before
/// the guest runs at all, so it is evidence of nothing.
///
/// Whoever waits for a boot needs this to know when "it has an IP" is an
/// answer and when it is only an arithmetic identity. It lives on the
/// backend rather than in a `backend.contains("cloud-hypervisor")` at the
/// call site for the reason ADR-0008 gives: the knowledge belongs to the
/// backend that does the predicting.
fn ip_is_predicted(&self) -> bool {
false
}
// … (excerto)Repara no ip_is_predicted: um método com implementação por omissão, sobrescrito só pelo backend cujo IP é calculado e não observado. O conhecimento fica onde pertence — no backend — em vez de um if no sítio da chamada.
Módulos, crates e workspaces#
- Módulo (
mod) — organização dentro de um crate; a visibilidade por omissão é privada. - Crate — a unidade de compilação (biblioteca ou binário).
- Workspace — vários crates com um só
Cargo.locke um sótarget/. O delonix tem 22, e uma tabela imposta por CI diz quem pode depender de quem — ver Anatomia do delonix.
# Cargo.toml (raiz) — como este tutorial organiza os seus dois crates
[workspace]
resolver = "3"
members = ["minicontainer", "examples"]
Verifica o que aprendeste#
cargo test -p examples ch01 # 4 testes — os que acabaste de ler
Antes de continuares, tenta (sem espreitar) prever o que faz parse_size("-1") e porquê parse_size("9999999999999G") não dá pânico. As respostas estão nos testes.
Para ir mais longe
O manual do contribuidor do próprio repositório tem uma cartilha de Rust dirigida ao motor: docs/dev/rust-primer.md. E o livro oficial — The Rust Programming Language — é a referência canónica para tudo o que aqui só se tocou.