1use crate::Paths;
19use crate::admitted;
20use crate::cmd::{Output, run};
21use planarian_changes::{parse as parse_overrides, render};
22use planarian_schema::*;
23use serde::{Deserialize, Serialize};
24use std::collections::{BTreeMap, BTreeSet};
25use std::path::{Path, PathBuf};
26use std::sync::{Arc, Mutex};
27use std::time::Duration;
28
29const NIX: Duration = Duration::from_secs(30 * 60);
30const QUICK: Duration = Duration::from_secs(30);
31const PARKING_GRACE: Duration = Duration::from_secs(48 * 3600);
35pub const ACTIVATION: Duration = Duration::from_secs(16 * 60);
36
37#[derive(Clone, Debug, Default, Serialize, Deserialize)]
38pub struct Registry {
39 pub next: u64,
40 pub paths: BTreeMap<u64, String>,
41 pub overrides: BTreeMap<u64, Hash>,
42}
43
44impl Registry {
45 pub fn id_for(&mut self, path: &str) -> GenerationId {
46 if let Some((id, _)) = self.paths.iter().find(|(_, p)| *p == path) {
47 return GenerationId(*id);
48 }
49 self.next = self.next.max(self.paths.keys().max().copied().unwrap_or(0)) + 1;
50 self.paths.insert(self.next, path.to_string());
51 GenerationId(self.next)
52 }
53}
54
55#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "camelCase", default)]
59pub struct Export {
60 pub units: Vec<UnitDef>,
61 pub user_units: Vec<UnitDef>,
62 pub core_units: BTreeMap<String, String>,
63 pub devices: BTreeMap<String, String>,
64 pub packages: BTreeMap<String, String>,
66 pub insecure: Vec<String>,
68 #[serde(default)]
70 pub declarations: BTreeMap<String, Vec<String>>,
71 #[serde(default)]
73 pub inputs: BTreeMap<String, ExportInput>,
74 #[serde(default)]
76 pub services: BTreeMap<String, Sensitivities>,
77}
78
79#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
80pub struct ExportInput {
81 #[serde(default)]
82 pub rev: String,
83 #[serde(default)]
84 pub path: String,
85}
86
87impl Export {
88 pub fn into_eval(self, source: Hash) -> EvaluatedGeneration {
89 EvaluatedGeneration {
90 source,
91 options: BTreeMap::new(),
92 units: self.units.into_iter().map(|d| (d.id.clone(), d)).collect(),
93 user_units: self.user_units,
94 activation: ActivationPlan::default(),
95 locked: BTreeSet::new(),
96 core_units: self
97 .core_units
98 .into_iter()
99 .map(|(k, v)| (k, Hash::from_hex(&v).unwrap_or(Hash::of(v.as_bytes()))))
100 .collect(),
101 packages: self.packages,
102 insecure: self.insecure.into_iter().collect(),
103 declarations: self.declarations,
104 inputs: self.inputs.into_iter().map(|(k, v)| (k, (v.rev, v.path))).collect(),
105 services: self.services,
106 }
107 }
108}
109
110pub fn dry_activate_failure(o: &Output) -> Option<String> {
112 if o.timed_out {
113 Some("dry activation timed out: its plan is unknown".into())
114 } else if o.truncated {
115 Some("dry activation output was cut short: its plan is incomplete".into())
116 } else if !o.ok() {
117 Some(format!("dry activation failed ({:?}): {}", o.status, o.tail()))
118 } else {
119 None
120 }
121}
122
123pub fn parse_dry_activate(text: &str) -> ActivationPlan {
125 let mut plan = ActivationPlan::default();
126 for line in text.lines() {
127 let line = line.trim();
128 let (set, list) = if let Some(l) = line.strip_prefix("would stop the following units: ") {
129 (&mut plan.stop, l)
130 } else if let Some(l) = line.strip_prefix("would restart the following units: ") {
131 (&mut plan.restart, l)
132 } else if let Some(l) = line.strip_prefix("would start the following units: ") {
133 (&mut plan.start, l)
134 } else if let Some(l) = line.strip_prefix("would reload the following units: ") {
135 (&mut plan.reload, l)
136 } else {
137 continue;
138 };
139 for u in list.split(',').map(str::trim).filter(|u| !u.is_empty()) {
140 if let Some(n) = u.strip_suffix(".service") {
141 set.insert(UnitId::service(n));
142 } else if let Some(n) = u.strip_suffix(".target") {
143 set.insert(UnitId::target(n));
144 }
145 }
146 }
147 plan
148}
149
150pub fn apply_expr(paths: &[String]) -> String {
153 let list = |p: &str| {
154 let segs: Vec<String> = p.split('.').map(|s| format!("{s:?}")).collect();
155 format!("[ {} ]", segs.join(" "))
156 };
157 let walk = |root: &str, p: &str| {
158 format!(
159 "(builtins.foldl' (a: k: if a != null && builtins.isAttrs a && a ? ${{k}} then a.${{k}} else null) {root} {})",
160 list(p)
161 )
162 };
163 let mut values = String::new();
164 let mut prios = String::new();
165 for p in paths {
166 values.push_str(&format!(" {p:?} = {};", walk("c.config", p)));
167 prios.push_str(&format!(
168 " {p:?} = (let o = {}; in if o != null && o ? highestPrio then o.highestPrio else 100);",
169 walk("c.options", p)
170 ));
171 }
172 format!("c: {{ export = c.config.services.planarian.export; values = {{{values} }}; prios = {{{prios} }}; }}")
173}
174
175fn closure_bytes(paths: &[&str]) -> Result<u64, Outcome> {
177 if paths.is_empty() {
178 return Ok(0);
179 }
180 let mut args = vec!["path-info", "--recursive", "--json"];
181 args.extend(paths);
182 let o = nix(&args, QUICK);
183 if !o.ok() {
184 return Err(err(&o));
185 }
186 closure_bytes_of(&o.stdout).ok_or_else(|| Outcome::Failed("nix path-info: unexpected output".into()))
187}
188
189pub fn nar_hash_of(json: &str, path: &str) -> Option<String> {
192 let v: serde_json::Value = serde_json::from_str(json).ok()?;
193 let entry = match &v {
194 serde_json::Value::Object(m) => m.get(path)?,
195 serde_json::Value::Array(a) => a.iter().find(|x| x["path"].as_str() == Some(path))?,
196 _ => return None,
197 };
198 entry["narHash"].as_str().map(String::from)
199}
200
201pub fn sri_sha256(sri: &str) -> Option<Hash> {
204 let b64 = sri.strip_prefix("sha256-")?;
205 let val = |c: u8| -> Option<u32> {
206 Some(match c {
207 b'A'..=b'Z' => c - b'A',
208 b'a'..=b'z' => c - b'a' + 26,
209 b'0'..=b'9' => c - b'0' + 52,
210 b'+' => 62,
211 b'/' => 63,
212 _ => return None,
213 } as u32)
214 };
215 let mut out = Vec::with_capacity(32);
216 let (mut acc, mut bits) = (0u32, 0u32);
217 for c in b64.bytes().take_while(|c| *c != b'=') {
218 acc = (acc << 6) | val(c)?;
219 bits += 6;
220 if bits >= 8 {
221 bits -= 8;
222 out.push((acc >> bits) as u8);
223 acc &= (1 << bits) - 1;
224 }
225 }
226 out.try_into().ok().map(Hash)
227}
228
229fn store_nar_hash(path: &str) -> Option<String> {
230 let o = nix(&["path-info", "--json", path], QUICK);
231 if o.ok() { nar_hash_of(&o.stdout, path) } else { None }
232}
233
234pub fn closure_bytes_of(json: &str) -> Option<u64> {
237 let v: serde_json::Value = serde_json::from_str(json).ok()?;
238 let sizes: Vec<u64> = match &v {
239 serde_json::Value::Object(m) => m.values().map(|x| x["narSize"].as_u64().unwrap_or(0)).collect(),
240 serde_json::Value::Array(a) => a.iter().map(|x| x["narSize"].as_u64().unwrap_or(0)).collect(),
241 _ => return None,
242 };
243 Some(sizes.iter().sum())
244}
245
246fn value_from_json(v: &serde_json::Value) -> Option<Value> {
247 Some(match v {
248 serde_json::Value::Bool(b) => Value::Bool(*b),
249 serde_json::Value::Number(n) => match n.as_i64() {
250 Some(i) => Value::Int(i),
251 None => Value::Str(n.to_string()),
252 },
253 serde_json::Value::String(s) => Value::Str(s.clone()),
254 serde_json::Value::Array(a) => Value::List(a.iter().filter_map(value_from_json).collect()),
255 _ => return None,
256 })
257}
258
259#[derive(Clone, Debug)]
260pub struct Nixos {
261 paths: Paths,
262 lock: Arc<Mutex<()>>,
263 budget: gc::GcBudget,
264}
265
266fn sha(text: &str) -> Hash {
267 Hash::of(text.as_bytes())
268}
269
270fn err(o: &Output) -> Outcome {
271 if o.timed_out { Outcome::Unknown("timed out".into()) } else { Outcome::Failed(o.tail()) }
272}
273
274fn nix(args: &[&str], timeout: Duration) -> Output {
275 let mut argv = vec!["nix", "--extra-experimental-features", "nix-command flakes"];
276 argv.extend_from_slice(args);
277 run(&argv, timeout)
278}
279
280fn nar_hash(dir: &Path) -> Result<Hash, Outcome> {
282 let o = run(&["nix-hash", "--type", "sha256", "--base16", &dir.to_string_lossy()], NIX);
283 if !o.ok() {
284 return Err(err(&o));
285 }
286 Hash::from_hex(o.stdout.trim())
287 .ok_or_else(|| Outcome::Failed(format!("unexpected nix-hash output {:?}", o.stdout.trim())))
288}
289
290fn copy_file(src: &Path, dst: &Path) -> std::io::Result<()> {
291 if let Some(p) = dst.parent() {
292 std::fs::create_dir_all(p)?;
293 }
294 let meta = std::fs::symlink_metadata(src)?;
295 if meta.file_type().is_symlink() {
296 std::os::unix::fs::symlink(std::fs::read_link(src)?, dst)
297 } else {
298 std::fs::copy(src, dst).map(|_| ())
299 }
300}
301
302fn walk(root: &Path, dir: &Path, out: &mut Vec<PathBuf>) -> std::io::Result<()> {
303 for e in std::fs::read_dir(dir)? {
304 let e = e?;
305 let p = e.path();
306 if p.file_name().is_some_and(|n| n == ".git") {
307 continue;
308 }
309 if e.file_type()?.is_dir() {
310 walk(root, &p, out)?;
311 } else {
312 out.push(p.strip_prefix(root).expect("under root").to_path_buf());
313 }
314 }
315 Ok(())
316}
317
318impl Nixos {
319 fn activate_live_inner(&self, g: GenerationId, op: &OpId, slow: Option<(std::time::Duration, bool)>) -> Outcome {
323 if !admitted(&self.paths.txn_dir(), op) {
324 return Outcome::fenced();
325 }
326 let path = match self.path_of(g) {
327 Ok(p) => p,
328 Err(o) => return o,
329 };
330 let unit = format!("--unit={}", Self::unit_name(op));
331 let switch = format!("{}/bin/switch-to-configuration", path.0);
332 let mut cmd: Vec<String> = [
333 "systemd-run",
334 &unit,
335 "--wait",
336 "--collect",
337 "--quiet",
338 "--service-type=oneshot",
339 "--property=TimeoutStartSec=15min",
340 ]
341 .map(String::from)
342 .to_vec();
343 match slow {
344 None => cmd.extend([switch, "test".into()]),
345 Some((d, uncancellable)) => {
346 if uncancellable {
347 cmd.extend(
348 [
349 "--property=KillSignal=SIGWINCH",
350 "--property=SendSIGKILL=no",
351 "--property=TimeoutStopSec=infinity",
352 ]
353 .map(String::from),
354 );
355 }
356 cmd.extend([
358 "/bin/sh".into(),
359 "-c".into(),
360 format!("PATH=/run/current-system/sw/bin; sleep {}; exec {switch} test", d.as_secs()),
361 ]);
362 }
363 }
364 let args: Vec<&str> = cmd.iter().map(String::as_str).collect();
365 let o = run(&args, ACTIVATION);
366 if o.ok() { Outcome::Succeeded } else { err(&o) }
367 }
368
369 pub fn new(paths: Paths) -> Nixos {
370 Nixos { paths, lock: Arc::new(Mutex::new(())), budget: gc::GcBudget::default() }
371 }
372
373 pub fn with_budget(mut self, budget: gc::GcBudget) -> Nixos {
375 self.budget = budget;
376 self
377 }
378
379 fn profile_generations(&self) -> Vec<(u64, String)> {
381 let dir = self.paths.profile.parent().expect("profile dir");
382 let prefix = format!("{}-", self.paths.profile.file_name().expect("name").to_string_lossy());
383 let mut gens: Vec<(u64, String)> = std::fs::read_dir(dir)
384 .map(|rd| {
385 rd.flatten()
386 .filter_map(|e| {
387 let n = e.file_name().to_string_lossy().into_owned();
388 let num = n.strip_prefix(&prefix)?.strip_suffix("-link")?.parse().ok()?;
389 Some((num, std::fs::canonicalize(e.path()).ok()?.to_string_lossy().into_owned()))
390 })
391 .collect()
392 })
393 .unwrap_or_default();
394 gens.sort();
395 gens
396 }
397
398 fn prune_roots(&self, profile: &[(u64, String)], protect: &[GenerationId]) {
404 let in_profile: BTreeSet<&str> = profile.iter().map(|(_, p)| p.as_str()).collect();
405 let entries = |d: &Path| std::fs::read_dir(d).map(|rd| rd.flatten().collect::<Vec<_>>()).unwrap_or_default();
406 for e in entries(&self.paths.gcroots.join("builds")) {
407 let target = std::fs::read_link(e.path()).ok().map(|t| t.to_string_lossy().into_owned());
408 if target.is_some_and(|t| in_profile.contains(t.as_str())) {
409 let _ = std::fs::remove_file(e.path());
410 }
411 }
412 for e in entries(&self.paths.gcroots) {
413 let name = e.file_name().to_string_lossy().into_owned();
414 let Some(n) = name.strip_prefix("gen-").and_then(|n| n.parse::<u64>().ok()) else { continue };
415 if !protect.contains(&GenerationId(n)) {
416 let _ = std::fs::remove_file(e.path());
417 }
418 }
419 }
420
421 fn age_out_roots_at(&self, now: std::time::SystemTime) -> Vec<String> {
425 let Ok(rd) = std::fs::read_dir(self.paths.gcroots.join("builds")) else { return vec![] };
426 let mut gone = vec![];
427 for e in rd.flatten() {
428 let old = std::fs::symlink_metadata(e.path())
429 .and_then(|m| m.modified())
430 .ok()
431 .and_then(|m| now.duration_since(m).ok())
432 .is_some_and(|age| age >= PARKING_GRACE);
433 if old && std::fs::remove_file(e.path()).is_ok() {
434 gone.push(e.file_name().to_string_lossy().into_owned());
435 }
436 }
437 gone.sort();
438 gone
439 }
440
441 fn plan_profile(
444 &self,
445 protect: &[GenerationId],
446 emergency: bool,
447 ) -> Result<(gc::GcPlan, Vec<(u64, String)>), Outcome> {
448 let protected_paths: BTreeSet<String> =
449 protect.iter().filter_map(|g| self.path_of(*g).ok()).map(|p| p.0).collect();
450 let gens = self.profile_generations();
451 let ids: Vec<GenerationId> = gens.iter().map(|(n, _)| GenerationId(*n)).collect();
452 let keep: Vec<GenerationId> =
453 gens.iter().filter(|(_, p)| protected_paths.contains(p)).map(|(n, _)| GenerationId(*n)).collect();
454 let fs_bytes = match rustix::fs::statvfs("/nix/store") {
455 Ok(v) => v.f_blocks.saturating_mul(v.f_frsize),
456 Err(e) => return Err(Outcome::Failed(format!("statvfs /nix/store: {e}"))),
457 };
458 let count = if emergency { self.budget.keep_emergency } else { self.budget.keep_generations };
459 let mut failure = None;
460 let keep = self.budget.protected(&ids, &keep);
461 let plan = gc::plan(&ids, &keep, count, self.budget.max_bytes(fs_bytes), |set| {
462 let paths: Vec<&str> =
463 gens.iter().filter(|(n, _)| set.contains(&GenerationId(*n))).map(|(_, p)| p.as_str()).collect();
464 closure_bytes(&paths).unwrap_or_else(|o| {
465 failure = Some(o);
466 0
467 })
468 });
469 match failure {
470 Some(o) => Err(o),
471 None => Ok((plan, gens)),
472 }
473 }
474
475 fn boot_menu_path(&self) -> PathBuf {
477 self.paths.state.join("boot-menu")
478 }
479
480 fn profile_generation(&self) -> Option<u64> {
486 let l = std::fs::read_link(&self.paths.profile).ok()?;
487 let name = l.file_name()?.to_string_lossy().into_owned();
488 name.strip_suffix("-link")?.rsplit('-').next()?.parse().ok()
489 }
490
491 fn boot_menu_for(&self, path: &str) -> bool {
492 match std::fs::read_to_string(self.boot_menu_path()) {
493 Ok(p) => p.trim() == path,
494 Err(_) => {
495 std::fs::canonicalize("/run/booted-system").ok().map(|p| p.to_string_lossy().into_owned())
496 == Some(path.to_string())
497 }
498 }
499 }
500
501 fn report_path(&self) -> PathBuf {
502 self.paths.state.join("gc-report.json")
503 }
504
505 fn registry_path(&self) -> PathBuf {
506 self.paths.state.join("generations.json")
507 }
508
509 fn with_registry<R>(&self, f: impl FnOnce(&mut Registry) -> R) -> Result<R, Outcome> {
510 let _g = self.lock.lock().unwrap_or_else(|e| e.into_inner());
511 let p = self.registry_path();
512 let mut reg: Registry = match std::fs::read(&p) {
513 Ok(b) => serde_json::from_slice(&b).map_err(|e| Outcome::Failed(format!("generations.json: {e}")))?,
514 Err(_) => Registry::default(),
515 };
516 let before = serde_json::to_vec(®).expect("serializes");
517 let r = f(&mut reg);
518 let after = serde_json::to_vec(®).expect("serializes");
519 if after != before {
520 let tmp = p.with_extension("json.tmp");
521 std::fs::create_dir_all(&self.paths.state).map_err(|e| Outcome::Failed(e.to_string()))?;
522 std::fs::write(&tmp, &after)
523 .and_then(|_| std::fs::rename(&tmp, &p))
524 .map_err(|e| Outcome::Failed(e.to_string()))?;
525 }
526 Ok(r)
527 }
528
529 fn id_of_link(&self, link: &Path) -> Result<GenerationId, Outcome> {
530 let target = std::fs::canonicalize(link).map_err(|e| Outcome::Failed(format!("{}: {e}", link.display())))?;
531 let t = target.to_string_lossy().into_owned();
532 self.with_registry(|r| r.id_for(&t))
533 }
534
535 fn override_file(&self, root: &Path) -> PathBuf {
536 root.join("planarian/overrides.nix")
537 }
538
539 fn store_dir(&self) -> PathBuf {
540 self.paths.state.join("overrides")
541 }
542
543 fn visible_files(&self, root: &Path) -> Result<(Vec<PathBuf>, Vec<String>), Outcome> {
545 let r = root.to_string_lossy().into_owned();
546 if root.join(".git").exists() {
547 let safe = format!("safe.directory={r}");
548 let tracked = run(&["git", "-c", &safe, "-C", &r, "ls-files", "-z", "--cached"], QUICK);
549 if !tracked.ok() {
550 return Err(err(&tracked));
551 }
552 let untracked =
553 run(&["git", "-c", &safe, "-C", &r, "ls-files", "-z", "--others", "--exclude-standard"], QUICK);
554 let files = tracked
555 .stdout
556 .split('\0')
557 .filter(|s| !s.is_empty())
558 .map(PathBuf::from)
559 .filter(|p| root.join(p).exists())
560 .collect();
561 let skipped = untracked.stdout.split('\0').filter(|s| !s.is_empty()).map(String::from).collect();
562 Ok((files, skipped))
563 } else {
564 let mut out = vec![];
565 walk(root, root, &mut out).map_err(|e| Outcome::Failed(e.to_string()))?;
566 Ok((out, vec![]))
567 }
568 }
569
570 fn copy_owner(&self, root: &Path) -> Result<(PathBuf, Vec<String>), Outcome> {
572 let (files, skipped) = self.visible_files(root)?;
573 let rel = Path::new("planarian/overrides.nix");
574 if !files.iter().any(|f| f == rel) {
575 return Err(Outcome::Failed(
576 "planarian/overrides.nix is not part of your configuration (for a git flake: `git add planarian/overrides.nix`)".into(),
577 ));
578 }
579 let stage = self.paths.state.join("stage");
580 let tmp = stage.join(format!("tmp-{}-{}", std::process::id(), Timestamp_now()));
581 for f in &files {
582 copy_file(&root.join(f), &tmp.join(f)).map_err(|e| Outcome::Failed(format!("{}: {e}", f.display())))?;
583 }
584 Ok((tmp, skipped))
585 }
586
587 fn flake_ref(&self, dir: &Path) -> String {
588 format!("path:{}#{}", dir.display(), self.paths.flake_attr)
589 }
590
591 fn staged_dir(&self, source: &Hash) -> PathBuf {
592 self.paths.state.join("stage").join(source.to_hex())
593 }
594
595 fn unit_name(op: &OpId) -> String {
596 format!("planarian-op-{}-{}-{}", op.txn.0, op.epoch, op.seq)
597 }
598
599 fn lock_dir(dir: &Path) -> std::io::Result<std::fs::File> {
604 let d = std::fs::File::open(dir)?;
605 rustix::fs::flock(&d, rustix::fs::FlockOperation::LockExclusive)?;
606 Ok(d)
607 }
608
609 fn lock_profile(&self) -> Result<std::fs::File, Outcome> {
612 std::fs::create_dir_all(&self.paths.state).map_err(|e| Outcome::Failed(e.to_string()))?;
613 let f = std::fs::OpenOptions::new()
614 .create(true)
615 .truncate(false)
616 .write(true)
617 .open(self.paths.state.join("profile.lock"))
618 .map_err(|e| Outcome::Failed(e.to_string()))?;
619 rustix::fs::flock(&f, rustix::fs::FlockOperation::LockExclusive).map_err(|e| Outcome::Failed(e.to_string()))?;
620 Ok(f)
621 }
622
623 fn prune_override_store(&self, keep_gens: &[GenerationId], now: std::time::SystemTime) -> Vec<String> {
629 let mut keep: BTreeSet<String> = BTreeSet::new();
630 if let Ok(reg) = self.with_registry(|r| r.overrides.clone()) {
631 for g in keep_gens {
632 if let Some(h) = reg.get(&g.0) {
633 keep.insert(h.to_hex());
634 }
635 }
636 }
637 if let Ok(t) = std::fs::read_to_string(self.override_file(&self.paths.owner)) {
638 keep.insert(sha(&t).to_hex());
639 }
640 let Ok(entries) = std::fs::read_dir(self.store_dir()) else { return vec![] };
641 let mut gone = vec![];
642 for e in entries.flatten() {
643 let name = e.file_name().to_string_lossy().into_owned();
644 let Some(hex) = name.strip_suffix(".nix") else { continue };
645 let recent = e
646 .metadata()
647 .and_then(|m| m.modified())
648 .ok()
649 .and_then(|m| now.duration_since(m).ok())
650 .is_none_or(|age| age < PARKING_GRACE);
651 if !keep.contains(hex) && !recent && std::fs::remove_file(e.path()).is_ok() {
652 gone.push(hex.to_string());
653 }
654 }
655 gone.sort();
656 gone
657 }
658
659 fn write_atomically(target: &Path, text: &str) -> std::io::Result<()> {
660 use std::io::Write;
661 let dir = target.parent().expect("has a parent");
662 let meta = std::fs::metadata(target).ok();
663 let tmp = dir.join(".overrides.nix.planarian-tmp");
664 {
665 let mut f = std::fs::File::create(&tmp)?;
666 f.write_all(text.as_bytes())?;
667 f.sync_all()?;
668 }
669 if let Some(m) = meta {
670 use std::os::unix::fs::MetadataExt;
671 let _ = rustix::fs::chown(
672 &tmp,
673 Some(rustix::fs::Uid::from_raw(m.uid())),
674 Some(rustix::fs::Gid::from_raw(m.gid())),
675 );
676 }
677 std::fs::rename(&tmp, target)?;
678 std::fs::File::open(dir)?.sync_all()
679 }
680}
681
682#[allow(non_snake_case)]
683fn Timestamp_now() -> u128 {
684 std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_nanos()).unwrap_or(0)
685}
686
687impl GenerationBackend for Nixos {
688 fn current(&self) -> Result<GenerationId, Outcome> {
689 self.id_of_link(Path::new("/run/current-system"))
690 }
691
692 fn boot_entries(&self) -> Result<Vec<GenerationId>, Outcome> {
693 let dir = self.paths.profile.parent().expect("profile has a directory");
694 let prefix = format!("{}-", self.paths.profile.file_name().expect("profile name").to_string_lossy());
695 let mut out = vec![];
696 for e in std::fs::read_dir(dir).map_err(|e| Outcome::Failed(e.to_string()))?.flatten() {
697 let n = e.file_name().to_string_lossy().into_owned();
698 if n.starts_with(&prefix) && n.ends_with("-link") {
699 out.push(self.id_of_link(&e.path())?);
700 }
701 }
702 out.sort();
703 out.dedup();
704 Ok(out)
705 }
706
707 fn boot_default(&self) -> Result<GenerationId, Outcome> {
708 self.id_of_link(&self.paths.profile)
709 }
710
711 fn last_booted(&self) -> Result<GenerationId, Outcome> {
712 self.id_of_link(Path::new("/run/booted-system"))
713 }
714
715 fn created_by(&self, g: GenerationId) -> Option<(u32, Timestamp)> {
719 use std::os::unix::fs::MetadataExt;
720 let dir = self.paths.profile.parent()?;
721 let path = self.path_of(g).ok()?;
725 let number = self.profile_generations().into_iter().filter(|(_, p)| *p == path.0).map(|(n, _)| n).max()?;
726 let name = format!("{}-{}-link", self.paths.profile.file_name()?.to_string_lossy(), number);
727 let m = std::fs::symlink_metadata(dir.join(name)).ok()?;
728 Some((m.uid(), Timestamp(u64::try_from(m.mtime()).ok()? * 1000)))
729 }
730
731 fn stage(&self, owner: &PinnedSource, overrides: &OverrideSet) -> Result<StagedSource, Outcome> {
732 let root = PathBuf::from(&owner.root);
733 let (tmp, excluded) = self.copy_owner(&root)?;
734 let live = std::fs::read_to_string(self.override_file(&tmp)).map_err(|e| Outcome::Failed(e.to_string()))?;
735 let base_source = nar_hash(&tmp)?;
736 let candidate = render(overrides);
737 std::fs::write(self.override_file(&tmp), &candidate).map_err(|e| Outcome::Failed(e.to_string()))?;
738 let staged_source = nar_hash(&tmp)?;
739 #[cfg(feature = "gym-hooks")]
740 eprintln!("gym: staged {}: base {} candidate {}", owner.root, base_source.to_hex(), staged_source.to_hex());
741 let dir = self.staged_dir(&staged_source);
742 if dir.exists() {
743 let _ = std::fs::remove_dir_all(&tmp);
744 } else {
745 std::fs::rename(&tmp, &dir).map_err(|e| Outcome::Failed(e.to_string()))?;
746 }
747 self.remember_override(overrides)?;
748 Ok(StagedSource {
749 dir: dir.to_string_lossy().into_owned(),
750 base_source,
751 staged_source,
752 base_override: sha(&live),
753 candidate_override: sha(&candidate),
754 excluded,
755 })
756 }
757
758 fn source_hash(&self, owner: &PinnedSource) -> Result<Hash, Outcome> {
759 let (tmp, _) = self.copy_owner(Path::new(&owner.root))?;
760 let h = nar_hash(&tmp);
761 let _ = std::fs::remove_dir_all(&tmp);
762 #[cfg(feature = "gym-hooks")]
763 eprintln!("gym: source hash of {} now {:?}", owner.root, h.as_ref().map(|h| h.to_hex()));
764 h
765 }
766
767 fn untracked(&self, owner: &PinnedSource) -> Vec<String> {
768 self.visible_files(Path::new(&owner.root)).map(|(_, skipped)| skipped).unwrap_or_default()
769 }
770
771 fn evaluate(&self, src: &StagedSource) -> Result<EvaluatedGeneration, Outcome> {
772 let dir = PathBuf::from(&src.dir);
773 let text = std::fs::read_to_string(self.override_file(&dir)).map_err(|e| Outcome::Failed(e.to_string()))?;
774 let set = parse_overrides(&text).unwrap_or_default();
775 let paths: Vec<String> = set.assignments.keys().cloned().collect();
776 let o = nix(
777 &["eval", "--json", "--option", "pure-eval", "true", &self.flake_ref(&dir), "--apply", &apply_expr(&paths)],
778 NIX,
779 );
780 if !o.ok() {
781 return Err(err(&o));
782 }
783 #[derive(Deserialize)]
784 struct Out {
785 export: Export,
786 values: BTreeMap<String, serde_json::Value>,
787 prios: BTreeMap<String, i64>,
788 }
789 let out: Out =
790 serde_json::from_str(&o.stdout).map_err(|e| Outcome::Failed(format!("evaluation output: {e}")))?;
791 let mut eval = out.export.into_eval(src.staged_source);
792 eval.options = out.values.iter().filter_map(|(k, v)| value_from_json(v).map(|v| (k.clone(), v))).collect();
793 eval.locked = out.prios.into_iter().filter(|(_, p)| *p < 90).map(|(k, _)| k).collect();
795 Ok(eval)
796 }
797
798 fn build(&self, eval: &EvaluatedGeneration) -> Result<Built, Outcome> {
799 #[cfg(feature = "gym-hooks")]
800 crate::gym::pause_point("build");
801 let dir = self.staged_dir(&eval.source);
802 let root = self.paths.gcroots.join("builds").join(eval.source.to_hex());
803 std::fs::create_dir_all(root.parent().expect("parent")).map_err(|e| Outcome::Failed(e.to_string()))?;
804 let attr = format!("{}.config.system.build.toplevel", self.flake_ref(&dir));
805 let o = nix(&["build", "--out-link", &root.to_string_lossy(), "--print-out-paths", &attr], NIX);
806 if !o.ok() {
807 return Err(err(&o));
808 }
809 let printed = o.stdout.lines().last().unwrap_or_default().trim().to_string();
810 let path = std::fs::canonicalize(&printed).map(|p| p.to_string_lossy().into_owned()).unwrap_or(printed);
813 let generation = self.with_registry(|r| r.id_for(&path))?;
814 let nar = store_nar_hash(&path).and_then(|h| sri_sha256(&h));
816 let activation = match self.dry_activation(&StorePath(path.clone())) {
818 Ok(a) => a,
819 Err(o) => {
820 let _ = std::fs::remove_file(&root);
821 return Err(o);
822 }
823 };
824 Ok(Built { generation, path: StorePath(path), root: root.to_string_lossy().into_owned(), activation, nar })
825 }
826
827 fn dry_activation(&self, path: &StorePath) -> Result<ActivationPlan, Outcome> {
828 let path = &path.0;
829 let dry = run(&[&format!("{path}/bin/switch-to-configuration"), "dry-activate"], Duration::from_secs(120));
830 if let Some(why) = dry_activate_failure(&dry) {
834 return Err(Outcome::Failed(why));
835 }
836 let mut activation = parse_dry_activate(&format!("{}\n{}", dry.stdout, dry.stderr));
837 let differs = |f: &str| {
838 std::fs::read_link(format!("{path}/{f}")).ok()
839 != std::fs::read_link(format!("/run/current-system/{f}")).ok()
840 };
841 activation.needs_reboot_to_confirm = differs("kernel") || differs("initrd") || {
842 std::fs::read_to_string(format!("{path}/kernel-params")).ok()
843 != std::fs::read_to_string("/run/current-system/kernel-params").ok()
844 };
845 Ok(activation)
846 }
847
848 fn verify_candidate(&self, path: &StorePath, nar: Option<&Hash>) -> Result<bool, Outcome> {
852 if !Path::new(&path.0).join("bin/switch-to-configuration").exists() {
853 return Ok(false);
854 }
855 if !run(&["nix-store", "--verify-path", &path.0], NIX).ok() {
856 return Ok(false);
857 }
858 Ok(match nar {
859 Some(want) => store_nar_hash(&path.0).and_then(|h| sri_sha256(&h)).is_some_and(|got| got == *want),
860 None => true,
863 })
864 }
865
866 fn pending_jobs(&self) -> Result<Vec<JobId>, Outcome> {
867 let o = run(
868 &[
869 "systemctl",
870 "list-units",
871 "--plain",
872 "--no-legend",
873 "--state=activating,active,deactivating,reloading",
874 "planarian-op-*",
875 ],
876 QUICK,
877 );
878 if !o.ok() {
879 return Err(err(&o));
880 }
881 Ok(o.stdout.lines().filter_map(|l| l.split_whitespace().next()).map(|n| JobId(n.into())).collect())
882 }
883
884 fn jobs_tagged(&self, op: &OpId) -> Result<Option<Vec<JobId>>, Outcome> {
885 #[cfg(feature = "gym-hooks")]
886 if crate::gym::hook("jobs_tagged", None).result.as_deref() == Some("None") {
887 return Ok(None);
888 }
889 let name = format!("{}.service", Self::unit_name(op));
890 let o = run(&["systemctl", "show", "--property=ActiveState,LoadState", &name], QUICK);
891 if !o.ok() {
892 return Ok(None);
893 }
894 let props = crate::systemd::parse_show(&o.stdout);
895 let active = matches!(
896 props.get("ActiveState").map(String::as_str),
897 Some("active" | "activating" | "deactivating" | "reloading")
898 );
899 Ok(Some(if active { vec![JobId(name)] } else { vec![] }))
900 }
901
902 fn override_hash(&self) -> Result<Hash, Outcome> {
903 std::fs::read_to_string(self.override_file(&self.paths.owner))
904 .map(|t| sha(&t))
905 .map_err(|e| Outcome::Failed(e.to_string()))
906 }
907
908 fn activate_live(&self, g: GenerationId, op: &OpId) -> Outcome {
909 #[cfg(feature = "gym-hooks")]
911 let (h, slow) = {
912 let mut h = crate::gym::hook("activate_live", Some(g));
913 let slow = h.delay.take().map(|d| (d, h.uncancellable));
914 (h, slow)
915 };
916 #[cfg(feature = "gym-hooks")]
917 return crate::gym::apply(h, || self.activate_live_inner(g, op, slow));
918 #[cfg(not(feature = "gym-hooks"))]
919 self.activate_live_inner(g, op, None)
920 }
921
922 fn publish_boot(&self, g: GenerationId, op: &OpId) -> Outcome {
923 #[cfg(feature = "gym-hooks")]
924 crate::gym::pause_point("publish_boot");
925 if !admitted(&self.paths.txn_dir(), op) {
926 return Outcome::fenced();
927 }
928 let _lock = match self.lock_profile() {
929 Ok(l) => l,
930 Err(o) => return o,
931 };
932 let path = match self.path_of(g) {
933 Ok(p) => p,
934 Err(o) => return o,
935 };
936 let profile = self.paths.profile.to_string_lossy().into_owned();
937 let already = std::fs::canonicalize(&self.paths.profile).ok().map(|p| p.to_string_lossy().into_owned())
938 == Some(path.0.clone());
939 if already && self.boot_menu_for(&path.0) {
943 return Outcome::Succeeded;
944 }
945 if !already {
946 let o = run(&["nix-env", "-p", &profile, "--set", &path.0], QUICK);
947 if !o.ok() {
948 return err(&o);
949 }
950 }
951 let o = run(&[&format!("{}/bin/switch-to-configuration", path.0), "boot"], Duration::from_secs(300));
952 if o.ok() {
953 let _ = std::fs::write(self.boot_menu_path(), &path.0);
954 return Outcome::Succeeded;
955 }
956 if !already {
961 let added = std::fs::read_link(&self.paths.profile)
962 .ok()
963 .and_then(|l| l.file_name().map(|n| n.to_string_lossy().into_owned()))
964 .and_then(|n| n.rsplit('-').nth(1).map(String::from));
965 let _ = run(&["nix-env", "-p", &profile, "--rollback"], QUICK);
966 if let Some(n) = added {
967 let _ = run(&["nix-env", "-p", &profile, "--delete-generations", &n], QUICK);
968 }
969 }
970 err(&o)
971 }
972
973 fn publish_boot_once(&self, g: GenerationId, keep: GenerationId, op: &OpId) -> Outcome {
979 if !admitted(&self.paths.txn_dir(), op) {
980 return Outcome::fenced();
981 }
982 let _lock = match self.lock_profile() {
983 Ok(l) => l,
984 Err(o) => return o,
985 };
986 let (Ok(path), Ok(kept)) = (self.path_of(g), self.path_of(keep)) else {
987 return Outcome::Failed("unknown generation".into());
988 };
989 let profile = self.paths.profile.to_string_lossy().into_owned();
990 let set = |p: &str| run(&["nix-env", "-p", &profile, "--set", p], QUICK);
991 let o = set(&path.0);
992 if !o.ok() {
993 return err(&o);
994 }
995 let Some(n) = self.profile_generation() else {
996 let _ = set(&kept.0);
997 return Outcome::Failed("cannot read the profile generation".into());
998 };
999 let o = set(&kept.0);
1000 if !o.ok() {
1001 return err(&o);
1002 }
1003 let o = run(&[&format!("{}/bin/switch-to-configuration", kept.0), "boot"], Duration::from_secs(300));
1004 if !o.ok() {
1005 return err(&o);
1006 }
1007 let _ = std::fs::write(self.boot_menu_path(), &kept.0);
1008 let o = run(&["bootctl", "set-oneshot", &format!("nixos-generation-{n}.conf")], QUICK);
1009 if o.ok() { Outcome::Succeeded } else { err(&o) }
1010 }
1011
1012 fn one_shot_pending(&self) -> Result<Option<GenerationId>, Outcome> {
1015 let efivars = Path::new("/sys/firmware/efi/efivars");
1016 if !efivars.is_dir() {
1017 return Err(Outcome::Unknown("no EFI variables".into()));
1018 }
1019 let var = efivars.join("LoaderEntryOneShot-4a67b082-0a4c-41cf-b6c7-440b29bb8c4f");
1020 let Ok(bytes) = std::fs::read(&var) else { return Ok(None) };
1021 let units: Vec<u16> =
1023 bytes.get(4..).unwrap_or(&[]).chunks_exact(2).map(|c| u16::from_le_bytes([c[0], c[1]])).collect();
1024 let name = String::from_utf16_lossy(&units).trim_end_matches('\0').to_string();
1025 let Some(n) = name.strip_prefix("nixos-generation-").and_then(|r| r.strip_suffix(".conf")) else {
1026 return Ok(None);
1027 };
1028 let link = self.paths.profile.with_file_name(format!("system-{n}-link"));
1029 self.id_of_link(&link).map(Some)
1030 }
1031
1032 fn protect(&self, g: GenerationId, op: &OpId) -> Outcome {
1033 if !admitted(&self.paths.txn_dir(), op) {
1034 return Outcome::fenced();
1035 }
1036 let path = match self.path_of(g) {
1037 Ok(p) => p,
1038 Err(o) => return o,
1039 };
1040 let link = self.paths.gcroots.join(format!("gen-{}", g.0));
1041 let _ = std::fs::create_dir_all(&self.paths.gcroots);
1042 let _ = std::fs::remove_file(&link);
1043 match std::os::unix::fs::symlink(&path.0, &link) {
1044 Ok(()) => Outcome::Succeeded,
1045 Err(e) => Outcome::Failed(e.to_string()),
1046 }
1047 }
1048
1049 fn cancel(&self, job: &JobId, op: &OpId) -> Outcome {
1050 if !admitted(&self.paths.txn_dir(), op) {
1051 return Outcome::fenced();
1052 }
1053 let o = run(&["systemctl", "stop", &job.0], Duration::from_secs(90));
1054 if o.ok() { Outcome::Succeeded } else { err(&o) }
1055 }
1056
1057 fn swap_override(&self, from: &Hash, to: &Hash, op: &OpId) -> Outcome {
1058 #[cfg(feature = "gym-hooks")]
1059 crate::gym::pause_point("swap_override");
1060 if !admitted(&self.paths.txn_dir(), op) {
1061 return Outcome::fenced();
1062 }
1063 let live = self.override_file(&self.paths.owner);
1064 let _lock = match Self::lock_dir(live.parent().expect("has a parent")) {
1067 Ok(l) => l,
1068 Err(e) => return Outcome::Failed(format!("cannot lock planarian/: {e}")),
1069 };
1070 let current = match std::fs::read_to_string(&live) {
1071 Ok(t) => sha(&t),
1072 Err(e) => return Outcome::Failed(e.to_string()),
1073 };
1074 if current == *to {
1075 return Outcome::Succeeded;
1076 }
1077 if current != *from {
1078 return Outcome::Failed("planarian/overrides.nix was changed by someone else".into());
1079 }
1080 let text = match std::fs::read_to_string(self.store_dir().join(format!("{}.nix", to.to_hex()))) {
1081 Ok(t) if sha(&t) == *to => t,
1082 _ => return Outcome::Failed("override content for this transaction is missing".into()),
1083 };
1084 match Self::write_atomically(&live, &text) {
1085 Ok(()) => Outcome::Succeeded,
1086 Err(e) => Outcome::Failed(e.to_string()),
1087 }
1088 }
1089
1090 fn path_of(&self, g: GenerationId) -> Result<StorePath, Outcome> {
1091 self.with_registry(|r| r.paths.get(&g.0).cloned())?
1092 .map(StorePath)
1093 .ok_or_else(|| Outcome::Failed(format!("unknown generation {}", g.0)))
1094 }
1095
1096 fn evaluate_built(&self, path: &StorePath) -> Result<EvaluatedGeneration, Outcome> {
1097 let p = Path::new(&path.0).join("etc/planarian/export.json");
1098 let text = std::fs::read_to_string(&p).map_err(|e| Outcome::Failed(format!("{}: {e}", p.display())))?;
1099 let export: Export =
1100 serde_json::from_str(&text).map_err(|e| Outcome::Failed(format!("{}: {e}", p.display())))?;
1101 Ok(export.into_eval(Hash::ZERO))
1102 }
1103
1104 fn read_overrides(&self) -> Result<(OverrideSet, Hash), Outcome> {
1105 let text = std::fs::read_to_string(self.override_file(&self.paths.owner))
1106 .map_err(|e| Outcome::Failed(e.to_string()))?;
1107 let set = parse_overrides(&text).ok_or_else(|| {
1108 Outcome::Failed(
1109 "planarian/overrides.nix was edited by hand; restore it from `git` or remove the edits".into(),
1110 )
1111 })?;
1112 Ok((set, sha(&text)))
1113 }
1114
1115 fn remember_override(&self, set: &OverrideSet) -> Result<Hash, Outcome> {
1116 let text = render(set);
1117 let h = sha(&text);
1118 let dir = self.store_dir();
1119 std::fs::create_dir_all(&dir).map_err(|e| Outcome::Failed(e.to_string()))?;
1120 std::fs::write(dir.join(format!("{}.nix", h.to_hex())), text).map_err(|e| Outcome::Failed(e.to_string()))?;
1121 Ok(h)
1122 }
1123
1124 fn override_for(&self, g: GenerationId) -> Result<Option<Hash>, Outcome> {
1125 self.with_registry(|r| r.overrides.get(&g.0).copied())
1126 }
1127
1128 fn override_content(&self, h: &Hash) -> Result<OverrideSet, Outcome> {
1129 let p = self.store_dir().join(format!("{}.nix", h.to_hex()));
1130 let text = std::fs::read_to_string(&p).map_err(|e| Outcome::Failed(format!("{}: {e}", p.display())))?;
1131 if sha(&text) != *h {
1132 return Err(Outcome::Failed(format!("{}: content does not match its hash", p.display())));
1133 }
1134 parse_overrides(&text).ok_or_else(|| Outcome::Failed(format!("{}: not a rendered override set", p.display())))
1135 }
1136
1137 fn record_override(&self, g: GenerationId, hash: &Hash) -> Result<(), Outcome> {
1138 self.with_registry(|r| {
1139 r.overrides.insert(g.0, *hash);
1140 })
1141 }
1142
1143 fn gc_plan(&self, protect: &[GenerationId], emergency: bool) -> Result<gc::GcPlan, Outcome> {
1144 self.plan_profile(protect, emergency).map(|(p, _)| p)
1145 }
1146
1147 fn space_report(&self) -> Option<gc::SpaceReport> {
1148 std::fs::read(self.report_path()).ok().and_then(|b| serde_json::from_slice(&b).ok())
1149 }
1150
1151 fn release_root(&self, root: &str) -> bool {
1152 Path::new(root).starts_with(&self.paths.gcroots) && std::fs::remove_file(root).is_ok()
1153 }
1154
1155 fn age_out_build_roots(&self) -> Vec<String> {
1156 self.age_out_roots_at(std::time::SystemTime::now())
1157 }
1158
1159 fn collect_garbage(&self, protect: &[GenerationId], emergency: bool, op: &OpId) -> Outcome {
1160 if !admitted(&self.paths.txn_dir(), op) {
1161 return Outcome::fenced();
1162 }
1163 let _lock = match self.lock_profile() {
1164 Ok(l) => l,
1165 Err(o) => return o,
1166 };
1167 let (plan, gens) = match self.plan_profile(protect, emergency) {
1168 Ok(p) => p,
1169 Err(o) => return o,
1170 };
1171 let delete: Vec<String> = plan.delete.iter().map(|g| g.0.to_string()).collect();
1173 if !delete.is_empty() {
1174 let profile = self.paths.profile.to_string_lossy().into_owned();
1175 let mut argv = vec!["nix-env", "-p", &profile, "--delete-generations"];
1176 argv.extend(delete.iter().map(String::as_str));
1177 let o = run(&argv, QUICK);
1178 if !o.ok() {
1179 return err(&o);
1180 }
1181 }
1182 let now = std::time::SystemTime::now()
1183 .duration_since(std::time::UNIX_EPOCH)
1184 .map(|d| d.as_millis() as u64)
1185 .unwrap_or(0);
1186 let report = gc::SpaceReport::of(&plan, Timestamp(now));
1187 if let Ok(json) = serde_json::to_vec_pretty(&report) {
1188 let _ = std::fs::write(self.report_path(), json);
1189 }
1190 for g in protect {
1191 if let Ok(p) = self.path_of(*g) {
1192 let link = self.paths.gcroots.join(format!("gen-{}", g.0));
1193 let _ = std::fs::create_dir_all(&self.paths.gcroots);
1194 if std::fs::read_link(&link).ok().map(|l| l.to_string_lossy().into_owned()) != Some(p.0.clone()) {
1195 let _ = std::fs::remove_file(&link);
1196 let _ = std::os::unix::fs::symlink(&p.0, &link);
1197 }
1198 }
1199 }
1200 self.prune_roots(&gens, protect);
1201 self.age_out_roots_at(std::time::SystemTime::now());
1202 let kept: Vec<GenerationId> = self
1203 .profile_generations()
1204 .iter()
1205 .filter_map(|(_, p)| {
1206 self.with_registry(|r| r.paths.iter().find(|(_, q)| *q == p).map(|(id, _)| GenerationId(*id)))
1207 .ok()
1208 .flatten()
1209 })
1210 .chain(protect.iter().copied())
1211 .collect();
1212 self.prune_override_store(&kept, std::time::SystemTime::now());
1213 let o = run(&["nix-store", "--gc"], NIX);
1214 if !o.ok() {
1215 return err(&o);
1216 }
1217 if !delete.is_empty() {
1218 let _ = run(
1219 &[&format!("{}/bin/switch-to-configuration", self.paths.profile.display()), "boot"],
1220 Duration::from_secs(300),
1221 );
1222 }
1223 Outcome::Succeeded
1224 }
1225}
1226
1227#[cfg(test)]
1228mod tests {
1229 use super::*;
1230
1231 #[test]
1235 fn stale_roots_are_pruned_before_a_collection() {
1236 let d = tempfile::tempdir().unwrap();
1237 let paths = Paths { gcroots: d.path().join("gcroots"), ..Default::default() };
1238 let builds = paths.gcroots.join("builds");
1239 std::fs::create_dir_all(&builds).unwrap();
1240 std::os::unix::fs::symlink("/nix/store/aaa-committed", builds.join("a")).unwrap();
1241 std::os::unix::fs::symlink("/nix/store/bbb-pending", builds.join("b")).unwrap();
1242 std::os::unix::fs::symlink("/nix/store/aaa-committed", paths.gcroots.join("gen-5")).unwrap();
1243 std::os::unix::fs::symlink("/nix/store/ccc", paths.gcroots.join("gen-6")).unwrap();
1244 let n = Nixos::new(paths.clone());
1245 n.prune_roots(&[(1, "/nix/store/aaa-committed".into())], &[GenerationId(6)]);
1246 assert!(std::fs::symlink_metadata(builds.join("a")).is_err(), "committed: the profile roots it");
1247 assert!(std::fs::symlink_metadata(builds.join("b")).is_ok(), "pending: still the build's");
1248 assert!(std::fs::symlink_metadata(paths.gcroots.join("gen-5")).is_err(), "no longer protected");
1249 assert!(std::fs::symlink_metadata(paths.gcroots.join("gen-6")).is_ok(), "protected");
1250 }
1251
1252 #[test]
1255 fn old_build_roots_age_out() {
1256 let d = tempfile::tempdir().unwrap();
1257 let paths = Paths { gcroots: d.path().join("gcroots"), ..Default::default() };
1258 let builds = paths.gcroots.join("builds");
1259 std::fs::create_dir_all(&builds).unwrap();
1260 std::os::unix::fs::symlink("/nix/store/aaa-crashed", builds.join("a")).unwrap();
1261 let n = Nixos::new(paths);
1262 let now = std::time::SystemTime::now();
1263 assert!(n.age_out_roots_at(now).is_empty(), "recent: a build may still be waiting on it");
1264 assert_eq!(n.age_out_roots_at(now + PARKING_GRACE), vec!["a".to_string()]);
1265 assert!(std::fs::symlink_metadata(builds.join("a")).is_err());
1266 }
1267
1268 #[test]
1270 fn sri_hashes_decode() {
1271 let h = sri_sha256("sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU=").unwrap();
1273 assert_eq!(h.to_hex(), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
1274 assert!(sri_sha256("sha512-AAAA").is_none());
1275 assert!(sri_sha256("sha256-AAAA").is_none(), "too short");
1276 }
1277
1278 #[test]
1280 fn nar_hash_from_both_json_shapes() {
1281 let p = "/nix/store/aaa-system";
1282 let object = r#"{"/nix/store/aaa-system": {"narHash": "sha256-AAA", "narSize": 1}}"#;
1283 let array = r#"[{"path": "/nix/store/aaa-system", "narHash": "sha256-AAA"}]"#;
1284 assert_eq!(nar_hash_of(object, p).as_deref(), Some("sha256-AAA"));
1285 assert_eq!(nar_hash_of(array, p).as_deref(), Some("sha256-AAA"));
1286 assert_eq!(nar_hash_of(array, "/nix/store/bbb"), None);
1287 }
1288
1289 fn override_rig() -> (tempfile::TempDir, Nixos, PathBuf) {
1290 let d = tempfile::tempdir().unwrap();
1291 let owner = d.path().join("owner");
1292 std::fs::create_dir_all(owner.join("planarian")).unwrap();
1293 let live = owner.join("planarian/overrides.nix");
1294 std::fs::write(&live, render(&OverrideSet::default())).unwrap();
1295 let paths = Paths { state: d.path().join("state"), owner, ..Default::default() };
1296 let lease = paths.txn_dir().join("9");
1297 std::fs::create_dir_all(&lease).unwrap();
1298 std::fs::write(lease.join("lease"), "1\n").unwrap();
1299 (d, Nixos::new(paths), live)
1300 }
1301
1302 #[test]
1306 fn the_override_swap_waits_for_the_directory_lock() {
1307 let (_d, n, live) = override_rig();
1308 let from = sha(&std::fs::read_to_string(&live).unwrap());
1309 let mut set = OverrideSet::default();
1310 set.assignments.insert("a.b".into(), Value::Bool(true));
1311 let to = n.remember_override(&set).unwrap();
1312 let (held_tx, held_rx) = std::sync::mpsc::channel();
1313 let writer = {
1314 let live = live.clone();
1315 std::thread::spawn(move || {
1316 let _l = Nixos::lock_dir(live.parent().unwrap()).unwrap();
1317 held_tx.send(()).unwrap();
1318 std::thread::sleep(Duration::from_millis(300));
1319 std::fs::write(&live, "# the owner's edit\n{ }\n").unwrap();
1320 })
1321 };
1322 held_rx.recv().unwrap();
1323 let op = OpId { txn: TxnId(9), epoch: 1, seq: 1 };
1324 let o = n.swap_override(&from, &to, &op);
1325 writer.join().unwrap();
1326 assert!(matches!(o, Outcome::Failed(ref m) if m.contains("changed by someone else")), "{o:?}");
1327 assert_eq!(std::fs::read_to_string(&live).unwrap(), "# the owner's edit\n{ }\n");
1328 let from = sha(&std::fs::read_to_string(&live).unwrap());
1330 std::fs::write(n.store_dir().join(format!("{}.nix", from.to_hex())), std::fs::read(&live).unwrap()).unwrap();
1331 assert_eq!(n.swap_override(&from, &to, &op), Outcome::Succeeded);
1332 assert_eq!(sha(&std::fs::read_to_string(&live).unwrap()), to);
1333 }
1334
1335 #[test]
1338 fn the_override_store_is_pruned() {
1339 let (_d, n, live) = override_rig();
1340 let remember = |k: &str| {
1341 let mut s = OverrideSet::default();
1342 s.assignments.insert(k.into(), Value::Bool(true));
1343 n.remember_override(&s).unwrap().to_hex()
1344 };
1345 let (kept, dropped, gone) = (remember("kept"), remember("dropped"), remember("orphan"));
1346 let live_hex = sha(&std::fs::read_to_string(&live).unwrap()).to_hex();
1347 std::fs::write(n.store_dir().join(format!("{live_hex}.nix")), std::fs::read(&live).unwrap()).unwrap();
1348 n.record_override(GenerationId(1), &Hash::from_hex(&kept).unwrap()).unwrap();
1349 n.record_override(GenerationId(2), &Hash::from_hex(&dropped).unwrap()).unwrap();
1350 let now = std::time::SystemTime::now();
1351 assert!(n.prune_override_store(&[GenerationId(1)], now).is_empty(), "all recent: kept");
1352 let later = now + PARKING_GRACE + Duration::from_secs(60);
1353 let mut want = vec![dropped, gone];
1354 want.sort();
1355 assert_eq!(n.prune_override_store(&[GenerationId(1)], later), want);
1356 for h in [&kept, &live_hex] {
1357 assert!(n.store_dir().join(format!("{h}.nix")).exists(), "{h} kept");
1358 }
1359 }
1360
1361 #[test]
1362 fn closure_sizes_from_both_json_shapes() {
1363 let object = r#"{"/nix/store/a": {"narSize": 100}, "/nix/store/b": {"narSize": 23}}"#;
1364 let array = r#"[{"path": "/nix/store/a", "narSize": 100}, {"path": "/nix/store/b", "narSize": 23}]"#;
1365 assert_eq!(closure_bytes_of(object), Some(123));
1366 assert_eq!(closure_bytes_of(array), Some(123));
1367 assert_eq!(closure_bytes_of("nonsense"), None);
1368 }
1369
1370 #[test]
1372 fn a_failed_dry_activation_is_no_plan() {
1373 let o = |status, timed_out, truncated| Output {
1374 status,
1375 stdout: "would start the following units: a.service\n".into(),
1376 stderr: String::new(),
1377 timed_out,
1378 truncated,
1379 };
1380 assert!(dry_activate_failure(&o(Some(0), false, false)).is_none());
1381 assert!(dry_activate_failure(&o(Some(1), false, false)).is_some());
1382 assert!(dry_activate_failure(&o(None, true, false)).is_some());
1383 assert!(dry_activate_failure(&o(Some(0), false, true)).is_some());
1384 }
1385
1386 #[test]
1387 fn dry_activate() {
1388 let text = "would stop the following units: gym-web.service, foo.socket\n\
1389 would NOT stop the following changed units: getty@tty1.service\n\
1390 would restart the following units: sshd.service\n\
1391 would start the following units: avahi-daemon.service, graphical-session.target\n";
1392 let p = parse_dry_activate(text);
1393 assert_eq!(p.stop, [UnitId::service("gym-web")].into());
1394 assert_eq!(p.restart, [UnitId::service("sshd")].into());
1395 assert!(p.start.contains(&UnitId::target("graphical-session")));
1396 }
1397
1398 #[test]
1399 fn registry_ids_are_stable() {
1400 let mut r = Registry::default();
1401 let a = r.id_for("/nix/store/a");
1402 let b = r.id_for("/nix/store/b");
1403 assert_eq!(r.id_for("/nix/store/a"), a);
1404 assert!(b > a);
1405 }
1406
1407 #[test]
1408 fn apply_expression_shape() {
1409 let e = apply_expr(&["services.openssh.enable".into()]);
1410 assert!(e.starts_with("c: { export = c.config.services.planarian.export;"));
1411 assert!(e.contains(r#"[ "services" "openssh" "enable" ]"#));
1412 assert!(e.contains("highestPrio"));
1413 }
1414
1415 #[test]
1416 fn export_roundtrip() {
1417 let e = Export {
1418 units: vec![UnitDef {
1419 id: UnitId::service("sshd"),
1420 desired: Desired { status: DesiredStatus::Running, when: Condition::Always },
1421 edges: vec![],
1422 sensitivity: Sensitivities::of(&[Sensitivity::Security]),
1423 restart_disruption: Disruption::Brief,
1424 probes: vec![],
1425 synonyms: vec!["ssh".into()],
1426 systemd_restart: None,
1427 startup_grace: None,
1428 runtime_repair: None,
1429 stops_are_faults: false,
1430 holds: false,
1431 hold_allow: vec![],
1432 hold_interfaces: vec![],
1433 }],
1434 ..Default::default()
1435 };
1436 let j = serde_json::to_string(&e).unwrap();
1437 assert!(j.contains("\"userUnits\""), "{j}");
1438 assert_eq!(serde_json::from_str::<Export>(&j).unwrap(), e);
1439 }
1440
1441 #[test]
1442 fn stage_copies_tracked_files_and_hashes_like_nix() {
1443 if run(&["nix-hash", "--version"], QUICK).status != Some(0) {
1444 assert!(
1446 std::env::var_os("PLANARIAN_REQUIRE_NIX_GIT_TESTS").is_none(),
1447 "PLANARIAN_REQUIRE_NIX_GIT_TESTS is set, and nix-hash is not on PATH"
1448 );
1449 eprintln!("SKIPPED: nix-hash not on PATH");
1450 return;
1451 }
1452 let d = tempfile::tempdir().unwrap();
1453 let owner = d.path().join("owner");
1454 std::fs::create_dir_all(owner.join("planarian")).unwrap();
1455 std::fs::write(owner.join("flake.nix"), "{ }").unwrap();
1456 std::fs::write(owner.join("planarian/overrides.nix"), render(&OverrideSet::default())).unwrap();
1457 let paths = Paths { state: d.path().join("state"), owner: owner.clone(), ..Default::default() };
1458 let n = Nixos::new(paths);
1459 let src = PinnedSource { root: owner.to_string_lossy().into_owned(), kind: SourceKind::Directory };
1460 let mut set = OverrideSet::default();
1461 set.assignments.insert("a.b".into(), Value::Bool(true));
1462 let s = n.stage(&src, &set).unwrap();
1463 assert_ne!(s.base_source, s.staged_source);
1464 assert_eq!(n.source_hash(&src).unwrap(), s.base_source, "owner tree unchanged → not stale");
1465 std::fs::write(owner.join("flake.nix"), "{ edited = 1; }").unwrap();
1466 assert_ne!(n.source_hash(&src).unwrap(), s.base_source, "owner edit → stale");
1467 assert_eq!(
1468 std::fs::read_to_string(PathBuf::from(&s.dir).join("planarian/overrides.nix")).unwrap(),
1469 render(&set)
1470 );
1471 assert_eq!(
1472 std::fs::read_to_string(owner.join("planarian/overrides.nix")).unwrap(),
1473 render(&OverrideSet::default()),
1474 "owner's file untouched"
1475 );
1476 }
1477
1478 #[test]
1479 fn git_owner_edit_after_staging_is_stale() {
1480 if run(&["nix-hash", "--version"], QUICK).status != Some(0)
1481 || run(&["git", "--version"], QUICK).status != Some(0)
1482 {
1483 assert!(
1485 std::env::var_os("PLANARIAN_REQUIRE_NIX_GIT_TESTS").is_none(),
1486 "PLANARIAN_REQUIRE_NIX_GIT_TESTS is set, and nix-hash or git is not on PATH"
1487 );
1488 eprintln!("SKIPPED: nix-hash or git not on PATH");
1489 return;
1490 }
1491 let d = tempfile::tempdir().unwrap();
1492 let owner = d.path().join("owner");
1493 std::fs::create_dir_all(owner.join("planarian")).unwrap();
1494 std::fs::write(owner.join("flake.nix"), "{ }").unwrap();
1495 std::fs::write(owner.join("configuration.nix"), "{ }\n").unwrap();
1496 std::fs::write(owner.join("planarian/overrides.nix"), render(&OverrideSet::default())).unwrap();
1497 let o = owner.to_string_lossy().into_owned();
1498 let git = |args: &[&str]| {
1499 let mut a = vec!["git", "-c", "user.email=t@t", "-c", "user.name=t", "-C", &o];
1500 a.extend_from_slice(args);
1501 assert!(run(&a, QUICK).ok(), "{args:?}");
1502 };
1503 git(&["init", "-q"]);
1504 git(&["add", "-A"]);
1505 git(&["commit", "-qm", "owner"]);
1506 let paths = Paths { state: d.path().join("state"), owner: owner.clone(), ..Default::default() };
1507 let n = Nixos::new(paths);
1508 let src = PinnedSource { root: o.clone(), kind: SourceKind::Directory };
1509 let mut set = OverrideSet::default();
1510 set.assignments.insert("a.b".into(), Value::Bool(true));
1511 let s = n.stage(&src, &set).unwrap();
1512 assert_eq!(n.source_hash(&src).unwrap(), s.base_source, "owner tree unchanged → not stale");
1513 std::fs::write(owner.join("configuration.nix"), "{ }\n# edited by the gym\n").unwrap();
1515 git(&["add", "configuration.nix"]);
1516 assert_ne!(n.source_hash(&src).unwrap(), s.base_source, "tracked edit → stale");
1517 }
1518}