Skip to main content

container_device_interface/
spec.rs

1use std::{collections::BTreeMap, path::PathBuf};
2
3use anyhow::{anyhow, Context, Result};
4use oci_spec::runtime as oci;
5use path_clean::clean;
6
7use crate::{
8    container_edits::ContainerEdits,
9    container_edits::Validate,
10    device::new_device,
11    device::Device,
12    internal::validation::validate::validate_spec_annotations,
13    parser::parse_qualifier,
14    parser::validate_class_name,
15    parser::validate_vendor_name,
16    specs::config::Spec as CDISpec,
17    utils::is_cdi_spec,
18    version::{
19        minimum_required_version, validate_declared_version_fields, VersionWrapper,
20        VALID_SPEC_VERSIONS,
21    },
22};
23
24// No leading dot: Path::set_extension takes the extension itself; a
25// dotted value produces "spec..yaml".
26const DEFAULT_SPEC_EXT_SUFFIX: &str = "yaml";
27
28// Spec represents a single CDI Spec. It is usually loaded from a
29// file and stored in a cache. The Spec has an associated priority.
30// This priority is inherited from the associated priority of the
31// CDI Spec directory that contains the CDI Spec file and is used
32// to resolve conflicts if multiple CDI Spec files contain entries
33// for the same fully qualified device.
34#[derive(Clone, Debug, Default, Eq, Hash, PartialEq)]
35pub struct Spec {
36    pub cdi_spec: CDISpec,
37    vendor: String,
38    class: String,
39    path: String,
40    priority: i32,
41    pub devices: BTreeMap<String, Device>,
42}
43
44impl Spec {
45    // get_vendor returns the vendor of this Spec.
46    pub fn get_vendor(&self) -> String {
47        self.vendor.clone()
48    }
49
50    // get_class returns the device class of this Spec.
51    pub fn get_class(&self) -> String {
52        self.class.clone()
53    }
54
55    // get_devices returns the devices list.
56    pub fn get_devices(&self) -> BTreeMap<String, Device> {
57        self.devices.clone()
58    }
59
60    // get_device returns the device for the given unqualified name.
61    pub fn get_device(&self, key: &str) -> Option<&Device> {
62        self.devices.get(key)
63    }
64
65    // get_path returns the filesystem path of this Spec.
66    pub fn get_path(&self) -> String {
67        self.path.clone()
68    }
69
70    // get_priority returns the priority of this Spec.
71    pub fn get_priority(&self) -> i32 {
72        self.priority
73    }
74
75    // edits returns the applicable global container edits for this spec.
76    pub fn edits(&mut self) -> Option<ContainerEdits> {
77        self.cdi_spec
78            .container_edits
79            .clone()
80            .map(|ce| ContainerEdits {
81                container_edits: ce,
82            })
83    }
84
85    // validate the Spec.
86    pub fn validate(&mut self) -> Result<BTreeMap<String, Device>> {
87        validate_version(&self.cdi_spec).context("validate cdi version failed")?;
88        validate_vendor_name(&self.vendor).context("validate vendor name failed")?;
89        validate_class_name(&self.class).context("validate class name failed")?;
90        validate_spec_annotations(&self.cdi_spec.kind, &self.cdi_spec.annotations)
91            .context("validate spec annotations failed")?;
92
93        if let Some(ref mut ce) = self.edits() {
94            ce.validate().context("validate container edits failed")?;
95        }
96
97        let mut devices = BTreeMap::new();
98        for d in &self.cdi_spec.devices {
99            let dev =
100                new_device(self, d).with_context(|| format!("failed to add device {}", d.name))?;
101            if devices.contains_key(&d.name) {
102                return Err(anyhow::anyhow!("invalid spec, multiple device {}", d.name));
103            }
104            devices.insert(d.name.clone(), dev);
105        }
106
107        if devices.is_empty() {
108            return Err(anyhow::anyhow!("invalid spec, no devices"));
109        }
110
111        Ok(devices)
112    }
113
114    // apply_edits applies the Spec's global-scope container edits to an OCI Spec.
115    pub fn apply_edits(&mut self, oci_spec: &mut oci::Spec) -> Result<()> {
116        if let Some(ref mut ce) = self.edits() {
117            ce.apply(oci_spec)
118                .context("container edits applys failed.")?;
119        }
120
121        Ok(())
122    }
123}
124
125pub fn parse_spec(path: &PathBuf) -> Result<CDISpec> {
126    if !path.exists() {
127        return Err(anyhow!("CDI spec path not found"));
128    }
129
130    let data = std::fs::read(path).context("read config file")?;
131    let cdi_spec: CDISpec = serde_yaml::from_slice(&data).context("serde yaml read from file")?;
132
133    Ok(cdi_spec)
134}
135
136// validate_spec validates the Spec using the extneral validator.
137pub fn validate_spec(raw_spec: &CDISpec) -> Result<()> {
138    let data = serde_yaml::to_string(raw_spec).context("marshal CDI spec for schema validation")?;
139    crate::schema::validate_builtin(data.as_bytes()).context("invalid CDI Spec schema")?;
140    Ok(())
141}
142
143// read_spec reads the given CDI Spec file. The resulting Spec is
144// assigned the given priority. If reading or parsing the Spec
145// data fails read_spec returns a nil Spec and an error.
146pub fn read_spec(path: &PathBuf, priority: i32) -> Result<Spec> {
147    let raw_spec = parse_spec(path).context("parse spec file failed")?;
148    let cdi_spec = new_spec(&raw_spec, path, priority).context("create a new cdi spec failed")?;
149
150    Ok(cdi_spec)
151}
152
153// new_spec creates a new Spec from the given CDI Spec data. The
154// Spec is marked as loaded from the given path with the given
155// priority. If Spec data validation fails new_spec returns an error.
156pub fn new_spec(raw_spec: &CDISpec, path: &PathBuf, priority: i32) -> Result<Spec> {
157    if raw_spec.devices.is_empty() {
158        return Err(anyhow::anyhow!("invalid spec, no devices"));
159    }
160
161    validate_spec(raw_spec).context("invalid CDI Spec")?;
162
163    let mut cleaned_path = clean(path);
164    if !is_cdi_spec(&cleaned_path) {
165        cleaned_path.set_extension(DEFAULT_SPEC_EXT_SUFFIX);
166    }
167
168    let (vendor, class) = parse_qualifier(&raw_spec.kind);
169
170    let mut spec: Spec = Spec {
171        cdi_spec: raw_spec.clone(),
172        path: cleaned_path.display().to_string(),
173        priority,
174        vendor: vendor.to_owned(),
175        class: class.to_owned(),
176        ..Default::default()
177    };
178    spec.devices = spec.validate().context("validate spec failed")?;
179
180    Ok(spec)
181}
182
183fn validate_version(cdi_spec: &CDISpec) -> Result<()> {
184    let version = &cdi_spec.version;
185    if !VALID_SPEC_VERSIONS.is_valid_version(version) {
186        return Err(anyhow::anyhow!("invalid version {}", version));
187    }
188
189    validate_declared_version_fields(cdi_spec)?;
190
191    let min_version = minimum_required_version(cdi_spec)
192        .with_context(|| "could not determine minimum required version")?;
193
194    if min_version.is_greater_than(&VersionWrapper::new(version)) {
195        return Err(anyhow::anyhow!(
196            "the spec version must be at least v{}",
197            min_version
198        ));
199    }
200
201    Ok(())
202}
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use oci_spec::runtime as oci;
208    use std::path::PathBuf;
209
210    #[test]
211    fn parse_spec_rejects_unknown_fields() {
212        let path = PathBuf::from("tests/fixtures/cdi-unknown-field.yaml");
213        let err = parse_spec(&path).expect_err("unknown field should fail");
214        assert!(err.to_string().contains("serde yaml read from file"));
215    }
216
217    #[test]
218    fn new_spec_rejects_empty_devices() {
219        let path = PathBuf::from("tests/fixtures/cdi-empty-devices.yaml");
220        let raw = parse_spec(&path).expect("empty device fixture parses");
221        let err = new_spec(&raw, &path, 0).expect_err("empty devices should fail");
222        assert!(err.to_string().contains("no devices"));
223    }
224
225    #[test]
226    fn new_spec_rejects_legacy_intel_rdt_fields_in_v1_1() {
227        let path = PathBuf::from("tests/fixtures/cdi-v1.1-legacy-intel-rdt.yaml");
228        let raw = parse_spec(&path).expect("legacy fields should parse before version validation");
229        let err = new_spec(&raw, &path, 0).expect_err("v1.1.0 should reject legacy fields");
230
231        assert!(format!("{err:#}").contains("enableCMT"));
232    }
233
234    #[test]
235    fn new_spec_processes_legacy_intel_rdt_fields_before_v1_1() {
236        let path = PathBuf::from("tests/fixtures/cdi-v1.0-legacy-intel-rdt.yaml");
237        let raw = parse_spec(&path).expect("v1.0.0 legacy Intel RDT fixture parses");
238        let mut spec = new_spec(&raw, &path, 0).expect("v1.0.0 legacy Intel RDT fixture validates");
239        let mut oci_spec = oci::Spec::default();
240
241        spec.apply_edits(&mut oci_spec)
242            .expect("legacy Intel RDT edits apply");
243
244        let rdt = oci_spec
245            .linux()
246            .as_ref()
247            .and_then(|linux| linux.intel_rdt().as_ref())
248            .expect("Intel RDT should be set");
249
250        #[allow(deprecated)]
251        {
252            assert_eq!(&Some(true), rdt.enable_cmt());
253            assert_eq!(&Some(true), rdt.enable_mbm());
254        }
255    }
256
257    #[test]
258    fn new_spec_rejects_empty_device_edits() {
259        let raw = CDISpec {
260            version: "1.1.0".to_string(),
261            kind: "vendor.com/device".to_string(),
262            devices: vec![crate::specs::config::Device {
263                name: "gpu0".to_string(),
264                container_edits: crate::specs::config::ContainerEdits::default(),
265                ..Default::default()
266            }],
267            ..Default::default()
268        };
269        let path = PathBuf::from("/tmp/vendor-device.yaml");
270
271        let err = new_spec(&raw, &path, 0).expect_err("empty device edits should fail");
272        let err = format!("{err:?}");
273
274        assert!(err.contains("empty device edits"), "{err}");
275    }
276
277    fn one_device(name: &str) -> crate::specs::config::Device {
278        crate::specs::config::Device {
279            name: name.to_string(),
280            container_edits: crate::specs::config::ContainerEdits {
281                env: Some(vec!["X=1".to_string()]),
282                ..Default::default()
283            },
284            ..Default::default()
285        }
286    }
287
288    #[test]
289    fn new_spec_rejects_duplicate_device_names() {
290        let raw = crate::specs::config::Spec {
291            version: "0.6.0".to_string(),
292            kind: "vendor.com/device".to_string(),
293            devices: vec![one_device("d0"), one_device("d0")],
294            ..Default::default()
295        };
296        let err = new_spec(&raw, &PathBuf::from("/tmp/x.yaml"), 0).unwrap_err();
297        assert!(format!("{err:?}").contains("multiple device"));
298    }
299
300    #[test]
301    fn new_spec_normalizes_non_spec_extensions() {
302        let raw = crate::specs::config::Spec {
303            version: "0.6.0".to_string(),
304            kind: "vendor.com/device".to_string(),
305            devices: vec![one_device("d0")],
306            ..Default::default()
307        };
308        let spec = new_spec(&raw, &PathBuf::from("/tmp/spec.conf"), 0).unwrap();
309        assert!(spec.get_path().ends_with("spec.yaml"));
310    }
311
312    #[test]
313    fn spec_level_edits_apply_to_oci_spec() {
314        let raw = crate::specs::config::Spec {
315            version: "0.6.0".to_string(),
316            kind: "vendor.com/device".to_string(),
317            container_edits: Some(crate::specs::config::ContainerEdits {
318                env: Some(vec!["GLOBAL=1".to_string()]),
319                ..Default::default()
320            }),
321            devices: vec![one_device("d0")],
322            ..Default::default()
323        };
324        let mut spec = new_spec(&raw, &PathBuf::from("/tmp/x.yaml"), 0).unwrap();
325        let mut oci_spec = oci::Spec::default();
326        spec.apply_edits(&mut oci_spec).unwrap();
327        let env = oci_spec.process().as_ref().unwrap().env().as_ref().unwrap();
328        assert!(env.contains(&"GLOBAL=1".to_string()));
329    }
330
331    #[test]
332    fn parse_spec_requires_an_existing_path() {
333        let err = parse_spec(&PathBuf::from("/nonexistent/spec.yaml")).unwrap_err();
334        assert!(err.to_string().contains("not found"));
335    }
336
337    #[test]
338    fn validate_version_rejects_unknown_and_understated_versions() {
339        let mut raw = crate::specs::config::Spec {
340            version: "9.9.9".to_string(),
341            kind: "vendor.com/device".to_string(),
342            devices: vec![one_device("d0")],
343            ..Default::default()
344        };
345        let err = new_spec(&raw, &PathBuf::from("/tmp/x.yaml"), 0).unwrap_err();
346        assert!(format!("{err:?}").contains("invalid version"));
347
348        // additionalGIDs require 0.7.0: declaring 0.5.0 understates it
349        raw.version = "0.5.0".to_string();
350        raw.devices[0].container_edits.additional_gids = Some(vec![5]);
351        let err = new_spec(&raw, &PathBuf::from("/tmp/x.yaml"), 0).unwrap_err();
352        assert!(format!("{err:?}").contains("must be at least"));
353    }
354}