container-device-interface 1.1.0

CDI (Container Device Interface), is a specification, for container-runtimes, to support third-party devices.
Documentation
use std::collections::BTreeMap;

use anyhow::{anyhow, Context, Result};
use jsonschema::{Draft, Validator};
use once_cell::sync::Lazy;
use serde_json::Value;

use crate::{
    internal::validation::validate::validate_spec_annotations, specs::config::Spec as CDISpec,
    version::validate_declared_version_fields,
};

const SCHEMA_JSON: &str = include_str!("schema.json");
const DEFS_JSON: &str = include_str!("defs.json");
static BUILTIN_SCHEMA: Lazy<Result<Validator, String>> =
    Lazy::new(|| compile_builtin_schema().map_err(|err| format!("{err:#}")));

pub fn builtin_schema_value() -> Result<Value> {
    cdi_schema_value(SCHEMA_JSON.as_bytes(), DEFS_JSON.as_bytes())
}

pub fn cdi_schema_value(schema_data: &[u8], defs_data: &[u8]) -> Result<Value> {
    let mut schema_json: Value =
        serde_json::from_slice(schema_data).context("parse CDI schema.json")?;
    let defs_json: Value = serde_json::from_slice(defs_data).context("parse CDI defs.json")?;
    rewrite_defs_json_refs(&mut schema_json);

    let schema = schema_json
        .as_object_mut()
        .ok_or_else(|| anyhow!("CDI schema must be a JSON object"))?;
    let definitions = defs_json
        .get("definitions")
        .cloned()
        .ok_or_else(|| anyhow!("CDI defs.json must contain definitions"))?;
    schema.insert("definitions".to_string(), definitions);

    Ok(schema_json)
}

fn rewrite_defs_json_refs(value: &mut Value) {
    match value {
        Value::Object(object) => {
            if let Some(Value::String(reference)) = object.get_mut("$ref") {
                if let Some(definition) = reference.strip_prefix("defs.json#/definitions/") {
                    *reference = format!("#/definitions/{definition}");
                }
            }

            for value in object.values_mut() {
                rewrite_defs_json_refs(value);
            }
        }
        Value::Array(values) => {
            for value in values {
                rewrite_defs_json_refs(value);
            }
        }
        _ => {}
    }
}

pub fn compile_builtin_schema() -> Result<Validator> {
    let schema_json = builtin_schema_value()?;
    Validator::options()
        .with_draft(Draft::Draft7)
        .build(&schema_json)
        .context("compile builtin CDI schema")
}

pub fn compile_cdi_schema(schema_data: &[u8], defs_data: &[u8]) -> Result<Validator> {
    let schema_json = cdi_schema_value(schema_data, defs_data)?;
    Validator::options()
        .with_draft(Draft::Draft7)
        .build(&schema_json)
        .context("compile CDI schema")
}

pub fn document_value(doc_data: &[u8]) -> Result<Value> {
    let yaml_value: serde_yaml::Value =
        serde_yaml::from_slice(doc_data).context("parse CDI document")?;
    serde_json::to_value(yaml_value).context("convert CDI document to JSON value")
}

pub fn validate(schema: &Validator, doc_data: &[u8]) -> Result<()> {
    let doc = document_value(doc_data)?;
    validate_value(schema, &doc)
}

pub fn validate_cdi(schema: &Validator, doc_data: &[u8]) -> Result<()> {
    let doc = document_value(doc_data)?;
    validate_value(schema, &doc)?;
    validate_cdi_document_content(&doc)?;
    validate_typed_cdi_document(doc_data)
}

pub fn validate_builtin(doc_data: &[u8]) -> Result<()> {
    let schema = BUILTIN_SCHEMA
        .as_ref()
        .map_err(|err| anyhow!("compile builtin CDI schema: {err}"))?;
    validate_cdi(schema, doc_data)
}

fn validate_value(schema: &Validator, doc: &Value) -> Result<()> {
    let errors: Vec<String> = schema
        .iter_errors(doc)
        .map(|error| error.to_string())
        .collect();

    if errors.is_empty() {
        return Ok(());
    }

    Err(anyhow!("schema validation failed: {}", errors.join("; ")))
}

fn validate_typed_cdi_document(doc_data: &[u8]) -> Result<()> {
    let spec: CDISpec =
        serde_yaml::from_slice(doc_data).context("parse CDI document using declared version")?;
    validate_declared_version_fields(&spec)
}

