1use std::collections::BTreeMap;
14
15use camino::Utf8Path;
16use serde::Serialize;
17
18use crate::diagnostic::{Diagnostic, Reason};
19use crate::digest::Digest;
20use crate::error::RkError;
21use crate::landing::{self, manifest};
22use crate::self_depend::manager::{self, Manager, PinRead};
23use crate::setup::journal::Journal;
24
25use super::{Classification, Operation, Plan, Postcondition, Readiness, fingerprint};
26
27pub const APPLY_SCHEMA: &str = "rk.reconcile-apply/1";
29
30#[derive(Debug, Clone, Serialize)]
32pub struct OperationResult {
33 pub op: &'static str,
35 #[serde(skip_serializing_if = "Option::is_none")]
37 pub path: Option<String>,
38 pub status: &'static str,
40}
41
42#[derive(Debug, Clone, Serialize)]
44pub struct PostconditionResult {
45 pub check: String,
47 pub status: &'static str,
50 #[serde(skip_serializing_if = "Option::is_none")]
52 pub detail: Option<String>,
53}
54
55#[derive(Debug, Serialize)]
57pub struct Applied {
58 pub schema: &'static str,
60 pub plan_id: String,
62 pub input_fingerprint: Digest,
64 pub target: String,
66 pub classification: Classification,
68 pub operations: Vec<OperationResult>,
70 pub postconditions: Vec<PostconditionResult>,
72 #[serde(skip_serializing_if = "Option::is_none")]
74 pub run_id: Option<String>,
75 pub next: Vec<String>,
77}
78
79impl Applied {
80 #[must_use]
82 pub fn failed(&self) -> Vec<&PostconditionResult> {
83 self.postconditions
84 .iter()
85 .filter(|result| result.status == "failed")
86 .collect()
87 }
88
89 #[must_use]
93 pub fn failure(&self) -> Option<RkError> {
94 let failed = self.failed();
95 if failed.is_empty() {
96 return None;
97 }
98 Some(RkError::check_failed(
99 Diagnostic::new(
100 Reason::PostconditionFailed,
101 format!(
102 "the writes landed and {} postcondition{} failed: {}",
103 failed.len(),
104 if failed.len() == 1 { "" } else { "s" },
105 failed
106 .iter()
107 .map(|result| {
108 format!(
109 "{} ({})",
110 result.check,
111 result.detail.as_deref().unwrap_or("no detail")
112 )
113 })
114 .collect::<Vec<_>>()
115 .join(", ")
116 ),
117 )
118 .expected("every postcondition the plan carries satisfied after the writes")
119 .action(format!(
120 "rk status --target {} names what is short",
121 self.target
122 ))
123 .target_state("written"),
124 ))
125 }
126}
127
128pub fn gate(plan: &Plan) -> Result<(), RkError> {
136 match plan.readiness {
137 Readiness::Ready => Ok(()),
138 Readiness::NeedsDecision => {
139 let ids: Vec<String> = plan
140 .preconditions
141 .iter()
142 .filter(|p| !p.evaluation.holds())
143 .filter_map(|p| p.decision.clone())
144 .collect();
145 Err(RkError::refusal(
146 Diagnostic::new(
147 Reason::PlanNotReady,
148 format!(
149 "plan {} waits on a decision, and nothing was written: {}",
150 plan.identity.plan_id,
151 ids.join(", ")
152 ),
153 )
154 .expected("every decision the plan names selected")
155 .action("rk reconcile plan --decide <id>=<answer> selects an answer and stores a fresh plan")
156 .target_state("unchanged"),
157 ))
158 }
159 Readiness::Blocked => {
160 let failed: Vec<String> = plan
161 .preconditions
162 .iter()
163 .filter(|p| p.requirement == super::Requirement::Required && !p.evaluation.holds())
164 .map(|p| {
165 let reason = match &p.evaluation {
166 super::Evaluation::Satisfied => String::new(),
167 super::Evaluation::NotObserved { reason }
168 | super::Evaluation::Unsatisfied { reason } => reason.clone(),
169 };
170 format!("{} ({reason})", p.id)
171 })
172 .collect();
173 Err(RkError::refusal(
174 Diagnostic::new(
175 Reason::PlanNotReady,
176 format!(
177 "plan {} is blocked, and nothing was written: {}",
178 plan.identity.plan_id,
179 failed.join(", ")
180 ),
181 )
182 .expected("every required precondition satisfied")
183 .action("resolve each, then rk reconcile plan again")
184 .target_state("unchanged"),
185 ))
186 }
187 }
188}
189
190pub fn gate_fresh(fresh: &Plan) -> Result<(), RkError> {
198 if fresh.readiness == Readiness::Ready {
199 return Ok(());
200 }
201 let ids: Vec<String> = fresh
202 .preconditions
203 .iter()
204 .filter(|p| !p.evaluation.holds())
205 .map(|p| p.id.clone())
206 .collect();
207 Err(RkError::refusal(
208 Diagnostic::new(
209 Reason::PlanNotReady,
210 format!(
211 "the target is no longer ready for plan {}, and nothing was written: {}",
212 fresh.identity.plan_id,
213 ids.join(", ")
214 ),
215 )
216 .expected("the target as ready as it was when the plan was approved")
217 .action("rk reconcile plan computes a fresh plan over what is there now")
218 .target_state("unchanged"),
219 ))
220}
221
222pub fn verify_record_last(plan: &Plan) -> Result<(), RkError> {
235 let records = plan
236 .operations
237 .iter()
238 .filter(|operation| matches!(operation, Operation::WriteRecord { .. }))
239 .count();
240 let last_is_record = plan
241 .operations
242 .last()
243 .is_some_and(|operation| matches!(operation, Operation::WriteRecord { .. }));
244 if records == 0 || (records == 1 && last_is_record) {
245 return Ok(());
246 }
247 let detail = if records > 1 {
248 format!("{records} record writes")
249 } else {
250 "the record write is not the last operation".to_owned()
251 };
252 Err(RkError::refusal(
253 Diagnostic::new(
254 Reason::StateDrift,
255 format!(
256 "plan {} does not write the record last, and nothing was written: {detail}",
257 plan.identity.plan_id
258 ),
259 )
260 .expected("one record write, last, after every file it describes")
261 .action("rk reconcile plan computes a fresh plan")
262 .target_state("unchanged"),
263 ))
264}
265
266pub fn verify_blobs(plan: &Plan, blobs: &BTreeMap<Digest, Vec<u8>>) -> Result<(), RkError> {
279 let mut bad: Vec<String> = Vec::new();
280 for operation in &plan.operations {
281 let (subject, digest) = match operation {
282 Operation::WriteFile { path, after, .. }
283 | Operation::SpliceBlock { path, after, .. } => (path.clone(), after),
284 Operation::WriteRecord { after, .. } => (manifest::MANIFEST_PATH.to_owned(), after),
285 Operation::RemoveOwnedFile { .. } | Operation::UpdatePin { .. } => continue,
286 };
287 let Some(held) = blobs.get(digest) else {
288 bad.push(format!("{subject} (no blob for {digest})"));
289 continue;
290 };
291 if &Digest::of(held) != digest {
292 bad.push(format!("{subject} (the blob for {digest} was altered)"));
293 }
294 }
295 if bad.is_empty() {
296 return Ok(());
297 }
298 Err(RkError::refusal(
299 Diagnostic::new(
300 Reason::StateDrift,
301 format!(
302 "plan {} names bytes its store no longer holds, and nothing was written: {}",
303 plan.identity.plan_id,
304 bad.join(", ")
305 ),
306 )
307 .expected("every blob digesting to the name the plan filed it under")
308 .action("rk reconcile plan computes a fresh plan and stores its bytes again")
309 .target_state("unchanged"),
310 ))
311}
312
313pub fn revalidate(stored: &Plan, fresh: &Plan) -> Result<(), RkError> {
321 let stored_now = fingerprint::compute(stored);
324 if stored.input_fingerprint == fresh.input_fingerprint && stored_now == stored.input_fingerprint
325 {
326 return Ok(());
327 }
328 let before = fingerprint::canonical(stored);
329 let after = fingerprint::canonical(fresh);
330 let before_lines: Vec<&str> = before.lines().collect();
331 let after_lines: Vec<&str> = after.lines().collect();
332 let mut moved: Vec<String> = Vec::new();
333 if stored_now != stored.input_fingerprint {
334 moved.push("the stored plan".to_owned());
335 }
336 for line in before_lines
337 .iter()
338 .filter(|line| !after_lines.contains(line))
339 .chain(
340 after_lines
341 .iter()
342 .filter(|line| !before_lines.contains(line)),
343 )
344 {
345 let label = label(line);
346 if !moved.contains(&label) {
347 moved.push(label);
348 }
349 }
350 Err(RkError::refusal(
351 Diagnostic::new(
352 Reason::StateDrift,
353 format!(
354 "plan {} no longer matches its inputs, and nothing was written: {}",
355 stored.identity.plan_id,
356 moved.join(", ")
357 ),
358 )
359 .expected("the target, the bundle, and the decisions as the plan observed them")
360 .action("rk reconcile plan computes a fresh plan over what is there now")
361 .target_state("unchanged"),
362 ))
363}
364
365fn label(line: &str) -> String {
367 let mut parts = line.split('\t');
368 match parts.next().unwrap_or_default() {
369 "target" => "the target".to_owned(),
370 "candidate" => "the candidate bundle".to_owned(),
371 "record" => "the record".to_owned(),
372 "configuration" => "the configuration".to_owned(),
373 "operation" => {
374 let kind = parts.next().unwrap_or_default();
375 let subject = parts.next().unwrap_or_default();
376 format!("operation {kind} {subject}")
377 }
378 "precondition" => format!("precondition {}", parts.next().unwrap_or_default()),
379 "decision" => format!("decision {}", parts.next().unwrap_or_default()),
380 other => other.to_owned(),
381 }
382}
383
384pub fn verify_before_digests(target: &Utf8Path, plan: &Plan) -> Result<(), RkError> {
392 let mut moved: Vec<String> = Vec::new();
393 let pin = crate::self_depend::observe(target).ok();
394 for operation in &plan.operations {
395 let (subject, held, wanted): (String, Option<String>, Option<String>) = match operation {
396 Operation::WriteFile { path, before, .. }
397 | Operation::SpliceBlock { path, before, .. } => (
398 path.clone(),
399 landing::read_recorded(target, path)?.map(|bytes| Digest::of(&bytes).to_string()),
400 before.as_ref().map(ToString::to_string),
401 ),
402 Operation::RemoveOwnedFile { path, before } => (
403 path.clone(),
404 read_optional(target, path)?.map(|bytes| Digest::of(&bytes).to_string()),
405 Some(before.to_string()),
406 ),
407 Operation::WriteRecord { before, .. } => (
408 manifest::MANIFEST_PATH.to_owned(),
409 read_optional(target, manifest::MANIFEST_PATH)?
410 .map(|bytes| Digest::of(&bytes).to_string()),
411 before.as_ref().map(ToString::to_string),
412 ),
413 Operation::UpdatePin {
414 manager, before, ..
415 } => (
416 format!("the {manager} pin"),
417 pin.as_ref().and_then(|observed| {
418 parse_manager(manager)
419 .and_then(|m| observed.entry(m))
420 .and_then(|entry| entry.version.clone())
421 }),
422 Some(before.clone()),
423 ),
424 };
425 if held != wanted {
426 moved.push(subject);
427 }
428 }
429 if moved.is_empty() {
430 return Ok(());
431 }
432 Err(RkError::refusal(
433 Diagnostic::new(
434 Reason::StateDrift,
435 format!(
436 "these destinations changed since the plan observed them, and nothing was written: {}",
437 moved.join(", ")
438 ),
439 )
440 .expected("every destination holding what the plan's before digest names")
441 .action("rk reconcile plan computes a fresh plan over what is there now")
442 .target_state("unchanged"),
443 ))
444}
445
446pub fn run(
458 target: &Utf8Path,
459 stored: &Plan,
460 blobs: &BTreeMap<Digest, Vec<u8>>,
461 fresh: &Plan,
462 journal: Option<Journal>,
463) -> Result<Applied, RkError> {
464 let _lock = super::lock::acquire(target)?;
465 run_locked(target, stored, blobs, fresh, journal)
466}
467
468pub fn run_locked(
478 target: &Utf8Path,
479 stored: &Plan,
480 blobs: &BTreeMap<Digest, Vec<u8>>,
481 fresh: &Plan,
482 mut journal: Option<Journal>,
483) -> Result<Applied, RkError> {
484 let outcome = execute(target, stored, blobs, fresh, journal.as_mut());
485 if let Some(journal) = journal.as_mut() {
486 match &outcome {
487 Ok(applied) => {
488 let failed = applied.failed();
489 if failed.is_empty() {
490 journal.finish(0, None);
491 } else {
492 journal.finish(1, Some(Reason::PostconditionFailed.as_str()));
493 }
494 }
495 Err(error) => {
496 journal.finish(i32::from(error.exit_code()), Some(error.reason().as_str()));
497 }
498 }
499 }
500 let run_id = journal.as_ref().map(|journal| journal.run_id().to_owned());
501 outcome.map(|mut applied| {
502 applied.run_id = run_id;
503 applied
504 })
505}
506
507fn execute(
508 target: &Utf8Path,
509 stored: &Plan,
510 blobs: &BTreeMap<Digest, Vec<u8>>,
511 fresh: &Plan,
512 mut journal: Option<&mut Journal>,
513) -> Result<Applied, RkError> {
514 gate(stored)?;
515 revalidate(stored, fresh)?;
521 gate_fresh(fresh)?;
522 verify_record_last(stored)?;
523 verify_blobs(stored, blobs)?;
524 verify_before_digests(target, stored)?;
525 if let Some(journal) = journal.as_deref_mut() {
526 journal.event_line(&format!(
527 r#"{{"event":"plan","plan_id":"{}","input_fingerprint":"{}","operations":{}}}"#,
528 stored.identity.plan_id,
529 stored.input_fingerprint,
530 stored.operations.len()
531 ));
532 }
533 let (txn, removals) = stage(target, stored, blobs)?;
534 let stop = std::env::var_os("RK_APPLY_INTERRUPT_AT").map(std::path::PathBuf::from);
536 let landed = txn
537 .commit_stopping_at(stop.as_deref())
538 .map_err(|interrupted| {
539 if let Some(journal) = journal.as_deref_mut() {
540 for path in &interrupted.landed {
541 journal.event_line(&format!(
542 r#"{{"event":"operation","path":"{}","status":"ok"}}"#,
543 path.display()
544 ));
545 }
546 journal.event_line(&format!(
547 r#"{{"event":"operation","path":"{}","status":"failed","detail":"{}"}}"#,
548 interrupted.failed.display(),
549 interrupted.error
550 ));
551 }
552 interrupted_error(&interrupted)
553 })?;
554 for path in &removals {
555 std::fs::remove_file(target.join(path))?;
556 }
557 debug_assert_eq!(landed.len(), txn_len(&stored.operations));
558 let operations: Vec<OperationResult> = stored
559 .operations
560 .iter()
561 .map(|operation| OperationResult {
562 op: operation.kind(),
563 path: match operation {
564 Operation::WriteRecord { .. } => Some(manifest::MANIFEST_PATH.to_owned()),
565 Operation::UpdatePin { manager, .. } => Some(format!("the {manager} pin")),
566 other => other.path().map(str::to_owned),
567 },
568 status: "ok",
569 })
570 .collect();
571 let postconditions = postconditions(target, stored);
572 if let Some(journal) = journal {
573 for result in &operations {
574 journal.event_line(&format!(
575 r#"{{"event":"operation","op":"{}","path":"{}","status":"{}"}}"#,
576 result.op,
577 result.path.as_deref().unwrap_or_default(),
578 result.status
579 ));
580 }
581 for result in &postconditions {
582 journal.event_line(&format!(
583 r#"{{"event":"postcondition","check":"{}","status":"{}"}}"#,
584 result.check, result.status
585 ));
586 }
587 }
588 Ok(Applied {
589 schema: APPLY_SCHEMA,
590 plan_id: stored.identity.plan_id.clone(),
591 input_fingerprint: stored.input_fingerprint.clone(),
592 target: target.to_string(),
593 classification: stored.classification,
594 operations,
595 postconditions,
596 run_id: None,
597 next: vec![
598 "commit the written files, the record included".to_owned(),
599 format!("rk status --target {target} reports the result"),
600 ],
601 })
602}
603
604fn stage(
609 target: &Utf8Path,
610 stored: &Plan,
611 blobs: &BTreeMap<Digest, Vec<u8>>,
612) -> Result<(crate::atomic::Transaction, Vec<String>), RkError> {
613 let mut txn = crate::atomic::Transaction::new();
614 let mut removals: Vec<String> = Vec::new();
615 let pin = stored
616 .operations
617 .iter()
618 .any(|operation| matches!(operation, Operation::UpdatePin { .. }))
619 .then(|| crate::self_depend::observe(target))
620 .transpose()?;
621 for operation in &stored.operations {
622 match operation {
623 Operation::WriteFile { path, after, .. } => {
624 txn.stage(target.join(path).as_std_path(), blob(blobs, after)?)?;
625 }
626 Operation::SpliceBlock { path, after, .. } => {
627 let existing = read_optional(target, path)?
628 .map(|bytes| String::from_utf8_lossy(&bytes).into_owned());
629 let block = String::from_utf8_lossy(blob(blobs, after)?).into_owned();
630 let spliced = if path == landing::HOOKS_DESTINATION {
631 landing::splice_hooks_block(existing.as_deref(), &block)
632 .map_err(std::io::Error::other)?
633 } else {
634 landing::splice_agents_block(existing.as_deref(), &block)
635 };
636 txn.stage(target.join(path).as_std_path(), spliced.as_bytes())?;
637 }
638 Operation::RemoveOwnedFile { path, .. } => removals.push(path.clone()),
639 Operation::WriteRecord { after, .. } => {
640 txn.stage(
641 target.join(manifest::MANIFEST_PATH).as_std_path(),
642 blob(blobs, after)?,
643 )?;
644 }
645 Operation::UpdatePin {
646 manager,
647 before,
648 after,
649 } => {
650 let (file, text) = pin_text(pin.as_ref(), manager, before)?;
651 let PinRead::One { line, .. } =
652 manager::read_pin(parse_manager(manager).unwrap_or(Manager::Mise), &text)
653 else {
654 return Err(pin_moved(manager));
655 };
656 let rewritten = manager::rewrite_line(&text, line, before, after);
657 txn.stage(target.join(&file).as_std_path(), rewritten.as_bytes())?;
658 }
659 }
660 }
661 Ok((txn, removals))
662}
663
664fn interrupted_error(interrupted: &crate::atomic::Interrupted) -> RkError {
667 RkError::Io(std::io::Error::new(
668 interrupted.error.kind(),
669 format!(
670 "the apply stopped at {}: {}; each destination holds its previous bytes or its new ones, and these landed before it: {}",
671 interrupted.failed.display(),
672 interrupted.error,
673 if interrupted.landed.is_empty() {
674 "none".to_owned()
675 } else {
676 interrupted
677 .landed
678 .iter()
679 .map(|path| path.display().to_string())
680 .collect::<Vec<_>>()
681 .join(", ")
682 }
683 ),
684 ))
685}
686
687fn txn_len(operations: &[Operation]) -> usize {
689 operations
690 .iter()
691 .filter(|operation| !matches!(operation, Operation::RemoveOwnedFile { .. }))
692 .count()
693}
694
695#[must_use]
697pub fn postconditions(target: &Utf8Path, plan: &Plan) -> Vec<PostconditionResult> {
698 let pin = plan
699 .postconditions
700 .iter()
701 .any(|check| matches!(check, Postcondition::PinReads { .. }))
702 .then(|| crate::self_depend::observe(target).ok())
703 .flatten();
704 plan.postconditions
705 .iter()
706 .map(|check| {
707 let (name, outcome): (String, Result<(), String>) = match check {
708 Postcondition::RecordReadsBack { sha256 } => (
709 "record-reads-back".to_owned(),
710 holds(read_optional(target, manifest::MANIFEST_PATH), sha256),
711 ),
712 Postcondition::DestinationHolds { path, sha256 } => (
713 format!("destination-holds:{path}"),
714 holds(
715 landing::read_recorded(target, path).map_err(RkError::Io),
716 sha256,
717 ),
718 ),
719 Postcondition::StatusCheckClean => {
720 ("status-check-clean".to_owned(), status_check(target))
721 }
722 Postcondition::PinReads { manager, version } => (
723 format!("pin-reads:{manager}"),
724 match pin
725 .as_ref()
726 .and_then(|observed| parse_manager(manager).and_then(|m| observed.entry(m)))
727 .and_then(|entry| entry.version.clone())
728 {
729 Some(found) if &found == version => Ok(()),
730 Some(found) => Err(format!("the {manager} pin reads {found}")),
731 None => Err(format!("no {manager} pin reads")),
732 },
733 ),
734 };
735 let advisory = matches!(check, Postcondition::StatusCheckClean);
739 match outcome {
740 Ok(()) => PostconditionResult {
741 check: name,
742 status: "ok",
743 detail: None,
744 },
745 Err(detail) => PostconditionResult {
746 check: name,
747 status: if advisory { "reported" } else { "failed" },
748 detail: Some(detail),
749 },
750 }
751 })
752 .collect()
753}
754
755fn holds(read: Result<Option<Vec<u8>>, RkError>, wanted: &Digest) -> Result<(), String> {
757 match read {
758 Ok(Some(bytes)) if Digest::of(&bytes) == *wanted => Ok(()),
759 Ok(Some(bytes)) => Err(format!("holds {}", Digest::of(&bytes))),
760 Ok(None) => Err("absent".to_owned()),
761 Err(error) => Err(error.to_string()),
762 }
763}
764
765fn status_check(target: &Utf8Path) -> Result<(), String> {
768 let exe = std::env::current_exe().map_err(|error| format!("no engine path: {error}"))?;
769 let mut command = std::process::Command::new(exe);
770 for var in crate::maintenance::GIT_HOOK_VARS {
771 command.env_remove(var);
772 }
773 let out = command
774 .args(["status", "--check", "--target"])
775 .arg(target)
776 .output()
777 .map_err(|error| format!("rk status --check could not run: {error}"))?;
778 if out.status.success() {
779 return Ok(());
780 }
781 let stderr = String::from_utf8_lossy(&out.stderr);
782 Err(stderr
783 .lines()
784 .find(|line| !line.trim().is_empty())
785 .unwrap_or("rk status --check exited nonzero")
786 .trim()
787 .to_owned())
788}
789
790fn blob<'a>(blobs: &'a BTreeMap<Digest, Vec<u8>>, digest: &Digest) -> Result<&'a [u8], RkError> {
791 blobs.get(digest).map(Vec::as_slice).ok_or_else(|| {
792 RkError::Other(anyhow::anyhow!(
793 "the plan names digest {digest} and its store holds no such blob"
794 ))
795 })
796}
797
798fn read_optional(target: &Utf8Path, rel: &str) -> Result<Option<Vec<u8>>, RkError> {
799 match std::fs::read(target.join(rel)) {
800 Ok(bytes) => Ok(Some(bytes)),
801 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
802 Err(error) => Err(RkError::Io(error)),
803 }
804}
805
806fn parse_manager(name: &str) -> Option<Manager> {
807 Manager::ALL.into_iter().find(|m| m.as_str() == name)
808}
809
810fn pin_text(
813 observed: Option<&crate::self_depend::Observed>,
814 manager: &str,
815 before: &str,
816) -> Result<(String, String), RkError> {
817 let parsed = parse_manager(manager).ok_or_else(|| pin_moved(manager))?;
818 if parsed == Manager::Flake {
819 return Err(RkError::refusal(
820 Diagnostic::new(
821 Reason::PrerequisiteUnmet,
822 "the flake pin moves through the self-depend sync verb under --apply, with nix and the network; an offline apply cannot stage it",
823 )
824 .expected("a one-fact manager pin, or the sync verb for the flake pair")
825 .target_state("unchanged"),
826 ));
827 }
828 let entry = observed
829 .and_then(|observed| observed.entry(parsed))
830 .filter(|entry| entry.version.as_deref() == Some(before))
831 .ok_or_else(|| pin_moved(manager))?;
832 match (&entry.file, &entry.text) {
833 (Some(file), Some(text)) => Ok((file.clone(), text.clone())),
834 _ => Err(pin_moved(manager)),
835 }
836}
837
838fn pin_moved(manager: &str) -> RkError {
839 RkError::refusal(
840 Diagnostic::new(
841 Reason::StateDrift,
842 format!(
843 "the {manager} pin no longer reads as the plan observed it, and nothing was written"
844 ),
845 )
846 .expected("the manager file as the plan observed it")
847 .action("rk reconcile plan computes a fresh plan over what is there now")
848 .target_state("unchanged"),
849 )
850}
851
852#[cfg(test)]
853mod tests {
854 use camino::Utf8PathBuf;
855
856 use super::{PostconditionResult, postconditions};
857 use crate::digest::Digest;
858 use crate::plan::{Plan, Postcondition};
859
860 fn plan_with(checks: &[Postcondition]) -> Plan {
862 let json = serde_json::json!({
863 "schema": crate::plan::PLAN_SCHEMA,
864 "identity": {"plan_id": "0123456789abcdef", "created_at": "2026-01-01T00:00:00Z", "engine_version": "0.0.0"},
865 "classification": "setup",
866 "findings": [],
867 "desired_state": {"intent": "reconcile", "selector": "embedded", "release": {"version": "0.0.0", "venue": "embedded", "payload_sha256": Digest::of(b"a").to_string(), "payload_schema": 1}},
868 "observed_state": {
869 "repository": {"target": "/tmp/t", "git": false, "tags": 0, "long_lived_branches": [], "release_markers": [], "collisions": [], "verdict": "greenfield", "evidence_refs": []},
870 "installation": {"record": {"state": "absent"}, "configuration": {"present": false, "pending": []}, "destinations": [], "evidence_refs": []},
871 "host": {"engine_version": "0.0.0", "evidence_refs": []},
872 "forge": {"state": "not-observed", "reason": "not requested"}
873 },
874 "release": {"candidate": {"version": "0.0.0", "payload_sha256": Digest::of(b"a").to_string(), "payload_schema": 1, "artifacts": 0, "evidence_refs": []}, "verification": {"method": "embedded"}, "baseline": {"state": "not-needed"}, "compatibility": {"engine_schema": 1, "bundle_schema": 1, "readable": true, "intermediate": [], "evidence_refs": []}, "guidance": {"coverage": {"state": "not-needed"}, "steps": [], "excluded": 0, "evidence_refs": []}},
875 "operations": [],
876 "preconditions": [],
877 "decisions": [],
878 "postconditions": checks,
879 "evidence": [],
880 "readiness": "ready",
881 "input_fingerprint": Digest::of(b"a").to_string()
882 });
883 serde_json::from_value(json).expect("a plan deserializes")
884 }
885
886 #[test]
890 fn postconditions_run_and_a_failure_is_reported() {
891 let dir = tempfile::tempdir().expect("a scratch target exists");
892 let target = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).expect("utf-8");
893 std::fs::write(target.join("SECURITY.md"), b"held").expect("writes");
894 let plan = plan_with(&[
895 Postcondition::DestinationHolds {
896 path: "SECURITY.md".into(),
897 sha256: Digest::of(b"held"),
898 },
899 Postcondition::DestinationHolds {
900 path: "SECURITY.md".into(),
901 sha256: Digest::of(b"promised"),
902 },
903 Postcondition::RecordReadsBack {
904 sha256: Digest::of(b"record"),
905 },
906 ]);
907 let results: Vec<PostconditionResult> = postconditions(&target, &plan);
908 assert_eq!(results.len(), 3);
909 assert_eq!(results[0].status, "ok");
910 assert_eq!(results[1].status, "failed");
911 assert_eq!(
912 results[1].detail.as_deref(),
913 Some(format!("holds {}", Digest::of(b"held")).as_str())
914 );
915 assert_eq!(results[2].status, "failed");
916 assert_eq!(results[2].detail.as_deref(), Some("absent"));
917 let applied = super::Applied {
918 schema: super::APPLY_SCHEMA,
919 plan_id: "0123456789abcdef".into(),
920 input_fingerprint: Digest::of(b"a"),
921 target: target.to_string(),
922 classification: crate::plan::Classification::Setup,
923 operations: vec![],
924 postconditions: results,
925 run_id: None,
926 next: vec![],
927 };
928 let failure = applied
929 .failure()
930 .expect("a failed postcondition is a failure");
931 assert_eq!(failure.exit_code(), 1);
932 assert_eq!(
933 failure.reason(),
934 crate::diagnostic::Reason::PostconditionFailed
935 );
936 }
937
938 #[test]
940 fn the_apply_report_schema_snapshot_holds() {
941 let applied = super::Applied {
942 schema: super::APPLY_SCHEMA,
943 plan_id: "0123456789abcdef".into(),
944 input_fingerprint: Digest::of(b"a"),
945 target: "/tmp/t".into(),
946 classification: crate::plan::Classification::Upgrade,
947 operations: vec![super::OperationResult {
948 op: "write-record",
949 path: Some(".release-kit/manifest.json".into()),
950 status: "ok",
951 }],
952 postconditions: vec![PostconditionResult {
953 check: "record-reads-back".into(),
954 status: "failed",
955 detail: Some("absent".into()),
956 }],
957 run_id: Some("run".into()),
958 next: vec!["commit the written files, the record included".into()],
959 };
960 assert_eq!(
961 serde_json::to_string(&applied).expect("serializes"),
962 format!(
963 r#"{{"schema":"rk.reconcile-apply/1","plan_id":"0123456789abcdef","input_fingerprint":"{}","target":"/tmp/t","classification":"upgrade","operations":[{{"op":"write-record","path":".release-kit/manifest.json","status":"ok"}}],"postconditions":[{{"check":"record-reads-back","status":"failed","detail":"absent"}}],"run_id":"run","next":["commit the written files, the record included"]}}"#,
964 Digest::of(b"a")
965 )
966 );
967 }
968}