agent: resolve and launch CoCo guest components via addon manifest

Make the agent's handling of CoCo guest components data-driven so that a
new addon bundle can be introduced without changing agent code.

Add a new `addon` module defining a generic component manifest contract.
When a CoCo addon image is mounted at /run/kata-addons/coco/, the agent
reads etc/kata-addons/components.toml from it to discover:

- path-only components (looked up by id under the `[paths]` table), used
  for the ocicrypt config and the pause bundle, and
- launchable processes (`[[process]]` entries) describing the binary
  path, args, optional args, config file, environment, socket to wait on
  and timeout.

`${var}` tokens in process fields are substituted from a fixed,
documented context the agent builds at runtime (attestation socket/uri,
config paths, rest-api features, initdata toml path, launch timeout,
resolved ocicrypt config path). Referencing an unknown variable is a
hard error, and manifest paths are validated against traversal, so the
contract stays fail-closed.

Processes are gated by a numeric `level` mapped from
guest_components_procs (AttestationAgent < ConfidentialDataHub <
ApiServerRest), preserving the existing implication semantics.

When no addon is mounted, launch_plan() returns None and the agent uses
a built-in plan that reproduces the legacy behaviour byte-for-byte
(binaries under /usr/local/bin, /etc/ocicrypt_config.json, /pause_bundle).
Monolithic / non-split confidential images are therefore unaffected.

Signed-off-by: Fabiano Fidêncio <ffidencio@nvidia.com>
Assisted-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Fabiano Fidêncio
2026-06-10 16:59:49 +02:00
parent 407ffa2e5a
commit 9747a8bb91
3 changed files with 1015 additions and 127 deletions

803
src/agent/src/addon.rs Normal file
View File

@@ -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/<name>/` 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<String, String>,
#[serde(default)]
process: Vec<RawProcessSpec>,
}
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<String>,
#[serde(default)]
select: Option<String>,
#[serde(default)]
variants: HashMap<String, Variant>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
optional_args: Vec<OptionalArgs>,
#[serde(default)]
config: Option<String>,
#[serde(default)]
env: HashMap<String, String>,
#[serde(default)]
wait_socket: Option<String>,
#[serde(default)]
timeout_secs: Option<String>,
}
#[derive(Debug, Deserialize, Clone)]
struct OptionalArgs {
when: String,
args: Vec<String>,
}
/// 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<String>,
#[serde(default)]
args: Vec<String>,
#[serde(default)]
env: HashMap<String, String>,
}
/// 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<String>,
pub config: Option<String>,
pub env: Vec<(String, String)>,
pub wait_socket: Option<String>,
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<PathBuf> {
validate_addon_name(addon_name)?;
Ok(addons_root.join(addon_name))
}
fn manifest_path(addons_root: &Path, addon_name: &str) -> Result<PathBuf> {
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<PathBuf> {
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<PathBuf> {
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<Option<AddonManifest>> {
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<PathBuf> {
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<PathBuf> {
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<String, String>) -> Result<String> {
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<String, String>,
) -> Result<Option<&'a Variant>> {
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<String, String>,
) -> Result<LaunchSpec> {
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<String, String> = 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::<u64>().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<String, String>,
) -> Result<Option<Vec<LaunchSpec>>> {
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<String, String>,
) -> Result<Option<Vec<LaunchSpec>>> {
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<String, String> {
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"));
}
}

View File

@@ -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<PathBuf> {
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<oci::Process> {
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<oci::Process> {
pub fn unpack_pause_image(cid: &str) -> Result<String> {
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(|| {

View File

@@ -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<InitdataReturnValue>,
) -> Result<std::collections::HashMap<String, String>> {
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<InitdataReturnValue>,
max_level: u32,
) -> Vec<addon::LaunchSpec> {
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<InitdataReturnValue>,
procs: GuestComponentsProcs,
) -> Result<Vec<addon::LaunchSpec>> {
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<InitdataReturnValue>,
) -> 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<InitdataReturnValue>,
) -> 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 {