delonix-rust tutorial

Fundamentos · 30 min de leitura

Rust idiomático#

Saber a sintaxe não chega: o que separa código Rust de sistemas bom é fazer o compilador trabalhar para ti. A ideia central deste capítulo, e de todo o delonix: parse, don't validate — valida uma vez, na fronteira, e a partir daí o tipo prova que o valor é válido.

Newtype: validar uma vez, à entrada#

Um id de container entra em caminhos de disco, nomes de cgroup e argumentos de ssh. Se for uma String solta, todo o código a jusante tem de desconfiar dele — e basta um esquecer-se para haver um path traversal. Com um newtype, a validação acontece uma vez e o tipo carrega a garantia:

examples/src/ch02_idiomatico.rs · newtypever no GitHub ↗
/// Um id de container **validado uma vez**, à entrada. Todo o código a jusante recebe
/// `ContainerId` e não precisa de re-validar — e não há forma de construir um inválido.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct ContainerId(String);

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum IdError {
    #[error("container id must not be empty")]
    Empty,
    #[error("container id too long ({0} > 64)")]
    TooLong(usize),
    #[error("invalid character {0:?} in container id")]
    BadChar(char),
    #[error("container id must not start with '-' or '.'")]
    BadStart,
}

impl TryFrom<&str> for ContainerId {
    type Error = IdError;

