delonix-rust tutorial

Projecto: minicontainer · 15 min de leitura

1 · Spec e erros#

Começamos por onde o input entra. Antes de tocar no kernel, o mc tem de responder a duas perguntas: o que me pedem? e posso cumprir?

A spec como tipos#

O config.json da runtime-spec tem dezenas de campos; o minicontainer modela um subconjunto, com serde:

minicontainer/src/spec.rs · spec-structsver no GitHub ↗91 linhas
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Spec {
    pub oci_version: String,
    pub process: Process,
    pub root: Root,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub hostname: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub mounts: Vec<Mount>,
    #[serde(default)]
    pub linux: Linux,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Process {
    #[serde(default)]
    pub terminal: bool,
    pub args: Vec<String>,
    #[serde(default)]
    pub env: Vec<String>,
    #[serde(default = "root_dir")]
    pub cwd: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Root {
    pub path: PathBuf,
    #[serde(default)]
    pub readonly: bool,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Mount {
    pub destination: PathBuf,
    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
    pub kind: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub source: Option<PathBuf>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub options: Vec<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Linux {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub resources: Option<Resources>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub namespaces: Vec<Namespace>,
    /// Presente só para o podermos RECUSAR (não implementamos seccomp).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub seccomp: Option<serde_json::Value>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Namespace {
    #[serde(rename = "type")]
    pub kind: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub path: Option<PathBuf>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct Resources {
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub memory: Option<Memory>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub pids: Option<Pids>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub cpu: Option<Cpu>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Memory {
    /// Bytes; `-1` (ou ausente) = sem limite.
    pub limit: Option<i64>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pids {
    pub limit: i64,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Cpu {
    /// Microssegundos por período.
    pub quota: Option<i64>,
    pub period: Option<u64>,
}

Decisões de design que se repetem em todo o motor:

  • #[serde(default)] nos opcionais — um config.json mínimo tem de bastar.
  • Sem deny_unknown_fields — a spec OCI tem muitos campos que ignoramos legitimamente (user, capabilities, annotations…), e um documento gerado pelo runc spec tem de carregar. É um compromisso consciente: o teste parses_a_real_runc_style_document fixa-o.
  • seccomp: Option<Value> só existe para o poder recusar. Deserializamos o campo para saber se veio, e depois recusamos.

Recusar, não ignorar#

Esta é a regra do capítulo: um campo que o cliente escreve e o sistema ignora é pior do que um campo que não existe. Se um bundle pede seccomp e o runtime o ignora em silêncio, o operador julga o container protegido e não está. Por isso o validate é uma lista de recusas com nome:

minicontainer/src/spec.rs · spec-validatever no GitHub ↗
    /// Fail-closed: recusa o que não sabemos cumprir.
    pub fn validate(&self) -> Result<()> {
        if !self.oci_version.starts_with("1.") {
            return Err(Error::Spec(format!("ociVersion {:?} is not 1.x", self.oci_version)));
        }
        if self.process.args.is_empty() {
            return Err(Error::Spec("process.args must not be empty".into()));
        }
        if !self.process.cwd.starts_with('/') {
            return Err(Error::Spec("process.cwd must be absolute".into()));
        }
        if self.process.terminal {
            return Err(Error::Unsupported("process.terminal=true (no pty support)".into()));
        }
        if self.linux.seccomp.is_some() {
            return Err(Error::Unsupported("linux.seccomp (no filter support)".into()));
        }
        for ns in &self.linux.namespaces {
            if ns.path.is_some() {
                return Err(Error::Unsupported(format!(
                    "linux.namespaces[{}].path (joining an existing namespace)",
                    ns.kind
                )));
            }
        }
        if !self.linux.namespaces.is_empty() {
            for needed in ALWAYS_ISOLATED {
                if !self.linux.namespaces.iter().any(|n| n.kind == needed) {
                    return Err(Error::Unsupported(format!(
                        "linux.namespaces without {needed:?}: this runtime always isolates it"
                    )));
                }
            }
        }
        Ok(())
    }

Repare no que acontece com os namespaces: um bundle que peça menos isolamento do que o runtime dá (por exemplo, sem network) também é recusado — porque este runtime sempre isola, e um bundle que julgue estar na rede do host estaria enganado.

Os testes fixam cada recusa:

#[test]
fn refuses_what_it_cannot_honour() {
    let mut s = base();
    s.linux.seccomp = Some(serde_json::json!({"defaultAction": "SCMP_ACT_ALLOW"}));
    assert!(matches!(s.validate(), Err(Error::Unsupported(_))));
    // … terminal: true, e namespaces com `path` (juntar-se a um existente)
}

E o efeito, visto pelo utilizador:

saída real · medida neste host
$ mc run sc -b bundle
mc: unsupported: linux.seccomp (no filter support)
[exit 2]

O código de saída 2 (não 1) — porque «o pedido é inválido» é uma classe diferente de «rebentou».

Erros: um enum, uma tabela#

minicontainer/src/error.rs · errorver no GitHub ↗
pub type Result<T> = std::result::Result<T, Error>;

#[derive(Debug, thiserror::Error)]
pub enum Error {
    #[error("{context}: {source}")]
    Io { context: String, source: io::Error },

    #[error("invalid JSON in {path}: {source}")]
    Json { path: PathBuf, source: serde_json::Error },

    #[error("system call failed: {0}")]
    Sys(#[from] nix::Error),

    /// O bundle/config.json está mal formado ou incoerente.
    #[error("invalid bundle: {0}")]
    Spec(String),

    /// O bundle pede algo que este runtime NÃO implementa. Recusa-se — nunca se ignora.
    #[error("unsupported: {0}")]
    Unsupported(String),

    #[error("digest mismatch for {what}: expected {expected}, got {actual}")]
    Digest { what: String, expected: String, actual: String },

    #[error("unsafe path refused: {0}")]
    UnsafePath(PathBuf),

    #[error("no such container: {0}")]
    NotFound(String),

    #[error("container already exists: {0}")]
    Conflict(String),

    #[error("container {id} is {status}, expected {expected}")]
    WrongState { id: String, status: String, expected: &'static str },

    #[error("cgroup: {0}")]
    Cgroup(String),

    #[error("container setup failed: {0}")]
    Setup(String),
}

impl Error {
    /// Constrói um `Error::Io` com contexto (o caminho ou a acção que falhou).
    pub fn io(context: impl Into<String>, source: io::Error) -> Self {
        Self::Io { context: context.into(), source }
    }

    /// Classe de saída, ao estilo do delonix: «não existe» (4) e «conflito» (5)
    /// distinguem-se de «rebentou» (1) sem ninguém ter de ler a mensagem.
    pub fn exit_code(&self) -> u8 {
        match self {
            Self::NotFound(_) => 4,
            Self::Conflict(_) | Self::WrongState { .. } => 5,
            Self::Spec(_) | Self::Unsupported(_) => 2,
            _ => 1,
        }
    }
}

/// Extensão para anexar contexto a um `io::Result` sem repetir `map_err` em todo o lado.
pub trait IoContext<T> {
    fn ctx(self, context: impl FnOnce() -> String) -> Result<T>;
}

impl<T> IoContext<T> for std::result::Result<T, io::Error> {
    fn ctx(self, context: impl FnOnce() -> String) -> Result<T> {
        self.map_err(|e| Error::io(context(), e))
    }
}

Três coisas para reparar:

  1. #[from] nix::Error — o operador ? converte automaticamente erros de chamadas de sistema. O thiserror gera o From.
  2. Error::io(contexto, source) e o trait de extensão .ctx(|| …) — um io::Error sozinho diz «No such file or directory» sem dizer qual ficheiro. Acrescentar o contexto no sítio onde se sabe (reading /path/config.json) custa uma linha e poupa uma hora de depuração.
  3. exit_code() num só sítio. Os códigos que vês nas saídas dos capítulos seguintes — 4 para «não existe», 5 para «já existe» ou «estado errado», 2 para spec inválida — vêm todos desta função:
Situação Erro Saída
mc state naoexiste NotFound 4
mc create com um id já usado Conflict 5
mc delete de um container vivo WrongState 5
bundle com seccomp Unsupported 2
tudo o resto — 1
o workload faz exit 7 — 7 (propagado)

Armadilha: o código do workload não é o do runtime

mc run devolve o código de saída do processo do container (7, 127, 137…), não uma das classes acima. É a regressão mais fácil de introduzir: se o run passasse por Error::exit_code, um exit 4 do utilizador confundir-se-ia com «container não existe». O teste propagates_the_exit_code_of_the_workload guarda-o.

Validar identificadores#

O id do container entra num caminho de disco (<estado>/<id>/) e num nome de cgroup (mc-<id>). Lista branca, não «limpeza»:

minicontainer/src/fsutil.rs · valid-idver no GitHub ↗
/// Identificadores de container: entram em caminhos de disco e de cgroup, logo lista branca.
pub fn valid_id(id: &str) -> Result<()> {
    let ok = !id.is_empty()
        && id.len() <= 64
        && !id.starts_with(['-', '.'])
        && id.bytes().all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'_' | b'-'));
    if ok { Ok(()) } else { Err(Error::Spec(format!("invalid container id {id:?}"))) }
}

O teste percorre os casos hostis — "", "..", "-x", "a/b", "a b", 65 caracteres.

Verifica#

cargo test -p minicontainer spec::        # 4 testes
cargo test -p minicontainer fsutil::      # 4 testes

Próximo: com o input validado, a primeira coisa que o runtime faz com uma imagem — desempacotá-la.