Skip to content

Commit eaf15a8

Browse files
UnbreakableMJclaude
andcommitted
feat(engine): Engine::bootstrap wires LiveNixInstaller + writes /etc/nix/nix.conf
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) <noreply@anthropic.com>
1 parent 2f875d0 commit eaf15a8

5 files changed

Lines changed: 361 additions & 1 deletion

File tree

crates/pearlite-engine/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ pearlite-snapper = { path = "../pearlite-snapper", version = "0.1" }
3333
pearlite-state = { path = "../pearlite-state", version = "0.1" }
3434
pearlite-systemd = { path = "../pearlite-systemd", version = "0.1" }
3535
pearlite-userenv = { path = "../pearlite-userenv", version = "0.1" }
36+
nix = { workspace = true }
3637
rayon = { workspace = true }
3738
serde = { workspace = true }
3839
serde_json = { workspace = true }
Lines changed: 322 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,322 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Engine-side wiring for ADR-0012 (`pearlite bootstrap`).
5+
//!
6+
//! [`Engine::bootstrap`] is the apply-side companion to
7+
//! [`Engine::plan`](crate::Engine::plan). It runs once per host, on
8+
//! demand, and:
9+
//!
10+
//! 1. Loads the host's declared state via the existing Nickel adapter.
11+
//! 2. Verifies a `[nix.installer]` block is declared (without it,
12+
//! bootstrapping makes no sense — operator should declare nix or
13+
//! not call bootstrap).
14+
//! 3. Hands the [`NixInstaller`] adapter the operator-supplied
15+
//! installer script bytes plus the declared SHA-256 pin. The
16+
//! adapter's `install_if_missing` short-circuits when nix is
17+
//! already on `PATH`.
18+
//! 4. Writes `/etc/nix/nix.conf` idempotently with
19+
//! `experimental-features = nix-command flakes` per ADR-0013.
20+
//!
21+
//! Bootstrap intentionally does **not** call
22+
//! [`pearlite_schema::validate`]: it's a one-shot side-effect, not
23+
//! the full apply contract. Schema validation is `plan`'s
24+
//! responsibility. The post-validation `[nix.installer]` block check
25+
//! lives here as a narrower precondition.
26+
//!
27+
//! Bootstrap state isn't recorded in `state.toml` (ADR-0012 decision
28+
//! 4): nix presence is a runtime fact, not a managed declaration.
29+
30+
use crate::errors::BootstrapError;
31+
use crate::plan::Engine;
32+
use pearlite_userenv::{InstallOutcome, NixInstaller};
33+
use serde::{Deserialize, Serialize};
34+
use std::path::Path;
35+
36+
/// One literal line that ADR-0013 requires `/etc/nix/nix.conf` to
37+
/// contain. Bootstrap writes this file iff the line isn't already
38+
/// present (any line in the file that trims to this string counts).
39+
const NIX_CONF_LINE: &str = "experimental-features = nix-command flakes";
40+
41+
/// Outcome of a successful [`Engine::bootstrap`] run.
42+
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
43+
pub struct BootstrapOutcome {
44+
/// What [`NixInstaller::install_if_missing`] returned.
45+
pub install: InstallOutcome,
46+
/// `true` when bootstrap wrote `/etc/nix/nix.conf` (file was
47+
/// missing, or didn't contain the experimental-features line).
48+
/// `false` when the file already had what we'd write (idempotent
49+
/// no-op).
50+
pub nix_conf_written: bool,
51+
}
52+
53+
impl Engine {
54+
/// Bootstrap nix on a fresh host (ADR-0012).
55+
///
56+
/// `host_file` is the per-host Nickel config; the operator's
57+
/// declared `nix.installer.expected_sha256` is read from it.
58+
/// `installer` is the [`NixInstaller`] adapter (production:
59+
/// `LiveNixInstaller`). `script_path` points at the
60+
/// already-downloaded Determinate installer script — fetching is
61+
/// the CLI layer's responsibility (or future
62+
/// `pearlite-bootstrap` chunk), kept out of the engine to
63+
/// preserve the "no network I/O" invariant. `nix_conf_path`
64+
/// targets `/etc/nix/nix.conf` in production; tests inject a
65+
/// tempdir.
66+
///
67+
/// # Errors
68+
/// - [`BootstrapError::Nickel`] — Nickel evaluator failed.
69+
/// - [`BootstrapError::NixNotDeclared`] — the declared host has no
70+
/// `[nix.installer]` block.
71+
/// - [`BootstrapError::Installer`] — installer SHA-256 mismatch
72+
/// (ADR-004), missing shell, or non-zero exit from the script.
73+
/// - [`BootstrapError::Fs`] — atomic write of `nix.conf` failed.
74+
/// - [`BootstrapError::Io`] — reading existing `nix.conf` failed
75+
/// for a reason other than "not found".
76+
pub fn bootstrap(
77+
&self,
78+
host_file: &Path,
79+
installer: &dyn NixInstaller,
80+
script_path: &Path,
81+
nix_conf_path: &Path,
82+
) -> Result<BootstrapOutcome, BootstrapError> {
83+
let declared = pearlite_nickel::load_host(host_file, self.nickel())?;
84+
let nix = declared
85+
.nix
86+
.as_ref()
87+
.ok_or(BootstrapError::NixNotDeclared)?;
88+
89+
let install = installer.install_if_missing(
90+
script_path,
91+
&nix.installer.expected_sha256,
92+
&["install", "--determinate", "--no-confirm"],
93+
)?;
94+
95+
let nix_conf_written = write_nix_conf_if_needed(nix_conf_path)?;
96+
97+
Ok(BootstrapOutcome {
98+
install,
99+
nix_conf_written,
100+
})
101+
}
102+
}
103+
104+
/// Read existing `nix_conf_path` (if any) and write the
105+
/// experimental-features line iff missing.
106+
///
107+
/// Idempotent: if any trimmed line equals [`NIX_CONF_LINE`], we leave
108+
/// the file alone. Otherwise we write a fresh single-line file via
109+
/// [`pearlite_fs::write_etc_atomic`] (ADR-0013 explicitly says
110+
/// Pearlite owns only this minimum line; per-user nix.conf is HM's
111+
/// territory).
112+
fn write_nix_conf_if_needed(path: &Path) -> Result<bool, BootstrapError> {
113+
let existing = match std::fs::read_to_string(path) {
114+
Ok(s) => Some(s),
115+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => None,
116+
Err(e) => return Err(BootstrapError::Io(e)),
117+
};
118+
if let Some(content) = existing.as_ref() {
119+
if content.lines().any(|l| l.trim() == NIX_CONF_LINE) {
120+
return Ok(false);
121+
}
122+
}
123+
124+
if let Some(parent) = path.parent() {
125+
if !parent.as_os_str().is_empty() {
126+
std::fs::create_dir_all(parent).map_err(BootstrapError::Io)?;
127+
}
128+
}
129+
130+
let owner = pearlite_fs::name_for_uid(nix::unistd::getuid().as_raw());
131+
let group = pearlite_fs::name_for_gid(nix::unistd::getgid().as_raw());
132+
let body = format!("{NIX_CONF_LINE}\n");
133+
pearlite_fs::write_etc_atomic(path, body.as_bytes(), 0o644, &owner, &group)?;
134+
Ok(true)
135+
}
136+
137+
#[cfg(test)]
138+
#[allow(
139+
clippy::expect_used,
140+
clippy::unwrap_used,
141+
clippy::panic,
142+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
143+
)]
144+
mod tests {
145+
use super::*;
146+
use crate::mock_probe::MockProbe;
147+
use pearlite_nickel::MockNickel;
148+
use pearlite_schema::{HostInfo, KernelInfo, ProbedState};
149+
use pearlite_userenv::MockNixInstaller;
150+
use std::path::PathBuf;
151+
use tempfile::TempDir;
152+
use time::OffsetDateTime;
153+
154+
const HOST_WITH_NIX: &str = r#"
155+
[meta]
156+
hostname = "forge"
157+
timezone = "UTC"
158+
arch_level = "v4"
159+
locale = "en_US.UTF-8"
160+
keymap = "us"
161+
162+
[kernel]
163+
package = "linux-cachyos"
164+
165+
[nix.installer]
166+
expected_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
167+
"#;
168+
169+
const HOST_WITHOUT_NIX: &str = r#"
170+
[meta]
171+
hostname = "forge"
172+
timezone = "UTC"
173+
arch_level = "v4"
174+
locale = "en_US.UTF-8"
175+
keymap = "us"
176+
177+
[kernel]
178+
package = "linux-cachyos"
179+
"#;
180+
181+
fn engine_with_host(host_path: PathBuf, host_body: &str) -> Engine {
182+
let mut nickel = MockNickel::new();
183+
nickel.seed(host_path, host_body);
184+
let probed = ProbedState {
185+
probed_at: OffsetDateTime::from_unix_timestamp(1_777_000_000).expect("ts"),
186+
host: HostInfo {
187+
hostname: "forge".to_owned(),
188+
},
189+
pacman: None,
190+
cargo: None,
191+
config_files: None,
192+
services: None,
193+
kernel: KernelInfo::default(),
194+
};
195+
Engine::new(
196+
Box::new(nickel),
197+
Box::new(MockProbe::with_state(probed)),
198+
PathBuf::from("/cfg-repo"),
199+
)
200+
}
201+
202+
#[test]
203+
fn bootstrap_calls_installer_with_declared_sha() {
204+
let dir = TempDir::new().expect("tempdir");
205+
let host_file = dir.path().join("forge.ncl");
206+
let nix_conf = dir.path().join("nix.conf");
207+
let script = dir.path().join("installer.sh");
208+
std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script");
209+
210+
let installer = MockNixInstaller::new();
211+
let engine = engine_with_host(host_file.clone(), HOST_WITH_NIX);
212+
213+
let outcome = engine
214+
.bootstrap(&host_file, &installer, &script, &nix_conf)
215+
.expect("bootstrap");
216+
217+
assert_eq!(outcome.install, InstallOutcome::Installed);
218+
assert!(outcome.nix_conf_written);
219+
220+
let history = installer.install_history();
221+
assert_eq!(history.len(), 1);
222+
assert_eq!(
223+
history[0].expected_sha256,
224+
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
225+
);
226+
assert_eq!(
227+
history[0].args,
228+
vec!["install", "--determinate", "--no-confirm"]
229+
);
230+
assert_eq!(history[0].script_path, script);
231+
}
232+
233+
#[test]
234+
fn bootstrap_writes_nix_conf_when_absent() {
235+
let dir = TempDir::new().expect("tempdir");
236+
let host_file = dir.path().join("forge.ncl");
237+
let nix_conf = dir.path().join("nix.conf");
238+
let script = dir.path().join("installer.sh");
239+
std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script");
240+
241+
let installer = MockNixInstaller::new();
242+
let engine = engine_with_host(host_file.clone(), HOST_WITH_NIX);
243+
244+
engine
245+
.bootstrap(&host_file, &installer, &script, &nix_conf)
246+
.expect("bootstrap");
247+
248+
let written = std::fs::read_to_string(&nix_conf).expect("read");
249+
assert!(written.contains(NIX_CONF_LINE), "got {written:?}");
250+
}
251+
252+
#[test]
253+
fn bootstrap_skips_nix_conf_when_line_already_present() {
254+
let dir = TempDir::new().expect("tempdir");
255+
let host_file = dir.path().join("forge.ncl");
256+
let nix_conf = dir.path().join("nix.conf");
257+
let script = dir.path().join("installer.sh");
258+
std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script");
259+
std::fs::write(
260+
&nix_conf,
261+
format!(
262+
"# operator preamble\n{NIX_CONF_LINE}\nsubstituters = https://cache.nixos.org\n"
263+
),
264+
)
265+
.expect("write existing nix.conf");
266+
let original = std::fs::read_to_string(&nix_conf).expect("read original");
267+
268+
let installer = MockNixInstaller::new();
269+
let engine = engine_with_host(host_file.clone(), HOST_WITH_NIX);
270+
271+
let outcome = engine
272+
.bootstrap(&host_file, &installer, &script, &nix_conf)
273+
.expect("bootstrap");
274+
275+
assert!(!outcome.nix_conf_written);
276+
let after = std::fs::read_to_string(&nix_conf).expect("read after");
277+
assert_eq!(
278+
after, original,
279+
"operator preamble must be preserved untouched"
280+
);
281+
}
282+
283+
#[test]
284+
fn bootstrap_errors_when_nix_block_not_declared() {
285+
let dir = TempDir::new().expect("tempdir");
286+
let host_file = dir.path().join("forge.ncl");
287+
let nix_conf = dir.path().join("nix.conf");
288+
let script = dir.path().join("installer.sh");
289+
std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script");
290+
291+
let installer = MockNixInstaller::new();
292+
let engine = engine_with_host(host_file.clone(), HOST_WITHOUT_NIX);
293+
294+
let err = engine
295+
.bootstrap(&host_file, &installer, &script, &nix_conf)
296+
.expect_err("must fail");
297+
assert!(matches!(err, BootstrapError::NixNotDeclared), "got {err:?}");
298+
assert!(installer.install_history().is_empty());
299+
}
300+
301+
#[test]
302+
fn bootstrap_short_circuits_when_nix_already_installed() {
303+
let dir = TempDir::new().expect("tempdir");
304+
let host_file = dir.path().join("forge.ncl");
305+
let nix_conf = dir.path().join("nix.conf");
306+
let script = dir.path().join("installer.sh");
307+
std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script");
308+
309+
let installer = MockNixInstaller::with_already_installed();
310+
let engine = engine_with_host(host_file.clone(), HOST_WITH_NIX);
311+
312+
let outcome = engine
313+
.bootstrap(&host_file, &installer, &script, &nix_conf)
314+
.expect("bootstrap");
315+
316+
assert_eq!(outcome.install, InstallOutcome::Already);
317+
// nix.conf still gets written even if nix was already
318+
// installed — the experimental-features line is independent
319+
// of installer state.
320+
assert!(outcome.nix_conf_written);
321+
}
322+
}

