diff --git a/src/agent/src/addon.rs b/src/agent/src/addon.rs new file mode 100644 index 0000000000..f825deb482 --- /dev/null +++ b/src/agent/src/addon.rs @@ -0,0 +1,803 @@ +// Copyright (c) 2026 NVIDIA Corporation +// +// SPDX-License-Identifier: Apache-2.0 +// + +//! Generic, data-driven contract for guest components shipped in addon images. +//! +//! An addon image is mounted read-only at `/run/kata-addons//` and ships a +//! manifest (`etc/kata-addons/components.toml`) describing the components it +//! provides. The agent consumes that manifest instead of carrying hard-coded +//! knowledge about any particular addon, so a new bundle can be introduced +//! without changing agent code as long as it only relies on the documented +//! substitution variables below. +//! +//! Manifest schema (TOML): +//! +//! ```toml +//! schema_version = 1 +//! +//! # Path-only components, looked up by id and resolved relative to the addon root. +//! [paths] +//! "ocicrypt-config" = "etc/ocicrypt_config.json" +//! "pause-bundle" = "pause_bundle" +//! +//! # Launchable processes, started in declaration order. A process is launched +//! # only when its `level` is <= the level requested by the agent configuration. +//! [[process]] +//! id = "attestation-agent" +//! level = 1 +//! path = "usr/local/bin/attestation-agent" +//! args = ["--attestation_sock", "${aa_attestation_uri}"] +//! optional_args = [{ when = "initdata_toml_path", args = ["--initdata-toml", "${initdata_toml_path}"] }] +//! config = "${aa_config_path}" +//! wait_socket = "${aa_attestation_socket}" +//! timeout_secs = "${launch_process_timeout}" +//! ``` +//! +//! `${name}` tokens in `args`, `optional_args`, `config`, `env` values, +//! `wait_socket` and `timeout_secs` are substituted from a context map supplied +//! by the agent at runtime. Referencing an unknown variable is a hard error +//! (fail-closed). An `optional_args` group is included only when the variable +//! named by `when` is present and non-empty in the context. + +use anyhow::{anyhow, bail, Context, Result}; +use serde::Deserialize; +use std::collections::HashMap; +use std::fs; +use std::path::{Component, Path, PathBuf}; + +pub const COCO_COMPONENT_OCICRYPT_CONFIG: &str = "ocicrypt-config"; +pub const COCO_COMPONENT_PAUSE_BUNDLE: &str = "pause-bundle"; +pub const COCO_ADDON_NAME: &str = "coco"; + +const ADDONS_ROOT: &str = "/run/kata-addons"; +const COMPONENTS_MANIFEST_REL_PATH: &str = "etc/kata-addons/components.toml"; + +#[derive(Debug, Deserialize)] +struct AddonManifest { + #[serde(default = "default_schema_version")] + schema_version: u32, + #[serde(default)] + paths: HashMap, + #[serde(default)] + process: Vec, +} + +fn default_schema_version() -> u32 { + 1 +} + +#[derive(Debug, Deserialize, Clone)] +struct RawProcessSpec { + id: String, + #[serde(default)] + level: u32, + // `path` is optional: a process either declares its `path` directly, or + // provides `variants` and lets `select` choose which one is active. + #[serde(default)] + path: Option, + #[serde(default)] + select: Option, + #[serde(default)] + variants: HashMap, + #[serde(default)] + args: Vec, + #[serde(default)] + optional_args: Vec, + #[serde(default)] + config: Option, + #[serde(default)] + env: HashMap, + #[serde(default)] + wait_socket: Option, + #[serde(default)] + timeout_secs: Option, +} + +#[derive(Debug, Deserialize, Clone)] +struct OptionalArgs { + when: String, + args: Vec, +} + +/// A selectable flavour of a process. The active variant contributes its +/// `path` (overriding the process-level `path`, if any) and merges its extra +/// `args` (appended) and `env` (overriding on key conflicts) onto the base +/// process definition. This lets a single addon image ship several flavours of +/// the same component (e.g. a stock attestation-agent and an NVIDIA-attester +/// build) and have the consumer pick one without forking the image. +#[derive(Debug, Deserialize, Clone, Default)] +struct Variant { + #[serde(default)] + path: Option, + #[serde(default)] + args: Vec, + #[serde(default)] + env: HashMap, +} + +/// Default variant name used when the `select` value is empty or unset. +const DEFAULT_VARIANT: &str = "default"; + +/// A fully resolved description of a process the agent should launch. Paths are +/// absolute, all `${var}` tokens have been substituted, and the gating decision +/// has already been made. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LaunchSpec { + pub id: String, + pub path: PathBuf, + pub args: Vec, + pub config: Option, + pub env: Vec<(String, String)>, + pub wait_socket: Option, + pub timeout_secs: u64, +} + +fn validate_addon_name(addon_name: &str) -> Result<()> { + if addon_name.is_empty() { + bail!("invalid empty addon name"); + } + if addon_name.contains('/') + || Path::new(addon_name).components().any(|c| { + matches!( + c, + Component::ParentDir | Component::RootDir | Component::Prefix(_) + ) + }) + { + bail!("invalid addon name '{}'", addon_name); + } + Ok(()) +} + +fn addon_root(addons_root: &Path, addon_name: &str) -> Result { + validate_addon_name(addon_name)?; + Ok(addons_root.join(addon_name)) +} + +fn manifest_path(addons_root: &Path, addon_name: &str) -> Result { + Ok(addon_root(addons_root, addon_name)?.join(COMPONENTS_MANIFEST_REL_PATH)) +} + +/// Absolute mount root of an addon (e.g. `/run/kata-addons/coco`). Exposed so +/// callers can publish it as a manifest substitution variable. +pub fn addon_mount_root(addon_name: &str) -> Result { + addon_root(Path::new(ADDONS_ROOT), addon_name) +} + +/// Resolve a relative path declared inside a manifest against the addon root, +/// rejecting absolute paths and `..` traversal. +fn resolve_rel_path(root: &Path, rel_path: &str, what: &str) -> Result { + let rel_path = rel_path.trim(); + if rel_path.is_empty() { + bail!("empty path for {}", what); + } + if Path::new(rel_path) + .components() + .any(|c| matches!(c, Component::ParentDir | Component::Prefix(_))) + { + bail!("invalid path '{}' for {}", rel_path, what); + } + Ok(root.join(rel_path.trim_start_matches('/'))) +} + +fn load_manifest(addons_root: &Path, addon_name: &str) -> Result> { + let root = addon_root(addons_root, addon_name)?; + if !root.exists() { + // The addon is not mounted at all; callers fall back to legacy paths or + // their built-in defaults. + return Ok(None); + } + + let manifest_path = manifest_path(addons_root, addon_name)?; + if !manifest_path.exists() { + // A mounted addon without a manifest is a packaging bug: fail closed so + // the misconfiguration is surfaced rather than silently ignored. + bail!( + "addon '{}' is mounted but manifest is missing at {}", + addon_name, + manifest_path.display() + ); + } + + let data = fs::read_to_string(&manifest_path) + .with_context(|| format!("failed to read addon manifest {}", manifest_path.display()))?; + let manifest: AddonManifest = toml::from_str(&data) + .with_context(|| format!("failed to parse addon manifest {}", manifest_path.display()))?; + + if manifest.schema_version != 1 { + bail!( + "unsupported addon manifest schema_version {} in {}", + manifest.schema_version, + manifest_path.display() + ); + } + + Ok(Some(manifest)) +} + +fn resolve_component_path_in_root( + addons_root: &Path, + addon_name: &str, + component_id: &str, + legacy_path: &str, +) -> Result { + let manifest = match load_manifest(addons_root, addon_name)? { + Some(manifest) => manifest, + None => return Ok(PathBuf::from(legacy_path)), + }; + + let rel_path = manifest.paths.get(component_id).ok_or_else(|| { + anyhow!( + "component '{}' not found in addon '{}' manifest", + component_id, + addon_name + ) + })?; + + let root = addon_root(addons_root, addon_name)?; + resolve_rel_path( + &root, + rel_path, + &format!("component '{}' in addon '{}'", component_id, addon_name), + ) +} + +/// Resolve the absolute path of a path-only component declared in the addon's +/// `[paths]` table. When the addon is not mounted, `legacy_path` is returned so +/// non-addon (e.g. monolithic) images keep working unchanged. +pub fn resolve_component_path( + addon_name: &str, + component_id: &str, + legacy_path: &str, +) -> Result { + resolve_component_path_in_root( + Path::new(ADDONS_ROOT), + addon_name, + component_id, + legacy_path, + ) +} + +/// Replace every `${name}` token in `template` with the matching value from +/// `ctx`. An unknown variable is a hard error (fail-closed). +fn substitute(template: &str, ctx: &HashMap) -> Result { + let mut out = String::with_capacity(template.len()); + let bytes = template.as_bytes(); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'$' && i + 1 < bytes.len() && bytes[i + 1] == b'{' { + let start = i + 2; + let end = template[start..] + .find('}') + .map(|p| start + p) + .ok_or_else(|| anyhow!("unterminated '${{' in '{}'", template))?; + let name = &template[start..end]; + let value = ctx + .get(name) + .ok_or_else(|| anyhow!("unknown substitution variable '{}'", name))?; + out.push_str(value); + i = end + 1; + } else { + // Push a full UTF-8 char to avoid splitting multi-byte sequences. + let ch = template[i..].chars().next().unwrap(); + out.push(ch); + i += ch.len_utf8(); + } + } + Ok(out) +} + +// Resolve the active variant (if any) for a process. Returns `None` when the +// process declares no variants. +fn select_variant<'a>( + raw: &'a RawProcessSpec, + ctx: &HashMap, +) -> Result> { + if raw.variants.is_empty() { + return Ok(None); + } + + let mut selector = match &raw.select { + Some(s) => substitute(s, ctx)?, + None => String::new(), + }; + if selector.is_empty() { + selector = DEFAULT_VARIANT.to_string(); + } + + let variant = raw.variants.get(&selector).ok_or_else(|| { + anyhow!( + "process '{}' has no variant '{}' (available: {})", + raw.id, + selector, + { + let mut names: Vec<&str> = raw.variants.keys().map(String::as_str).collect(); + names.sort_unstable(); + names.join(", ") + } + ) + })?; + + Ok(Some(variant)) +} + +fn build_launch_spec( + root: &Path, + raw: &RawProcessSpec, + ctx: &HashMap, +) -> Result { + let variant = select_variant(raw, ctx)?; + + let path_template = variant + .and_then(|v| v.path.as_deref()) + .or(raw.path.as_deref()) + .ok_or_else(|| { + anyhow!( + "process '{}' declares neither 'path' nor a variant path", + raw.id + ) + })?; + let path_template = substitute(path_template, ctx)?; + let path = resolve_rel_path(root, &path_template, &format!("process '{}'", raw.id))?; + + let mut args = Vec::with_capacity(raw.args.len()); + for a in &raw.args { + args.push(substitute(a, ctx)?); + } + for group in &raw.optional_args { + let enabled = ctx.get(&group.when).map(|v| !v.is_empty()).unwrap_or(false); + if enabled { + for a in &group.args { + args.push(substitute(a, ctx)?); + } + } + } + if let Some(v) = variant { + for a in &v.args { + args.push(substitute(a, ctx)?); + } + } + + let config = match &raw.config { + Some(c) => { + let c = substitute(c, ctx)?; + if c.is_empty() { + None + } else { + Some(c) + } + } + None => None, + }; + + // Base env first, then variant env (variant overrides on key conflict). + let mut env_map: HashMap = HashMap::new(); + for (k, v) in &raw.env { + env_map.insert(k.clone(), substitute(v, ctx)?); + } + if let Some(var) = variant { + for (k, v) in &var.env { + env_map.insert(k.clone(), substitute(v, ctx)?); + } + } + let mut env: Vec<(String, String)> = env_map.into_iter().collect(); + // Deterministic order keeps behaviour reproducible and testable. + env.sort(); + + let wait_socket = match &raw.wait_socket { + Some(s) => { + let s = substitute(s, ctx)?; + if s.is_empty() { + None + } else { + Some(s) + } + } + None => None, + }; + + let timeout_secs = match &raw.timeout_secs { + Some(t) => { + let t = substitute(t, ctx)?; + if t.is_empty() { + 0 + } else { + t.parse::().with_context(|| { + format!("invalid timeout_secs '{}' for process '{}'", t, raw.id) + })? + } + } + None => 0, + }; + + Ok(LaunchSpec { + id: raw.id.clone(), + path, + args, + config, + env, + wait_socket, + timeout_secs, + }) +} + +fn launch_plan_in_root( + addons_root: &Path, + addon_name: &str, + max_level: u32, + ctx: &HashMap, +) -> Result>> { + let manifest = match load_manifest(addons_root, addon_name)? { + Some(manifest) => manifest, + None => return Ok(None), + }; + + let root = addon_root(addons_root, addon_name)?; + let mut specs = Vec::new(); + for raw in &manifest.process { + if raw.level == 0 || raw.level > max_level { + continue; + } + specs.push(build_launch_spec(&root, raw, ctx)?); + } + Ok(Some(specs)) +} + +/// Build the ordered list of processes the agent should launch for an addon, +/// gated by `max_level` and with all `${var}` tokens substituted from `ctx`. +/// +/// Returns `Ok(None)` when the addon is not mounted, signalling the caller to +/// use its built-in default launch behaviour. +pub fn launch_plan( + addon_name: &str, + max_level: u32, + ctx: &HashMap, +) -> Result>> { + launch_plan_in_root(Path::new(ADDONS_ROOT), addon_name, max_level, ctx) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + fn write_manifest(root: &Path, addon_name: &str, content: &str) { + let manifest_dir = root.join(addon_name).join("etc/kata-addons"); + fs::create_dir_all(&manifest_dir).unwrap(); + fs::write(manifest_dir.join("components.toml"), content).unwrap(); + } + + fn ctx() -> HashMap { + let mut c = HashMap::new(); + c.insert("aa_attestation_uri".into(), "unix:///run/aa.sock".into()); + c.insert("aa_attestation_socket".into(), "/run/aa.sock".into()); + c.insert("aa_config_path".into(), "/run/initdata/aa.toml".into()); + c.insert("cdh_config_path".into(), "/run/initdata/cdh.toml".into()); + c.insert("cdh_socket".into(), "/run/cdh.sock".into()); + c.insert( + "ocicrypt_config_path".into(), + "/run/kata-addons/coco/etc/ocicrypt_config.json".into(), + ); + c.insert("rest_api_features".into(), "all".into()); + c.insert("launch_process_timeout".into(), "6".into()); + c.insert("initdata_toml_path".into(), "".into()); + c.insert("attester_variant".into(), "".into()); + c.insert("addon_root".into(), "/run/kata-addons/coco".into()); + c + } + + const COCO_MANIFEST: &str = r#" +schema_version = 1 + +[paths] +"ocicrypt-config" = "etc/ocicrypt_config.json" +"pause-bundle" = "pause_bundle" + +[[process]] +id = "attestation-agent" +level = 1 +args = ["--attestation_sock", "${aa_attestation_uri}"] +optional_args = [{ when = "initdata_toml_path", args = ["--initdata-toml", "${initdata_toml_path}"] }] +config = "${aa_config_path}" +wait_socket = "${aa_attestation_socket}" +timeout_secs = "${launch_process_timeout}" +select = "${attester_variant}" + + [process.variants.default] + path = "usr/local/bin/attestation-agent" + + [process.variants.nvidia] + path = "usr/local/bin/attestation-agent-nv" + env = { LD_LIBRARY_PATH = "${addon_root}/usr/local/lib" } + +[[process]] +id = "confidential-data-hub" +level = 2 +path = "usr/local/bin/confidential-data-hub" +config = "${cdh_config_path}" +env = { OCICRYPT_KEYPROVIDER_CONFIG = "${ocicrypt_config_path}" } +wait_socket = "${cdh_socket}" +timeout_secs = "${launch_process_timeout}" + +[[process]] +id = "api-server-rest" +level = 3 +path = "usr/local/bin/api-server-rest" +args = ["--features", "${rest_api_features}"] +timeout_secs = "0" +"#; + + #[test] + fn falls_back_to_legacy_when_addon_not_mounted() { + let dir = tempdir().unwrap(); + let legacy = "/usr/local/bin/attestation-agent"; + let got = resolve_component_path_in_root( + dir.path(), + COCO_ADDON_NAME, + COCO_COMPONENT_OCICRYPT_CONFIG, + legacy, + ) + .unwrap(); + assert_eq!(got, PathBuf::from(legacy)); + } + + #[test] + fn launch_plan_is_none_when_addon_not_mounted() { + let dir = tempdir().unwrap(); + let got = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 3, &ctx()).unwrap(); + assert!(got.is_none()); + } + + #[test] + fn fails_when_addon_root_exists_but_manifest_missing() { + let dir = tempdir().unwrap(); + fs::create_dir_all(dir.path().join(COCO_ADDON_NAME)).unwrap(); + + let err = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 3, &ctx()).unwrap_err(); + assert!(err.to_string().contains("manifest is missing")); + } + + #[test] + fn resolves_component_from_manifest() { + let dir = tempdir().unwrap(); + write_manifest(dir.path(), COCO_ADDON_NAME, COCO_MANIFEST); + let got = resolve_component_path_in_root( + dir.path(), + COCO_ADDON_NAME, + COCO_COMPONENT_PAUSE_BUNDLE, + "/legacy/pause_bundle", + ) + .unwrap(); + assert_eq!(got, dir.path().join(COCO_ADDON_NAME).join("pause_bundle")); + } + + #[test] + fn fails_when_component_is_missing_in_manifest() { + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + COCO_ADDON_NAME, + r#" +schema_version = 1 +[paths] +"pause-bundle" = "pause_bundle" +"#, + ); + let err = resolve_component_path_in_root( + dir.path(), + COCO_ADDON_NAME, + COCO_COMPONENT_OCICRYPT_CONFIG, + "/legacy", + ) + .unwrap_err(); + assert!(err.to_string().contains("not found")); + } + + #[test] + fn rejects_path_traversal_in_manifest() { + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + COCO_ADDON_NAME, + r#" +schema_version = 1 +[paths] +"ocicrypt-config" = "../outside" +"#, + ); + let err = resolve_component_path_in_root( + dir.path(), + COCO_ADDON_NAME, + COCO_COMPONENT_OCICRYPT_CONFIG, + "/legacy", + ) + .unwrap_err(); + assert!(err.to_string().contains("invalid path")); + } + + #[test] + fn builds_full_coco_launch_plan() { + let dir = tempdir().unwrap(); + write_manifest(dir.path(), COCO_ADDON_NAME, COCO_MANIFEST); + let specs = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 3, &ctx()) + .unwrap() + .unwrap(); + + assert_eq!(specs.len(), 3); + + let root = dir.path().join(COCO_ADDON_NAME); + let aa = &specs[0]; + assert_eq!(aa.id, "attestation-agent"); + assert_eq!(aa.path, root.join("usr/local/bin/attestation-agent")); + assert_eq!(aa.args, vec!["--attestation_sock", "unix:///run/aa.sock"]); + assert_eq!(aa.config.as_deref(), Some("/run/initdata/aa.toml")); + assert_eq!(aa.wait_socket.as_deref(), Some("/run/aa.sock")); + assert_eq!(aa.timeout_secs, 6); + + let cdh = &specs[1]; + assert_eq!(cdh.id, "confidential-data-hub"); + assert_eq!( + cdh.env, + vec![( + "OCICRYPT_KEYPROVIDER_CONFIG".to_string(), + "/run/kata-addons/coco/etc/ocicrypt_config.json".to_string() + )] + ); + + let api = &specs[2]; + assert_eq!(api.id, "api-server-rest"); + assert_eq!(api.args, vec!["--features", "all"]); + assert_eq!(api.timeout_secs, 0); + } + + #[test] + fn gating_limits_launched_processes() { + let dir = tempdir().unwrap(); + write_manifest(dir.path(), COCO_ADDON_NAME, COCO_MANIFEST); + + let specs = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 1, &ctx()) + .unwrap() + .unwrap(); + assert_eq!(specs.len(), 1); + assert_eq!(specs[0].id, "attestation-agent"); + } + + #[test] + fn optional_args_included_when_variable_set() { + let dir = tempdir().unwrap(); + write_manifest(dir.path(), COCO_ADDON_NAME, COCO_MANIFEST); + + let mut ctx = ctx(); + ctx.insert( + "initdata_toml_path".into(), + "/run/initdata/initdata.toml".into(), + ); + let specs = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 1, &ctx) + .unwrap() + .unwrap(); + assert_eq!( + specs[0].args, + vec![ + "--attestation_sock", + "unix:///run/aa.sock", + "--initdata-toml", + "/run/initdata/initdata.toml" + ] + ); + } + + #[test] + fn unknown_substitution_variable_fails() { + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + COCO_ADDON_NAME, + r#" +schema_version = 1 +[[process]] +id = "broken" +level = 1 +path = "usr/local/bin/broken" +args = ["--flag", "${does_not_exist}"] +"#, + ); + let err = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 1, &ctx()).unwrap_err(); + assert!(err.to_string().contains("unknown substitution variable")); + } + + #[test] + fn unsupported_schema_version_fails() { + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + COCO_ADDON_NAME, + r#" +schema_version = 2 +[paths] +"pause-bundle" = "pause_bundle" +"#, + ); + let err = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 3, &ctx()).unwrap_err(); + assert!(err.to_string().contains("schema_version")); + } + + #[test] + fn default_variant_selected_when_selector_empty() { + let dir = tempdir().unwrap(); + write_manifest(dir.path(), COCO_ADDON_NAME, COCO_MANIFEST); + + // ctx() leaves attester_variant empty -> "default". + let specs = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 1, &ctx()) + .unwrap() + .unwrap(); + let aa = &specs[0]; + assert_eq!( + aa.path, + dir.path() + .join(COCO_ADDON_NAME) + .join("usr/local/bin/attestation-agent") + ); + assert!(aa.env.is_empty()); + } + + #[test] + fn nvidia_variant_selects_nv_binary_and_merges_env() { + let dir = tempdir().unwrap(); + write_manifest(dir.path(), COCO_ADDON_NAME, COCO_MANIFEST); + + let mut ctx = ctx(); + ctx.insert("attester_variant".into(), "nvidia".into()); + let specs = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 1, &ctx) + .unwrap() + .unwrap(); + let aa = &specs[0]; + assert_eq!( + aa.path, + dir.path() + .join(COCO_ADDON_NAME) + .join("usr/local/bin/attestation-agent-nv") + ); + assert_eq!( + aa.env, + vec![( + "LD_LIBRARY_PATH".to_string(), + "/run/kata-addons/coco/usr/local/lib".to_string() + )] + ); + // Base args are preserved for the selected variant. + assert_eq!(aa.args, vec!["--attestation_sock", "unix:///run/aa.sock"]); + } + + #[test] + fn unknown_variant_fails() { + let dir = tempdir().unwrap(); + write_manifest(dir.path(), COCO_ADDON_NAME, COCO_MANIFEST); + + let mut ctx = ctx(); + ctx.insert("attester_variant".into(), "does-not-exist".into()); + let err = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 1, &ctx).unwrap_err(); + assert!(err.to_string().contains("has no variant 'does-not-exist'")); + } + + #[test] + fn process_without_path_or_variants_fails() { + let dir = tempdir().unwrap(); + write_manifest( + dir.path(), + COCO_ADDON_NAME, + r#" +schema_version = 1 +[[process]] +id = "broken" +level = 1 +args = ["--flag"] +"#, + ); + let err = launch_plan_in_root(dir.path(), COCO_ADDON_NAME, 1, &ctx()).unwrap_err(); + assert!(err + .to_string() + .contains("neither 'path' nor a variant path")); + } +} diff --git a/src/agent/src/confidential_data_hub/image.rs b/src/agent/src/confidential_data_hub/image.rs index 680c77a742..7438af43a7 100644 --- a/src/agent/src/confidential_data_hub/image.rs +++ b/src/agent/src/confidential_data_hub/image.rs @@ -8,12 +8,13 @@ use safe_path::scoped_join; use std::collections::HashMap; use std::fs; -use std::path::Path; +use std::path::{Path, PathBuf}; use anyhow::{anyhow, bail, Context, Result}; use kata_sys_util::validate::verify_id; use oci_spec::runtime as oci; +use crate::addon; use crate::rpc::CONTAINER_BASE; use kata_types::mount::KATA_VIRTUAL_VOLUME_IMAGE_GUEST_PULL; @@ -22,14 +23,15 @@ use protocols::agent::Storage; pub const KATA_IMAGE_WORK_DIR: &str = "/run/kata-containers/image/"; const CONFIG_JSON: &str = "config.json"; const KATA_PAUSE_BUNDLE: &str = "/pause_bundle"; -const KATA_PAUSE_BUNDLE_ADDON: &str = "/run/kata-addons/coco/pause_bundle"; -fn resolve_pause_bundle() -> &'static str { - if Path::new(KATA_PAUSE_BUNDLE_ADDON).exists() { - KATA_PAUSE_BUNDLE_ADDON - } else { - KATA_PAUSE_BUNDLE - } +// Resolve the pause bundle directory: when the CoCo addon image is mounted its +// manifest decides the location, otherwise the legacy rootfs path is used. +fn resolve_pause_bundle() -> Result { + addon::resolve_component_path( + addon::COCO_ADDON_NAME, + addon::COCO_COMPONENT_PAUSE_BUNDLE, + KATA_PAUSE_BUNDLE, + ) } const K8S_CONTAINER_TYPE_KEYS: [&str; 2] = [ @@ -55,11 +57,11 @@ fn copy_if_not_exists(src: &Path, dst: &Path) -> Result<()> { /// get guest pause image process specification fn get_pause_image_process() -> Result { - let guest_pause_bundle = Path::new(resolve_pause_bundle()); + let guest_pause_bundle = resolve_pause_bundle()?; if !guest_pause_bundle.exists() { bail!("Pause image not present in rootfs"); } - let guest_pause_config = scoped_join(guest_pause_bundle, CONFIG_JSON)?; + let guest_pause_config = scoped_join(&guest_pause_bundle, CONFIG_JSON)?; let image_oci = oci::Spec::load(guest_pause_config.to_str().ok_or_else(|| { anyhow!( @@ -79,11 +81,11 @@ fn get_pause_image_process() -> Result { pub fn unpack_pause_image(cid: &str) -> Result { verify_id(cid).context("The guest pause image cid contains invalid characters.")?; - let guest_pause_bundle = Path::new(resolve_pause_bundle()); + let guest_pause_bundle = resolve_pause_bundle()?; if !guest_pause_bundle.exists() { bail!("Pause image not present in rootfs"); } - let guest_pause_config = scoped_join(guest_pause_bundle, CONFIG_JSON)?; + let guest_pause_config = scoped_join(&guest_pause_bundle, CONFIG_JSON)?; info!(sl(), "use guest pause image cid {:?}", cid); let image_oci = oci::Spec::load(guest_pause_config.to_str().ok_or_else(|| { diff --git a/src/agent/src/main.rs b/src/agent/src/main.rs index 30e0af4da4..fd009ee564 100644 --- a/src/agent/src/main.rs +++ b/src/agent/src/main.rs @@ -38,6 +38,7 @@ use std::process::exit; use std::sync::Arc; use tracing::{instrument, span}; +mod addon; mod confidential_data_hub; mod config; mod console; @@ -96,34 +97,21 @@ const NAME: &str = "kata-agent"; const UNIX_SOCKET_PREFIX: &str = "unix://"; -const COCO_ADDON_DIR: &str = "/run/kata-addons/coco"; - +// Legacy (non-addon) rootfs locations for the CoCo guest components. They are +// used to build the built-in launch plan when no CoCo addon image is mounted, +// keeping monolithic / non-confidential images working unchanged. const AA_PATH: &str = "/usr/local/bin/attestation-agent"; -const AA_ADDON_PATH: &str = concatcp!(COCO_ADDON_DIR, "/usr/local/bin/attestation-agent"); const AA_ATTESTATION_SOCKET: &str = "/run/confidential-containers/attestation-agent/attestation-agent.sock"; const AA_ATTESTATION_URI: &str = concatcp!(UNIX_SOCKET_PREFIX, AA_ATTESTATION_SOCKET); const CDH_PATH: &str = "/usr/local/bin/confidential-data-hub"; -const CDH_ADDON_PATH: &str = concatcp!(COCO_ADDON_DIR, "/usr/local/bin/confidential-data-hub"); const CDH_SOCKET: &str = "/run/confidential-containers/cdh.sock"; const CDH_SOCKET_URI: &str = concatcp!(UNIX_SOCKET_PREFIX, CDH_SOCKET); const API_SERVER_PATH: &str = "/usr/local/bin/api-server-rest"; -const API_SERVER_ADDON_PATH: &str = concatcp!(COCO_ADDON_DIR, "/usr/local/bin/api-server-rest"); const OCICRYPT_CONFIG_PATH: &str = "/etc/ocicrypt_config.json"; -const OCICRYPT_CONFIG_ADDON_PATH: &str = concatcp!(COCO_ADDON_DIR, "/etc/ocicrypt_config.json"); - -/// Resolve a binary path: prefer the CoCo addon location, fall back to the -/// legacy rootfs path. -fn resolve_path<'a>(addon_path: &'a str, legacy_path: &'a str) -> &'a str { - if Path::new(addon_path).exists() { - addon_path - } else { - legacy_path - } -} lazy_static! { static ref AGENT_CONFIG: AgentConfig = @@ -422,13 +410,14 @@ async fn start_sandbox( let initdata_return_value = initdata::initialize_initdata(logger).await?; let gc_procs = config.guest_components_procs; - if !attestation_binaries_available(logger, &gc_procs) { + let launch_plan = build_coco_launch_plan(config, &initdata_return_value, gc_procs)?; + if !attestation_components_available(logger, &launch_plan) { warn!( logger, "attestation binaries requested for launch not available" ); } else { - init_attestation_components(logger, config, &initdata_return_value).await?; + init_attestation_components(logger, &launch_plan).await?; } // if policy is given via initdata, use it @@ -471,114 +460,212 @@ async fn start_sandbox( Ok(()) } -// Check if required attestation binaries are available, looking in the CoCo -// addon mount first, then in the legacy rootfs paths. -fn attestation_binaries_available(logger: &Logger, procs: &GuestComponentsProcs) -> bool { - let binaries: Vec<(&str, &str)> = match procs { - GuestComponentsProcs::AttestationAgent => vec![(AA_ADDON_PATH, AA_PATH)], - GuestComponentsProcs::ConfidentialDataHub => { - vec![(AA_ADDON_PATH, AA_PATH), (CDH_ADDON_PATH, CDH_PATH)] - } - GuestComponentsProcs::ApiServerRest => vec![ - (AA_ADDON_PATH, AA_PATH), - (CDH_ADDON_PATH, CDH_PATH), - (API_SERVER_ADDON_PATH, API_SERVER_PATH), - ], - _ => vec![], +// Map the requested guest-components level to the numeric gating level used by +// addon manifests. A process is launched only when its declared `level` is +// <= this value. The ordering mirrors the implications documented on +// `GuestComponentsProcs` (ApiServerRest implies CDH implies AttestationAgent). +fn guest_components_max_level(procs: GuestComponentsProcs) -> u32 { + match procs { + GuestComponentsProcs::None => 0, + GuestComponentsProcs::AttestationAgent => 1, + GuestComponentsProcs::ConfidentialDataHub => 2, + GuestComponentsProcs::ApiServerRest => 3, + } +} + +// Build the substitution context exposed to addon manifests. New addon bundles +// can rely on these variables without requiring agent code changes; introducing +// a brand new variable is the only case that needs touching the agent. +fn build_substitution_ctx( + config: &AgentConfig, + initdata_return_value: &Option, +) -> Result> { + let ocicrypt_config_path = addon::resolve_component_path( + addon::COCO_ADDON_NAME, + addon::COCO_COMPONENT_OCICRYPT_CONFIG, + OCICRYPT_CONFIG_PATH, + )?; + + let initdata_toml_path = if initdata_return_value.is_some() { + initdata::INITDATA_TOML_PATH.to_string() + } else { + String::new() }; - for (addon, legacy) in binaries.iter() { - let resolved = resolve_path(addon, legacy); - let exists = Path::new(resolved) + + let addon_root = addon::addon_mount_root(addon::COCO_ADDON_NAME)?; + + let mut ctx = std::collections::HashMap::new(); + ctx.insert( + "aa_attestation_uri".to_string(), + AA_ATTESTATION_URI.to_string(), + ); + ctx.insert( + "aa_attestation_socket".to_string(), + AA_ATTESTATION_SOCKET.to_string(), + ); + ctx.insert("aa_config_path".to_string(), AA_CONFIG_PATH.to_string()); + ctx.insert("cdh_config_path".to_string(), CDH_CONFIG_PATH.to_string()); + ctx.insert("cdh_socket".to_string(), CDH_SOCKET.to_string()); + ctx.insert( + "ocicrypt_config_path".to_string(), + ocicrypt_config_path.to_string_lossy().into_owned(), + ); + ctx.insert( + "rest_api_features".to_string(), + config.guest_components_rest_api.to_string(), + ); + ctx.insert( + "launch_process_timeout".to_string(), + config.launch_process_timeout.as_secs().to_string(), + ); + ctx.insert("initdata_toml_path".to_string(), initdata_toml_path); + ctx.insert( + "addon_root".to_string(), + addon_root.to_string_lossy().into_owned(), + ); + // kata-agent always runs the stock attestation-agent; NVRC selects the + // nvidia variant by injecting a different value here. + ctx.insert("attester_variant".to_string(), "default".to_string()); + + Ok(ctx) +} + +// Built-in launch plan used when no CoCo addon image is mounted. It reproduces +// the legacy behaviour of launching the guest components from the rootfs +// (`/usr/local/bin/...`), so monolithic and non-confidential images are +// unaffected by the addon machinery. +fn builtin_coco_plan( + config: &AgentConfig, + initdata_return_value: &Option, + max_level: u32, +) -> Vec { + let mut plan = Vec::new(); + + if max_level >= 1 { + let mut args = vec![ + "--attestation_sock".to_string(), + AA_ATTESTATION_URI.to_string(), + ]; + if initdata_return_value.is_some() { + args.push("--initdata-toml".to_string()); + args.push(initdata::INITDATA_TOML_PATH.to_string()); + } + plan.push(addon::LaunchSpec { + id: "attestation-agent".to_string(), + path: Path::new(AA_PATH).to_path_buf(), + args, + config: Some(AA_CONFIG_PATH.to_string()), + env: vec![], + wait_socket: Some(AA_ATTESTATION_SOCKET.to_string()), + timeout_secs: config.launch_process_timeout.as_secs(), + }); + } + + if max_level >= 2 { + plan.push(addon::LaunchSpec { + id: "confidential-data-hub".to_string(), + path: Path::new(CDH_PATH).to_path_buf(), + args: vec![], + config: Some(CDH_CONFIG_PATH.to_string()), + env: vec![( + "OCICRYPT_KEYPROVIDER_CONFIG".to_string(), + OCICRYPT_CONFIG_PATH.to_string(), + )], + wait_socket: Some(CDH_SOCKET.to_string()), + timeout_secs: config.launch_process_timeout.as_secs(), + }); + } + + if max_level >= 3 { + plan.push(addon::LaunchSpec { + id: "api-server-rest".to_string(), + path: Path::new(API_SERVER_PATH).to_path_buf(), + args: vec![ + "--features".to_string(), + config.guest_components_rest_api.to_string(), + ], + config: None, + env: vec![], + wait_socket: None, + timeout_secs: 0, + }); + } + + plan +} + +// Build the ordered launch plan for the guest components. When a CoCo addon +// image is mounted its manifest drives the plan (so new bundles need no agent +// changes); otherwise the built-in legacy plan is used. +fn build_coco_launch_plan( + config: &AgentConfig, + initdata_return_value: &Option, + procs: GuestComponentsProcs, +) -> Result> { + let max_level = guest_components_max_level(procs); + let ctx = build_substitution_ctx(config, initdata_return_value)?; + match addon::launch_plan(addon::COCO_ADDON_NAME, max_level, &ctx)? { + Some(plan) => Ok(plan), + None => Ok(builtin_coco_plan(config, initdata_return_value, max_level)), + } +} + +// Check that every process in the launch plan is present on disk. A missing +// binary means the components were not provisioned (e.g. a non-confidential +// rootfs), in which case launching is skipped. +fn attestation_components_available(logger: &Logger, plan: &[addon::LaunchSpec]) -> bool { + for spec in plan { + let exists = spec + .path .try_exists() .unwrap_or_else(|error| match error.kind() { ErrorKind::NotFound => { - warn!(logger, "{} not found", resolved); + warn!(logger, "{} not found", spec.path.display()); false } - _ => panic!("Path existence check failed for '{}': {}", resolved, error), + _ => panic!( + "Path existence check failed for '{}': {}", + spec.path.display(), + error + ), }); if !exists { + warn!(logger, "{} not found", spec.path.display()); return false; } } true } -async fn launch_guest_component_procs( - logger: &Logger, - config: &AgentConfig, - initdata_return_value: &Option, -) -> Result<()> { - if config.guest_components_procs == GuestComponentsProcs::None { - return Ok(()); +async fn launch_guest_component_procs(logger: &Logger, plan: &[addon::LaunchSpec]) -> Result<()> { + for spec in plan { + let path = spec + .path + .to_str() + .ok_or_else(|| anyhow!("non-utf8 component path {}", spec.path.display()))?; + debug!(logger, "spawning addon component process {}", spec.id); + + let args: Vec<&str> = spec.args.iter().map(String::as_str).collect(); + let envs: Vec<(&str, &str)> = spec + .env + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + + launch_process( + logger, + path, + args, + spec.config.as_deref(), + spec.wait_socket.as_deref().unwrap_or(""), + spec.timeout_secs, + &envs, + ) + .await + .map_err(|e| anyhow!("launch_process {} failed: {:?}", path, e))?; } - let aa_path = resolve_path(AA_ADDON_PATH, AA_PATH); - debug!(logger, "spawning attestation-agent process {}", aa_path); - let mut aa_args = vec!["--attestation_sock", AA_ATTESTATION_URI]; - if initdata_return_value.is_some() { - aa_args.push("--initdata-toml"); - aa_args.push(initdata::INITDATA_TOML_PATH); - } - - launch_process( - logger, - aa_path, - aa_args, - Some(AA_CONFIG_PATH), - AA_ATTESTATION_SOCKET, - config.launch_process_timeout.as_secs(), - &[], - ) - .await - .map_err(|e| anyhow!("launch_process {} failed: {:?}", aa_path, e))?; - - if config.guest_components_procs == GuestComponentsProcs::AttestationAgent { - return Ok(()); - } - - let cdh_path = resolve_path(CDH_ADDON_PATH, CDH_PATH); - let ocicrypt_path = resolve_path(OCICRYPT_CONFIG_ADDON_PATH, OCICRYPT_CONFIG_PATH); - debug!( - logger, - "spawning confidential-data-hub process {}", cdh_path - ); - - launch_process( - logger, - cdh_path, - vec![], - Some(CDH_CONFIG_PATH), - CDH_SOCKET, - config.launch_process_timeout.as_secs(), - &[("OCICRYPT_KEYPROVIDER_CONFIG", ocicrypt_path)], - ) - .await - .map_err(|e| anyhow!("launch_process {} failed: {:?}", cdh_path, e))?; - - if config.guest_components_procs == GuestComponentsProcs::ConfidentialDataHub { - return Ok(()); - } - - let api_path = resolve_path(API_SERVER_ADDON_PATH, API_SERVER_PATH); - let features = config.guest_components_rest_api; - debug!( - logger, - "spawning api-server-rest process {} --features {}", api_path, features - ); - launch_process( - logger, - api_path, - vec!["--features", &features.to_string()], - None, - "", - 0, - &[], - ) - .await - .map_err(|e| anyhow!("launch_process {} failed: {:?}", api_path, e))?; - Ok(()) } @@ -586,12 +673,8 @@ async fn launch_guest_component_procs( // and the corresponding procs are enabled in the agent configuration. the process will be // launched in the background and the function will return immediately. // If the CDH is started, a CDH client will be instantiated and returned. -async fn init_attestation_components( - logger: &Logger, - config: &AgentConfig, - initdata_return_value: &Option, -) -> Result<()> { - launch_guest_component_procs(logger, config, initdata_return_value).await?; +async fn init_attestation_components(logger: &Logger, plan: &[addon::LaunchSpec]) -> Result<()> { + launch_guest_component_procs(logger, plan).await?; // If a CDH socket exists, initialize the CDH client and enable ocicrypt match tokio::fs::metadata(CDH_SOCKET).await {