|
9 | 9 | //! |
10 | 10 | //! ## Scope (this chunk) |
11 | 11 | //! |
12 | | -//! `meta`, `kernel`, and `packages` are emitted. Unprobed `meta` |
13 | | -//! fields (`timezone`, `arch_level`, `locale`, `keymap`) emit |
| 12 | +//! `meta`, `kernel`, `packages`, and `services` are emitted. Unprobed |
| 13 | +//! `meta` fields (`timezone`, `arch_level`, `locale`, `keymap`) emit |
14 | 14 | //! conservative defaults that the operator hand-edits after reconcile |
15 | 15 | //! lands the file at `<config_dir>/hosts/<host>.imported.ncl`. |
16 | | -//! Subsequent chunks add the `services`, `users`, and config-file |
17 | | -//! blocks. |
| 16 | +//! Subsequent chunks add the `users` and config-file blocks. |
| 17 | +//! |
| 18 | +//! ## Services-block caveat |
| 19 | +//! |
| 20 | +//! On a typical CachyOS install, `systemctl list-unit-files` reports |
| 21 | +//! hundreds of enabled units (every package-installed default), so |
| 22 | +//! emitting `services.enabled` verbatim produces a noisy starting |
| 23 | +//! point. Per PRD §11 the imported file is a *review draft*, not a |
| 24 | +//! polished declaration; operators are expected to curate. We emit |
| 25 | +//! the full sorted/deduped set rather than try to guess "noise" |
| 26 | +//! versus "intentional" — guessing wrong silently drops state that |
| 27 | +//! ought to be declared. |
18 | 28 | //! |
19 | 29 | //! The `packages` block buckets explicit packages by their probed |
20 | 30 | //! repo: `core`/`extra`/`multilib`/unknown → `packages.core`, |
|
35 | 45 | //! output is operator-readable, not pretty-printed by `nickel |
36 | 46 | //! format`. Operators run that themselves if desired. |
37 | 47 |
|
38 | | -use pearlite_schema::{CargoInventory, PacmanInventory, ProbedState}; |
| 48 | +use pearlite_schema::{CargoInventory, PacmanInventory, ProbedState, ServiceInventory}; |
39 | 49 | use std::collections::BTreeMap; |
40 | 50 |
|
41 | 51 | /// Render a Nickel host-config string from `probed`. |
@@ -64,6 +74,7 @@ pub fn emit_host(probed: &ProbedState) -> String { |
64 | 74 | push_meta(&mut out, &probed.host.hostname); |
65 | 75 | push_kernel(&mut out, &probed.kernel.package); |
66 | 76 | push_packages(&mut out, probed.pacman.as_ref(), probed.cargo.as_ref()); |
| 77 | + push_services(&mut out, probed.services.as_ref()); |
67 | 78 | out.push_str("}\n"); |
68 | 79 | out |
69 | 80 | } |
@@ -139,6 +150,28 @@ fn bucket_packages( |
139 | 150 | buckets |
140 | 151 | } |
141 | 152 |
|
| 153 | +fn push_services(out: &mut String, services: Option<&ServiceInventory>) { |
| 154 | + out.push_str(" services = {\n"); |
| 155 | + if let Some(s) = services { |
| 156 | + push_service_array(out, "enabled", &s.enabled); |
| 157 | + push_service_array(out, "disabled", &s.disabled); |
| 158 | + push_service_array(out, "masked", &s.masked); |
| 159 | + } else { |
| 160 | + // Probe didn't return a service inventory — emit empty |
| 161 | + // declarations so the schema's #[serde(default)] doesn't have |
| 162 | + // to absorb a missing block. |
| 163 | + push_array_field(out, "enabled", &[]); |
| 164 | + push_array_field(out, "disabled", &[]); |
| 165 | + push_array_field(out, "masked", &[]); |
| 166 | + } |
| 167 | + out.push_str(" },\n"); |
| 168 | +} |
| 169 | + |
| 170 | +fn push_service_array(out: &mut String, key: &str, units: &std::collections::BTreeSet<String>) { |
| 171 | + let v: Vec<String> = units.iter().cloned().collect(); |
| 172 | + push_array_field(out, key, &v); |
| 173 | +} |
| 174 | + |
142 | 175 | fn bucket_for_repo(repo: Option<&str>) -> &'static str { |
143 | 176 | match repo { |
144 | 177 | Some("cachyos") => "cachyos", |
@@ -207,7 +240,7 @@ fn escape(s: &str) -> String { |
207 | 240 | )] |
208 | 241 | mod tests { |
209 | 242 | use super::*; |
210 | | - use pearlite_schema::{HostInfo, KernelInfo}; |
| 243 | + use pearlite_schema::{HostInfo, KernelInfo, ServiceInventory}; |
211 | 244 | use std::collections::{BTreeMap, BTreeSet}; |
212 | 245 | use time::OffsetDateTime; |
213 | 246 |
|
@@ -363,6 +396,55 @@ mod tests { |
363 | 396 | assert!(out.contains(r#"core = ["chaotic-pkg"],"#)); |
364 | 397 | } |
365 | 398 |
|
| 399 | + fn make_services(enabled: &[&str], disabled: &[&str], masked: &[&str]) -> ServiceInventory { |
| 400 | + let to_set = |
| 401 | + |xs: &[&str]| -> BTreeSet<String> { xs.iter().map(|x| (*x).to_owned()).collect() }; |
| 402 | + ServiceInventory { |
| 403 | + enabled: to_set(enabled), |
| 404 | + disabled: to_set(disabled), |
| 405 | + masked: to_set(masked), |
| 406 | + active: BTreeSet::new(), |
| 407 | + } |
| 408 | + } |
| 409 | + |
| 410 | + #[test] |
| 411 | + fn emit_services_emits_three_arrays() { |
| 412 | + let mut probed = probed_with("forge", "linux-cachyos"); |
| 413 | + probed.services = Some(make_services( |
| 414 | + &["sshd.service", "NetworkManager.service"], |
| 415 | + &["bluetooth.service"], |
| 416 | + &["systemd-resolved.service"], |
| 417 | + )); |
| 418 | + let out = emit_host(&probed); |
| 419 | + // BTreeSet enumerates in sorted order: NetworkManager < |
| 420 | + // sshd lexicographically. |
| 421 | + assert!(out.contains(r#"enabled = ["NetworkManager.service", "sshd.service"],"#)); |
| 422 | + assert!(out.contains(r#"disabled = ["bluetooth.service"],"#)); |
| 423 | + assert!(out.contains(r#"masked = ["systemd-resolved.service"],"#)); |
| 424 | + } |
| 425 | + |
| 426 | + #[test] |
| 427 | + fn emit_services_emits_empty_block_when_inventory_absent() { |
| 428 | + let probed = probed_with("forge", "linux-cachyos"); |
| 429 | + let out = emit_host(&probed); |
| 430 | + assert!(out.contains("services = {")); |
| 431 | + assert!(out.contains("enabled = [],")); |
| 432 | + assert!(out.contains("disabled = [],")); |
| 433 | + assert!(out.contains("masked = [],")); |
| 434 | + } |
| 435 | + |
| 436 | + #[test] |
| 437 | + fn emit_services_appears_after_packages() { |
| 438 | + let probed = probed_with("forge", "linux-cachyos"); |
| 439 | + let out = emit_host(&probed); |
| 440 | + let packages_at = out.find("packages = ").expect("packages block"); |
| 441 | + let services_at = out.find("services = ").expect("services block"); |
| 442 | + assert!( |
| 443 | + packages_at < services_at, |
| 444 | + "packages must precede services for stable golden-fixture diffs" |
| 445 | + ); |
| 446 | + } |
| 447 | + |
366 | 448 | #[test] |
367 | 449 | fn emit_meta_block_appears_before_kernel_block() { |
368 | 450 | // Stable ordering matters for operator review (and for |
|
0 commit comments