Skip to content

Commit 2f83813

Browse files
feat(cli): pearlite bootstrap subcommand wires Engine::bootstrap (#57)
Surfaces ADR-0012's bootstrap entry point to operators. Verifies the operator-supplied Determinate installer script against the host's declared SHA-256 pin, runs the installer if nix is missing, and writes /etc/nix/nix.conf with the experimental-features minimum (ADR-0013). - New `Command::Bootstrap { host_file, installer_script, nix_conf }` in args.rs. `installer_script` is required (operator pre-downloads; URL-fetch is a follow-up). `nix_conf` defaults to /etc/nix/nix.conf. - `dispatch_bootstrap` calls Engine::bootstrap with the per-host declared sha and renders BootstrapOutcome as the envelope `data`. - `bootstrap_error_payload` maps every BootstrapError variant to a typed error code with a runnable hint: - NIX_NOT_DECLARED — host has no [nix.installer] block - NIX_INSTALLER_SHA256_MISMATCH — declared vs. observed sha (ADR-004) - NIX_INSTALLER_FAILED — script exec, missing shell, or non-zero exit - BOOTSTRAP_NIX_CONF_WRITE_FAILED / BOOTSTRAP_NIX_CONF_READ_FAILED - BOOTSTRAP_NICKEL_FAILED All are class=preflight, exit=2 — bootstrap halts before mutating state, so even installer failures are recoverable from the operator's perspective. - RunContext gains `nix_installer: Box<dyn NixInstaller>`. main.rs wires LiveNixInstaller; the six dispatch test sites wire MockNixInstaller. Two new dispatch tests cover the happy path and the NIX_NOT_DECLARED error. Tests: 314 passing (+2). Clippy clean. fmt clean. audit clean. The next chunk lands the apply-side preflight check (NIX_NOT_INSTALLED when home_manager.enabled = true and `nix --version` fails). Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent a94e552 commit 2f83813

3 files changed

Lines changed: 250 additions & 3 deletions

File tree

crates/pearlite-cli/src/args.rs

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,35 @@ pub enum Command {
133133
#[arg(long)]
134134
bare: bool,
135135
},
136+
/// Bootstrap nix on a fresh host (ADR-0012).
137+
///
138+
/// One-shot side-effect: verifies the operator-supplied installer
139+
/// script against the SHA-256 declared in the host's
140+
/// `nix.installer.expected_sha256`, runs the Determinate Nix
141+
/// installer if `nix --version` fails, and writes
142+
/// `/etc/nix/nix.conf` with `experimental-features = nix-command
143+
/// flakes` (idempotent — operator preamble is preserved).
144+
///
145+
/// Bootstrap is **not** rolled back by `pearlite rollback`: nix
146+
/// touches `/nix` (its own subvolume on btrfs) which lives outside
147+
/// the Snapper-managed root. Recovery uses Determinate's own
148+
/// uninstall path.
149+
Bootstrap {
150+
/// Host file to evaluate. Defaults to
151+
/// `<config_dir>/hosts/<hostname>.ncl`.
152+
#[arg(long)]
153+
host_file: Option<PathBuf>,
154+
/// Path to the already-downloaded Determinate Nix installer
155+
/// script. The SHA-256 of this file's bytes is checked against
156+
/// the host's `nix.installer.expected_sha256`; the script is
157+
/// **never** executed if the hash mismatches (ADR-004).
158+
#[arg(long)]
159+
installer_script: PathBuf,
160+
/// Path to the system-wide nix.conf. Defaults to
161+
/// `/etc/nix/nix.conf`. Override only for tests.
162+
#[arg(long, default_value = "/etc/nix/nix.conf")]
163+
nix_conf: PathBuf,
164+
},
136165
}
137166

138167
/// Sub-actions for [`Command::Gen`].

crates/pearlite-cli/src/dispatch.rs

Lines changed: 219 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use pearlite_pacman::Pacman;
1111
use pearlite_snapper::Snapper;
1212
use pearlite_state::{State, StateError, StateStore};
1313
use pearlite_systemd::Systemd;
14-
use pearlite_userenv::HomeManagerBackend;
14+
use pearlite_userenv::{HomeManagerBackend, NixInstaller};
1515
use std::path::{Path, PathBuf};
1616
use std::time::Instant;
1717
use time::OffsetDateTime;
@@ -41,6 +41,10 @@ pub struct RunContext {
4141
pub snapper: Box<dyn Snapper>,
4242
/// Home Manager backend (`apply` phase 7).
4343
pub home_manager: Box<dyn HomeManagerBackend>,
44+
/// Determinate Nix installer adapter (`bootstrap` only). Per
45+
/// ADR-0012 / ADR-004: the only curl-piped script Pearlite
46+
/// tolerates, defended by a hash-pin from the host config.
47+
pub nix_installer: Box<dyn NixInstaller>,
4448
}
4549

4650
/// Dispatch the parsed [`Args`] against a [`RunContext`] and return
@@ -122,6 +126,18 @@ pub fn dispatch(args: &Args, ctx: &RunContext) -> Envelope {
122126
snapper_config,
123127
} => dispatch_rollback(ctx, *plan_id, snapper_config, &metadata_at),
124128
Command::Gen { gen_command } => dispatch_gen(ctx, gen_command, &metadata_at),
129+
Command::Bootstrap {
130+
host_file,
131+
installer_script,
132+
nix_conf,
133+
} => dispatch_bootstrap(
134+
args,
135+
ctx,
136+
host_file.as_ref(),
137+
installer_script,
138+
nix_conf,
139+
&metadata_at,
140+
),
125141
Command::Schema { bare } => {
126142
if *bare {
127143
Envelope::success(metadata_at(None), bare_schema())
@@ -276,6 +292,122 @@ fn dispatch_apply(
276292
}
277293
}
278294

295+
/// Dispatch arm for `pearlite bootstrap` (ADR-0012).
296+
///
297+
/// Wires `Engine::bootstrap` with the operator-supplied installer
298+
/// script. The host file's `nix.installer.expected_sha256` defends
299+
/// the script (ADR-004). Bootstrap state is not recorded in
300+
/// `state.toml` — see ADR-0012 decision 4.
301+
fn dispatch_bootstrap(
302+
args: &Args,
303+
ctx: &RunContext,
304+
host_file: Option<&PathBuf>,
305+
installer_script: &Path,
306+
nix_conf: &Path,
307+
metadata_at: &dyn Fn(Option<String>) -> Metadata,
308+
) -> Envelope {
309+
let host_path = host_file
310+
.cloned()
311+
.unwrap_or_else(|| default_host_file(&args.config_dir, &ctx.fallback_host));
312+
match ctx.engine.bootstrap(
313+
&host_path,
314+
ctx.nix_installer.as_ref(),
315+
installer_script,
316+
nix_conf,
317+
) {
318+
Ok(outcome) => Envelope::success(
319+
metadata_at(Some(ctx.fallback_host.clone())),
320+
bootstrap_outcome_view(outcome),
321+
),
322+
Err(e) => Envelope::failure(
323+
metadata_at(Some(ctx.fallback_host.clone())),
324+
bootstrap_error_payload(&e),
325+
),
326+
}
327+
}
328+
329+
/// Render-friendly view of [`pearlite_engine::BootstrapOutcome`].
330+
fn bootstrap_outcome_view(outcome: pearlite_engine::BootstrapOutcome) -> serde_json::Value {
331+
serde_json::json!({
332+
"install": match outcome.install {
333+
pearlite_userenv::InstallOutcome::Already => "already",
334+
pearlite_userenv::InstallOutcome::Installed => "installed",
335+
},
336+
"nix_conf_written": outcome.nix_conf_written,
337+
})
338+
}
339+
340+
/// Map `BootstrapError` to a typed [`ErrorPayload`].
341+
///
342+
/// All bootstrap failures land in PRD §8.5 class 2 (preflight) —
343+
/// nothing on the system has been irreversibly mutated by the time
344+
/// these surface. Exit code 2 throughout.
345+
fn bootstrap_error_payload(err: &pearlite_engine::BootstrapError) -> ErrorPayload {
346+
use pearlite_engine::BootstrapError as B;
347+
use pearlite_userenv::InstallerError as I;
348+
match err {
349+
B::Nickel(e) => ErrorPayload {
350+
code: "BOOTSTRAP_NICKEL_FAILED".to_owned(),
351+
class: "preflight".to_owned(),
352+
exit_code: 2,
353+
message: format!("could not load host file: {e}"),
354+
hint: "verify the host file path; run `pearlite plan` first to surface schema issues"
355+
.to_owned(),
356+
details: serde_json::Value::Null,
357+
},
358+
B::NixNotDeclared => ErrorPayload {
359+
code: "NIX_NOT_DECLARED".to_owned(),
360+
class: "preflight".to_owned(),
361+
exit_code: 2,
362+
message: "host file has no [nix.installer] block".to_owned(),
363+
hint: "declare nix.installer.expected_sha256 in your host file, or skip \
364+
`pearlite bootstrap` for hosts that don't need nix"
365+
.to_owned(),
366+
details: serde_json::Value::Null,
367+
},
368+
B::Installer(I::Sha256Mismatch { expected, actual }) => ErrorPayload {
369+
code: "NIX_INSTALLER_SHA256_MISMATCH".to_owned(),
370+
class: "preflight".to_owned(),
371+
exit_code: 2,
372+
message: format!(
373+
"installer script SHA-256 mismatch: declared {expected}, got {actual}"
374+
),
375+
hint: "update the host's nix.installer.expected_sha256 to match the script you're \
376+
bootstrapping against, or fetch the matching installer version"
377+
.to_owned(),
378+
details: serde_json::json!({
379+
"expected_sha256": expected,
380+
"actual_sha256": actual,
381+
}),
382+
},
383+
B::Installer(other) => ErrorPayload {
384+
code: "NIX_INSTALLER_FAILED".to_owned(),
385+
class: "preflight".to_owned(),
386+
exit_code: 2,
387+
message: format!("Determinate Nix installer failed: {other}"),
388+
hint: "inspect the installer's stderr above; re-run after addressing the cause"
389+
.to_owned(),
390+
details: serde_json::Value::Null,
391+
},
392+
B::Fs(e) => ErrorPayload {
393+
code: "BOOTSTRAP_NIX_CONF_WRITE_FAILED".to_owned(),
394+
class: "preflight".to_owned(),
395+
exit_code: 2,
396+
message: format!("writing /etc/nix/nix.conf failed: {e}"),
397+
hint: "ensure pearlite is invoked as root for the nix.conf write".to_owned(),
398+
details: serde_json::Value::Null,
399+
},
400+
B::Io(e) => ErrorPayload {
401+
code: "BOOTSTRAP_NIX_CONF_READ_FAILED".to_owned(),
402+
class: "preflight".to_owned(),
403+
exit_code: 2,
404+
message: format!("reading existing /etc/nix/nix.conf failed: {e}"),
405+
hint: "check the file's permissions; re-run as root if needed".to_owned(),
406+
details: serde_json::Value::Null,
407+
},
408+
}
409+
}
410+
279411
/// Default plans directory: `<state_file dir>/plans`.
280412
///
281413
/// Mirrors [`default_failures_dir`]: on a production install with
@@ -559,6 +691,7 @@ fn label_for(command: &Command) -> String {
559691
GenCommand::Show { .. } => "pearlite gen show".to_owned(),
560692
},
561693
Command::Schema { .. } => "pearlite schema".to_owned(),
694+
Command::Bootstrap { .. } => "pearlite bootstrap".to_owned(),
562695
}
563696
}
564697

@@ -923,7 +1056,7 @@ mod tests {
9231056
use pearlite_snapper::MockSnapper;
9241057
use pearlite_state::SCHEMA_VERSION;
9251058
use pearlite_systemd::MockSystemd;
926-
use pearlite_userenv::MockHmBackend;
1059+
use pearlite_userenv::{MockHmBackend, MockNixInstaller};
9271060
use tempfile::TempDir;
9281061

9291062
const MINIMAL_HOST: &str = r#"
@@ -966,6 +1099,7 @@ package = "linux-cachyos"
9661099
systemd: Box::new(MockSystemd::new()),
9671100
snapper: Box::new(MockSnapper::new()),
9681101
home_manager: Box::new(MockHmBackend::new()),
1102+
nix_installer: Box::new(MockNixInstaller::new()),
9691103
}
9701104
}
9711105

@@ -1108,6 +1242,7 @@ package = "linux-cachyos"
11081242
systemd: Box::new(MockSystemd::new()),
11091243
snapper: Box::new(MockSnapper::new()),
11101244
home_manager: Box::new(MockHmBackend::new()),
1245+
nix_installer: Box::new(MockNixInstaller::new()),
11111246
};
11121247
let args = args_for_plan(host, state_path);
11131248
let env = dispatch(&args, &ctx);
@@ -1368,6 +1503,7 @@ package = "linux-cachyos"
13681503
systemd: Box::new(MockSystemd::new()),
13691504
snapper: Box::new(MockSnapper::new()),
13701505
home_manager: Box::new(MockHmBackend::new()),
1506+
nix_installer: Box::new(MockNixInstaller::new()),
13711507
}
13721508
}
13731509

@@ -1462,6 +1598,7 @@ package = "linux-cachyos"
14621598
systemd: Box::new(MockSystemd::new()),
14631599
snapper: Box::new(MockSnapper::new()),
14641600
home_manager: Box::new(MockHmBackend::new()),
1601+
nix_installer: Box::new(MockNixInstaller::new()),
14651602
};
14661603
let args = args_for_apply(host, state_path);
14671604
let env = dispatch(&args, &ctx);
@@ -1974,6 +2111,7 @@ package = "linux-cachyos"
19742111
systemd: Box::new(MockSystemd::new()),
19752112
snapper: Box::new(snapper_with_n_snapshots(50)),
19762113
home_manager: Box::new(MockHmBackend::new()),
2114+
nix_installer: Box::new(MockNixInstaller::new()),
19772115
};
19782116
let args = args_for_rollback(plan_id, state_path);
19792117
let env = dispatch(&args, &ctx);
@@ -2065,6 +2203,7 @@ package = "linux-cachyos"
20652203
systemd: Box::new(MockSystemd::new()),
20662204
snapper: Box::new(FailingSnapper),
20672205
home_manager: Box::new(MockHmBackend::new()),
2206+
nix_installer: Box::new(MockNixInstaller::new()),
20682207
};
20692208
let args = args_for_rollback(plan_id, state_path);
20702209
let env = dispatch(&args, &ctx);
@@ -2075,6 +2214,84 @@ package = "linux-cachyos"
20752214
assert_eq!(err.class, "apply-recoverable");
20762215
}
20772216

