1use serde::{Deserialize, Serialize};
36use tatara_lisp::DeriveTataraDomain;
37
38use crate::SpecError;
39
40#[derive(DeriveTataraDomain, Serialize, Deserialize, Debug, Clone)]
46#[tatara(keyword = "defactivation-script-algorithm")]
47pub struct ActivationScriptAlgorithm {
48 pub name: String,
49 pub target: ActivationTarget,
50 pub phases: Vec<ActivationPhase>,
51}
52
53#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub enum ActivationTarget {
58 NixOS,
60 Darwin,
62 HomeManager,
65}
66
67#[derive(Serialize, Deserialize, Debug, Clone)]
70pub struct ActivationPhase {
71 pub kind: ActivationPhaseKind,
72 #[serde(default)]
73 pub bind: Option<String>,
74 #[serde(default)]
75 pub from: Option<String>,
76}
77
78#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ActivationPhaseKind {
82 ResolveSystemBuildToplevel,
86 GenerateSystemdUnits,
89 GenerateLaunchdPlists,
93 GenerateEtcSymlinks,
96 ResolveSecretRefs,
99 ComposeActivationScript,
102 WriteActivationDerivation,
105 AttestClosureToChain,
108}
109
110use crate::module_system::{Config, NixValue};
113
114pub struct ActivationArgs {
116 pub config: Config,
120 pub host: String,
122 pub user: String,
124 pub toplevel_path: String,
128}
129
130#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ActivationOutcome {
133 pub script_text: String,
137 pub artifacts: std::collections::BTreeMap<String, String>,
140 pub target: ActivationTarget,
142}
143
144pub fn apply(
156 algo: &ActivationScriptAlgorithm,
157 args: &ActivationArgs,
158) -> Result<ActivationOutcome, SpecError> {
159 let mut artifacts: std::collections::BTreeMap<String, String> = Default::default();
160 let mut script_lines: Vec<String> = Vec::new();
161
162 script_lines.push("#!/bin/sh".into());
164 script_lines.push(format!(
165 "# sui-spec activation script for {:?} on `{}`",
166 algo.target, args.host,
167 ));
168 script_lines.push("set -eu".into());
169 script_lines.push(String::new());
170
171 for phase in &algo.phases {
172 match phase.kind {
173 ActivationPhaseKind::ResolveSystemBuildToplevel => {
174 script_lines.push(format!(
175 "# resolve toplevel → {}",
176 args.toplevel_path,
177 ));
178 artifacts.insert("toplevel".into(), args.toplevel_path.clone());
179 }
180 ActivationPhaseKind::GenerateSystemdUnits => {
181 if algo.target == ActivationTarget::Darwin {
183 continue;
184 }
185 let units = list_unit_paths(&args.config);
186 script_lines.push("# systemd units:".into());
187 for u in &units {
188 script_lines.push(format!("# {u}"));
189 }
190 artifacts.insert("systemd-units".into(),
191 format!("/nix/store/zzz-systemd-units-{}", args.host));
192 script_lines.push(format!(
193 "systemctl daemon-reload # {} units",
194 units.len(),
195 ));
196 }
197 ActivationPhaseKind::GenerateLaunchdPlists => {
198 if algo.target == ActivationTarget::NixOS {
199 continue;
200 }
201 let plists = list_launchd_paths(&args.config);
202 script_lines.push("# launchd plists:".into());
203 for p in &plists {
204 script_lines.push(format!("# {p}"));
205 }
206 artifacts.insert("launchd-plists".into(),
207 format!("/nix/store/zzz-launchd-{}", args.host));
208 }
209 ActivationPhaseKind::GenerateEtcSymlinks => {
210 if algo.target == ActivationTarget::HomeManager {
211 continue;
212 }
213 let entries = list_etc_entries(&args.config);
214 script_lines.push("# /etc symlink farm:".into());
215 for (target, source) in &entries {
216 script_lines.push(format!("# /etc/{target} → {source}"));
217 }
218 artifacts.insert("etc-farm".into(),
219 format!("/nix/store/zzz-etc-{}", args.host));
220 }
221 ActivationPhaseKind::ResolveSecretRefs => {
222 let refs = list_secret_refs(&args.config);
223 script_lines.push(format!(
224 "# {} secret refs resolved (handled out-of-band by cofre)",
225 refs.len(),
226 ));
227 }
228 ActivationPhaseKind::ComposeActivationScript => {
229 script_lines.push(String::new());
230 script_lines.push("# main activation".into());
231 script_lines.push(format!(
232 "echo \"activating generation for {}\" >&2",
233 args.host,
234 ));
235 }
236 ActivationPhaseKind::WriteActivationDerivation => {
237 artifacts.insert("activation-drv".into(),
240 format!("/nix/store/zzz-activation-{}.drv", args.host));
241 }
242 ActivationPhaseKind::AttestClosureToChain => {
243 artifacts.insert("attest-request".into(),
247 format!("pending:{}", args.host));
248 }
249 }
250 }
251
252 Ok(ActivationOutcome {
253 script_text: script_lines.join("\n"),
254 artifacts,
255 target: algo.target,
256 })
257}
258
259fn list_unit_paths(config: &Config) -> Vec<String> {
262 let mut out: Vec<String> = config
265 .keys()
266 .filter(|k| k.starts_with("systemd.services."))
267 .cloned()
268 .collect();
269 out.sort();
270 out
271}
272
273fn list_launchd_paths(config: &Config) -> Vec<String> {
274 let mut out: Vec<String> = config
277 .keys()
278 .filter(|k| k.starts_with("launchd.daemons.")
279 || k.starts_with("launchd.user.agents."))
280 .cloned()
281 .collect();
282 out.sort();
283 out
284}
285
286fn list_etc_entries(config: &Config) -> Vec<(String, String)> {
287 let mut out: Vec<(String, String)> = Vec::new();
290 for (path, value) in config {
291 let Some(rest) = path.strip_prefix("environment.etc.") else { continue; };
292 let source = match value {
293 NixValue::String(s) => s.clone(),
294 NixValue::Object(o) => o
295 .get("source")
296 .and_then(|v| v.as_str())
297 .unwrap_or("<inline>")
298 .into(),
299 _ => "<unknown>".into(),
300 };
301 out.push((rest.into(), source));
302 }
303 out.sort();
304 out
305}
306
307fn list_secret_refs(config: &Config) -> Vec<String> {
308 let mut out: Vec<String> = Vec::new();
312 for (path, value) in config {
313 if let NixValue::Object(o) = value {
314 if o.contains_key("__secretRef") {
315 out.push(path.clone());
316 }
317 }
318 }
319 out
320}
321
322pub const CANONICAL_ACTIVATION_LISP: &str =
325 include_str!("../specs/activation_script.lisp");
326
327pub fn load_canonical() -> Result<Vec<ActivationScriptAlgorithm>, SpecError> {
334 crate::loader::load_all::<ActivationScriptAlgorithm>(
335 CANONICAL_ACTIVATION_LISP,
336 )
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn canonical_specs_parse() {
345 let algos = load_canonical().expect("canonical activation specs must compile");
346 assert!(
347 !algos.is_empty(),
348 "canonical activation corpus must contain at least one algorithm",
349 );
350 }
351
352 #[test]
353 fn three_targets_covered() {
354 let algos = load_canonical().unwrap();
355 let targets: std::collections::HashSet<ActivationTarget> =
356 algos.iter().map(|a| a.target).collect();
357 for required in [
358 ActivationTarget::NixOS,
359 ActivationTarget::Darwin,
360 ActivationTarget::HomeManager,
361 ] {
362 assert!(
363 targets.contains(&required),
364 "missing activation algorithm for target {required:?}",
365 );
366 }
367 }
368
369 #[test]
370 fn every_algorithm_resolves_then_composes_then_writes() {
371 let algos = load_canonical().unwrap();
372 for algo in &algos {
373 let kinds: Vec<ActivationPhaseKind> =
374 algo.phases.iter().map(|p| p.kind).collect();
375 assert!(
377 kinds.contains(&ActivationPhaseKind::ResolveSystemBuildToplevel),
378 "{}: missing ResolveSystemBuildToplevel",
379 algo.name,
380 );
381 assert!(
382 kinds.contains(&ActivationPhaseKind::ComposeActivationScript),
383 "{}: missing ComposeActivationScript",
384 algo.name,
385 );
386 assert!(
387 kinds.contains(&ActivationPhaseKind::WriteActivationDerivation),
388 "{}: missing WriteActivationDerivation",
389 algo.name,
390 );
391 }
392 }
393
394 #[test]
395 fn darwin_uses_launchd_not_systemd() {
396 let algos = load_canonical().unwrap();
397 let darwin = algos
398 .iter()
399 .find(|a| a.target == ActivationTarget::Darwin)
400 .expect("darwin algo must exist");
401 let kinds: Vec<ActivationPhaseKind> =
402 darwin.phases.iter().map(|p| p.kind).collect();
403 assert!(kinds.contains(&ActivationPhaseKind::GenerateLaunchdPlists));
404 assert!(!kinds.contains(&ActivationPhaseKind::GenerateSystemdUnits));
405 }
406
407 #[test]
408 fn nixos_uses_systemd_not_launchd() {
409 let algos = load_canonical().unwrap();
410 let nixos = algos
411 .iter()
412 .find(|a| a.target == ActivationTarget::NixOS)
413 .expect("nixos algo must exist");
414 let kinds: Vec<ActivationPhaseKind> =
415 nixos.phases.iter().map(|p| p.kind).collect();
416 assert!(kinds.contains(&ActivationPhaseKind::GenerateSystemdUnits));
417 assert!(!kinds.contains(&ActivationPhaseKind::GenerateLaunchdPlists));
418 }
419
420 fn empty_args(host: &str, target: ActivationTarget) -> ActivationArgs {
423 let _ = target;
424 ActivationArgs {
425 config: Config::new(),
426 host: host.into(),
427 user: "drzzln".into(),
428 toplevel_path: format!("/nix/store/zzz-toplevel-{host}"),
429 }
430 }
431
432 #[test]
433 fn empty_darwin_activation_produces_valid_shebang() {
434 let algo = load_canonical().unwrap()
435 .into_iter()
436 .find(|a| a.target == ActivationTarget::Darwin)
437 .unwrap();
438 let outcome = apply(&algo, &empty_args("cid", ActivationTarget::Darwin)).unwrap();
439 assert!(outcome.script_text.starts_with("#!/bin/sh"));
440 assert!(outcome.script_text.contains("set -eu"));
441 assert_eq!(outcome.target, ActivationTarget::Darwin);
442 assert!(outcome.artifacts.contains_key("launchd-plists"));
444 assert!(!outcome.artifacts.contains_key("systemd-units"));
445 }
446
447 #[test]
448 fn nixos_activation_records_systemd_units_not_launchd() {
449 let algo = load_canonical().unwrap()
450 .into_iter()
451 .find(|a| a.target == ActivationTarget::NixOS)
452 .unwrap();
453 let outcome = apply(&algo, &empty_args("rio", ActivationTarget::NixOS)).unwrap();
454 assert!(outcome.artifacts.contains_key("systemd-units"));
455 assert!(!outcome.artifacts.contains_key("launchd-plists"));
456 }
457
458 #[test]
459 fn home_manager_activation_skips_etc_farm() {
460 let algo = load_canonical().unwrap()
461 .into_iter()
462 .find(|a| a.target == ActivationTarget::HomeManager)
463 .unwrap();
464 let outcome = apply(&algo, &empty_args("cid", ActivationTarget::HomeManager)).unwrap();
465 assert!(!outcome.artifacts.contains_key("etc-farm"));
467 }
468
469 #[test]
470 fn config_with_systemd_units_surfaces_them() {
471 let algo = load_canonical().unwrap()
472 .into_iter()
473 .find(|a| a.target == ActivationTarget::NixOS)
474 .unwrap();
475 let mut args = empty_args("rio", ActivationTarget::NixOS);
476 args.config.insert(
477 "systemd.services.nginx".into(),
478 serde_json::json!({"description": "web"}),
479 );
480 args.config.insert(
481 "systemd.services.postgres".into(),
482 serde_json::json!({"description": "db"}),
483 );
484 let outcome = apply(&algo, &args).unwrap();
485 assert!(outcome.script_text.contains("systemd.services.nginx"));
486 assert!(outcome.script_text.contains("systemd.services.postgres"));
487 assert!(outcome.script_text.contains("2 units"));
488 }
489
490 #[test]
491 fn etc_entries_render_into_script() {
492 let algo = load_canonical().unwrap()
493 .into_iter()
494 .find(|a| a.target == ActivationTarget::NixOS)
495 .unwrap();
496 let mut args = empty_args("rio", ActivationTarget::NixOS);
497 args.config.insert(
498 "environment.etc.nixos/configuration.nix".into(),
499 serde_json::json!({"source": "/nix/store/xyz-config"}),
500 );
501 let outcome = apply(&algo, &args).unwrap();
502 assert!(outcome.script_text.contains("nixos/configuration.nix"));
503 assert!(outcome.script_text.contains("/nix/store/xyz-config"));
504 }
505
506 #[test]
507 fn secret_refs_counted_in_script() {
508 let algo = load_canonical().unwrap()
509 .into_iter()
510 .find(|a| a.target == ActivationTarget::NixOS)
511 .unwrap();
512 let mut args = empty_args("rio", ActivationTarget::NixOS);
513 args.config.insert(
514 "services.foo.password".into(),
515 serde_json::json!({"__secretRef": "akeyless://prod/db-password"}),
516 );
517 let outcome = apply(&algo, &args).unwrap();
518 assert!(outcome.script_text.contains("1 secret refs"));
519 }
520
521 #[test]
525 fn cross_domain_chain_module_system_then_activation() {
526 use crate::module_system::{
527 eval_modules, Module, OptionDecl, Definition, NixValue,
528 };
529 use crate::module_system as ms;
530
531 let mut module = Module::default();
533 module.options.insert(
534 "systemd.services.nginx".into(),
535 OptionDecl {
536 type_name: "attrsOf".into(),
537 ..Default::default()
538 },
539 );
540 module.config.push(Definition {
541 path: "systemd.services.nginx".into(),
542 value: serde_json::json!({"description": "test"}),
543 priority: 100,
544 cond: None,
545 });
546
547 let registry = crate::module_system::load_canonical().unwrap().types;
549 let config = eval_modules(&[module], ®istry).unwrap();
550 let _ = ms::NixValue::Null; let algo = load_canonical().unwrap()
554 .into_iter()
555 .find(|a| a.target == ActivationTarget::NixOS)
556 .unwrap();
557 let outcome = apply(&algo, &ActivationArgs {
558 config,
559 host: "rio".into(),
560 user: "drzzln".into(),
561 toplevel_path: "/nix/store/zzz-toplevel-rio".into(),
562 }).unwrap();
563
564 assert!(outcome.script_text.contains("systemd.services.nginx"),
567 "activation script must list the configured unit; got: {}",
568 outcome.script_text,
569 );
570 let _ = NixValue::Null;
571 }
572}