From eaf15a8274f2734e2a539666f987c596a1ab56fc Mon Sep 17 00:00:00 2001 From: UnbreakableMJ <34196588+UnbreakableMJ@users.noreply.github.com> Date: Wed, 29 Apr 2026 01:17:23 +0300 Subject: [PATCH] feat(engine): Engine::bootstrap wires LiveNixInstaller + writes /etc/nix/nix.conf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements ADR-0012 + ADR-0013 on the engine side. The CLI surface (`pearlite bootstrap` subcommand + script-fetch path) lands in the next chunk; this PR wires the engine method that subcommand will dispatch to. - New `crates/pearlite-engine/src/bootstrap.rs`: - `BootstrapOutcome { install: InstallOutcome, nix_conf_written: bool }`. - `Engine::bootstrap(host_file, installer, script_path, nix_conf_path)` loads the host via the existing Nickel adapter, requires a declared `[nix.installer]` block, calls `installer.install_if_missing` with the operator-supplied SHA pin, then writes `/etc/nix/nix.conf` idempotently per ADR-0013. - `BootstrapError` enum in `errors.rs`: Nickel / NixNotDeclared / Installer / Fs / Io variants. `From` impls for the wrapped errors. - `Engine::nickel()` accessor (pub(crate)) so bootstrap.rs can reuse the same evaluator without duplicating the field. - nix moved from dev-dep to regular dep so we can resolve current uid/gid for the temp-dir-injected nix.conf write in tests; the production write uses the same pearlite-fs::write_etc_atomic pipeline as ConfigWrite, no new I/O surface. - Five tests with MockNixInstaller covering: declared-SHA pass-through, fresh nix.conf write, idempotent skip when line already present, NixNotDeclared error, and short-circuit when nix is already installed. Tests: 312 passing (+5). Clippy clean. fmt clean. audit clean. Bootstrap intentionally does not run schema validation — that's plan's job; bootstrap is a one-shot side-effect with its own narrower precondition. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/pearlite-engine/Cargo.toml | 1 + crates/pearlite-engine/src/bootstrap.rs | 322 ++++++++++++++++++++++++ crates/pearlite-engine/src/errors.rs | 28 +++ crates/pearlite-engine/src/lib.rs | 4 +- crates/pearlite-engine/src/plan.rs | 7 + 5 files changed, 361 insertions(+), 1 deletion(-) create mode 100644 crates/pearlite-engine/src/bootstrap.rs diff --git a/crates/pearlite-engine/Cargo.toml b/crates/pearlite-engine/Cargo.toml index fe773aa..f21ec68 100644 --- a/crates/pearlite-engine/Cargo.toml +++ b/crates/pearlite-engine/Cargo.toml @@ -33,6 +33,7 @@ pearlite-snapper = { path = "../pearlite-snapper", version = "0.1" } pearlite-state = { path = "../pearlite-state", version = "0.1" } pearlite-systemd = { path = "../pearlite-systemd", version = "0.1" } pearlite-userenv = { path = "../pearlite-userenv", version = "0.1" } +nix = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } diff --git a/crates/pearlite-engine/src/bootstrap.rs b/crates/pearlite-engine/src/bootstrap.rs new file mode 100644 index 0000000..39a556d --- /dev/null +++ b/crates/pearlite-engine/src/bootstrap.rs @@ -0,0 +1,322 @@ +// SPDX-License-Identifier: GPL-3.0-or-later +// Copyright (C) 2026 Mohamed Hammad + +//! Engine-side wiring for ADR-0012 (`pearlite bootstrap`). +//! +//! [`Engine::bootstrap`] is the apply-side companion to +//! [`Engine::plan`](crate::Engine::plan). It runs once per host, on +//! demand, and: +//! +//! 1. Loads the host's declared state via the existing Nickel adapter. +//! 2. Verifies a `[nix.installer]` block is declared (without it, +//! bootstrapping makes no sense — operator should declare nix or +//! not call bootstrap). +//! 3. Hands the [`NixInstaller`] adapter the operator-supplied +//! installer script bytes plus the declared SHA-256 pin. The +//! adapter's `install_if_missing` short-circuits when nix is +//! already on `PATH`. +//! 4. Writes `/etc/nix/nix.conf` idempotently with +//! `experimental-features = nix-command flakes` per ADR-0013. +//! +//! Bootstrap intentionally does **not** call +//! [`pearlite_schema::validate`]: it's a one-shot side-effect, not +//! the full apply contract. Schema validation is `plan`'s +//! responsibility. The post-validation `[nix.installer]` block check +//! lives here as a narrower precondition. +//! +//! Bootstrap state isn't recorded in `state.toml` (ADR-0012 decision +//! 4): nix presence is a runtime fact, not a managed declaration. + +use crate::errors::BootstrapError; +use crate::plan::Engine; +use pearlite_userenv::{InstallOutcome, NixInstaller}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +/// One literal line that ADR-0013 requires `/etc/nix/nix.conf` to +/// contain. Bootstrap writes this file iff the line isn't already +/// present (any line in the file that trims to this string counts). +const NIX_CONF_LINE: &str = "experimental-features = nix-command flakes"; + +/// Outcome of a successful [`Engine::bootstrap`] run. +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)] +pub struct BootstrapOutcome { + /// What [`NixInstaller::install_if_missing`] returned. + pub install: InstallOutcome, + /// `true` when bootstrap wrote `/etc/nix/nix.conf` (file was + /// missing, or didn't contain the experimental-features line). + /// `false` when the file already had what we'd write (idempotent + /// no-op). + pub nix_conf_written: bool, +} + +impl Engine { + /// Bootstrap nix on a fresh host (ADR-0012). + /// + /// `host_file` is the per-host Nickel config; the operator's + /// declared `nix.installer.expected_sha256` is read from it. + /// `installer` is the [`NixInstaller`] adapter (production: + /// `LiveNixInstaller`). `script_path` points at the + /// already-downloaded Determinate installer script — fetching is + /// the CLI layer's responsibility (or future + /// `pearlite-bootstrap` chunk), kept out of the engine to + /// preserve the "no network I/O" invariant. `nix_conf_path` + /// targets `/etc/nix/nix.conf` in production; tests inject a + /// tempdir. + /// + /// # Errors + /// - [`BootstrapError::Nickel`] — Nickel evaluator failed. + /// - [`BootstrapError::NixNotDeclared`] — the declared host has no + /// `[nix.installer]` block. + /// - [`BootstrapError::Installer`] — installer SHA-256 mismatch + /// (ADR-004), missing shell, or non-zero exit from the script. + /// - [`BootstrapError::Fs`] — atomic write of `nix.conf` failed. + /// - [`BootstrapError::Io`] — reading existing `nix.conf` failed + /// for a reason other than "not found". + pub fn bootstrap( + &self, + host_file: &Path, + installer: &dyn NixInstaller, + script_path: &Path, + nix_conf_path: &Path, + ) -> Result { + let declared = pearlite_nickel::load_host(host_file, self.nickel())?; + let nix = declared + .nix + .as_ref() + .ok_or(BootstrapError::NixNotDeclared)?; + + let install = installer.install_if_missing( + script_path, + &nix.installer.expected_sha256, + &["install", "--determinate", "--no-confirm"], + )?; + + let nix_conf_written = write_nix_conf_if_needed(nix_conf_path)?; + + Ok(BootstrapOutcome { + install, + nix_conf_written, + }) + } +} + +/// Read existing `nix_conf_path` (if any) and write the +/// experimental-features line iff missing. +/// +/// Idempotent: if any trimmed line equals [`NIX_CONF_LINE`], we leave +/// the file alone. Otherwise we write a fresh single-line file via +/// [`pearlite_fs::write_etc_atomic`] (ADR-0013 explicitly says +/// Pearlite owns only this minimum line; per-user nix.conf is HM's +/// territory). +fn write_nix_conf_if_needed(path: &Path) -> Result { + let existing = match std::fs::read_to_string(path) { + Ok(s) => Some(s), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => None, + Err(e) => return Err(BootstrapError::Io(e)), + }; + if let Some(content) = existing.as_ref() { + if content.lines().any(|l| l.trim() == NIX_CONF_LINE) { + return Ok(false); + } + } + + if let Some(parent) = path.parent() { + if !parent.as_os_str().is_empty() { + std::fs::create_dir_all(parent).map_err(BootstrapError::Io)?; + } + } + + let owner = pearlite_fs::name_for_uid(nix::unistd::getuid().as_raw()); + let group = pearlite_fs::name_for_gid(nix::unistd::getgid().as_raw()); + let body = format!("{NIX_CONF_LINE}\n"); + pearlite_fs::write_etc_atomic(path, body.as_bytes(), 0o644, &owner, &group)?; + Ok(true) +} + +#[cfg(test)] +#[allow( + clippy::expect_used, + clippy::unwrap_used, + clippy::panic, + reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md" +)] +mod tests { + use super::*; + use crate::mock_probe::MockProbe; + use pearlite_nickel::MockNickel; + use pearlite_schema::{HostInfo, KernelInfo, ProbedState}; + use pearlite_userenv::MockNixInstaller; + use std::path::PathBuf; + use tempfile::TempDir; + use time::OffsetDateTime; + + const HOST_WITH_NIX: &str = r#" +[meta] +hostname = "forge" +timezone = "UTC" +arch_level = "v4" +locale = "en_US.UTF-8" +keymap = "us" + +[kernel] +package = "linux-cachyos" + +[nix.installer] +expected_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +"#; + + const HOST_WITHOUT_NIX: &str = r#" +[meta] +hostname = "forge" +timezone = "UTC" +arch_level = "v4" +locale = "en_US.UTF-8" +keymap = "us" + +[kernel] +package = "linux-cachyos" +"#; + + fn engine_with_host(host_path: PathBuf, host_body: &str) -> Engine { + let mut nickel = MockNickel::new(); + nickel.seed(host_path, host_body); + let probed = ProbedState { + probed_at: OffsetDateTime::from_unix_timestamp(1_777_000_000).expect("ts"), + host: HostInfo { + hostname: "forge".to_owned(), + }, + pacman: None, + cargo: None, + config_files: None, + services: None, + kernel: KernelInfo::default(), + }; + Engine::new( + Box::new(nickel), + Box::new(MockProbe::with_state(probed)), + PathBuf::from("/cfg-repo"), + ) + } + + #[test] + fn bootstrap_calls_installer_with_declared_sha() { + let dir = TempDir::new().expect("tempdir"); + let host_file = dir.path().join("forge.ncl"); + let nix_conf = dir.path().join("nix.conf"); + let script = dir.path().join("installer.sh"); + std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script"); + + let installer = MockNixInstaller::new(); + let engine = engine_with_host(host_file.clone(), HOST_WITH_NIX); + + let outcome = engine + .bootstrap(&host_file, &installer, &script, &nix_conf) + .expect("bootstrap"); + + assert_eq!(outcome.install, InstallOutcome::Installed); + assert!(outcome.nix_conf_written); + + let history = installer.install_history(); + assert_eq!(history.len(), 1); + assert_eq!( + history[0].expected_sha256, + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + ); + assert_eq!( + history[0].args, + vec!["install", "--determinate", "--no-confirm"] + ); + assert_eq!(history[0].script_path, script); + } + + #[test] + fn bootstrap_writes_nix_conf_when_absent() { + let dir = TempDir::new().expect("tempdir"); + let host_file = dir.path().join("forge.ncl"); + let nix_conf = dir.path().join("nix.conf"); + let script = dir.path().join("installer.sh"); + std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script"); + + let installer = MockNixInstaller::new(); + let engine = engine_with_host(host_file.clone(), HOST_WITH_NIX); + + engine + .bootstrap(&host_file, &installer, &script, &nix_conf) + .expect("bootstrap"); + + let written = std::fs::read_to_string(&nix_conf).expect("read"); + assert!(written.contains(NIX_CONF_LINE), "got {written:?}"); + } + + #[test] + fn bootstrap_skips_nix_conf_when_line_already_present() { + let dir = TempDir::new().expect("tempdir"); + let host_file = dir.path().join("forge.ncl"); + let nix_conf = dir.path().join("nix.conf"); + let script = dir.path().join("installer.sh"); + std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script"); + std::fs::write( + &nix_conf, + format!( + "# operator preamble\n{NIX_CONF_LINE}\nsubstituters = https://cache.nixos.org\n" + ), + ) + .expect("write existing nix.conf"); + let original = std::fs::read_to_string(&nix_conf).expect("read original"); + + let installer = MockNixInstaller::new(); + let engine = engine_with_host(host_file.clone(), HOST_WITH_NIX); + + let outcome = engine + .bootstrap(&host_file, &installer, &script, &nix_conf) + .expect("bootstrap"); + + assert!(!outcome.nix_conf_written); + let after = std::fs::read_to_string(&nix_conf).expect("read after"); + assert_eq!( + after, original, + "operator preamble must be preserved untouched" + ); + } + + #[test] + fn bootstrap_errors_when_nix_block_not_declared() { + let dir = TempDir::new().expect("tempdir"); + let host_file = dir.path().join("forge.ncl"); + let nix_conf = dir.path().join("nix.conf"); + let script = dir.path().join("installer.sh"); + std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script"); + + let installer = MockNixInstaller::new(); + let engine = engine_with_host(host_file.clone(), HOST_WITHOUT_NIX); + + let err = engine + .bootstrap(&host_file, &installer, &script, &nix_conf) + .expect_err("must fail"); + assert!(matches!(err, BootstrapError::NixNotDeclared), "got {err:?}"); + assert!(installer.install_history().is_empty()); + } + + #[test] + fn bootstrap_short_circuits_when_nix_already_installed() { + let dir = TempDir::new().expect("tempdir"); + let host_file = dir.path().join("forge.ncl"); + let nix_conf = dir.path().join("nix.conf"); + let script = dir.path().join("installer.sh"); + std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script"); + + let installer = MockNixInstaller::with_already_installed(); + let engine = engine_with_host(host_file.clone(), HOST_WITH_NIX); + + let outcome = engine + .bootstrap(&host_file, &installer, &script, &nix_conf) + .expect("bootstrap"); + + assert_eq!(outcome.install, InstallOutcome::Already); + // nix.conf still gets written even if nix was already + // installed — the experimental-features line is independent + // of installer state. + assert!(outcome.nix_conf_written); + } +} diff --git a/crates/pearlite-engine/src/errors.rs b/crates/pearlite-engine/src/errors.rs index 2ed4b24..7075cf7 100644 --- a/crates/pearlite-engine/src/errors.rs +++ b/crates/pearlite-engine/src/errors.rs @@ -91,6 +91,34 @@ pub enum RollbackError { }, } +/// Errors emitted by [`Engine::bootstrap`](crate::Engine::bootstrap) +/// (ADR-0012). +#[derive(Debug, Error)] +pub enum BootstrapError { + /// Nickel evaluator failed loading the host file. + #[error(transparent)] + Nickel(#[from] pearlite_nickel::NickelError), + /// The declared host has no `[nix.installer]` block; bootstrap + /// makes no sense for hosts that don't need nix. Hint: declare + /// `nix.installer.expected_sha256` or skip this command. + #[error( + "host file has no [nix.installer] block; declare nix.installer.expected_sha256 to bootstrap" + )] + NixNotDeclared, + /// Determinate Nix installer adapter failed (SHA mismatch, + /// non-zero script exit, missing shell, etc.). + #[error(transparent)] + Installer(#[from] pearlite_userenv::InstallerError), + /// Filesystem operation (atomic write of `/etc/nix/nix.conf`) + /// failed. + #[error(transparent)] + Fs(#[from] pearlite_fs::FsError), + /// Reading existing `/etc/nix/nix.conf` failed for a reason other + /// than "not found". + #[error("failed to read /etc/nix/nix.conf: {0}")] + Io(#[source] std::io::Error), +} + /// Errors emitted by [`Engine::plan`](crate::Engine::plan) and friends. #[derive(Debug, Error)] pub enum EngineError { diff --git a/crates/pearlite-engine/src/lib.rs b/crates/pearlite-engine/src/lib.rs index 0b63b26..711357d 100644 --- a/crates/pearlite-engine/src/lib.rs +++ b/crates/pearlite-engine/src/lib.rs @@ -8,13 +8,15 @@ //! Apply, rollback, and reconcile arrive in M2+ per Plan §7. mod apply; +mod bootstrap; mod errors; mod plan; mod probe; mod rollback; pub use apply::{ApplyContext, ApplyOutcome, FailureRecord}; -pub use errors::{ApplyError, EngineError, ProbeError, RollbackError}; +pub use bootstrap::BootstrapOutcome; +pub use errors::{ApplyError, BootstrapError, EngineError, ProbeError, RollbackError}; pub use plan::Engine; pub use probe::{LiveProbe, SystemProbe}; pub use rollback::RollbackOutcome; diff --git a/crates/pearlite-engine/src/plan.rs b/crates/pearlite-engine/src/plan.rs index 4fe7fbf..4739721 100644 --- a/crates/pearlite-engine/src/plan.rs +++ b/crates/pearlite-engine/src/plan.rs @@ -55,6 +55,13 @@ impl Engine { &self.repo_root } + /// Borrow the Nickel evaluator. Crate-internal so other engine + /// modules (`bootstrap`) can re-use the same adapter without + /// pushing it through the call surface. + pub(crate) fn nickel(&self) -> &dyn NickelEvaluator { + &*self.nickel + } + /// Compute a [`Plan`] without changing any system state. /// /// Steps: