@@ -11,7 +11,7 @@ use pearlite_pacman::Pacman;
1111use pearlite_snapper:: Snapper ;
1212use pearlite_state:: { State , StateError , StateStore } ;
1313use pearlite_systemd:: Systemd ;
14- use pearlite_userenv:: HomeManagerBackend ;
14+ use pearlite_userenv:: { HomeManagerBackend , NixInstaller } ;
1515use std:: path:: { Path , PathBuf } ;
1616use std:: time:: Instant ;
1717use 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\n exit 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\n exit 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" ) ;
0 commit comments