Skip to content

Commit e69fabf

Browse files
feat(engine): Engine::reconcile writes <hostname>.imported.ncl (#65)
Wires emit_host into the engine layer per Plan §7.5 M4 W1 — the read-only half of reconcile that produces the operator's review draft. Probes the live system, renders Nickel via emit_host, and atomically writes <config_dir>/hosts/<hostname>.imported.ncl (tempfile + fsync + rename — no chmod/chown since the file lives in the operator's config repo, not under /etc). Hostname is validated: empty hostname (EmptyHostname) and hostnames containing path separators or NUL (InvalidHostname) are refused before any I/O. An existing target file yields AlreadyExists rather than clobbering operator review state. Out of scope (follow-up PRs): - reconcile_commit (state.toml writes, drift-threshold safety) - CLI surface (`pearlite reconcile`, `--adopt-all`) - VM scenario vm-10-reconcile-fresh-install.sh Refs: PRD §11, Plan §7.5 Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent dc30393 commit e69fabf

5 files changed

Lines changed: 350 additions & 1 deletion

File tree

crates/pearlite-engine/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ nix = { workspace = true }
3737
rayon = { workspace = true }
3838
serde = { workspace = true }
3939
serde_json = { workspace = true }
40+
tempfile = { workspace = true }
4041
thiserror = { workspace = true }
4142
time = { workspace = true }
4243
uuid = { workspace = true }

