delonix-rust tutorial

Fundamentos · 25 min de leitura

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.

examples/src/ch01_essencial.rs · ownershipver no GitHub ↗
/// `&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:

examples/src/ch01_essencial.rs · enumsver no GitHub ↗
#[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:

examples/src/ch01_essencial.rs · resultver no GitHub ↗
#[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:

  1. Erros como valores (ParseError), não strings — o chamador pode fazer match e decidir.
  2. saturating_mul em vez de *: input hostil (9999999999999G) não dá volta ao inteiro em silêncio. O delonix apanhou um bug real desta família — as u64 sobre um f64 satura em Rust, e uma quota de «99999999999t» tornou-se u64::MAX, ou seja, quota nenhuma, com o inspect a mostrá-la como definida.
  3. 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:

examples/src/ch01_essencial.rs · traitsver no GitHub ↗
/// 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:

delonix-runtime · crates/adapters/delonix-vm/src/lib.rs · linhas 814–850ver no GitHub ↗
/// 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.lock e 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.