fn validate_cdi_document_content(doc: &Value) -> Result<()> {
    if doc
        .get("devices")
        .and_then(Value::as_array)
        .is_some_and(Vec::is_empty)
    {
        return Err(anyhow!(
            "CDI schema validation failed: top-level devices array must not be empty"
        ));
    }

    validate_annotations("", doc.get("annotations"))?;

    if let Some(devices) = doc.get("devices").and_then(Value::as_array) {
        for device in devices {
            let name = device
                .get("name")
                .and_then(Value::as_str)
                .unwrap_or_default();
            validate_annotations(name, device.get("annotations"))?;
        }
    }

    Ok(())
}

fn validate_annotations(name: &str, annotations: Option<&Value>) -> Result<()> {
    let Some(Value::Object(annotations)) = annotations else {
        return Ok(());
    };

    let mut parsed = BTreeMap::new();
    for (key, value) in annotations {
        let Some(value) = value.as_str() else {
            return Err(anyhow!(
                "invalid annotation {}.{}; annotation value is not a string",
                name,
                key
            ));
        };
        parsed.insert(key.clone(), value.to_string());
    }

    validate_spec_annotations(name, &parsed)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn builtin_schema_accepts_v1_1_features() {
        let doc = br#"
cdiVersion: "1.1.0"
kind: "vendor.com/device"
containerEdits:
  netDevices:
    - hostInterfaceName: "eth0"
      name: "container_eth0"
  intelRdt:
    schemata:
      - "L3:0=ffff"
    enableMonitoring: true
devices:
  - name: "gpu0"
    containerEdits:
      deviceNodes:
        - path: "/dev/null"
"#;

        validate_builtin(doc).expect("v1.1.0 document should validate");
    }

    #[test]
    fn builtin_schema_rejects_wrong_type() {
        let doc = br#"
cdiVersion: "1.1.0"
kind: "vendor.com/device"
devices: "not-an-array"
"#;

        assert!(validate_builtin(doc).is_err());
    }

    #[test]
    fn builtin_schema_rejects_v1_1_legacy_intel_rdt_fields() {
        let doc = br#"
cdiVersion: "1.1.0"
kind: "vendor.com/device"
containerEdits:
  intelRdt:
    enableCMT: true
devices:
  - name: "gpu0"
    containerEdits:
      deviceNodes:
        - path: "/dev/null"
"#;

        let err = validate_builtin(doc).expect_err("v1.1.0 must reject legacy Intel RDT fields");

        assert!(err.to_string().contains("enableCMT"));
    }

    #[test]
    fn builtin_schema_rejects_v1_0_intel_rdt_enable_monitoring_field() {
        let doc = br#"
cdiVersion: "1.0.0"
kind: "vendor.com/device"
containerEdits:
  intelRdt:
    enableMonitoring: false
devices:
  - name: "gpu0"
    containerEdits:
      deviceNodes:
        - path: "/dev/null"
"#;

        let err = validate_builtin(doc).expect_err("v1.0.0 must reject v1.1.0 Intel RDT fields");

        assert!(err.to_string().contains("enableMonitoring"));
    }

    #[test]
    fn generic_validate_allows_empty_devices_when_schema_allows_it() {
        let schema_json: Value = serde_json::json!({
            "type": "object"
        });
        let schema = Validator::options()
            .with_draft(Draft::Draft7)
            .build(&schema_json)
            .expect("compile permissive schema");
        let doc = br#"
devices: []
"#;

        validate(&schema, doc).expect("generic validation should only apply the supplied schema");
    }

    #[test]
    fn cdi_schema_rejects_invalid_spec_annotations() {
        let doc = br#"
cdiVersion: "1.1.0"
kind: "vendor.com/device"
annotations:
  "inva$$lid_CDIKEY": "value"
devices:
  - name: "gpu0"
    containerEdits:
      deviceNodes:
        - path: "/dev/null"
"#;

        let err = validate_builtin(doc).expect_err("invalid annotation key should fail");

        assert!(err.to_string().contains("annotations"));
    }

    #[test]
    fn cdi_schema_rejects_invalid_device_annotations() {
        let doc = br#"
cdiVersion: "1.1.0"
kind: "vendor.com/device"
devices:
  - name: "gpu0"
    annotations:
      "inva$$lid_CDIKEY": "value"
    containerEdits:
      deviceNodes:
        - path: "/dev/null"
"#;

        let err = validate_builtin(doc).expect_err("invalid device annotation key should fail");

        assert!(err.to_string().contains("gpu0.annotations"));
    }
}