Skip to main content

sui_spec/
activation_script.rs

1//! Activation script generation — typed border for the cppnix
2//! `switch-to-configuration` / `darwin-rebuild` / `home-manager
3//! activate` script generators.
4//!
5//! Given an evaluated NixOS / nix-darwin / home-manager config, the
6//! activation phase produces a typed bundle:
7//!
8//! - the activation script text (and its store path)
9//! - the per-host generation metadata (number, hash, profile path)
10//! - the realised closure of files in `/etc`, systemd units, etc.
11//!
12//! Today the cppnix Nix expressions in `nixos-rebuild` /
13//! `nix-darwin` / `home-manager` produce this through hand-coded
14//! `pkgs.runCommand` derivations.  Per the spec pattern this module
15//! names the typed contract: three algorithms, each authored as a
16//! phase pipeline, drive the activation surface uniformly.
17//!
18//! M2 status: typed border + Lisp spec; interpreter is the M3 step
19//! (depends on a working module system).
20//!
21//! ## Authoring surface
22//!
23//! ```lisp
24//! (defactivation-script-algorithm cppnix-darwin
25//!   :name   "cppnix-darwin"
26//!   :target Darwin
27//!   :phases ((:kind ResolveSystemBuildToplevel)
28//!            (:kind GenerateLaunchdPlists)
29//!            (:kind GenerateEtcSymlinks)
30//!            (:kind ResolveSecretRefs)
31//!            (:kind ComposeActivationScript :bind "script")
32//!            (:kind WriteActivationDerivation :from "script")))
33//! ```
34
35use serde::{Deserialize, Serialize};
36use tatara_lisp::DeriveTataraDomain;
37
38use crate::SpecError;
39
40// ── Typed border ───────────────────────────────────────────────────
41
42/// One activation-script algorithm authored as
43/// `(defactivation-script-algorithm …)`.  Variants by `target` map
44/// to the three cppnix surfaces (NixOS, nix-darwin, home-manager).
45#[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/// Which system surface the algorithm targets.  The phase set is
54/// largely shared, but the launchd / systemd / per-user distinction
55/// determines a few platform-specific phases.
56#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, Hash)]
57pub enum ActivationTarget {
58    /// NixOS (`/run/current-system`, systemd, `switch-to-configuration`).
59    NixOS,
60    /// nix-darwin (`/run/current-system`, launchd daemons, `darwin-rebuild`).
61    Darwin,
62    /// home-manager (per-user activation, launchd user agents or
63    /// systemd user units, `home-manager activate`).
64    HomeManager,
65}
66
67/// One phase of an activation pipeline.  Same flat-kwarg shape as
68/// [`crate::derivation::Phase`] for visual + cognitive consistency.
69#[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/// Closed set of activation phases.  Adding a variant IS extending
79/// the spec language.
80#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
81pub enum ActivationPhaseKind {
82    /// Evaluate `<config>.system.build.toplevel` for the host —
83    /// the entry point for everything else.  Requires the module
84    /// system (see [`crate::module_system`]).
85    ResolveSystemBuildToplevel,
86    /// Generate the systemd unit set from `services.*` modules.
87    /// NixOS + home-manager (linux user) only.
88    GenerateSystemdUnits,
89    /// Generate launchd plists (`/Library/LaunchDaemons/*.plist`,
90    /// `~/Library/LaunchAgents/*.plist`).  Darwin + home-manager
91    /// (mac user) only.
92    GenerateLaunchdPlists,
93    /// Generate the `/etc` symlink farm for the new generation.
94    /// NixOS + Darwin only.
95    GenerateEtcSymlinks,
96    /// Resolve any `SecretRef` value into its materialised cipher
97    /// path before they cross into the activation script.
98    ResolveSecretRefs,
99    /// Compose the activation script text — concatenate per-module
100    /// activation snippets in topological order.
101    ComposeActivationScript,
102    /// Write the activation script + closure as a sui-built
103    /// derivation, returning the store path.
104    WriteActivationDerivation,
105    /// Hash the final activation closure for the OutcomeChain.
106    /// Optional — not every host attaches; per-cluster policy.
107    AttestClosureToChain,
108}
109
110// ── Spec interpreter (M3.0 minimal) ────────────────────────────────
111
112use crate::module_system::{Config, NixValue};
113
114/// Inputs to an activation-script algorithm.
115pub struct ActivationArgs {
116    /// The evaluated module-system config (typically the output of
117    /// `module_system::eval_modules` for the operator's host).
118    /// Carries every option's resolved value path-keyed.
119    pub config: Config,
120    /// Hostname the activation will install on (`hostname -s`).
121    pub host: String,
122    /// Username for HomeManager target; ignored for NixOS/Darwin.
123    pub user: String,
124    /// Path to the typed system-build-toplevel store entry.
125    /// (Sui-build's derivation realisation produces this for M3.1;
126    /// M3.0 callers pass a placeholder.)
127    pub toplevel_path: String,
128}
129
130/// Result of an activation-script run.
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub struct ActivationOutcome {
133    /// The composed activation script text.  In production this is
134    /// written to a `pkgs.runCommand` derivation; M3.0 returns it
135    /// as a plain String for testability.
136    pub script_text: String,
137    /// Per-phase artifact paths produced during the run.  E.g.
138    /// `{"plists": "/nix/store/abc-launchd-plists", "etc": "..."}`.
139    pub artifacts: std::collections::BTreeMap<String, String>,
140    /// Target the script is for — mirrors the algorithm's target.
141    pub target: ActivationTarget,
142}
143
144/// Apply the activation algorithm.  M3.0 implementation: walks the
145/// authored phase pipeline, dispatching each phase to a small text-
146/// generation routine.  No actual filesystem writes — output is
147/// the activation script TEXT, ready to be written to a derivation.
148/// (M3.1 + sui-build will materialise the derivation.)
149///
150/// # Errors
151///
152/// - `SpecError::Interp { phase: "<phase-name>" }` for any phase
153///   that needs the M3.1 derivation-build bridge (today: only
154///   `WriteActivationDerivation` and `AttestClosureToChain`).
155pub 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    // Shebang + header common to every target.
163    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                // Only meaningful for NixOS / HomeManager (linux user).
182                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                // M3.1 — actual derivation write.  For M3.0 we record
238                // a placeholder path.
239                artifacts.insert("activation-drv".into(),
240                    format!("/nix/store/zzz-activation-{}.drv", args.host));
241            }
242            ActivationPhaseKind::AttestClosureToChain => {
243                // Optional — operator opts in via the algorithm
244                // variant.  M3.0 records the request; M3.x lands
245                // the tameshi OutcomeChain bridge.
246                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
259// ── Config inspection helpers ──────────────────────────────────────
260
261fn list_unit_paths(config: &Config) -> Vec<String> {
262    // Convention: any path under `systemd.services.<name>` is a
263    // unit.  cppnix uses the same path structure.
264    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    // Convention: `launchd.daemons.<name>` for system daemons,
275    // `launchd.user.agents.<name>` for user agents.
276    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    // Convention: `environment.etc.<name>` declares an /etc entry.
288    // Value is typically `{ text = "..."; source = path; }`.
289    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    // Convention: any value that's `{ __secretRef = "..." }` is a
309    // typed secret reference.  cofre materialises these at
310    // activation time.
311    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
322// ── Canonical spec, compiled in ────────────────────────────────────
323
324pub const CANONICAL_ACTIVATION_LISP: &str =
325    include_str!("../specs/activation_script.lisp");
326
327/// Compile the canonical activation specs.  Returns one
328/// [`ActivationScriptAlgorithm`] per target.
329///
330/// # Errors
331///
332/// Returns an error if the Lisp source fails to parse.
333pub 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            // The triplet that EVERY activation surface must run.
376            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    // ── M3.0 interpreter tests ─────────────────────────────────
421
422    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        // Darwin must record launchd-plists artifact, NOT systemd-units.
443        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        // HomeManager doesn't write /etc.
466        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    /// The headline chain test: module_system::eval_modules feeds
522    /// activation_script::apply.  Proves the substrate primitives
523    /// compose end-to-end.
524    #[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        // Build a trivial NixOS-shaped module with one systemd unit.
532        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        // Run M2.1 eval_modules.
548        let registry = crate::module_system::load_canonical().unwrap().types;
549        let config = eval_modules(&[module], &registry).unwrap();
550        let _ = ms::NixValue::Null; // silence unused-import
551
552        // Feed the resulting Config into M3.0 activation_script.
553        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        // Activation script must reference the systemd unit we
565        // configured.
566        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}