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 — umconfig.jsonmínimo tem de bastar.- Sem
deny_unknown_fields— a spec OCI tem muitos campos que ignoramos legitimamente (user,capabilities,annotations…), e um documento gerado pelorunc spectem de carregar. É um compromisso consciente: o testeparses_a_real_runc_style_documentfixa-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:
/// 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:
$ 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#
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:
#[from] nix::Error— o operador?converte automaticamente erros de chamadas de sistema. Othiserrorgera oFrom.Error::io(contexto, source)e o trait de extensão.ctx(|| …)— umio::Errorsozinho 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.exit_code()num só sítio. Os códigos que vês nas saídas dos capítulos seguintes —4para «não existe»,5para «já existe» ou «estado errado»,2para 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»:
/// 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.