1use std::collections::BTreeMap;
13
14use camino::Utf8Path;
15use serde::Serialize;
16
17use crate::cli::reconcile::{
18 ApplyArgs, ListArgs, Observe, PlanArgs, ReconcileAction, ReconcileArgs, ShowArgs,
19};
20use crate::diagnostic::{Diagnostic, Reason};
21use crate::error::RkError;
22use crate::landing::manifest;
23use crate::output::Output;
24use crate::plan::apply::{self, Applied};
25use crate::plan::gather::{self, Flags, RecordRead, Request};
26use crate::plan::planner::{self, Baseline, Candidate};
27use crate::plan::store;
28use crate::plan::{
29 Classification, Intent, Operation, Plan, PlanRequest, Planned, Readiness, ResolvedRelease,
30 Verification,
31};
32use crate::release::declared;
33use crate::release::{CrateReleaseSource, EmbeddedReleaseSource, ReleaseSource};
34use crate::setup::journal::Journal;
35
36pub fn run(args: &ReconcileArgs) -> Result<(), RkError> {
42 match &args.action {
43 ReconcileAction::Plan(plan_args) => plan(plan_args),
44 ReconcileAction::Show(show_args) => show(show_args),
45 ReconcileAction::Apply(apply_args) => apply_stored(apply_args),
46 ReconcileAction::List(list_args) => list(list_args),
47 }
48}
49
50fn plan(args: &PlanArgs) -> Result<(), RkError> {
52 let out = Output::new(args.json);
53 let decisions = parse_decisions(&args.decide)?;
54 let nix = match args.nix.as_deref() {
55 None => None,
56 Some("on") => Some(true),
57 Some("off") => Some(false),
58 Some(other) => {
59 return Err(RkError::Usage(format!(
60 "unknown --nix value '{other}'; the values are: on, off"
61 )));
62 }
63 };
64 let request = PlanRequest {
65 target: args.target.clone(),
66 intent: Intent::Reconcile,
67 selector: args.to.clone(),
68 fetch: args.fetch,
69 observe_forge: args.observe.contains(&Observe::Forge),
70 flags: Flags {
71 tech: args.tech.clone(),
72 forge: args.forge.clone(),
73 repo: args.repo.clone(),
74 workflow: args.workflow.clone(),
75 style: args.style.clone(),
76 nix,
77 },
78 decisions,
79 }
80 .canonicalized()?;
81 let planned = compute(&request, &manifest::now())?;
82 store::persist(&planned, &request)?;
83 render(out, &planned.plan, true);
84 out.emit(&planned.plan)
85}
86
87fn show(args: &ShowArgs) -> Result<(), RkError> {
89 let out = Output::new(args.json);
90 let stored = store::load(&args.plan_id)?;
91 render(out, &stored.plan, false);
92 out.emit(&stored.plan)
93}
94
95fn apply_stored(args: &ApplyArgs) -> Result<(), RkError> {
97 let out = Output::new(args.json);
98 let stored = store::load(&args.plan_id)?;
99 let _lock = crate::plan::lock::acquire(&stored.request.target)?;
104 let fresh = compute_frozen(
111 &stored.request,
112 &stored.plan.identity.created_at,
113 Some(&stored.plan.desired_state.release),
114 )?;
115 let journal = open_journal("reconcile apply", &stored.plan).map_err(|error| {
116 RkError::refusal(
117 Diagnostic::new(
118 Reason::JournalUnavailable,
119 format!("the run journal could not be created, and nothing was written: {error}"),
120 )
121 .expected("a writable state root for the journal")
122 .target_state("unchanged"),
123 )
124 })?;
125 let applied = apply::run_locked(
126 &stored.request.target,
127 &stored.plan,
128 &stored.blobs,
129 &fresh.plan,
130 Some(journal),
131 )?;
132 render_applied(out, &applied);
133 out.emit(&applied)?;
134 applied.failure().map_or(Ok(()), Err)
135}
136
137#[derive(Debug, Serialize)]
139struct ListReport {
140 schema: &'static str,
142 plans: Vec<ListRow>,
144}
145
146#[derive(Debug, Serialize)]
148struct ListRow {
149 plan_id: String,
151 created_at: String,
153}
154
155fn list(args: &ListArgs) -> Result<(), RkError> {
156 let out = Output::new(args.json);
157 let rows: Vec<ListRow> = store::list()
158 .into_iter()
159 .map(|(created_at, plan_id)| ListRow {
160 plan_id,
161 created_at,
162 })
163 .collect();
164 for row in &rows {
165 out.result_line(format!("{} {}", row.plan_id, row.created_at));
166 }
167 if rows.is_empty() {
168 out.result_line("no plans are stored");
169 }
170 out.emit(&ListReport {
171 schema: "rk.reconcile-list/1",
172 plans: rows,
173 })
174}
175
176#[derive(Debug, Serialize)]
178pub struct Trace {
179 pub plan_id: String,
181 pub input_fingerprint: crate::digest::Digest,
183 pub stored: bool,
185 #[serde(skip_serializing_if = "Option::is_none")]
187 pub run_id: Option<String>,
188}
189
190impl FrontApplied {
191 #[must_use]
193 pub fn trace(&self) -> Trace {
194 Trace {
195 plan_id: self.applied.plan_id.clone(),
196 input_fingerprint: self.applied.input_fingerprint.clone(),
197 stored: self.stored,
198 run_id: self.applied.run_id.clone(),
199 }
200 }
201
202 #[must_use]
204 pub fn line(&self) -> String {
205 self.applied.run_id.as_ref().map_or_else(
206 || format!("applied plan {}", self.applied.plan_id),
207 |run_id| format!("applied plan {} (run {run_id})", self.applied.plan_id),
208 )
209 }
210
211 #[must_use]
213 pub fn written<'a>(planned: &'a Planned, path: &str) -> Option<&'a [u8]> {
214 planned
215 .plan
216 .operations
217 .iter()
218 .find_map(|operation| match operation {
219 Operation::WriteFile { path: p, after, .. }
220 | Operation::SpliceBlock { path: p, after, .. }
221 if p == path =>
222 {
223 planned.blobs.get(after).map(Vec::as_slice)
224 }
225 _ => None,
226 })
227 }
228}
229
230#[derive(Debug)]
233pub struct FrontApplied {
234 pub applied: Applied,
236 pub stored: bool,
239}
240
241pub fn apply_in_process(
260 planned: &Planned,
261 request: &PlanRequest,
262 command: &str,
263) -> Result<FrontApplied, RkError> {
264 let _lock = crate::plan::lock::acquire(&request.target)?;
265 let fresh = compute_frozen(
270 request,
271 &planned.plan.identity.created_at,
272 Some(&planned.plan.desired_state.release),
273 )?;
274 let stored = store::persist(planned, request).is_ok();
275 let journal = open_journal(command, &planned.plan).ok();
276 let applied = apply::run_locked(
277 &request.target,
278 &planned.plan,
279 &planned.blobs,
280 &fresh.plan,
281 journal,
282 )?;
283 Ok(FrontApplied { applied, stored })
284}
285
286fn open_journal(command: &str, plan: &Plan) -> std::io::Result<Journal> {
287 let (forge, repo) = plan
288 .desired_state
289 .configuration
290 .as_ref()
291 .map_or(("", ""), |c| (c.forge.as_str(), c.repo.as_str()));
292 Journal::create(command, &plan.observed_state.repository.target, forge, repo)
293}
294
295pub fn compute(request: &PlanRequest, clock: &str) -> Result<Planned, RkError> {
302 compute_frozen(request, clock, None)
303}
304
305#[allow(
314 clippy::too_many_lines,
315 reason = "one computation is one linear sequence from the selector to the planner's inputs, and cutting it would separate a bundle from the observation it is read against"
316)]
317pub fn compute_frozen(
318 request: &PlanRequest,
319 clock: &str,
320 frozen: Option<&ResolvedRelease>,
321) -> Result<Planned, RkError> {
322 let target: &Utf8Path = &request.target;
323 let selector = request.selector.as_str();
324 let embedded = EmbeddedReleaseSource;
325 let crate_source = match frozen {
326 _ if selector == "embedded" => None,
327 Some(release) => {
328 let source = CrateReleaseSource::new(&release.version)?;
329 if !source.is_cached() {
330 return Err(RkError::refusal(
331 Diagnostic::new(
332 Reason::BundleUnverified,
333 format!(
334 "the plan froze release-kit {} ({}), and the release cache no longer holds that bundle; nothing was written",
335 release.version, release.payload_sha256
336 ),
337 )
338 .expected("the frozen release's verified bundle in the release cache")
339 .action("rk reconcile plan --to <version> resolves and caches it again")
340 .target_state("unchanged"),
341 ));
342 }
343 Some(source)
344 }
345 None => Some(CrateReleaseSource::new(selector)?),
346 };
347 let (candidate_source, venue, verification): (&dyn ReleaseSource, &str, Verification) =
348 match &crate_source {
349 None => (&embedded, "embedded", Verification::Embedded),
350 Some(source) => {
351 let resolved = source.resolve()?;
352 (
353 source,
354 "crates",
355 Verification::RegistryChecksum {
356 cksum: resolved.cksum.clone(),
357 },
358 )
359 }
360 };
361 let candidate_manifest = candidate_source.manifest()?;
362 let declared = declared::compatibility(candidate_source, &candidate_manifest)?;
363 let guidance_files = declared::guidance(candidate_source, &candidate_manifest)?;
364 let carries_guidance = declared::carries_guidance(&candidate_manifest);
365 let extra_paths: Vec<String> = guidance_files
366 .iter()
367 .flat_map(|file| file.destinations.iter().cloned())
368 .collect();
369 let gather_request = Request {
370 target,
371 flags: &request.flags,
372 decisions: &request.decisions,
373 observe_forge: request.observe_forge,
374 clock,
375 source: candidate_source,
376 extra_paths: &extra_paths,
377 };
378 let mut observation = gather::observe(&gather_request)?;
379 let resolution = gather::resolve(&gather_request, &observation)?;
380 gather::observe_host_tools(
381 &mut observation,
382 resolution.params.as_ref().map(crate::landing::Params::tech),
383 resolution
384 .params
385 .as_ref()
386 .map(crate::landing::Params::forge),
387 clock,
388 );
389
390 let recorded = match &observation.record {
394 RecordRead::Present { manifest, .. } => {
395 Some((manifest.rk_version.clone(), manifest.payload_sha256.clone()))
396 }
397 RecordRead::Absent | RecordRead::Invalid { .. } => None,
398 };
399 let baseline_crate = match &recorded {
400 Some((version, digest))
401 if *digest != EmbeddedReleaseSource::manifest_ref().payload_sha256
402 && *digest != candidate_manifest.payload_sha256 =>
403 {
404 let source = CrateReleaseSource::new(version)?;
405 if request.fetch || source.is_cached() {
406 Some(source)
407 } else {
408 None
409 }
410 }
411 _ => None,
412 };
413 let baseline = match &recorded {
414 None => Baseline::NotNeeded,
415 Some((_, digest)) if *digest == EmbeddedReleaseSource::manifest_ref().payload_sha256 => {
416 Baseline::Embedded(&embedded)
417 }
418 Some((version, digest)) if *digest == candidate_manifest.payload_sha256 => {
419 Baseline::Cached {
420 version: version.clone(),
421 source: candidate_source,
422 }
423 }
424 Some((version, _)) => cached_baseline(version, baseline_crate.as_ref()),
425 };
426 planner::plan(planner::Inputs {
427 intent: request.intent,
428 clock,
429 engine_version: env!("CARGO_PKG_VERSION"),
430 selector,
431 candidate: Candidate {
432 source: candidate_source,
433 manifest: candidate_manifest,
434 venue,
435 verification,
436 },
437 baseline,
438 observation,
439 resolution,
440 selected: &request.decisions,
441 declared: &declared,
442 guidance_files: &guidance_files,
443 carries_guidance,
444 })
445}
446
447fn cached_baseline<'a>(version: &str, source: Option<&'a CrateReleaseSource>) -> Baseline<'a> {
456 let Some(source) = source else {
457 return Baseline::NotObserved {
458 reason: format!(
459 "the recorded release {version} is not in the release cache; --fetch reads it through the crates venue"
460 ),
461 };
462 };
463 match source.resolve() {
464 Ok(_) => Baseline::Cached {
465 version: version.to_owned(),
466 source,
467 },
468 Err(error) => Baseline::NotObserved {
469 reason: format!(
470 "the recorded release {version} is in the release cache and did not verify: {}",
471 error.diagnostic().message
472 ),
473 },
474 }
475}
476
477pub fn parse_decisions(raw: &[String]) -> Result<BTreeMap<String, String>, RkError> {
484 let mut decisions = BTreeMap::new();
485 for item in raw {
486 let Some((id, answer)) = item.split_once('=') else {
487 return Err(RkError::Usage(format!(
488 "--decide takes <id>=<answer>; '{item}' has no '='"
489 )));
490 };
491 if id.is_empty() || answer.is_empty() {
492 return Err(RkError::Usage(format!(
493 "--decide takes <id>=<answer>; '{item}' leaves one side empty"
494 )));
495 }
496 let Some(choices) = crate::plan::decision_choices(id) else {
500 let ids: Vec<&str> = crate::plan::DECISION_CHOICES
501 .iter()
502 .map(|(id, _)| *id)
503 .collect();
504 return Err(RkError::Usage(format!(
505 "--decide names no decision '{id}'; the decisions are: {}",
506 ids.join(", ")
507 )));
508 };
509 if !choices.contains(&answer) {
510 return Err(RkError::Usage(format!(
511 "--decide {id}={answer} is not an answer it takes; the answers are: {}",
512 choices.join(", ")
513 )));
514 }
515 decisions.insert(id.to_owned(), answer.to_owned());
516 }
517 Ok(decisions)
518}
519
520fn render(out: Output, plan: &Plan, fresh: bool) {
524 out.result_line(format!(
525 "plan {} for {} toward release-kit {} ({}){}",
526 plan.identity.plan_id,
527 plan.observed_state.repository.target,
528 plan.desired_state.release.version,
529 plan.desired_state.release.venue,
530 if fresh { ", stored" } else { "" }
531 ));
532 out.result_line(format!("classification: {}", plan.classification.as_str()));
533 for finding in &plan.findings {
534 out.result_line(format!(" {}: {}", finding.code, finding.detail));
535 }
536 out.result_line(format!("readiness: {}", plan.readiness.as_str()));
537 let mut by_kind: BTreeMap<&str, usize> = BTreeMap::new();
538 for operation in &plan.operations {
539 *by_kind.entry(operation.kind()).or_default() += 1;
540 }
541 if by_kind.is_empty() {
542 out.result_line("operations: none");
543 } else {
544 out.result_line(format!(
545 "operations: {}",
546 by_kind
547 .iter()
548 .map(|(kind, count)| format!("{count} {kind}"))
549 .collect::<Vec<_>>()
550 .join(", ")
551 ));
552 for operation in &plan.operations {
553 out.result_line(format!(" {}", describe(operation)));
554 }
555 }
556 for precondition in plan.preconditions.iter().filter(|p| !p.evaluation.holds()) {
557 let reason = match &precondition.evaluation {
558 crate::plan::Evaluation::Satisfied => String::new(),
559 crate::plan::Evaluation::NotObserved { reason }
560 | crate::plan::Evaluation::Unsatisfied { reason } => reason.clone(),
561 };
562 out.result_line(format!(
563 "precondition {} ({}): {}: {reason}",
564 precondition.id,
565 precondition.requirement.as_str(),
566 precondition.evaluation.word()
567 ));
568 }
569 for decision in plan.decisions.iter().filter(|d| d.selected.is_none()) {
570 out.result_line(format!("decision {}: {}", decision.id, decision.question));
571 for choice in &decision.choices {
572 out.result_line(format!(" {}: {}", choice.answer, choice.consequence));
573 }
574 }
575 match &plan.release.guidance.coverage {
576 crate::plan::Coverage::NotNeeded => {}
577 coverage => {
578 let word = match coverage {
579 crate::plan::Coverage::Covered => "covered".to_owned(),
580 crate::plan::Coverage::Partial { since } => format!("partial above {since}"),
581 crate::plan::Coverage::Unavailable => "unavailable".to_owned(),
582 crate::plan::Coverage::NotNeeded => String::new(),
583 };
584 out.result_line(format!(
585 "guidance: {word}, {} step(s) for this target, {} excluded",
586 plan.release.guidance.steps.len(),
587 plan.release.guidance.excluded
588 ));
589 for step in &plan.release.guidance.steps {
590 out.result_line(format!(
591 " {} ({}): {} [{}]",
592 step.version,
593 step.action,
594 step.title,
595 step.destinations.join(", ")
596 ));
597 }
598 }
599 }
600 out.result_line(format!("fingerprint: {}", plan.input_fingerprint));
601 out.next(&next_lines(plan));
602}
603
604fn render_applied(out: Output, applied: &Applied) {
606 out.result_line(format!(
607 "applied plan {} to {}",
608 applied.plan_id, applied.target
609 ));
610 for result in &applied.operations {
611 out.result_line(format!(
612 " {} {}",
613 result.op,
614 result.path.as_deref().unwrap_or_default()
615 ));
616 }
617 for result in &applied.postconditions {
618 out.result_line(result.detail.as_ref().map_or_else(
619 || format!("postcondition {}: {}", result.check, result.status),
620 |detail| {
621 format!(
622 "postcondition {}: {} ({detail})",
623 result.check, result.status
624 )
625 },
626 ));
627 }
628 if let Some(run_id) = &applied.run_id {
629 out.result_line(format!("journal: run {run_id}"));
630 }
631 out.next(&applied.next);
632}
633
634pub(crate) fn describe(operation: &Operation) -> String {
636 match operation {
637 Operation::WriteFile { path, kind, .. } => format!("write-file {path} ({})", kind.as_str()),
638 Operation::SpliceBlock { path, .. } => format!("splice-block {path}"),
639 Operation::RemoveOwnedFile { path, .. } => format!("remove-owned-file {path}"),
640 Operation::WriteRecord { .. } => format!("write-record {}", manifest::MANIFEST_PATH),
641 Operation::UpdatePin {
642 manager,
643 before,
644 after,
645 } => format!("update-pin {manager} {before} -> {after}"),
646 }
647}
648
649fn next_lines(plan: &Plan) -> Vec<String> {
651 let target = &plan.observed_state.repository.target;
652 match plan.readiness {
653 Readiness::Blocked => {
654 vec!["resolve each unsatisfied required precondition above, then plan again".to_owned()]
655 }
656 Readiness::NeedsDecision => plan
657 .decisions
658 .iter()
659 .filter(|d| d.selected.is_none())
660 .map(|d| {
661 format!(
662 "rk reconcile plan --target {target} --decide {}=<{}> selects an answer",
663 d.id,
664 d.choices
665 .iter()
666 .map(|c| c.answer.as_str())
667 .collect::<Vec<_>>()
668 .join("|")
669 )
670 })
671 .collect(),
672 Readiness::Ready => match plan.classification {
673 Classification::Upgrade if plan.operations.is_empty() => {
674 vec!["nothing to take: the target is at this release".to_owned()]
675 }
676 Classification::Setup | Classification::Migration | Classification::Upgrade => {
677 vec![format!(
678 "rk reconcile apply {} executes exactly these operations",
679 plan.identity.plan_id
680 )]
681 }
682 Classification::Drift | Classification::Invalid => {
683 vec!["resolve the findings above, then plan again".to_owned()]
684 }
685 },
686 }
687}