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}