2217+
const HOST_WITH_NIX: &str = r#"
2218+
[meta]
2219+
hostname = "forge"
2220+
timezone = "UTC"
2221+
arch_level = "v4"
2222+
locale = "en_US.UTF-8"
2223+
keymap = "us"
2224+
2225+
[kernel]
2226+
package = "linux-cachyos"
2227+
2228+
[nix.installer]
2229+
expected_sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
2230+
"#;
2231+
2232+
fn args_for_bootstrap(
2233+
host_file: PathBuf,
2234+
state_file: PathBuf,
2235+
installer_script: PathBuf,
2236+
nix_conf: PathBuf,
2237+
) -> Args {
2238+
Args {
2239+
format: OutputFormat::Json,
2240+
config_dir: PathBuf::from("/etc/pearlite/repo"),
2241+
state_file,
2242+
command: Command::Bootstrap {
2243+
host_file: Some(host_file),
2244+
installer_script,
2245+
nix_conf,
2246+
},
2247+
}
2248+
}
2249+
2250+
#[test]
2251+
fn bootstrap_dispatches_through_engine_and_renders_outcome() {
2252+
let dir = TempDir::new().expect("tempdir");
2253+
let host = dir.path().join("forge.ncl");
2254+
let state_path = dir.path().join("state.toml");
2255+
let nix_conf = dir.path().join("nix.conf");
2256+
let script = dir.path().join("installer.sh");
2257+
std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script");
2258+
2259+
let ctx = ctx_with(host.clone(), HOST_WITH_NIX, state_path.clone());
2260+
let args = args_for_bootstrap(host, state_path, script, nix_conf);
2261+
let env = dispatch(&args, &ctx);
2262+
2263+
assert!(env.error.is_none(), "got error {:?}", env.error);
2264+
let data = env.data.expect("data populated");
2265+
assert_eq!(
2266+
data.get("install").and_then(serde_json::Value::as_str),
2267+
Some("installed")
2268+
);
2269+
assert_eq!(
2270+
data.get("nix_conf_written")
2271+
.and_then(serde_json::Value::as_bool),
2272+
Some(true)
2273+
);
2274+
}
2275+
2276+
#[test]
2277+
fn bootstrap_surfaces_nix_not_declared_when_block_missing() {
2278+
let dir = TempDir::new().expect("tempdir");
2279+
let host = dir.path().join("forge.ncl");
2280+
let state_path = dir.path().join("state.toml");
2281+
let nix_conf = dir.path().join("nix.conf");
2282+
let script = dir.path().join("installer.sh");
2283+
std::fs::write(&script, b"#!/bin/sh\nexit 0\n").expect("write script");
2284+
2285+
let ctx = ctx_with(host.clone(), MINIMAL_HOST, state_path.clone());
2286+
let args = args_for_bootstrap(host, state_path, script, nix_conf);
2287+
let env = dispatch(&args, &ctx);
2288+
2289+
let err = env.error.expect("must error");
2290+
assert_eq!(err.code, "NIX_NOT_DECLARED");
2291+
assert_eq!(err.class, "preflight");
2292+
assert_eq!(err.exit_code, 2);
2293+
}
2294+
20782295
#[test]
20792296
fn missing_state_file_is_tolerated() {
20802297
let dir = TempDir::new().expect("tempdir");

crates/pearlite-cli/src/main.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ use pearlite_nickel::LiveNickel;
1111
use pearlite_pacman::LivePacman;
1212
use pearlite_snapper::LiveSnapper;
1313
use pearlite_systemd::LiveSystemd;
14-
use pearlite_userenv::LiveHmBackend;
14+
use pearlite_userenv::{LiveHmBackend, LiveNixInstaller};
1515
use std::io::{IsTerminal as _, Write as _};
1616
use std::path::PathBuf;
1717
use std::process::ExitCode;
@@ -40,6 +40,7 @@ fn main() -> ExitCode {
4040
systemd: Box::new(LiveSystemd::new()),
4141
snapper: Box::new(LiveSnapper::new()),
4242
home_manager: Box::new(LiveHmBackend::new()),
43+
nix_installer: Box::new(LiveNixInstaller::new()),
4344
};
4445

4546
let envelope = dispatch(&args, &ctx);

0 commit comments

Comments
 (0)