nixfleet_release/
lib.rs

1#![allow(clippy::doc_lazy_continuation)]
2//! Producer for `releases/fleet.resolved.json` and signed sidecars. Pipeline:
3//! enumerate hosts -> build -> push -> eval -> inject closureHashes -> stamp meta
4//! -> canonicalize -> sign -> smoke-verify -> atomic write -> optional git
5//! commit/push. Hook contract lives at the binary surface.
6
7use std::collections::BTreeMap;
8use std::path::{Path, PathBuf};
9use std::process::Command;
10
11use anyhow::{Context, Result, bail};
12use chrono::{DateTime, Utc};
13use nixfleet_proto::{FleetResolved, RevocationEntry, Revocations, RolloutId};
14use nixfleet_reconciler::project_manifest;
15use sha2::{Digest, Sha256};
16
17mod git;
18mod sign;
19
20pub use git::render_commit_message;
21
22use git::{git_commit_release, git_head_sha, git_push_release};
23use sign::{sign, smoke_verify, write_release};
24
25/// Hosts to release. Resolved against the consumer's flake at runtime.
26#[derive(Debug, Clone)]
27pub enum HostsSpec {
28    /// Union of `nixosConfigurations.*` and `darwinConfigurations.*`.
29    Auto,
30    /// `Auto` minus the listed names.
31    AutoExclude(Vec<String>),
32    /// Explicit list, order preserved. Names appearing in both
33    /// `nixosConfigurations` and `darwinConfigurations` error at classify
34    /// time; operator must disambiguate.
35    Explicit(Vec<String>),
36}
37
38/// Which `*Configurations` attrset a host lives in.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum HostKind {
41    Nixos,
42    Darwin,
43}
44
45impl HostKind {
46    pub fn attr_prefix(self) -> &'static str {
47        match self {
48            HostKind::Nixos => "nixosConfigurations",
49            HostKind::Darwin => "darwinConfigurations",
50        }
51    }
52}
53
54/// CLI-assembled config consumed by `run`.
55#[derive(Debug, Clone)]
56pub struct ReleaseConfig {
57    pub flake_dir: PathBuf,
58    /// Default `.#fleet.resolved`.
59    pub fleet_resolved_attr: String,
60    pub hosts: HostsSpec,
61    /// Env: `NIXFLEET_HOST`, `NIXFLEET_PATH`, `NIXFLEET_CLOSURE_HASH`.
62    pub push_cmd: Option<String>,
63    /// Env: `NIXFLEET_INPUT` (canonical bytes), `NIXFLEET_OUTPUT`
64    /// (where hook writes raw signature). Required.
65    pub sign_cmd: String,
66    /// `ed25519` | `ecdsa-p256`.
67    pub signature_algorithm: String,
68    pub release_dir: PathBuf,
69    pub artifact_name: String,
70    pub git_commit: bool,
71    pub git_push: Option<GitPushTarget>,
72    pub commit_template: String,
73    pub git_user_name: Option<String>,
74    pub git_user_email: Option<String>,
75    /// Structural smoke verify: canonicalize round-trip + schema parse +
76    /// non-zero sig length. Default on.
77    pub smoke_verify: bool,
78    /// Reuse `signedAt` when closureHashes match - produces byte-stable
79    /// releases on no-op runs.
80    pub reuse_unchanged_signature: bool,
81    /// Flake attr yielding the revocations list. When set, the pipeline signs
82    /// `revocations.json` alongside `fleet.resolved.json` via the same
83    /// `sign_cmd`. `None` skips the revocations artifact.
84    pub revocations_attr: Option<String>,
85    /// Flake attr yielding the bootstrap-nonces list. When set, the pipeline
86    /// signs `bootstrap-nonces.json` alongside `fleet.resolved.json` via the
87    /// same `sign_cmd`. `None` skips the artifact entirely (which means CP
88    /// enrolment is unusable in strict mode - only used in dev/test).
89    pub bootstrap_nonces_attr: Option<String>,
90    /// Source URL for building pinned hosts at non-current commits. Optional
91    /// at the type level but required at runtime iff any non-expired host pin
92    /// specifies a commit different from the current release commit
93    /// (validation in `validate_pin_source_url`).
94    pub pin_source_url: Option<String>,
95}
96
97#[derive(Debug, Clone)]
98pub struct GitPushTarget {
99    pub remote: String,
100    pub branch: String,
101}
102
103#[derive(Debug)]
104pub enum RunOutcome {
105    /// `commit_sha` is `Some` when `--git-commit` was set and a commit landed.
106    Released {
107        commit_sha: Option<String>,
108        hosts: Vec<String>,
109    },
110    /// Closure hashes unchanged; only reachable with `reuse_unchanged_signature`.
111    NoChange,
112}
113
114pub fn run(config: &ReleaseConfig) -> Result<RunOutcome> {
115    validate_config(config)?;
116
117    tracing::info!(
118        target: "nixfleet_release",
119        flake = %config.flake_dir.display(),
120        "release pipeline start",
121    );
122
123    let hosts = enumerate_hosts(config)?;
124    if hosts.is_empty() {
125        bail!("no hosts to release - empty enumeration");
126    }
127    let host_names: Vec<&str> = hosts.iter().map(|(n, _)| n.as_str()).collect();
128    tracing::info!(count = hosts.len(), hosts = ?host_names, "enumerated");
129
130    // Eval BEFORE build: pin metadata branches the build path per host. Safe
131    // to reorder - eval is its own nix invocation with no build dependency.
132    let mut resolved = eval_fleet_resolved(config)?;
133    let current_commit = git_head_sha(&config.flake_dir).ok();
134    let now = Utc::now();
135    filter_expired_pins(&mut resolved, now);
136    validate_pin_source_url(config, &resolved, current_commit.as_deref())?;
137
138    let built = build_hosts(config, &hosts, &resolved, current_commit.as_deref())?;
139    tracing::info!(built = built.len(), total = hosts.len(), "build done");
140
141    if let Some(cmd) = &config.push_cmd {
142        for (host, path) in built.iter() {
143            let hash = closure_hash(path);
144            push_one(cmd, host, path, &hash)?;
145        }
146    }
147
148    let hashes: BTreeMap<String, String> = built
149        .iter()
150        .map(|(h, p)| (h.clone(), closure_hash(p)))
151        .collect();
152    inject_closure_hashes(&mut resolved, &hashes);
153
154    let release_path = config.release_dir.join(&config.artifact_name);
155    let signature_path = config
156        .release_dir
157        .join(format!("{}.sig", config.artifact_name));
158    let preserved_signed_at: Option<DateTime<Utc>> = if config.reuse_unchanged_signature {
159        load_existing_signed_at_if_unchanged(&release_path, &resolved)?
160    } else {
161        None
162    };
163
164    let signed_at = preserved_signed_at.unwrap_or_else(Utc::now);
165    let ci_commit = current_commit.clone();
166    stamp_meta(
167        &mut resolved,
168        signed_at,
169        ci_commit.clone(),
170        &config.signature_algorithm,
171    );
172
173    let canonical = canonicalize_resolved(&resolved)?;
174
175    let sig_bytes = if preserved_signed_at.is_some() && signature_path.exists() {
176        std::fs::read(&signature_path).context("read existing signature")?
177    } else {
178        sign(&config.sign_cmd, canonical.as_bytes())?
179    };
180
181    if config.smoke_verify {
182        smoke_verify(canonical.as_bytes(), &sig_bytes)?;
183    }
184
185    write_release(
186        &config.release_dir,
187        &config.artifact_name,
188        canonical.as_bytes(),
189        &sig_bytes,
190    )?;
191
192    // LOADBEARING: empty list still emits the file. CP-rebuild recovery
193    // primes `cert_revocations` from this; a missing file would unlock every
194    // revoked cert on rebuild.
195    let mut revocations_paths: Vec<PathBuf> = Vec::new();
196    if let Some(attr) = &config.revocations_attr {
197        let entries = eval_revocations(config, attr)?;
198        let revs = Revocations {
199            schema_version: 1,
200            revocations: entries,
201            meta: nixfleet_proto::Meta {
202                schema_version: 1,
203                signed_at: Some(signed_at),
204                ci_commit: ci_commit.clone(),
205                signature_algorithm: Some(config.signature_algorithm.clone()),
206            },
207        };
208        let revs_json = serde_json::to_string(&revs).context("serialise revocations.json")?;
209        let revs_canonical = nixfleet_canonicalize::canonicalize(&revs_json)
210            .context("canonicalize revocations.json")?;
211        let revs_sig_path = config.release_dir.join("revocations.json.sig");
212        let revs_path = config.release_dir.join("revocations.json");
213        // Reuse on-disk signature when canonical bytes match (idempotent).
214        let revs_sig_bytes = if revs_path.exists()
215            && revs_sig_path.exists()
216            && std::fs::read(&revs_path).ok().as_deref() == Some(revs_canonical.as_bytes())
217        {
218            std::fs::read(&revs_sig_path).context("read existing revocations signature")?
219        } else {
220            sign(&config.sign_cmd, revs_canonical.as_bytes())?
221        };
222        write_release(
223            &config.release_dir,
224            "revocations.json",
225            revs_canonical.as_bytes(),
226            &revs_sig_bytes,
227        )?;
228        revocations_paths.push(revs_path);
229        revocations_paths.push(revs_sig_path);
230        tracing::info!(
231            target: "nixfleet_release",
232            entries = revs.revocations.len(),
233            "revocations.json signed + written",
234        );
235    }
236
237    // LOADBEARING: empty list still emits the file. CP-rebuild recovery
238    // primes the in-memory allowlist from this; a missing file would
239    // re-open the replay-after-wipe window for unprocessed nonces.
240    let mut bootstrap_nonces_paths: Vec<PathBuf> = Vec::new();
241    if let Some(attr) = &config.bootstrap_nonces_attr {
242        let raw_entries = eval_bootstrap_nonces(config, attr)?;
243        let pruned = prune_expired_bootstrap_nonces(raw_entries, signed_at);
244        let bn = nixfleet_proto::BootstrapNonces {
245            schema_version: 1,
246            bootstrap_nonces: pruned,
247            meta: nixfleet_proto::Meta {
248                schema_version: 1,
249                signed_at: Some(signed_at),
250                ci_commit: ci_commit.clone(),
251                signature_algorithm: Some(config.signature_algorithm.clone()),
252            },
253        };
254        let bn_json = serde_json::to_string(&bn).context("serialise bootstrap-nonces.json")?;
255        let bn_canonical = nixfleet_canonicalize::canonicalize(&bn_json)
256            .context("canonicalize bootstrap-nonces.json")?;
257        let bn_path = config.release_dir.join("bootstrap-nonces.json");
258        let bn_sig_path = config.release_dir.join("bootstrap-nonces.json.sig");
259        let bn_sig_bytes = if bn_path.exists()
260            && bn_sig_path.exists()
261            && std::fs::read(&bn_path).ok().as_deref() == Some(bn_canonical.as_bytes())
262        {
263            std::fs::read(&bn_sig_path).context("read existing bootstrap-nonces signature")?
264        } else {
265            sign(&config.sign_cmd, bn_canonical.as_bytes())?
266        };
267        write_release(
268            &config.release_dir,
269            "bootstrap-nonces.json",
270            bn_canonical.as_bytes(),
271            &bn_sig_bytes,
272        )?;
273        bootstrap_nonces_paths.push(bn_path);
274        bootstrap_nonces_paths.push(bn_sig_path);
275        tracing::info!(
276            target: "nixfleet_release",
277            entries = bn.bootstrap_nonces.len(),
278            "bootstrap-nonces.json signed + written",
279        );
280    }
281
282    // One signed manifest per channel; fleetResolvedHash binds each to this
283    // snapshot, blocking mix-and-match across rotations.
284    let mut manifest_paths: Vec<PathBuf> = Vec::new();
285    let fleet_resolved_hash = sha256_hex(canonical.as_bytes());
286    let rollouts_dir = config.release_dir.join("rollouts");
287    for (channel_name, _channel) in resolved.channels.iter() {
288        let manifest = match project_manifest(
289            &resolved,
290            channel_name,
291            &fleet_resolved_hash,
292            signed_at,
293            ci_commit.as_deref(),
294            &config.signature_algorithm,
295        )? {
296            Some(m) => m,
297            None => continue,
298        };
299
300        let manifest_json = serde_json::to_string(&manifest)
301            .with_context(|| format!("serialise manifest for channel {channel_name}"))?;
302        let manifest_canonical = nixfleet_canonicalize::canonicalize(&manifest_json)
303            .with_context(|| format!("canonicalize manifest for channel {channel_name}"))?;
304        // LOADBEARING: rolloutId is the canonical RFC-0008 §6.3 composite,
305        // built from the FULL channel_ref. Not display_name, which truncates
306        // to a 7-char short ref for operator-facing labels. Identical
307        // (channel, channel_ref) inputs produce identical rolloutId, so
308        // identical content still hits the same path on republish.
309        let rollout_id = RolloutId::new(&manifest.channel, &manifest.channel_ref)
310            .as_str()
311            .to_string();
312
313        let artifact_name = format!("{rollout_id}.json");
314        let manifest_path = rollouts_dir.join(&artifact_name);
315        let sig_path = rollouts_dir.join(format!("{artifact_name}.sig"));
316
317        // Reuse on-disk signature when canonical bytes match (idempotent
318        // republish; mTLS + signature gate authenticates the bytes).
319        let sig_bytes = if manifest_path.exists()
320            && sig_path.exists()
321            && std::fs::read(&manifest_path).ok().as_deref() == Some(manifest_canonical.as_bytes())
322        {
323            std::fs::read(&sig_path).context("read existing manifest signature")?
324        } else {
325            sign(&config.sign_cmd, manifest_canonical.as_bytes())?
326        };
327
328        write_release(
329            &rollouts_dir,
330            &artifact_name,
331            manifest_canonical.as_bytes(),
332            &sig_bytes,
333        )?;
334        manifest_paths.push(manifest_path);
335        manifest_paths.push(sig_path);
336
337        tracing::info!(
338            target: "nixfleet_release",
339            rollout_id = %rollout_id,
340            channel = %channel_name,
341            host_count = manifest.host_set.len(),
342            "rollout manifest signed + written",
343        );
344    }
345
346    let mut commit_sha = None;
347    if config.git_commit {
348        let mut release_files = vec![release_path.clone(), signature_path.clone()];
349        release_files.extend(revocations_paths.iter().cloned());
350        release_files.extend(bootstrap_nonces_paths.iter().cloned());
351        release_files.extend(manifest_paths.iter().cloned());
352        let committed =
353            git_commit_release(config, &release_files, ci_commit.as_deref(), signed_at)?;
354        if let Some(c) = &config.git_push {
355            if committed {
356                git_push_release(&config.flake_dir, c)?;
357            } else {
358                tracing::info!("no release change - skip push");
359            }
360        }
361        commit_sha = if committed {
362            git_head_sha(&config.flake_dir).ok()
363        } else {
364            None
365        };
366        if !committed && preserved_signed_at.is_some() {
367            return Ok(RunOutcome::NoChange);
368        }
369    }
370
371    let host_names: Vec<String> = hashes.keys().cloned().collect();
372    Ok(RunOutcome::Released {
373        commit_sha,
374        hosts: host_names,
375    })
376}
377
378fn validate_config(c: &ReleaseConfig) -> Result<()> {
379    match c.signature_algorithm.as_str() {
380        "ed25519" | "ecdsa-p256" => {}
381        other => bail!("--signature-algorithm must be 'ed25519' or 'ecdsa-p256', got '{other}'"),
382    }
383    if c.git_push.is_some() && !c.git_commit {
384        bail!("--git-push requires --git-commit");
385    }
386    if c.sign_cmd.trim().is_empty() {
387        bail!("--sign-cmd is required and cannot be empty");
388    }
389    Ok(())
390}
391
392/// `(host, kind)` pairs. NixOS sorted, then Darwin sorted; `Explicit`
393/// preserves caller order. Missing attrsets are empty, not errors.
394fn enumerate_hosts(config: &ReleaseConfig) -> Result<Vec<(String, HostKind)>> {
395    let mut nixos = list_attr_optional(&config.flake_dir, "nixosConfigurations")?;
396    nixos.sort();
397    nixos.dedup();
398    let mut darwin = list_attr_optional(&config.flake_dir, "darwinConfigurations")?;
399    darwin.sort();
400    darwin.dedup();
401
402    let in_nixos = |n: &str| nixos.iter().any(|h| h == n);
403    let in_darwin = |n: &str| darwin.iter().any(|h| h == n);
404
405    Ok(match &config.hosts {
406        HostsSpec::Auto => nixos
407            .iter()
408            .map(|n| (n.clone(), HostKind::Nixos))
409            .chain(darwin.iter().map(|n| (n.clone(), HostKind::Darwin)))
410            .collect(),
411        HostsSpec::AutoExclude(exclude) => {
412            let kept_nixos = nixos
413                .iter()
414                .filter(|h| !exclude.iter().any(|e| e == *h))
415                .map(|n| (n.clone(), HostKind::Nixos));
416            let kept_darwin = darwin
417                .iter()
418                .filter(|h| !exclude.iter().any(|e| e == *h))
419                .map(|n| (n.clone(), HostKind::Darwin));
420            kept_nixos.chain(kept_darwin).collect()
421        }
422        HostsSpec::Explicit(list) => list
423            .iter()
424            .map(|n| match (in_nixos(n), in_darwin(n)) {
425                (true, false) => Ok((n.clone(), HostKind::Nixos)),
426                (false, true) => Ok((n.clone(), HostKind::Darwin)),
427                (true, true) => Err(anyhow::anyhow!(
428                    "host '{n}' is declared in both nixosConfigurations and \
429                     darwinConfigurations - disambiguate before releasing"
430                )),
431                (false, false) => Err(anyhow::anyhow!(
432                    "host '{n}' is in neither nixosConfigurations nor \
433                     darwinConfigurations of flake {}",
434                    config.flake_dir.display()
435                )),
436            })
437            .collect::<Result<Vec<_>>>()?,
438    })
439}
440
441fn sha256_hex(bytes: &[u8]) -> String {
442    hex::encode(Sha256::digest(bytes))
443}
444
445/// Strip entries with `expires_at < signed_at`. Run at sign time so the
446/// signed artifact only contains the operational set; fleet.nix can keep
447/// historical entries as an audit log.
448pub(crate) fn prune_expired_bootstrap_nonces(
449    entries: Vec<nixfleet_proto::BootstrapNonceEntry>,
450    signed_at: DateTime<Utc>,
451) -> Vec<nixfleet_proto::BootstrapNonceEntry> {
452    entries
453        .into_iter()
454        .filter(|e| e.expires_at >= signed_at)
455        .collect()
456}
457
458fn eval_revocations(config: &ReleaseConfig, attr: &str) -> Result<Vec<RevocationEntry>> {
459    let output = Command::new("nix")
460        .args(["eval", "--json", "--no-warn-dirty", &format!(".#{attr}")])
461        .current_dir(&config.flake_dir)
462        .output()
463        .with_context(|| format!("invoke `nix eval .#{attr}`"))?;
464    if !output.status.success() {
465        bail!(
466            "nix eval .#{attr}: {}",
467            String::from_utf8_lossy(&output.stderr)
468        );
469    }
470    serde_json::from_slice(&output.stdout)
471        .with_context(|| format!("parse revocations from `nix eval .#{attr}`"))
472}
473
474fn eval_bootstrap_nonces(
475    config: &ReleaseConfig,
476    attr: &str,
477) -> Result<Vec<nixfleet_proto::BootstrapNonceEntry>> {
478    let output = Command::new("nix")
479        .args(["eval", "--json", "--no-warn-dirty", &format!(".#{attr}")])
480        .current_dir(&config.flake_dir)
481        .output()
482        .with_context(|| format!("invoke `nix eval .#{attr}`"))?;
483    if !output.status.success() {
484        bail!(
485            "nix eval .#{attr}: {}",
486            String::from_utf8_lossy(&output.stderr)
487        );
488    }
489    serde_json::from_slice(&output.stdout)
490        .with_context(|| format!("parse bootstrap nonces from `nix eval .#{attr}`"))
491}
492
493/// Enumerate attribute names; missing attrset -> empty. "Missing attribute"
494/// matches a small set of stable nix-eval phrasings.
495fn list_attr_optional(flake_dir: &Path, attr_path: &str) -> Result<Vec<String>> {
496    let output = Command::new("nix")
497        .args([
498            "eval",
499            "--json",
500            "--no-warn-dirty",
501            &format!(".#{attr_path}"),
502            "--apply",
503            "builtins.attrNames",
504        ])
505        .current_dir(flake_dir)
506        .output()
507        .with_context(|| format!("invoke `nix eval .#{attr_path}`"))?;
508    if !output.status.success() {
509        let stderr = String::from_utf8_lossy(&output.stderr);
510        let lowered = stderr.to_lowercase();
511        let is_missing = [
512            "does not provide attribute",
513            "has no attribute",
514            "attribute 'darwinconfigurations' missing",
515            "attribute 'nixosconfigurations' missing",
516        ]
517        .iter()
518        .any(|needle| lowered.contains(needle));
519        if is_missing {
520            tracing::debug!(
521                attr_path,
522                "flake does not declare {attr_path}; treating as empty"
523            );
524            return Ok(vec![]);
525        }
526        bail!("nix eval .#{attr_path}: {stderr}");
527    }
528    let names: Vec<String> = serde_json::from_slice(&output.stdout)
529        .with_context(|| format!("parse JSON from `nix eval .#{attr_path}`"))?;
530    Ok(names)
531}
532
533/// Sequential build. No pin or pin.commit == current_commit -> local build
534/// path; non-current commit -> flake-ref build via `<pin_source_url>?rev=<commit>`.
535/// Cross-platform builds rely on the operator's `nix.buildMachines`. Failures
536/// abort before any push.
537fn build_hosts(
538    config: &ReleaseConfig,
539    hosts: &[(String, HostKind)],
540    resolved: &FleetResolved,
541    current_commit: Option<&str>,
542) -> Result<BTreeMap<String, PathBuf>> {
543    let mut out = BTreeMap::new();
544    for (host, kind) in hosts {
545        let pinned_commit = pin_target_commit(resolved, host, current_commit);
546        let path = match pinned_commit {
547            None => {
548                let attr = format!(
549                    ".#{}.{host}.config.system.build.toplevel",
550                    kind.attr_prefix()
551                );
552                build_local(&config.flake_dir, &attr)
553                    .with_context(|| format!("build host {host}"))?
554            }
555            Some(commit) => {
556                let url = config.pin_source_url.as_deref().ok_or_else(|| {
557                    // Defensive bail; `validate_pin_source_url` catches this
558                    // earlier in normal flow.
559                    anyhow::anyhow!(
560                        "host '{host}' is pinned to commit '{commit}' but \
561                         --pin-source-url is unset"
562                    )
563                })?;
564                build_pinned(url, commit, *kind, host)
565                    .with_context(|| format!("build pinned host {host} @ {commit}"))?
566            }
567        };
568        tracing::info!(host = %host, kind = ?kind, path, "built");
569        out.insert(host.clone(), PathBuf::from(path));
570    }
571    Ok(out)
572}
573
574/// `Some(commit)` iff the host has a pin AND `pin.commit ≠ current_commit`.
575/// Same-commit pins return `None` so the local build path handles them.
576fn pin_target_commit<'a>(
577    resolved: &'a FleetResolved,
578    host: &str,
579    current_commit: Option<&str>,
580) -> Option<&'a str> {
581    let pin = resolved.hosts.get(host)?.pin.as_ref()?;
582    if Some(pin.commit.as_str()) == current_commit {
583        return None;
584    }
585    Some(pin.commit.as_str())
586}
587
588fn build_local(flake_dir: &Path, attr: &str) -> Result<String> {
589    let output = Command::new("nix")
590        .args([
591            "build",
592            "--no-link",
593            "--print-out-paths",
594            "--no-warn-dirty",
595            attr,
596        ])
597        .current_dir(flake_dir)
598        .output()
599        .with_context(|| format!("invoke `nix build {attr}`"))?;
600    interpret_build_output(attr, output)
601}
602
603/// Build via flake-ref at a different commit. The `url?rev=<commit>` form lets
604/// Nix handle checkout + caching; we don't manage a worktree ourselves.
605fn build_pinned(pin_source_url: &str, commit: &str, kind: HostKind, host: &str) -> Result<String> {
606    let attr = format!(
607        "{pin_source_url}?rev={commit}#{}.{host}.config.system.build.toplevel",
608        kind.attr_prefix()
609    );
610    tracing::info!(
611        host = %host,
612        commit = %commit,
613        url = %pin_source_url,
614        "building pinned host via flake-ref",
615    );
616    let output = Command::new("nix")
617        .args([
618            "build",
619            "--no-link",
620            "--print-out-paths",
621            "--no-warn-dirty",
622            &attr,
623        ])
624        .output()
625        .with_context(|| format!("invoke `nix build {attr}`"))?;
626    interpret_build_output(&attr, output)
627}
628
629fn interpret_build_output(attr: &str, output: std::process::Output) -> Result<String> {
630    if !output.status.success() {
631        bail!(
632            "nix build {attr}: {}",
633            String::from_utf8_lossy(&output.stderr)
634        );
635    }
636    let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
637    if path.is_empty() {
638        bail!("nix build {attr}: empty output");
639    }
640    Ok(path)
641}
642
643/// Drop expired pins (past `expires_at`); affected hosts fall back to the
644/// current-commit build path. Pins with no `expires_at` always remain.
645pub fn filter_expired_pins(resolved: &mut FleetResolved, now: DateTime<Utc>) {
646    for (host_name, host) in resolved.hosts.iter_mut() {
647        if let Some(pin) = host.pin.as_ref()
648            && let Some(expires) = pin.expires_at
649            && expires <= now
650        {
651            tracing::info!(
652                host = %host_name,
653                expired_at = %expires,
654                commit = %pin.commit,
655                "pin expired - falling back to current-commit build",
656            );
657            host.pin = None;
658        }
659    }
660}
661
662/// Errors when any host has a non-current-commit pin but `--pin-source-url`
663/// is unset. Run AFTER `filter_expired_pins` so expired pins aren't counted.
664fn validate_pin_source_url(
665    config: &ReleaseConfig,
666    resolved: &FleetResolved,
667    current_commit: Option<&str>,
668) -> Result<()> {
669    if config.pin_source_url.is_some() {
670        return Ok(());
671    }
672    let needs: Vec<&str> = resolved
673        .hosts
674        .iter()
675        .filter_map(|(name, host)| {
676            host.pin.as_ref().and_then(|p| {
677                if Some(p.commit.as_str()) != current_commit {
678                    Some(name.as_str())
679                } else {
680                    None
681                }
682            })
683        })
684        .collect();
685    if !needs.is_empty() {
686        bail!(
687            "--pin-source-url is required: hosts with non-current-commit pins ({}) \
688             can't be built without a source URL",
689            needs.join(", ")
690        );
691    }
692    Ok(())
693}
694
695fn closure_hash(path: &Path) -> String {
696    path.file_name()
697        .and_then(|s| s.to_str())
698        .unwrap_or_default()
699        .to_string()
700}
701
702fn push_one(cmd: &str, host: &str, path: &Path, closure_hash: &str) -> Result<()> {
703    tracing::info!(host = %host, "push hook");
704    let status = Command::new("sh")
705        .arg("-c")
706        .arg(cmd)
707        .env("NIXFLEET_HOST", host)
708        .env("NIXFLEET_PATH", path)
709        .env("NIXFLEET_CLOSURE_HASH", closure_hash)
710        .status()
711        .with_context(|| format!("invoke push hook for {host}"))?;
712    if !status.success() {
713        bail!(
714            "push hook for {host} exited {} ({:?})",
715            status.code().unwrap_or(-1),
716            cmd,
717        );
718    }
719    Ok(())
720}
721
722pub(crate) fn eval_fleet_resolved(config: &ReleaseConfig) -> Result<FleetResolved> {
723    let output = Command::new("nix")
724        .args([
725            "eval",
726            "--json",
727            "--no-warn-dirty",
728            &config.fleet_resolved_attr,
729        ])
730        .current_dir(&config.flake_dir)
731        .output()
732        .with_context(|| format!("invoke `nix eval {}`", config.fleet_resolved_attr))?;
733    if !output.status.success() {
734        bail!(
735            "nix eval {}: {}",
736            config.fleet_resolved_attr,
737            String::from_utf8_lossy(&output.stderr)
738        );
739    }
740    let resolved: FleetResolved = serde_json::from_slice(&output.stdout)
741        .with_context(|| format!("parse {} as FleetResolved", config.fleet_resolved_attr))?;
742    Ok(resolved)
743}
744
745/// Sets `hosts[h].closureHash`. Unknown hosts in `hashes` are silently skipped.
746pub fn inject_closure_hashes(resolved: &mut FleetResolved, hashes: &BTreeMap<String, String>) {
747    for (host, hash) in hashes {
748        if let Some(h) = resolved.hosts.get_mut(host) {
749            h.closure_hash = Some(hash.clone());
750        }
751    }
752}
753
754pub fn stamp_meta(
755    resolved: &mut FleetResolved,
756    signed_at: DateTime<Utc>,
757    ci_commit: Option<String>,
758    signature_algorithm: &str,
759) {
760    resolved.meta.signed_at = Some(signed_at);
761    resolved.meta.ci_commit = ci_commit;
762    resolved.meta.signature_algorithm = Some(signature_algorithm.to_string());
763}
764
765pub fn canonicalize_resolved(resolved: &FleetResolved) -> Result<String> {
766    let raw =
767        serde_json::to_string(resolved).context("serialize FleetResolved before canonicalize")?;
768    nixfleet_canonicalize::canonicalize(&raw).context("canonicalize fleet.resolved")
769}
770
771/// Returns existing `meta.signedAt` when on-disk closure hashes match.
772fn load_existing_signed_at_if_unchanged(
773    release_path: &Path,
774    resolved: &FleetResolved,
775) -> Result<Option<DateTime<Utc>>> {
776    if !release_path.exists() {
777        return Ok(None);
778    }
779    let raw = std::fs::read_to_string(release_path)
780        .with_context(|| format!("read existing release {}", release_path.display()))?;
781    let existing: FleetResolved =
782        serde_json::from_str(&raw).context("parse existing release file")?;
783
784    let cur_hashes: BTreeMap<&str, Option<&str>> = resolved
785        .hosts
786        .iter()
787        .map(|(k, v)| (k.as_str(), v.closure_hash.as_deref()))
788        .collect();
789    let prev_hashes: BTreeMap<&str, Option<&str>> = existing
790        .hosts
791        .iter()
792        .map(|(k, v)| (k.as_str(), v.closure_hash.as_deref()))
793        .collect();
794
795    if cur_hashes == prev_hashes {
796        Ok(existing.meta.signed_at)
797    } else {
798        Ok(None)
799    }
800}
801
802#[cfg(test)]
803mod bootstrap_nonces_tests {
804    use super::*;
805    use nixfleet_proto::BootstrapNonceEntry;
806
807    fn entry(nonce: &str, expires_at: &str) -> BootstrapNonceEntry {
808        BootstrapNonceEntry {
809            nonce: nonce.into(),
810            hostname: "agent-01".into(),
811            expires_at: expires_at.parse().unwrap(),
812            minted_at: None,
813            minted_by: None,
814        }
815    }
816
817    #[test]
818    fn prune_drops_entries_with_expires_at_before_signed_at() {
819        let signed_at: DateTime<Utc> = "2026-05-13T10:00:00Z".parse().unwrap();
820        let entries = vec![
821            entry("expired", "2026-05-12T10:00:00Z"),
822            entry("fresh", "2026-05-14T10:00:00Z"),
823            entry("exactly-now", "2026-05-13T10:00:00Z"),
824        ];
825        let kept = prune_expired_bootstrap_nonces(entries, signed_at);
826        let nonces: Vec<&str> = kept.iter().map(|e| e.nonce.as_str()).collect();
827        // expiresAt < signedAt is dropped; expiresAt == signedAt is kept
828        // (still has zero seconds of validity at signing instant; CP will
829        // reject when it sees it at a later wall-clock moment).
830        assert_eq!(nonces, vec!["fresh", "exactly-now"]);
831    }
832
833    #[test]
834    fn prune_empty_list_is_empty() {
835        let signed_at: DateTime<Utc> = "2026-05-13T10:00:00Z".parse().unwrap();
836        let kept = prune_expired_bootstrap_nonces(vec![], signed_at);
837        assert!(kept.is_empty());
838    }
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844    use nixfleet_proto::{Channel, Host, Meta};
845
846    fn dummy_resolved() -> FleetResolved {
847        let mut hosts = std::collections::HashMap::new();
848        hosts.insert(
849            "test-host".to_string(),
850            Host {
851                system: "x86_64-linux".into(),
852                tags: vec![],
853                channel: "stable".into(),
854                closure_hash: None,
855                pubkey: None,
856                pin: None,
857            },
858        );
859        hosts.insert(
860            "host-03".to_string(),
861            Host {
862                system: "aarch64-darwin".into(),
863                tags: vec![],
864                channel: "stable".into(),
865                closure_hash: None,
866                pubkey: None,
867                pin: None,
868            },
869        );
870        let mut channels = std::collections::HashMap::new();
871        channels.insert(
872            "stable".to_string(),
873            Channel {
874                rollout_policy: "default".into(),
875                reconcile_interval_minutes: 5,
876                freshness_window: 60,
877                signing_interval_minutes: 30,
878            },
879        );
880        FleetResolved {
881            schema_version: 1,
882            hosts,
883            channels,
884            rollout_policies: Default::default(),
885            waves: Default::default(),
886            edges: vec![],
887            channel_edges: vec![],
888            disruption_budgets: vec![],
889            meta: Meta {
890                schema_version: 1,
891                signed_at: None,
892                ci_commit: None,
893                signature_algorithm: Some("ed25519".into()),
894            },
895        }
896    }
897
898    #[test]
899    fn inject_sets_closure_hash_for_known_hosts_and_skips_unknown() {
900        let mut r = dummy_resolved();
901        let mut hashes = BTreeMap::new();
902        hashes.insert(
903            "test-host".to_string(),
904            "abc123-nixos-system-test-host".to_string(),
905        );
906        hashes.insert("ghost".to_string(), "should-be-ignored".to_string());
907        inject_closure_hashes(&mut r, &hashes);
908        assert_eq!(
909            r.hosts["test-host"].closure_hash.as_deref(),
910            Some("abc123-nixos-system-test-host")
911        );
912        assert!(r.hosts["host-03"].closure_hash.is_none());
913        assert!(!r.hosts.contains_key("ghost"));
914    }
915
916    #[test]
917    fn stamp_meta_writes_three_fields() {
918        let mut r = dummy_resolved();
919        let ts = DateTime::parse_from_rfc3339("2026-04-27T12:00:00Z")
920            .unwrap()
921            .with_timezone(&Utc);
922        stamp_meta(&mut r, ts, Some("deadbeef".into()), "ed25519");
923        assert_eq!(r.meta.signed_at, Some(ts));
924        assert_eq!(r.meta.ci_commit.as_deref(), Some("deadbeef"));
925        assert_eq!(r.meta.signature_algorithm.as_deref(), Some("ed25519"));
926    }
927
928    #[test]
929    fn canonicalize_round_trip_is_byte_stable() {
930        let mut r = dummy_resolved();
931        let ts = DateTime::parse_from_rfc3339("2026-04-27T12:00:00Z")
932            .unwrap()
933            .with_timezone(&Utc);
934        stamp_meta(&mut r, ts, Some("deadbeef".into()), "ed25519");
935        let c1 = canonicalize_resolved(&r).unwrap();
936        let parsed: FleetResolved = serde_json::from_str(&c1).unwrap();
937        let c2 = canonicalize_resolved(&parsed).unwrap();
938        assert_eq!(c1, c2);
939    }
940
941    #[test]
942    fn render_commit_message_substitutes() {
943        let ts = DateTime::parse_from_rfc3339("2026-04-27T12:00:00Z")
944            .unwrap()
945            .with_timezone(&Utc);
946        let m = render_commit_message(
947            "chore(ci): release {sha:0:8} [skip ci]",
948            "deadbeefcafebabe",
949            ts,
950        );
951        assert_eq!(m, "chore(ci): release deadbeef [skip ci]");
952
953        let m2 = render_commit_message("ts={ts}, sha={sha}", "abc", ts);
954        assert_eq!(m2, "ts=2026-04-27T12:00:00+00:00, sha=abc");
955    }
956
957    fn manifest_resolved() -> FleetResolved {
958        use nixfleet_proto::{HealthGate, PolicyWave, RolloutPolicy, Selector, Wave};
959        let mut hosts = std::collections::HashMap::new();
960        hosts.insert(
961            "agent-02".to_string(),
962            Host {
963                system: "x86_64-linux".into(),
964                tags: vec![],
965                channel: "stable".into(),
966                closure_hash: Some("aaaa-host-b".into()),
967                pubkey: None,
968                pin: None,
969            },
970        );
971        hosts.insert(
972            "agent-01".to_string(),
973            Host {
974                system: "x86_64-linux".into(),
975                tags: vec![],
976                channel: "stable".into(),
977                closure_hash: Some("aaaa-host-a".into()),
978                pubkey: None,
979                pin: None,
980            },
981        );
982        hosts.insert(
983            "agent-no-closure".to_string(),
984            Host {
985                system: "x86_64-linux".into(),
986                tags: vec![],
987                channel: "stable".into(),
988                closure_hash: None,
989                pubkey: None,
990                pin: None,
991            },
992        );
993        let mut channels = std::collections::HashMap::new();
994        channels.insert(
995            "stable".to_string(),
996            Channel {
997                rollout_policy: "default".into(),
998                reconcile_interval_minutes: 5,
999                freshness_window: 60,
1000                signing_interval_minutes: 30,
1001            },
1002        );
1003        let mut rollout_policies = std::collections::HashMap::new();
1004        rollout_policies.insert(
1005            "default".to_string(),
1006            RolloutPolicy {
1007                strategy: "waves".into(),
1008                waves: vec![PolicyWave {
1009                    selector: Selector {
1010                        tags: vec![],
1011                        tags_any: vec![],
1012                        hosts: vec![],
1013                        channel: None,
1014                        all: true,
1015                    },
1016                    soak_minutes: 5,
1017                }],
1018                health_gate: HealthGate::default(),
1019                on_health_failure: nixfleet_proto::OnHealthFailure::Halt,
1020            },
1021        );
1022        let mut waves = std::collections::HashMap::new();
1023        waves.insert(
1024            "stable".to_string(),
1025            vec![
1026                Wave {
1027                    hosts: vec!["agent-01".into()],
1028                    soak_minutes: 5,
1029                },
1030                Wave {
1031                    hosts: vec!["agent-02".into()],
1032                    soak_minutes: 5,
1033                },
1034            ],
1035        );
1036        FleetResolved {
1037            schema_version: 1,
1038            hosts,
1039            channels,
1040            rollout_policies,
1041            waves,
1042            edges: vec![],
1043            channel_edges: vec![],
1044            disruption_budgets: vec![],
1045            meta: Meta {
1046                schema_version: 1,
1047                signed_at: None,
1048                ci_commit: None,
1049                signature_algorithm: Some("ed25519".into()),
1050            },
1051        }
1052    }
1053
1054    #[test]
1055    fn project_manifest_emits_sorted_host_set_with_correct_wave_indices() {
1056        let r = manifest_resolved();
1057        let ts = DateTime::parse_from_rfc3339("2026-04-30T12:00:00Z")
1058            .unwrap()
1059            .with_timezone(&Utc);
1060        let m = project_manifest(&r, "stable", "feedface", ts, Some("def45678"), "ed25519")
1061            .unwrap()
1062            .expect("non-empty manifest");
1063        assert_eq!(m.host_set[0].hostname, "agent-01");
1064        assert_eq!(m.host_set[1].hostname, "agent-02");
1065        assert_eq!(m.host_set.len(), 2);
1066        assert_eq!(m.host_set[0].wave_index, 0);
1067        assert_eq!(m.host_set[1].wave_index, 1);
1068        assert_eq!(m.host_set[0].target_closure, "aaaa-host-a");
1069        assert_eq!(m.host_set[1].target_closure, "aaaa-host-b");
1070        assert_eq!(m.fleet_resolved_hash, "feedface");
1071        assert_eq!(m.display_name, "stable@def45678");
1072        assert_eq!(m.channel_ref, "def45678");
1073        assert_eq!(m.meta.signed_at, Some(ts));
1074    }
1075
1076    #[test]
1077    fn project_manifest_returns_none_when_no_host_has_closure_hash() {
1078        let r = dummy_resolved();
1079        let mut r = r;
1080        r.rollout_policies.insert(
1081            "default".to_string(),
1082            nixfleet_proto::RolloutPolicy {
1083                strategy: "waves".into(),
1084                waves: vec![],
1085                health_gate: nixfleet_proto::HealthGate::default(),
1086                on_health_failure: nixfleet_proto::OnHealthFailure::Halt,
1087            },
1088        );
1089        let ts = Utc::now();
1090        let result = project_manifest(&r, "stable", "deadbeef", ts, None, "ed25519").unwrap();
1091        assert!(result.is_none());
1092    }
1093
1094    #[test]
1095    fn project_manifest_errors_on_missing_channel() {
1096        let r = manifest_resolved();
1097        let ts = Utc::now();
1098        let err = project_manifest(&r, "ghost", "feedface", ts, None, "ed25519").unwrap_err();
1099        assert!(err.to_string().contains("channel ghost"));
1100    }
1101
1102    #[test]
1103    fn sha256_hex_is_64_char_lowercase() {
1104        let h = sha256_hex(b"hello world");
1105        assert_eq!(h.len(), 64);
1106        assert!(
1107            h.chars()
1108                .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
1109        );
1110        assert_eq!(
1111            h,
1112            "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
1113        );
1114    }
1115
1116    #[test]
1117    fn validate_rejects_bad_algorithm() {
1118        let mut c = base_config();
1119        c.signature_algorithm = "rsa".into();
1120        let err = validate_config(&c).unwrap_err();
1121        assert!(err.to_string().contains("signature-algorithm"));
1122    }
1123
1124    #[test]
1125    fn validate_rejects_push_without_commit() {
1126        let mut c = base_config();
1127        c.git_push = Some(GitPushTarget {
1128            remote: "origin".into(),
1129            branch: "main".into(),
1130        });
1131        c.git_commit = false;
1132        let err = validate_config(&c).unwrap_err();
1133        assert!(err.to_string().contains("--git-commit"));
1134    }
1135
1136    fn base_config() -> ReleaseConfig {
1137        ReleaseConfig {
1138            flake_dir: PathBuf::from("."),
1139            fleet_resolved_attr: ".#fleet.resolved".into(),
1140            hosts: HostsSpec::Auto,
1141            push_cmd: None,
1142            sign_cmd: "true".into(),
1143            signature_algorithm: "ed25519".into(),
1144            release_dir: PathBuf::from("releases"),
1145            artifact_name: "fleet.resolved.json".into(),
1146            git_commit: false,
1147            git_push: None,
1148            commit_template: "release {sha:0:8}".into(),
1149            git_user_name: None,
1150            git_user_email: None,
1151            smoke_verify: true,
1152            reuse_unchanged_signature: false,
1153            revocations_attr: None,
1154            bootstrap_nonces_attr: None,
1155            pin_source_url: None,
1156        }
1157    }
1158
1159    #[test]
1160    fn host_kind_attr_prefix_matches_flake_convention() {
1161        assert_eq!(HostKind::Nixos.attr_prefix(), "nixosConfigurations");
1162        assert_eq!(HostKind::Darwin.attr_prefix(), "darwinConfigurations");
1163    }
1164
1165    fn pin_resolved(host_pin: Option<nixfleet_proto::Pin>) -> FleetResolved {
1166        let mut r = dummy_resolved();
1167        r.hosts.get_mut("test-host").unwrap().pin = host_pin;
1168        r
1169    }
1170
1171    fn fresh_pin(commit: &str, expires_at: Option<DateTime<Utc>>) -> nixfleet_proto::Pin {
1172        nixfleet_proto::Pin {
1173            commit: commit.to_string(),
1174            reason: "test".to_string(),
1175            expires_at,
1176        }
1177    }
1178
1179    #[test]
1180    fn filter_expired_drops_past_expiry_and_keeps_future() {
1181        let now = Utc::now();
1182        let past = now - chrono::Duration::days(1);
1183        let future = now + chrono::Duration::days(1);
1184
1185        let mut r = pin_resolved(Some(fresh_pin("c1", Some(past))));
1186        filter_expired_pins(&mut r, now);
1187        assert!(
1188            r.hosts["test-host"].pin.is_none(),
1189            "expired pin must be dropped",
1190        );
1191
1192        let mut r = pin_resolved(Some(fresh_pin("c2", Some(future))));
1193        filter_expired_pins(&mut r, now);
1194        assert!(r.hosts["test-host"].pin.is_some(), "fresh pin must survive",);
1195
1196        let mut r = pin_resolved(Some(fresh_pin("c3", None)));
1197        filter_expired_pins(&mut r, now);
1198        assert!(
1199            r.hosts["test-host"].pin.is_some(),
1200            "pin with no expiry must always survive",
1201        );
1202    }
1203
1204    #[test]
1205    fn filter_expired_treats_exact_now_as_expired() {
1206        // `<=` boundary: window-closes-at-instant means now == expiresAt drops.
1207        let now = Utc::now();
1208        let mut r = pin_resolved(Some(fresh_pin("c", Some(now))));
1209        filter_expired_pins(&mut r, now);
1210        assert!(r.hosts["test-host"].pin.is_none());
1211    }
1212
1213    #[test]
1214    fn pin_target_commit_none_for_unpinned_host() {
1215        let r = pin_resolved(None);
1216        assert!(pin_target_commit(&r, "test-host", Some("abc")).is_none());
1217    }
1218
1219    #[test]
1220    fn pin_target_commit_none_when_pin_matches_release_commit() {
1221        let r = pin_resolved(Some(fresh_pin("abc1234", None)));
1222        assert!(
1223            pin_target_commit(&r, "test-host", Some("abc1234")).is_none(),
1224            "same-commit pin must hit the local build path, not flake-ref",
1225        );
1226    }
1227
1228    #[test]
1229    fn pin_target_commit_some_when_pin_diverges_from_release_commit() {
1230        let r = pin_resolved(Some(fresh_pin("frozen-abc", None)));
1231        assert_eq!(
1232            pin_target_commit(&r, "test-host", Some("current-def")),
1233            Some("frozen-abc"),
1234        );
1235    }
1236
1237    #[test]
1238    fn validate_pin_source_url_errors_when_needed_and_unset() {
1239        let mut c = base_config();
1240        c.pin_source_url = None;
1241        let r = pin_resolved(Some(fresh_pin("frozen-abc", None)));
1242        let err = validate_pin_source_url(&c, &r, Some("current-def")).unwrap_err();
1243        let msg = format!("{err:#}");
1244        assert!(
1245            msg.contains("--pin-source-url is required"),
1246            "error must mention the missing flag: {msg}",
1247        );
1248        assert!(
1249            msg.contains("test-host"),
1250            "error must list the offending host: {msg}",
1251        );
1252    }
1253
1254    #[test]
1255    fn validate_pin_source_url_ok_when_no_pins() {
1256        let c = base_config();
1257        let r = dummy_resolved();
1258        validate_pin_source_url(&c, &r, Some("any-commit")).unwrap();
1259    }
1260
1261    #[test]
1262    fn validate_pin_source_url_ok_when_pin_matches_release_commit() {
1263        let c = base_config();
1264        let r = pin_resolved(Some(fresh_pin("matching-commit", None)));
1265        validate_pin_source_url(&c, &r, Some("matching-commit")).unwrap();
1266    }
1267
1268    #[test]
1269    fn validate_pin_source_url_ok_when_url_is_set() {
1270        let mut c = base_config();
1271        c.pin_source_url = Some("git+ssh://example/fleet".into());
1272        let r = pin_resolved(Some(fresh_pin("frozen-abc", None)));
1273        validate_pin_source_url(&c, &r, Some("current-def")).unwrap();
1274    }
1275}