agent: resolve and launch CoCo guest components via extension manifest

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

Add a new `extension` module defining a generic component manifest contract.
When a CoCo extension image is mounted at /run/kata-extensions/coco/, the agent
reads etc/kata-extensions/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 extension 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
committed by Fabiano Fidêncio
parent 9b6453eb20
commit effac686fc
3 changed files with 1041 additions and 93 deletions

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::guest_extension_image;
use crate::rpc::CONTAINER_BASE;
use kata_types::mount::KATA_VIRTUAL_VOLUME_IMAGE_GUEST_PULL;
@@ -23,6 +24,16 @@ pub const KATA_IMAGE_WORK_DIR: &str = "/run/kata-containers/image/";
const CONFIG_JSON: &str = "config.json";
const KATA_PAUSE_BUNDLE: &str = "/pause_bundle";
// Resolve the pause bundle directory: when the CoCo extension image is mounted its
// manifest decides the location, otherwise the legacy rootfs path is used.
fn resolve_pause_bundle() -> Result<PathBuf> {
guest_extension_image::resolve_component_path(
guest_extension_image::COCO_EXTENSION_NAME,
guest_extension_image::COCO_COMPONENT_PAUSE_BUNDLE,
KATA_PAUSE_BUNDLE,
)
}
const K8S_CONTAINER_TYPE_KEYS: [&str; 2] = [
"io.kubernetes.cri.container-type",
"io.kubernetes.cri-o.ContainerType",
@@ -46,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(KATA_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!(
@@ -70,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(KATA_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

@@ -0,0 +1,810 @@
// Copyright (c) 2026 NVIDIA Corporation
//
// SPDX-License-Identifier: Apache-2.0
//
//! Generic, data-driven contract for guest components shipped in extension images.
//!
//! An extension image is mounted read-only at `/run/kata-extensions/<name>/` and
//! ships a manifest (`etc/kata-extensions/components.toml`) that declares the
//! path-only components and launchable processes it provides. The agent consumes
//! that manifest and builds its launch plan from it, instead of carrying
//! hard-coded knowledge about any particular extension.
//!
//! This keeps the agent generic for anything that can be expressed purely in the
//! manifest: adding, removing or reordering processes, changing their args, env,
//! paths or `${var}` substitutions, or selecting a build variant all happen with
//! no agent change. Workflows that need the agent to actively do something new
//! (e.g. publish a new substitution variable into the context, or react to a
//! component in a bespoke way) still require agent changes -- the CoCo
//! guest-component launching built on top of this module is one such case.
//!
//! The manifest schema, the `${var}` substitution variables the agent publishes,
//! and the attester-variant / NVRC contract are documented in
//! `docs/design/proposals/composable-vm-images.md`, which is the source of
//! truth; this module must stay consistent with it.
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_EXTENSION_NAME: &str = "coco";
const EXTENSIONS_ROOT: &str = "/run/kata-extensions";
const COMPONENTS_MANIFEST_REL_PATH: &str = "etc/kata-extensions/components.toml";
#[derive(Debug, Deserialize)]
struct ExtensionManifest {
// Required: the manifest is a versioned contract, so a manifest without an
// explicit schema_version is rejected rather than silently assumed to be v1.
schema_version: u32,
#[serde(default)]
paths: HashMap<String, String>,
#[serde(default)]
process: Vec<RawProcessSpec>,
}
#[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 extension 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_extension_name(extension_name: &str) -> Result<()> {
if extension_name.is_empty() {
bail!("invalid empty extension name");
}
if extension_name.contains('/')
|| Path::new(extension_name).components().any(|c| {
matches!(
c,
Component::ParentDir | Component::RootDir | Component::Prefix(_)
)
})
{
bail!("invalid extension name '{}'", extension_name);
}
Ok(())
}
fn extension_root(extensions_root: &Path, extension_name: &str) -> Result<PathBuf> {
validate_extension_name(extension_name)?;
Ok(extensions_root.join(extension_name))
}
fn manifest_path(extensions_root: &Path, extension_name: &str) -> Result<PathBuf> {
Ok(extension_root(extensions_root, extension_name)?.join(COMPONENTS_MANIFEST_REL_PATH))
}
/// Absolute mount root of an extension (e.g. `/run/kata-extensions/coco`). Exposed so
/// callers can publish it as a manifest substitution variable.
pub fn extension_mount_root(extension_name: &str) -> Result<PathBuf> {
extension_root(Path::new(EXTENSIONS_ROOT), extension_name)
}
/// Resolve a relative path declared inside a manifest against the extension 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(
extensions_root: &Path,
extension_name: &str,
) -> Result<Option<ExtensionManifest>> {
let root = extension_root(extensions_root, extension_name)?;
if !root.exists() {
// The extension is not mounted at all; callers fall back to legacy paths or
// their built-in defaults.
return Ok(None);
}
let manifest_path = manifest_path(extensions_root, extension_name)?;
if !manifest_path.exists() {
// A mounted extension without a manifest is a packaging bug: fail closed so
// the misconfiguration is surfaced rather than silently ignored.
bail!(
"extension '{}' is mounted but manifest is missing at {}",
extension_name,
manifest_path.display()
);
}
let data = fs::read_to_string(&manifest_path).with_context(|| {
format!(
"failed to read extension manifest {}",
manifest_path.display()
)
})?;
let manifest: ExtensionManifest = toml::from_str(&data).with_context(|| {
format!(
"failed to parse extension manifest {}",
manifest_path.display()
)
})?;
if manifest.schema_version != 1 {
bail!(
"unsupported extension manifest schema_version {} in {}",
manifest.schema_version,
manifest_path.display()
);
}
Ok(Some(manifest))
}
fn resolve_component_path_in_root(
extensions_root: &Path,
extension_name: &str,
component_id: &str,
legacy_path: &str,
) -> Result<PathBuf> {
let manifest = match load_manifest(extensions_root, extension_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 extension '{}' manifest",
component_id,
extension_name
)
})?;
let root = extension_root(extensions_root, extension_name)?;
resolve_rel_path(
&root,
rel_path,
&format!(
"component '{}' in extension '{}'",
component_id, extension_name
),
)
}
/// Resolve the absolute path of a path-only component declared in the extension's
/// `[paths]` table. When the extension is not mounted, `legacy_path` is returned so
/// non-extension (e.g. monolithic) images keep working unchanged.
pub fn resolve_component_path(
extension_name: &str,
component_id: &str,
legacy_path: &str,
) -> Result<PathBuf> {
resolve_component_path_in_root(
Path::new(EXTENSIONS_ROOT),
extension_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(
extensions_root: &Path,
extension_name: &str,
max_level: u32,
ctx: &HashMap<String, String>,
) -> Result<Option<Vec<LaunchSpec>>> {
let manifest = match load_manifest(extensions_root, extension_name)? {
Some(manifest) => manifest,
None => return Ok(None),
};
let root = extension_root(extensions_root, extension_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 extension,
/// gated by `max_level` and with all `${var}` tokens substituted from `ctx`.
///
/// Returns `Ok(None)` when the extension is not mounted, signalling the caller to
/// use its built-in default launch behaviour.
pub fn launch_plan(
extension_name: &str,
max_level: u32,
ctx: &HashMap<String, String>,
) -> Result<Option<Vec<LaunchSpec>>> {
launch_plan_in_root(Path::new(EXTENSIONS_ROOT), extension_name, max_level, ctx)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
fn write_manifest(root: &Path, extension_name: &str, content: &str) {
let manifest_dir = root.join(extension_name).join("etc/kata-extensions");
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-extensions/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("extension_root".into(), "/run/kata-extensions/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 = "${extension_root}/usr/local/lib:/run/kata-extensions/gpu/usr/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}", PATH = "${extension_root}/usr/sbin:/bin:/sbin:/usr/bin:/usr/sbin" }
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_extension_not_mounted() {
let dir = tempdir().unwrap();
let legacy = "/usr/local/bin/attestation-agent";
let got = resolve_component_path_in_root(
dir.path(),
COCO_EXTENSION_NAME,
COCO_COMPONENT_OCICRYPT_CONFIG,
legacy,
)
.unwrap();
assert_eq!(got, PathBuf::from(legacy));
}
#[test]
fn launch_plan_is_none_when_extension_not_mounted() {
let dir = tempdir().unwrap();
let got = launch_plan_in_root(dir.path(), COCO_EXTENSION_NAME, 3, &ctx()).unwrap();
assert!(got.is_none());
}
#[test]
fn fails_when_extension_root_exists_but_manifest_missing() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join(COCO_EXTENSION_NAME)).unwrap();
let err = launch_plan_in_root(dir.path(), COCO_EXTENSION_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_EXTENSION_NAME, COCO_MANIFEST);
let got = resolve_component_path_in_root(
dir.path(),
COCO_EXTENSION_NAME,
COCO_COMPONENT_PAUSE_BUNDLE,
"/legacy/pause_bundle",
)
.unwrap();
assert_eq!(
got,
dir.path().join(COCO_EXTENSION_NAME).join("pause_bundle")
);
}
#[test]
fn fails_when_component_is_missing_in_manifest() {
let dir = tempdir().unwrap();
write_manifest(
dir.path(),
COCO_EXTENSION_NAME,
r#"
schema_version = 1
[paths]
"pause-bundle" = "pause_bundle"
"#,
);
let err = resolve_component_path_in_root(
dir.path(),
COCO_EXTENSION_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_EXTENSION_NAME,
r#"
schema_version = 1
[paths]
"ocicrypt-config" = "../outside"
"#,
);
let err = resolve_component_path_in_root(
dir.path(),
COCO_EXTENSION_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_EXTENSION_NAME, COCO_MANIFEST);
let specs = launch_plan_in_root(dir.path(), COCO_EXTENSION_NAME, 3, &ctx())
.unwrap()
.unwrap();
assert_eq!(specs.len(), 3);
let root = dir.path().join(COCO_EXTENSION_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");
// env is sorted by key, so OCICRYPT_KEYPROVIDER_CONFIG precedes PATH.
// PATH puts the extension's cryptsetup (usr/sbin) ahead of the base dirs
// that carry mke2fs/mkfs.ext4/dd, which CDH's secure_mount shells out to.
assert_eq!(
cdh.env,
vec![
(
"OCICRYPT_KEYPROVIDER_CONFIG".to_string(),
"/run/kata-extensions/coco/etc/ocicrypt_config.json".to_string()
),
(
"PATH".to_string(),
"/run/kata-extensions/coco/usr/sbin:/bin:/sbin:/usr/bin:/usr/sbin".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_EXTENSION_NAME, COCO_MANIFEST);
let specs = launch_plan_in_root(dir.path(), COCO_EXTENSION_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_EXTENSION_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_EXTENSION_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_EXTENSION_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_EXTENSION_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_EXTENSION_NAME,
r#"
schema_version = 2
[paths]
"pause-bundle" = "pause_bundle"
"#,
);
let err = launch_plan_in_root(dir.path(), COCO_EXTENSION_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_EXTENSION_NAME, COCO_MANIFEST);
// ctx() leaves attester_variant empty -> "default".
let specs = launch_plan_in_root(dir.path(), COCO_EXTENSION_NAME, 1, &ctx())
.unwrap()
.unwrap();
let aa = &specs[0];
assert_eq!(
aa.path,
dir.path()
.join(COCO_EXTENSION_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_EXTENSION_NAME, COCO_MANIFEST);
let mut ctx = ctx();
ctx.insert("attester_variant".into(), "nvidia".into());
let specs = launch_plan_in_root(dir.path(), COCO_EXTENSION_NAME, 1, &ctx)
.unwrap()
.unwrap();
let aa = &specs[0];
assert_eq!(
aa.path,
dir.path()
.join(COCO_EXTENSION_NAME)
.join("usr/local/bin/attestation-agent-nv")
);
assert_eq!(
aa.env,
vec![(
"LD_LIBRARY_PATH".to_string(),
"/run/kata-extensions/coco/usr/local/lib:/run/kata-extensions/gpu/usr/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_EXTENSION_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_EXTENSION_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_EXTENSION_NAME,
r#"
schema_version = 1
[[process]]
id = "broken"
level = 1
args = ["--flag"]
"#,
);
let err = launch_plan_in_root(dir.path(), COCO_EXTENSION_NAME, 1, &ctx()).unwrap_err();
assert!(err
.to_string()
.contains("neither 'path' nor a variant path"));
}
}

View File

@@ -43,6 +43,7 @@ mod config;
mod console;
mod device;
mod features;
mod guest_extension_image;
mod initdata;
mod linux_abi;
mod metrics;
@@ -96,6 +97,9 @@ const NAME: &str = "kata-agent";
const UNIX_SOCKET_PREFIX: &str = "unix://";
// Legacy (non-extension) rootfs locations for the CoCo guest components. They are
// used to build the built-in launch plan when no CoCo extension image is mounted,
// keeping monolithic / non-confidential images working unchanged.
const AA_PATH: &str = "/usr/local/bin/attestation-agent";
const AA_ATTESTATION_SOCKET: &str =
"/run/confidential-containers/attestation-agent/attestation-agent.sock";
@@ -107,8 +111,6 @@ const CDH_SOCKET_URI: &str = concatcp!(UNIX_SOCKET_PREFIX, CDH_SOCKET);
const API_SERVER_PATH: &str = "/usr/local/bin/api-server-rest";
/// Path of ocicrypt config file. This is used by CDH when decrypting image.
/// TODO: remove this when we move the launch of CDH out of the kata-agent.
const OCICRYPT_CONFIG_PATH: &str = "/etc/ocicrypt_config.json";
lazy_static! {
@@ -412,13 +414,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
@@ -461,26 +464,192 @@ async fn start_sandbox(
Ok(())
}
// Check if required attestation binaries are available on the rootfs.
fn attestation_binaries_available(logger: &Logger, procs: &GuestComponentsProcs) -> bool {
let binaries = match procs {
GuestComponentsProcs::AttestationAgent => vec![AA_PATH],
GuestComponentsProcs::ConfidentialDataHub => vec![AA_PATH, CDH_PATH],
GuestComponentsProcs::ApiServerRest => vec![AA_PATH, CDH_PATH, API_SERVER_PATH],
_ => vec![],
// Map the requested guest-components level to the numeric gating level used by
// extension 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 extension manifests. New extension 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 = guest_extension_image::resolve_component_path(
guest_extension_image::COCO_EXTENSION_NAME,
guest_extension_image::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 binary in binaries.iter() {
let exists = Path::new(binary)
let extension_root =
guest_extension_image::extension_mount_root(guest_extension_image::COCO_EXTENSION_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(
"extension_root".to_string(),
extension_root.to_string_lossy().into_owned(),
);
// The CoCo extension ships several attestation-agent flavours and selects one
// via the manifest's "attester_variant". The guest init (NVRC) owns that
// decision: with a GPU present it sets KATA_ATTESTER_VARIANT=nvidia so the
// NVIDIA-attester build launches (it emits the GPU evidence a KBS GPU
// policy requires). Absent that signal we fall back to the stock attester.
// Cross-component contract: the env var name and "nvidia" value are set by
// NVRC (src/kata_agent.rs, src/gpu.rs); keep them in sync.
let attester_variant = env::var("KATA_ATTESTER_VARIANT")
.ok()
.filter(|v| !v.is_empty())
.unwrap_or_else(|| "default".to_string());
ctx.insert("attester_variant".to_string(), attester_variant);
Ok(ctx)
}
// Built-in launch plan used when no CoCo extension 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 extension machinery.
fn builtin_coco_plan(
config: &AgentConfig,
initdata_return_value: &Option<InitdataReturnValue>,
max_level: u32,
) -> Vec<guest_extension_image::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(guest_extension_image::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(guest_extension_image::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(guest_extension_image::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 extension
// 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<guest_extension_image::LaunchSpec>> {
let max_level = guest_components_max_level(procs);
let ctx = build_substitution_ctx(config, initdata_return_value)?;
match guest_extension_image::launch_plan(
guest_extension_image::COCO_EXTENSION_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: &[guest_extension_image::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", binary);
false
}
_ => panic!("Path existence check failed for '{}': {}", binary, error),
ErrorKind::NotFound => false,
_ => panic!(
"Path existence check failed for '{}': {}",
spec.path.display(),
error
),
});
if !exists {
warn!(logger, "{} not found", spec.path.display());
return false;
}
}
@@ -489,76 +658,35 @@ fn attestation_binaries_available(logger: &Logger, procs: &GuestComponentsProcs)
async fn launch_guest_component_procs(
logger: &Logger,
config: &AgentConfig,
initdata_return_value: &Option<InitdataReturnValue>,
plan: &[guest_extension_image::LaunchSpec],
) -> Result<()> {
if config.guest_components_procs == GuestComponentsProcs::None {
return Ok(());
for spec in plan {
let path = spec
.path
.to_str()
.ok_or_else(|| anyhow!("non-utf8 component path {}", spec.path.display()))?;
debug!(logger, "spawning extension 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))?;
}
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))?;
// skip launch of confidential-data-hub and api-server-rest
if config.guest_components_procs == GuestComponentsProcs::AttestationAgent {
return Ok(());
}
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_CONFIG_PATH)],
)
.await
.map_err(|e| anyhow!("launch_process {} failed: {:?}", CDH_PATH, e))?;
// skip launch of api-server-rest
if config.guest_components_procs == GuestComponentsProcs::ConfidentialDataHub {
return Ok(());
}
let features = config.guest_components_rest_api;
debug!(
logger,
"spawning api-server-rest process {} --features {}", API_SERVER_PATH, features
);
launch_process(
logger,
API_SERVER_PATH,
vec!["--features", &features.to_string()],
None,
"",
0,
&[],
)
.await
.map_err(|e| anyhow!("launch_process {} failed: {:?}", API_SERVER_PATH, e))?;
Ok(())
}
@@ -568,10 +696,9 @@ async fn launch_guest_component_procs(
// 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>,
plan: &[guest_extension_image::LaunchSpec],
) -> Result<()> {
launch_guest_component_procs(logger, config, initdata_return_value).await?;
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 {