crates/pearlite-engine/src/errors.rs

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,51 @@ pub enum BootstrapError {
127127
Io(#[source] std::io::Error),
128128
}
129129

130+
/// Errors emitted by [`Engine::reconcile`](crate::Engine::reconcile)
131+
/// (PRD §11, M4 W1).
132+
///
133+
/// `reconcile` is read-only: it probes the live system and writes a
134+
/// fresh `<config_dir>/hosts/<hostname>.imported.ncl` for the operator
135+
/// to review. Variants identify whether the failure was on the probe
136+
/// side, validation of the probed hostname, or the filesystem write.
137+
#[derive(Debug, Error)]
138+
pub enum ReconcileError {
139+
/// Probing the live system failed.
140+
#[error(transparent)]
141+
Probe(#[from] ProbeError),
142+
/// Probe returned an empty hostname; reconcile refuses rather than
143+
/// landing the import at `<config_dir>/hosts/.imported.ncl`.
144+
#[error(
145+
"probe returned an empty hostname; set /etc/hostname before running `pearlite reconcile`"
146+
)]
147+
EmptyHostname,
148+
/// Probed hostname contains characters disallowed in a filename
149+
/// (path separators, NUL). RFC 1123 hostnames are already
150+
/// constrained, so this only fires on malformed system state.
151+
#[error("probed hostname {hostname:?} is not a valid filename component")]
152+
InvalidHostname {
153+
/// The offending hostname value as probed.
154+
hostname: String,
155+
},
156+
/// Target `.imported.ncl` already exists. Reconcile refuses to
157+
/// clobber operator review state; the operator removes or renames
158+
/// the existing file and retries.
159+
#[error("{path} already exists; remove or rename it before re-running reconcile")]
160+
AlreadyExists {
161+
/// Path that would have been written.
162+
path: std::path::PathBuf,
163+
},
164+
/// Filesystem operation (mkdir, atomic write) failed.
165+
#[error("I/O error at {path}: {source}")]
166+
Io {
167+
/// Path involved in the failed operation.
168+
path: std::path::PathBuf,
169+
/// Underlying I/O error.
170+
#[source]
171+
source: std::io::Error,
172+
},
173+
}
174+
130175
/// Errors emitted by [`Engine::plan`](crate::Engine::plan) and friends.
131176
#[derive(Debug, Error)]
132177
pub enum EngineError {

crates/pearlite-engine/src/lib.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,17 @@ mod bootstrap;
1212
mod errors;
1313
mod plan;
1414
mod probe;
15+
mod reconcile;
1516
mod rollback;
1617

1718
pub use apply::{ApplyContext, ApplyOutcome, FailureRecord};
1819
pub use bootstrap::BootstrapOutcome;
19-
pub use errors::{ApplyError, BootstrapError, EngineError, ProbeError, RollbackError};
20+
pub use errors::{
21+
ApplyError, BootstrapError, EngineError, ProbeError, ReconcileError, RollbackError,
22+
};
2023
pub use plan::Engine;
2124
pub use probe::{LiveProbe, SystemProbe};
25+
pub use reconcile::ReconcileOutcome;
2226
pub use rollback::RollbackOutcome;
2327

2428
#[cfg(any(test, feature = "test-mocks"))]

crates/pearlite-engine/src/plan.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,12 @@ impl Engine {
6262
&*self.nickel
6363
}
6464

65+
/// Borrow the system probe. Crate-internal so `reconcile`
66+
/// re-uses the same adapter without an extra trait surface.
67+
pub(crate) fn probe(&self) -> &dyn SystemProbe {
68+
&*self.probe
69+
}
70+
6571
/// Compute a [`Plan`] without changing any system state.
6672
///
6773
/// Steps:
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! [`Engine::reconcile`] — read-only flow that probes the live system
5+
//! and writes `<config_dir>/hosts/<hostname>.imported.ncl` for operator
6+
//! review (PRD §11, Plan §7.5 M4 W1).
7+
//!
8+
//! This is the *fresh-import* half of reconcile. The interactive
9+
//! `reconcile_commit` half (state.toml writes, drift-threshold safety,
10+
//! adoption prompts) lands in a follow-up PR.
11+
//!
12+
//! The flow:
13+
//!
14+
//! 1. Probe the live system via [`SystemProbe::probe`](crate::probe::SystemProbe::probe).
15+
//! 2. Render Nickel host text via
16+
//! [`pearlite_nickel::emit_host`](pearlite_nickel::emit_host).
17+
//! 3. Atomically write to
18+
//! `<config_dir>/hosts/<hostname>.imported.ncl`.
19+
//!
20+
//! No state.toml mutation, no schema validation of the emitted text —
21+
//! the file is a *review draft*, not a parsed declaration. Validation
22+
//! happens on the next `pearlite plan` once the operator has hand-
23+
//! curated and renamed it to `hosts/<hostname>.ncl`.
24+
25+
use crate::Engine;
26+
use crate::errors::ReconcileError;
27+
use std::io::Write as _;
28+
use std::path::{Path, PathBuf};
29+
use tempfile::NamedTempFile;
30+
31+
/// Result of a successful [`Engine::reconcile`] call.
32+
#[derive(Clone, Debug, PartialEq, Eq)]
33+
pub struct ReconcileOutcome {
34+
/// Absolute path of the written `.imported.ncl` file.
35+
pub path: PathBuf,
36+
/// Hostname the file was rendered for.
37+
pub hostname: String,
38+
}
39+
40+
impl Engine {
41+
/// Probe the live system and write
42+
/// `<config_dir>/hosts/<hostname>.imported.ncl`.
43+
///
44+
/// Refuses to clobber an existing `.imported.ncl` —
45+
/// [`ReconcileError::AlreadyExists`] surfaces and the operator
46+
/// removes or renames the existing file before retrying.
47+
///
48+
/// The hostname comes from the probe (typically `/etc/hostname`).
49+
/// An empty hostname yields [`ReconcileError::EmptyHostname`]; a
50+
/// hostname containing path separators or NUL yields
51+
/// [`ReconcileError::InvalidHostname`].
52+
///
53+
/// # Errors
54+
/// - [`ReconcileError::Probe`] — adapter or hostname-read failure.
55+
/// - [`ReconcileError::EmptyHostname`] — see above.
56+
/// - [`ReconcileError::InvalidHostname`] — see above.
57+
/// - [`ReconcileError::AlreadyExists`] — target already on disk.
58+
/// - [`ReconcileError::Io`] — mkdir or atomic-write failure.
59+
pub fn reconcile(&self, config_dir: &Path) -> Result<ReconcileOutcome, ReconcileError> {
60+
let probed = self.probe().probe()?;
61+
let hostname = validate_hostname(&probed.host.hostname)?;
62+
let nickel_text = pearlite_nickel::emit_host(&probed);
63+
64+
let hosts_dir = config_dir.join("hosts");
65+
let target = hosts_dir.join(format!("{hostname}.imported.ncl"));
66+
67+
if target.exists() {
68+
return Err(ReconcileError::AlreadyExists { path: target });
69+
}
70+
71+
std::fs::create_dir_all(&hosts_dir).map_err(|e| ReconcileError::Io {
72+
path: hosts_dir.clone(),
73+
source: e,
74+
})?;
75+
76+
write_text_atomic(&target, nickel_text.as_bytes())?;
77+
78+
Ok(ReconcileOutcome {
79+
path: target,
80+
hostname: hostname.to_owned(),
81+
})
82+
}
83+
}
84+
85+
/// Atomically write `content` to `target` via a sibling tempfile +
86+
/// fsync + rename. No mode/owner/group manipulation — the imported
87+
/// file lives in the operator's config repo, not under `/etc`, so we
88+
/// inherit the parent directory's defaults.
89+
fn write_text_atomic(target: &Path, content: &[u8]) -> Result<(), ReconcileError> {
90+
let parent = target.parent().ok_or_else(|| ReconcileError::Io {
91+
path: target.to_path_buf(),
92+
source: std::io::Error::new(
93+
std::io::ErrorKind::InvalidInput,
94+
"target has no parent directory",
95+
),
96+
})?;
97+
98+
let mut tmp = NamedTempFile::new_in(parent).map_err(|e| ReconcileError::Io {
99+
path: parent.to_path_buf(),
100+
source: e,
101+
})?;
102+
tmp.as_file_mut()
103+
.write_all(content)
104+
.map_err(|e| ReconcileError::Io {
105+
path: tmp.path().to_path_buf(),
106+
source: e,
107+
})?;
108+
tmp.as_file_mut()
109+
.sync_all()
110+
.map_err(|e| ReconcileError::Io {
111+
path: tmp.path().to_path_buf(),
112+
source: e,
113+
})?;
114+
115+
let tmp_path = tmp.path().to_path_buf();
116+
tmp.persist(target).map_err(|e| ReconcileError::Io {
117+
path: tmp_path,
118+
source: e.error,
119+
})?;
120+
121+
Ok(())
122+
}
123+
124+
fn validate_hostname(raw: &str) -> Result<&str, ReconcileError> {
125+
if raw.is_empty() {
126+
return Err(ReconcileError::EmptyHostname);
127+
}
128+
if raw.contains('/') || raw.contains('\\') || raw.contains('\0') {
129+
return Err(ReconcileError::InvalidHostname {
130+
hostname: raw.to_owned(),
131+
});
132+
}
133+
Ok(raw)
134+
}
135+
136+
#[cfg(test)]
137+
#[allow(
138+
clippy::expect_used,
139+
clippy::unwrap_used,
140+
clippy::panic,
141+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
142+
)]
143+
mod tests {
144+
use super::*;
145+
use crate::mock_probe::MockProbe;
146+
use crate::probe::SystemProbe;
147+
use pearlite_nickel::MockNickel;
148+
use pearlite_schema::{
149+
CargoInventory, HostInfo, KernelInfo, PacmanInventory, ProbedState, ServiceInventory,
150+
};
151+
use std::collections::BTreeSet;
152+
use tempfile::TempDir;
153+
use time::OffsetDateTime;
154+
155+
fn probed_with_hostname(hostname: &str) -> ProbedState {
156+
ProbedState {
157+
probed_at: OffsetDateTime::from_unix_timestamp(1_777_000_000).expect("ts"),
158+
host: HostInfo {
159+
hostname: hostname.to_owned(),
160+
},
161+
pacman: Some(PacmanInventory::default()),
162+
cargo: Some(CargoInventory::default()),
163+
config_files: None,
164+
services: Some(ServiceInventory::default()),
165+
kernel: KernelInfo {
166+
running_version: String::new(),
167+
package: "linux-cachyos".to_owned(),
168+
loaded_modules: BTreeSet::new(),
169+
},
170+
}
171+
}
172+
173+
fn make_engine(probe: Box<dyn SystemProbe>) -> Engine {
174+
// reconcile() does not consult Nickel; an unseeded MockNickel
175+
// is fine since the path never reaches the evaluator.
176+
let nickel = MockNickel::new();
177+
Engine::new(Box::new(nickel), probe, PathBuf::from("/cfg-repo"))
178+
}
179+
180+
#[test]
181+
fn reconcile_writes_imported_ncl_at_expected_path() {
182+
let tmp = TempDir::new().expect("tempdir");
183+
let probe = Box::new(MockProbe::with_state(probed_with_hostname("forge")));
184+
let engine = make_engine(probe);
185+
186+
let outcome = engine.reconcile(tmp.path()).expect("reconcile");
187+
188+
let expected = tmp.path().join("hosts").join("forge.imported.ncl");
189+
assert_eq!(outcome.path, expected);
190+
assert_eq!(outcome.hostname, "forge");
191+
assert!(expected.is_file(), "imported.ncl was not created on disk");
192+
}
193+
194+
#[test]
195+
fn reconcile_writes_emit_host_text_verbatim() {
196+
let tmp = TempDir::new().expect("tempdir");
197+
let probed = probed_with_hostname("forge");
198+
let probe = Box::new(MockProbe::with_state(probed.clone()));
199+
let engine = make_engine(probe);
200+
201+
let outcome = engine.reconcile(tmp.path()).expect("reconcile");
202+
203+
let on_disk = std::fs::read_to_string(&outcome.path).expect("read");
204+
let expected = pearlite_nickel::emit_host(&probed);
205+
assert_eq!(on_disk, expected);
206+
}
207+
208+
#[test]
209+
fn reconcile_creates_hosts_dir_when_missing() {
210+
let tmp = TempDir::new().expect("tempdir");
211+
// No `hosts/` subdir exists yet.
212+
let probe = Box::new(MockProbe::with_state(probed_with_hostname("forge")));
213+
let engine = make_engine(probe);
214+
215+
engine.reconcile(tmp.path()).expect("reconcile");
216+
217+
assert!(tmp.path().join("hosts").is_dir());
218+
}
219+
220+
#[test]
221+
fn reconcile_refuses_to_clobber_existing_file() {
222+
let tmp = TempDir::new().expect("tempdir");
223+
let hosts = tmp.path().join("hosts");
224+
std::fs::create_dir_all(&hosts).expect("mkdir");
225+
let target = hosts.join("forge.imported.ncl");
226+
std::fs::write(&target, "do not clobber").expect("seed");
227+
228+
let probe = Box::new(MockProbe::with_state(probed_with_hostname("forge")));
229+
let engine = make_engine(probe);
230+
231+
let err = engine.reconcile(tmp.path()).expect_err("must refuse");
232+
assert!(
233+
matches!(err, ReconcileError::AlreadyExists { .. }),
234+
"got {err:?}"
235+
);
236+
237+
let preserved = std::fs::read_to_string(&target).expect("read");
238+
assert_eq!(preserved, "do not clobber", "existing file was modified");
239+
}
240+
241+
#[test]
242+
fn reconcile_rejects_empty_hostname() {
243+
let tmp = TempDir::new().expect("tempdir");
244+
let probe = Box::new(MockProbe::with_state(probed_with_hostname("")));
245+
let engine = make_engine(probe);
246+
247+
let err = engine.reconcile(tmp.path()).expect_err("must reject");
248+
assert!(matches!(err, ReconcileError::EmptyHostname), "got {err:?}");
249+
}
250+
251+
#[test]
252+
fn reconcile_rejects_hostname_with_path_separator() {
253+
let tmp = TempDir::new().expect("tempdir");
254+
let probe = Box::new(MockProbe::with_state(probed_with_hostname("ev/il")));
255+
let engine = make_engine(probe);
256+
257+
let err = engine.reconcile(tmp.path()).expect_err("must reject");
258+
assert!(
259+
matches!(err, ReconcileError::InvalidHostname { ref hostname } if hostname == "ev/il"),
260+
"got {err:?}"
261+
);
262+
}
263+
264+
#[test]
265+
fn reconcile_propagates_probe_failure() {
266+
struct FailingProbe;
267+
impl SystemProbe for FailingProbe {
268+
fn probe(&self) -> Result<ProbedState, crate::errors::ProbeError> {
269+
Err(crate::errors::ProbeError::Pacman(
270+
pearlite_pacman::PacmanError::NotInPath {
271+
tool: "pacman",
272+
hint: "test",
273+
},
274+
))
275+
}
276+
}
277+
278+
let tmp = TempDir::new().expect("tempdir");
279+
let engine = make_engine(Box::new(FailingProbe));
280+
281+
let err = engine.reconcile(tmp.path()).expect_err("must fail");
282+
assert!(matches!(err, ReconcileError::Probe(_)), "got {err:?}");
283+
}
284+
285+
#[test]
286+
fn validate_hostname_accepts_normal_names() {
287+
assert_eq!(validate_hostname("forge").expect("ok"), "forge");
288+
assert_eq!(
289+
validate_hostname("anvil-01.local").expect("ok"),
290+
"anvil-01.local"
291+
);
292+
}
293+
}

0 commit comments

Comments
 (0)