    fn try_from(s: &str) -> Result<Self, IdError> {
        if s.is_empty() {
            return Err(IdError::Empty);
        }
        if s.len() > 64 {
            return Err(IdError::TooLong(s.len()));
        }
        if s.starts_with(['-', '.']) {
            return Err(IdError::BadStart);
        }
        if let Some(c) = s.chars().find(|c| !(c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-'))) {
            return Err(IdError::BadChar(c));
        }
        Ok(Self(s.to_owned()))
    }
}

impl ContainerId {
    pub fn as_str(&self) -> &str {
        &self.0
    }
}

ContainerId tem o campo privado e só se constrói por TryFrom. Não há forma de obter um id inválido — logo nenhuma função que o receba precisa de o re-validar.

Um bug real desta classe

O delonix já teve um path traversal por nome de VM (metadata.name: "../../.ssh/authorized_keys" escrevia fora do directório de estado) e outro por --name registry.npmjs.org a sequestrar o DNS interno. A correcção nos dois casos foi a mesma: validar na fronteira do motor (valid_vm_name, valid_container_name), com lista branca de caracteres — nunca «limpar» o input.

Typestate: transições ilegais que não compilam#

Em vez de um campo status verificado em runtime, codifica o estado no tipo. Cada transição consome self, por isso a fase antiga deixa de existir:

examples/src/ch02_idiomatico.rs · typestatever no GitHub ↗
#[derive(Debug)]
pub struct Created;
#[derive(Debug)]
pub struct Running;
#[derive(Debug)]
pub struct Stopped;

/// O parâmetro `S` é o estado. Cada método só existe no estado onde faz sentido, e
/// **consome** `self`: a fase antiga deixa de existir.
///
/// ```
/// use examples::ch02_idiomatico::{Container, ContainerId};
/// let id = ContainerId::try_from("web").unwrap();
/// let c = Container::new(id).start(4242).stop(0);
/// assert_eq!(c.exit_code(), 0);
/// ```
///
/// Isto **não compila** (`stop` só existe em `Container<Running>`):
///
/// ```compile_fail
/// use examples::ch02_idiomatico::{Container, ContainerId};
/// let c = Container::new(ContainerId::try_from("web").unwrap());
/// c.stop(0);
/// ```
///
/// Nem isto (`c` foi consumido pela primeira transição):
///
/// ```compile_fail
/// use examples::ch02_idiomatico::{Container, ContainerId};
/// let c = Container::new(ContainerId::try_from("web").unwrap());
/// let _r = c.start(1);
/// c.start(2);
/// ```
#[derive(Debug)]
pub struct Container<S> {
    id: ContainerId,
    pid: i32,
    exit_code: i32,
    _state: PhantomData<S>,
}

impl Container<Created> {
    pub fn new(id: ContainerId) -> Self {
        Self { id, pid: 0, exit_code: 0, _state: PhantomData }
    }
    pub fn start(self, pid: i32) -> Container<Running> {
        Container { id: self.id, pid, exit_code: 0, _state: PhantomData }
    }
}

impl Container<Running> {
    pub fn pid(&self) -> i32 {
        self.pid
    }
    pub fn stop(self, exit_code: i32) -> Container<Stopped> {
        Container { id: self.id, pid: self.pid, exit_code, _state: PhantomData }
    }
}

impl Container<Stopped> {
    pub fn exit_code(&self) -> i32 {
        self.exit_code
    }
    pub fn restart(self) -> Container<Created> {
        Container::new(self.id)
    }
}

impl<S> Container<S> {
    pub fn id(&self) -> &ContainerId {
        &self.id
    }
}

Os compile_fail nos comentários de documentação são testes: cargo test --doc confirma que c.stop(0) num container ainda Created não compila. É a mesma técnica que o motor usa no ciclo de vida do container:

delonix-runtime · crates/foundation/delonix-model/src/typestate.rs · linhas 1–60ver no GitHub ↗
//! Typestate of a container's lifecycle (Sprint 5 — Damas: *correctness by
//! construction*). The states are **types**, and the illegal transitions **do not
//! compile** — instead of being caught (or not) at runtime by a `match` over
//! a [`Status`].
//!
//! The model: `Created → Running → Stopped → (restart) Created`. Each transition
//! **consumes** the previous phase, so an obsolete phase cannot be reused.
//!
//! ```
//! use delonix_model::typestate::Phase;
//! use delonix_model::records::Status;
//!
//! let created = Phase::new("abc123");            // Phase<Created>
//! assert_eq!(created.status(), Status::Created);
//! let running = created.start(4242);             // Created → Running
//! assert_eq!(running.pid(), 4242);
//! let stopped = running.stop(0);                 // Running → Stopped
//! assert_eq!(stopped.status(), Status::Stopped);
//! let _again = stopped.restart();                // Stopped → Created (reuses the id)
//! ```
//!
//! The invalid transitions are **compilation errors**, not runtime bugs:
//!
//! ```compile_fail
//! use delonix_model::typestate::Phase;
//! let created = Phase::new("abc123"); // Phase<Created>
//! created.stop(0);                    // ERROR: `stop` only exists on Phase<Running>
//! ```
//!
//! ```compile_fail
//! use delonix_model::typestate::Phase;
//! let running = Phase::new("abc123").start(1); // Phase<Running>
//! running.start(2);                            // ERROR: `start` only exists on Phase<Created>
//! ```
//!
//! ```compile_fail
//! use delonix_model::typestate::Phase;
//! let created = Phase::new("abc123");
//! let _running = created.start(1);
//! created.start(2);                  // ERROR: `created` was consumed by the 1st transition
//! ```

use crate::records::Status;
use std::marker::PhantomData;

/// State: created, still without a `pid`.
pub struct Created;
/// State: running, with a live init `pid`.
pub struct Running;
/// State: terminated, with an exit code.
pub struct Stopped;

/// A **typed** phase of the lifecycle. The `S` parameter is the current state; the
/// transition methods only exist in the phases where they are valid.
pub struct Phase<S> {
    id: String,
    pid: Option<i32>,
    code: Option<i32>,
    _state: PhantomData<S>,
}

Quando usar — e quando não

Typestate brilha quando as fases são poucas, lineares e conhecidas em compilação (ciclo de vida de um objecto que vive num só processo). Não o uses para estado que vem do disco ou da rede (aí o estado só se conhece em runtime: usa um enum e match exaustivo). O delonix usa enum Status persistido e typestate no código que orquestra a transição.

Builder: muitos opcionais, um único ponto de validação#

examples/src/ch02_idiomatico.rs · builderver no GitHub ↗
#[derive(Debug, PartialEq, Eq)]
pub struct RunSpec {
    pub image: String,
    pub memory: Option<u64>,
    pub env: Vec<String>,
    pub read_only: bool,
}

#[derive(Debug, thiserror::Error, PartialEq, Eq)]
pub enum SpecError {
    #[error("an image is required")]
    NoImage,
    #[error("env entry {0:?} is not KEY=VALUE")]
    BadEnv(String),
}

#[derive(Debug, Default)]
pub struct RunSpecBuilder {
    image: Option<String>,
    memory: Option<u64>,
    env: Vec<String>,
    read_only: bool,
}

impl RunSpecBuilder {
    pub fn image(mut self, i: impl Into<String>) -> Self {
        self.image = Some(i.into());
        self
    }
    pub fn memory(mut self, bytes: u64) -> Self {
        self.memory = Some(bytes);
        self
    }
    pub fn env(mut self, kv: impl Into<String>) -> Self {
        self.env.push(kv.into());
        self
    }
    pub fn read_only(mut self, yes: bool) -> Self {
        self.read_only = yes;
        self
    }
    /// Toda a validação vive aqui — `RunSpec` só existe válido.
    pub fn build(self) -> Result<RunSpec, SpecError> {
        let image = self.image.ok_or(SpecError::NoImage)?;
        if let Some(bad) = self.env.iter().find(|e| !e.contains('=')) {
            return Err(SpecError::BadEnv(bad.clone()));
        }
        Ok(RunSpec { image, memory: self.memory, env: self.env, read_only: self.read_only })
    }
}

RunSpec só existe válido: toda a validação vive em build(). Chamar .env("SEM_IGUAL") só falha quando construíres — com uma mensagem que nomeia o valor, não com um pânico três camadas abaixo.

Erros tipados, com classe#

Duas escolas, e ambas têm o seu sítio:

thiserror (bibliotecas) anyhow (aplicações)
Tipo de erro enum fechado, cada variante documentada anyhow::Error opaco
O chamador pode… fazer match e decidir só imprimir/propagar
Usa em crates de motor (delonix-*) o main de uma ferramenta pequena

O motor vai um passo além: o erro tem uma classe que vira código de saída, num único sítio.

delonix-runtime · crates/foundation/delonix-model/src/exitcode.rs · linhas 142–163ver no GitHub ↗
/// The single place an engine error becomes an exit code.
///
/// Pure on purpose: two places deciding the same number is how they start
/// disagreeing, and a table this small is only worth anything if it is the
/// whole truth.
pub fn for_error(e: &Error) -> i32 {
    match e {
        Error::NotFound(_) | Error::VmNotFound(_) => NOT_FOUND,
        Error::NotRunning(_) => NOT_RUNNING,
        Error::Conflict(_) => CONFLICT,
        Error::Unavailable(_) => UNAVAILABLE,
        Error::Timeout(_) => TIMEOUT,
        Error::Coded { inner, .. } => for_error(inner),
        // The KIND is inspected rather than the variant: `Error::Io` is the
        // wrapper every filesystem refusal arrives in, and EACCES inside it is
        // a different answer for the caller than a full disk or a bad path.
        Error::Io(e) if e.kind() == std::io::ErrorKind::PermissionDenied => NO_PERMISSION,
        Error::Io(_) => IO,
        Error::Json(_) | Error::Runtime { .. } => GENERIC,
        Error::Invalid(_) | Error::Registry(_) => GENERIC,
    }
}

Isto não é cosmético. As mensagens são traduzidas (--l18n=pt) e mudam; um script que faça grep 'no such' funciona na máquina onde foi escrito e deixa de classificar num nó em português. O código de saída é contrato: 4 = «não existe», 5 = «conflito», 1 = «rebentou». É o que um reconciliador precisa para decidir cria ou pára. Vais implementar o mesmo no minicontainer (erros).

Armadilha: um match com _ =>

O match da tabela acima é exaustivo de propósito. Se lhe pusesses um _ =>, uma variante nova de Error seria arquivada em «genérico» sem ninguém decidir. Sem o _, o compilador pára e obriga a escolher.

unsafe: pequeno, comentado, isolado#

Um runtime tem de usar unsafe (fork, prctl, ioctl). A disciplina:

  1. Mínimo — o bloco mais pequeno possível, e nunca em torno de lógica.
  2. // SAFETY: — um comentário que diz porque as pré-condições se verificam ali.
  3. Isolado — atrás de uma função segura, para o resto do código não ver unsafe.
// SAFETY: o processo é single-thread neste ponto (nenhuma thread foi lançada), logo
// `fork` é seguro; o filho só chama funções async-signal-safe até ao `exec`/`_exit`.
match unsafe { fork() }? { /* ... */ }

Este comentário é do minicontainer, e a razão é real: fork() num processo multi-thread só deixa viva a thread que chamou, e se outra segurava o lock do malloc, o filho bloqueia para sempre. O delonix pagou isto: o servidor da API Docker era multi-thread e o clone() do arranque de containers bloqueava sob pedidos concorrentes; a correcção foi arrancar por re-exec de um processo novo.

Lint que ajuda

unsafe_op_in_unsafe_fn = "deny" (ver o Cargo.toml do workspace) obriga cada operação insegura, mesmo dentro de uma unsafe fn, a ter o seu próprio bloco unsafe — e portanto o seu próprio SAFETY.

Iteradores em vez de laços com estado#

/// Soma a memória pedida dos containers que vão correr, ignorando os sem limite.
pub fn total_memory(specs: &[RunSpec]) -> u64 {
    specs.iter().filter_map(|s| s.memory).sum()
}

Sem índices, sem acumulador mutável, sem off-by-one. filter_map combina «filtra os None» e «desembrulha os Some». Os iteradores são abstracções de custo zero: o compilador gera o mesmo código que o laço à mão.

Ferramentas que a comunidade espera de ti#

cargo fmt --check                                   # formatação canónica: zero discussões de estilo
cargo clippy --all-targets -- -D warnings           # centenas de lints: apanha bugs, não só estilo
cargo test --doc                                    # os exemplos da documentação são testes
cargo deny check                                    # licenças e advisories das dependências

Neste tutorial, tudo isto corre em CI (.github/workflows/ci.yml) — e é o mesmo conjunto que o delonix impõe antes de aceitar um PR.

Verifica o que aprendeste#

cargo test -p examples ch02        # 3 testes + 1 doctest + 2 compile_fail

Exercício: acrescenta o estado Paused ao typestate (Running → Paused → Running). Que métodos precisas de escrever? Que não deves escrever (por exemplo, stop em Container<Paused>)?