crates/pearlite-engine/src/errors.rs

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,34 @@ pub enum RollbackError {
9191
},
9292
}
9393

94+
/// Errors emitted by [`Engine::bootstrap`](crate::Engine::bootstrap)
95+
/// (ADR-0012).
96+
#[derive(Debug, Error)]
97+
pub enum BootstrapError {
98+
/// Nickel evaluator failed loading the host file.
99+
#[error(transparent)]
100+
Nickel(#[from] pearlite_nickel::NickelError),
101+
/// The declared host has no `[nix.installer]` block; bootstrap
102+
/// makes no sense for hosts that don't need nix. Hint: declare
103+
/// `nix.installer.expected_sha256` or skip this command.
104+
#[error(
105+
"host file has no [nix.installer] block; declare nix.installer.expected_sha256 to bootstrap"
106+
)]
107+
NixNotDeclared,
108+
/// Determinate Nix installer adapter failed (SHA mismatch,
109+
/// non-zero script exit, missing shell, etc.).
110+
#[error(transparent)]
111+
Installer(#[from] pearlite_userenv::InstallerError),
112+
/// Filesystem operation (atomic write of `/etc/nix/nix.conf`)
113+
/// failed.
114+
#[error(transparent)]
115+
Fs(#[from] pearlite_fs::FsError),
116+
/// Reading existing `/etc/nix/nix.conf` failed for a reason other
117+
/// than "not found".
118+
#[error("failed to read /etc/nix/nix.conf: {0}")]
119+
Io(#[source] std::io::Error),
120+
}
121+
94122
/// Errors emitted by [`Engine::plan`](crate::Engine::plan) and friends.
95123
#[derive(Debug, Error)]
96124
pub enum EngineError {

crates/pearlite-engine/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,15 @@
88
//! Apply, rollback, and reconcile arrive in M2+ per Plan §7.
99
1010
mod apply;
11+
mod bootstrap;
1112
mod errors;
1213
mod plan;
1314
mod probe;
1415
mod rollback;
1516

1617
pub use apply::{ApplyContext, ApplyOutcome, FailureRecord};
17-
pub use errors::{ApplyError, EngineError, ProbeError, RollbackError};
18+
pub use bootstrap::BootstrapOutcome;
19+
pub use errors::{ApplyError, BootstrapError, EngineError, ProbeError, RollbackError};
1820
pub use plan::Engine;
1921
pub use probe::{LiveProbe, SystemProbe};
2022
pub use rollback::RollbackOutcome;

crates/pearlite-engine/src/plan.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,13 @@ impl Engine {
5555
&self.repo_root
5656
}
5757

58+
/// Borrow the Nickel evaluator. Crate-internal so other engine
59+
/// modules (`bootstrap`) can re-use the same adapter without
60+
/// pushing it through the call surface.
61+
pub(crate) fn nickel(&self) -> &dyn NickelEvaluator {
62+
&*self.nickel
63+
}
64+
5865
/// Compute a [`Plan`] without changing any system state.
5966
///
6067
/// Steps:

0 commit comments

Comments
 (0)