Skip to main content

nex_pkg/
machine_profile.rs

1use std::fmt;
2use std::path::{Path, PathBuf};
3
4use anyhow::{bail, Context, Result};
5use serde::Deserialize;
6
7pub const MACHINE_PROFILE_FILE: &str = "machine-profile.pkl";
8pub const MACHINE_PROFILE_TOML_COMPAT_FILE: &str = "machine-profile.toml";
9pub const MACHINE_PROFILE_SCHEMA_V1: &str = "io.styrene.nex.machine-profile.v1";
10
11#[derive(Debug, Clone, Deserialize)]
12pub struct MachineProfileDocument {
13    pub machine_profile: MachineProfile,
14    #[serde(default)]
15    pub dependencies: Vec<MachineProfileDependency>,
16}
17
18#[derive(Debug, Clone, Deserialize)]
19pub struct MachineProfile {
20    pub schema: String,
21    pub id: String,
22    pub slug: String,
23    pub name: String,
24    pub version: String,
25    pub description: Option<String>,
26    pub license: Option<String>,
27    pub min_nex: String,
28    pub defaults: MachineProfileDefaults,
29    pub safety: MachineProfileSafety,
30    pub secrets: Option<MachineProfileSecrets>,
31}
32
33#[derive(Debug, Clone, Deserialize)]
34pub struct MachineProfileDefaults {
35    pub mode: MachineProfileMode,
36    pub target: MachineProfileTarget,
37}
38
39#[derive(Debug, Clone, Deserialize)]
40#[serde(rename_all = "kebab-case")]
41pub enum MachineProfileMode {
42    PlanOnly,
43    ImageBuild,
44    VmBuild,
45    Provision,
46    ApplyExisting,
47}
48
49impl fmt::Display for MachineProfileMode {
50    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
51        let value = match self {
52            Self::PlanOnly => "plan-only",
53            Self::ImageBuild => "image-build",
54            Self::VmBuild => "vm-build",
55            Self::Provision => "provision",
56            Self::ApplyExisting => "apply-existing",
57        };
58        f.write_str(value)
59    }
60}
61
62#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
63#[serde(rename_all = "kebab-case")]
64pub enum MachineProfileTarget {
65    NixDevshell,
66    OciImage,
67    Vm,
68    PhysicalMachine,
69    ExistingNixos,
70}
71
72impl fmt::Display for MachineProfileTarget {
73    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
74        let value = match self {
75            Self::NixDevshell => "nix-devshell",
76            Self::OciImage => "oci-image",
77            Self::Vm => "vm",
78            Self::PhysicalMachine => "physical-machine",
79            Self::ExistingNixos => "existing-nixos",
80        };
81        f.write_str(value)
82    }
83}
84
85#[derive(Debug, Clone, Deserialize)]
86pub struct MachineProfileSafety {
87    pub default_destructive: bool,
88    pub requires_confirmation: bool,
89    pub requires_target_attestation: bool,
90    pub allowed_targets: Vec<MachineProfileTarget>,
91}
92
93#[derive(Debug, Clone, Deserialize)]
94pub struct MachineProfileSecrets {
95    #[serde(default)]
96    pub required: Vec<String>,
97    #[serde(default)]
98    pub optional: Vec<String>,
99}
100
101#[derive(Debug, Clone, Deserialize)]
102pub struct MachineProfileDependency {
103    pub kind: MachineProfileDependencyKind,
104    pub id: String,
105    pub version: String,
106    pub required: bool,
107}
108
109#[derive(Debug, Clone, Deserialize)]
110#[serde(rename_all = "kebab-case")]
111pub enum MachineProfileDependencyKind {
112    ForgeTemplate,
113}
114
115impl fmt::Display for MachineProfileDependencyKind {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match self {
118            Self::ForgeTemplate => f.write_str("forge-template"),
119        }
120    }
121}
122
123impl MachineProfileDocument {
124    pub fn from_path(path: &Path) -> Result<Self> {
125        let manifest_path = resolve_manifest_path(path)?;
126        let loaded = crate::document::load_document::<Self>(&manifest_path, "machine profile")?;
127        if !loaded.is_canonical() {
128            tracing::debug!(path = %loaded.path.display(), format = ?loaded.format, "loaded compatibility machine profile");
129        }
130        loaded
131            .value
132            .validate()
133            .with_context(|| format!("validating {}", manifest_path.display()))?;
134        Ok(loaded.value)
135    }
136
137    pub fn parse(content: &str) -> Result<Self> {
138        let document: Self =
139            toml::from_str(content).context("invalid compatibility machine profile TOML")?;
140        document.validate()?;
141        Ok(document)
142    }
143
144    pub fn validate(&self) -> Result<()> {
145        let profile = &self.machine_profile;
146        require_non_empty("machine_profile.schema", &profile.schema)?;
147        require_non_empty("machine_profile.id", &profile.id)?;
148        require_non_empty("machine_profile.slug", &profile.slug)?;
149        require_non_empty("machine_profile.name", &profile.name)?;
150        require_non_empty("machine_profile.version", &profile.version)?;
151        require_non_empty("machine_profile.min_nex", &profile.min_nex)?;
152
153        if profile.schema != MACHINE_PROFILE_SCHEMA_V1 {
154            bail!(
155                "unsupported machine profile schema '{}'; expected '{}'",
156                profile.schema,
157                MACHINE_PROFILE_SCHEMA_V1
158            );
159        }
160
161        if !profile
162            .safety
163            .allowed_targets
164            .contains(&profile.defaults.target)
165        {
166            bail!(
167                "default target '{}' is not listed in machine_profile.safety.allowed_targets",
168                profile.defaults.target
169            );
170        }
171
172        if profile.safety.default_destructive && !profile.safety.requires_confirmation {
173            bail!("destructive machine profiles must require confirmation");
174        }
175
176        if profile.defaults.target == MachineProfileTarget::PhysicalMachine
177            && !profile.safety.requires_target_attestation
178        {
179            bail!("physical-machine targets require target attestation");
180        }
181
182        if let Some(secrets) = &profile.secrets {
183            for secret in secrets.required.iter().chain(secrets.optional.iter()) {
184                validate_secret_name(secret)?;
185            }
186        }
187
188        for dependency in &self.dependencies {
189            require_non_empty("dependencies.id", &dependency.id)?;
190            require_non_empty("dependencies.version", &dependency.version)?;
191            let _ = dependency.kind.to_string();
192            let _ = dependency.required;
193        }
194
195        Ok(())
196    }
197}
198
199pub fn resolve_manifest_path(path: &Path) -> Result<PathBuf> {
200    if path.is_dir() {
201        let canonical = path.join(MACHINE_PROFILE_FILE);
202        if canonical.exists() {
203            return Ok(canonical);
204        }
205        let compat = path.join(MACHINE_PROFILE_TOML_COMPAT_FILE);
206        if compat.exists() {
207            return Ok(compat);
208        }
209        Ok(canonical)
210    } else {
211        Ok(path.to_path_buf())
212    }
213}
214
215fn require_non_empty(field: &str, value: &str) -> Result<()> {
216    if value.trim().is_empty() {
217        bail!("{field} is required");
218    }
219    Ok(())
220}
221
222fn validate_secret_name(secret: &str) -> Result<()> {
223    require_non_empty("secret name", secret)?;
224    if secret.contains('=') || secret.contains(':') || secret.contains('/') || secret.contains('\\')
225    {
226        bail!("secret '{}' must be a name, not a value or path", secret);
227    }
228    if !secret
229        .chars()
230        .all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
231    {
232        bail!(
233            "secret '{}' must use uppercase environment-name syntax [A-Z0-9_]",
234            secret
235        );
236    }
237    Ok(())
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    fn valid_manifest() -> &'static str {
245        r#"
246[machine_profile]
247schema = "io.styrene.nex.machine-profile.v1"
248id = "io.styrene.nex.machine-profile.example"
249slug = "example"
250name = "Example Machine Profile"
251version = "1.0.0"
252description = "Reusable machine materialization policy."
253license = "MIT"
254min_nex = "0.18.0"
255
256[machine_profile.defaults]
257mode = "plan-only"
258target = "oci-image"
259
260[machine_profile.safety]
261default_destructive = false
262requires_confirmation = true
263requires_target_attestation = true
264allowed_targets = ["nix-devshell", "oci-image", "vm", "physical-machine", "existing-nixos"]
265
266[machine_profile.secrets]
267required = ["GITHUB_TOKEN"]
268optional = ["AWS_PROFILE"]
269
270[[dependencies]]
271kind = "forge-template"
272id = "nixos-workstation"
273version = ">=1.0.0"
274required = true
275"#
276    }
277
278    #[test]
279    fn valid_machine_profile_passes() {
280        MachineProfileDocument::parse(valid_manifest()).expect("valid manifest");
281    }
282
283    #[test]
284    fn rejects_wrong_schema() {
285        let manifest = valid_manifest().replace(MACHINE_PROFILE_SCHEMA_V1, "wrong");
286        let error = MachineProfileDocument::parse(&manifest).expect_err("schema rejected");
287        assert!(error
288            .to_string()
289            .contains("unsupported machine profile schema"));
290    }
291
292    #[test]
293    fn rejects_secret_values() {
294        let manifest = valid_manifest().replace("GITHUB_TOKEN", "GITHUB_TOKEN=secret");
295        let error = MachineProfileDocument::parse(&manifest).expect_err("secret value rejected");
296        assert!(error.to_string().contains("must be a name"));
297    }
298
299    #[test]
300    fn accepts_apply_existing_existing_nixos_without_attestation() {
301        let manifest = valid_manifest()
302            .replace("mode = \"plan-only\"", "mode = \"apply-existing\"")
303            .replace("target = \"oci-image\"", "target = \"existing-nixos\"")
304            .replace(
305                "requires_target_attestation = true",
306                "requires_target_attestation = false",
307            );
308        MachineProfileDocument::parse(&manifest).expect("valid apply-existing profile");
309    }
310
311    #[test]
312    fn rejects_physical_machine_without_attestation() {
313        let manifest = valid_manifest()
314            .replace("target = \"oci-image\"", "target = \"physical-machine\"")
315            .replace(
316                "requires_target_attestation = true",
317                "requires_target_attestation = false",
318            );
319        let error = MachineProfileDocument::parse(&manifest).expect_err("attestation required");
320        let message = format!("{error:#}");
321        assert!(message.contains("physical-machine targets require target attestation"));
322    }
323}