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 let planned = compute(&request, &manifest::now())?;
81 store::persist(&planned, &request)?;
82 render(out, &planned.plan, true);
83 out.emit(&planned.plan)
84}
85
86fn show(args: &ShowArgs) -> Result<(), RkError> {
88 let out = Output::new(args.json);
89 let stored = store::load(&args.plan_id)?;
90 render(out, &stored.plan, false);
91 out.emit(&stored.plan)
92}
93
94fn apply_stored(args: &ApplyArgs) -> Result<(), RkError> {
96 let out = Output::new(args.json);
97 let stored = store::load(&args.plan_id)?;
98 let fresh = compute_frozen(
105 &stored.request,
106 &stored.plan.identity.created_at,
107 Some(&stored.plan.desired_state.release),
108 )?;
109 let journal = open_journal("reconcile apply", &stored.plan).map_err(|error| {
110 RkError::refusal(
111 Diagnostic::new(
112 Reason::JournalUnavailable,
113 format!("the run journal could not be created, and nothing was written: {error}"),
114 )
115 .expected("a writable state root for the journal")
116 .target_state("unchanged"),
117 )
118 })?;
119 let applied = apply::run(
120 &stored.request.target,
121 &stored.plan,
122 &stored.blobs,
123 &fresh.plan,
124 Some(journal),
125 )?;
126 render_applied(out, &applied);
127 out.emit(&applied)?;
128 applied.failure().map_or(Ok(()), Err)
129}
130
131#[derive(Debug, Serialize)]
133struct ListReport {
134 schema: &'static str,
136 plans: Vec<ListRow>,
138}
139
140#[derive(Debug, Serialize)]
142struct ListRow {
143 plan_id: String,
145 created_at: String,
147}
148
149fn list(args: &ListArgs) -> Result<(), RkError> {
150 let out = Output::new(args.json);
151 let rows: Vec<ListRow> = store::list()
152 .into_iter()
153 .map(|(created_at, plan_id)| ListRow {
154 plan_id,
155 created_at,
156 })
157 .collect();
158 for row in &rows {
159 out.result_line(format!("{} {}", row.plan_id, row.created_at));
160 }
161 if rows.is_empty() {
162 out.result_line("no plans are stored");
163 }
164 out.emit(&ListReport {
165 schema: "rk.reconcile-list/1",
166 plans: rows,
167 })
168}
169
170#[derive(Debug, Serialize)]
172pub struct Trace {
173 pub plan_id: String,
175 pub input_fingerprint: crate::digest::Digest,
177 pub stored: bool,
179 #[serde(skip_serializing_if = "Option::is_none")]
181 pub run_id: Option<String>,
182}
183
184impl FrontApplied {
185 #[must_use]
187 pub fn trace(&self) -> Trace {
188 Trace {
189 plan_id: self.applied.plan_id.clone(),
190 input_fingerprint: self.applied.input_fingerprint.clone(),
191 stored: self.stored,
192 run_id: self.applied.run_id.clone(),
193 }
194 }
195
196 #[must_use]
198 pub fn line(&self) -> String {
199 self.applied.run_id.as_ref().map_or_else(
200 || format!("applied plan {}", self.applied.plan_id),
201 |run_id| format!("applied plan {} (run {run_id})", self.applied.plan_id),
202 )
203 }
204
205 #[must_use]
207 pub fn written<'a>(planned: &'a Planned, path: &str) -> Option<&'a [u8]> {
208 planned
209 .plan
210 .operations
211 .iter()
212 .find_map(|operation| match operation {
213 Operation::WriteFile { path: p, after, .. }
214 | Operation::SpliceBlock { path: p, after, .. }
215 if p == path =>
216 {
217 planned.blobs.get(after).map(Vec::as_slice)
218 }
219 _ => None,
220 })
221 }
222}
223
224#[derive(Debug)]
227pub struct FrontApplied {
228 pub applied: Applied,
230 pub stored: bool,
233}
234
235pub fn apply_in_process(
243 planned: &Planned,
244 request: &PlanRequest,
245 command: &str,
246) -> Result<FrontApplied, RkError> {
247 let stored = store::persist(planned, request).is_ok();
248 let journal = open_journal(command, &planned.plan).ok();
249 let applied = apply::run(
250 &request.target,
251 &planned.plan,
252 &planned.blobs,
253 &planned.plan,
254 journal,
255 )?;
256 Ok(FrontApplied { applied, stored })
257}
258
259fn open_journal(command: &str, plan: &Plan) -> std::io::Result<Journal> {
260 let (forge, repo) = plan
261 .desired_state
262 .configuration
263 .as_ref()
264 .map_or(("", ""), |c| (c.forge.as_str(), c.repo.as_str()));
265 Journal::create(command, &plan.observed_state.repository.target, forge, repo)
266}
267
268pub fn compute(request: &PlanRequest, clock: &str) -> Result<Planned, RkError> {
275 compute_frozen(request, clock, None)
276}
277
278#[allow(
287 clippy::too_many_lines,
288 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"
289)]
290pub fn compute_frozen(
291 request: &PlanRequest,
292 clock: &str,
293 frozen: Option<&ResolvedRelease>,
294) -> Result<Planned, RkError> {
295 let target: &Utf8Path = &request.target;
296 let selector = request.selector.as_str();
297 let embedded = EmbeddedReleaseSource;
298 let crate_source = match frozen {
299 _ if selector == "embedded" => None,
300 Some(release) => {
301 let source = CrateReleaseSource::new(&release.version)?;
302 if !source.is_cached() {
303 return Err(RkError::refusal(
304 Diagnostic::new(
305 Reason::BundleUnverified,
306 format!(
307 "the plan froze release-kit {} ({}), and the release cache no longer holds that bundle; nothing was written",
308 release.version, release.payload_sha256
309 ),
310 )
311 .expected("the frozen release's verified bundle in the release cache")
312 .action("rk reconcile plan --to <version> resolves and caches it again")
313 .target_state("unchanged"),
314 ));
315 }
316 Some(source)
317 }
318 None => Some(CrateReleaseSource::new(selector)?),
319 };
320 let (candidate_source, venue, verification): (&dyn ReleaseSource, &str, Verification) =
321 match &crate_source {
322 None => (&embedded, "embedded", Verification::Embedded),
323 Some(source) => {
324 let resolved = source.resolve()?;
325 (
326 source,
327 "crates",
328 Verification::RegistryChecksum {
329 cksum: resolved.cksum.clone(),
330 },
331 )
332 }
333 };
334 let candidate_manifest = candidate_source.manifest()?;
335 let declared = declared::compatibility(candidate_source, &candidate_manifest)?;
336 let guidance_files = declared::guidance(candidate_source, &candidate_manifest)?;
337 let carries_guidance = declared::carries_guidance(&candidate_manifest);
338 let extra_paths: Vec<String> = guidance_files
339 .iter()
340 .flat_map(|file| file.destinations.iter().cloned())
341 .collect();
342 let gather_request = Request {
343 target,
344 flags: &request.flags,
345 decisions: &request.decisions,
346 observe_forge: request.observe_forge,
347 clock,
348 source: candidate_source,
349 extra_paths: &extra_paths,
350 };
351 let mut observation = gather::observe(&gather_request)?;
352 let resolution = gather::resolve(&gather_request, &observation)?;
353 gather::observe_host_tools(
354 &mut observation,
355 resolution.params.as_ref().map(crate::landing::Params::tech),
356 resolution
357 .params
358 .as_ref()
359 .map(crate::landing::Params::forge),
360 clock,
361 );
362
363 let recorded = match &observation.record {
367 RecordRead::Present { manifest, .. } => {
368 Some((manifest.rk_version.clone(), manifest.payload_sha256.clone()))
369 }
370 RecordRead::Absent | RecordRead::Invalid { .. } => None,
371 };
372 let baseline_crate = match &recorded {
373 Some((version, digest))
374 if *digest != EmbeddedReleaseSource::manifest_ref().payload_sha256
375 && *digest != candidate_manifest.payload_sha256 =>
376 {
377 let source = CrateReleaseSource::new(version)?;
378 if request.fetch || source.is_cached() {
379 Some(source)
380 } else {
381 None
382 }
383 }
384 _ => None,
385 };
386 let baseline = match &recorded {
387 None => Baseline::NotNeeded,
388 Some((_, digest)) if *digest == EmbeddedReleaseSource::manifest_ref().payload_sha256 => {
389 Baseline::Embedded(&embedded)
390 }
391 Some((version, digest)) if *digest == candidate_manifest.payload_sha256 => {
392 Baseline::Cached {
393 version: version.clone(),
394 source: candidate_source,
395 }
396 }
397 Some((version, _)) => match &baseline_crate {
398 Some(source) => {
399 source.resolve()?;
400 Baseline::Cached {
401 version: version.clone(),
402 source,
403 }
404 }
405 None => Baseline::NotObserved {
406 reason: format!(
407 "the recorded release {version} is not in the release cache; --fetch reads it through the crates venue"
408 ),
409 },
410 },
411 };
412 planner::plan(planner::Inputs {
413 intent: request.intent,
414 clock,
415 engine_version: env!("CARGO_PKG_VERSION"),
416 selector,
417 candidate: Candidate {
418 source: candidate_source,
419 manifest: candidate_manifest,
420 venue,
421 verification,
422 },
423 baseline,
424 observation,
425 resolution,
426 selected: &request.decisions,
427 declared: &declared,
428 guidance_files: &guidance_files,
429 carries_guidance,
430 })
431}
432
433pub fn parse_decisions(raw: &[String]) -> Result<BTreeMap<String, String>, RkError> {
440 let mut decisions = BTreeMap::new();
441 for item in raw {
442 let Some((id, answer)) = item.split_once('=') else {
443 return Err(RkError::Usage(format!(
444 "--decide takes <id>=<answer>; '{item}' has no '='"
445 )));
446 };
447 if id.is_empty() || answer.is_empty() {
448 return Err(RkError::Usage(format!(
449 "--decide takes <id>=<answer>; '{item}' leaves one side empty"
450 )));
451 }
452 decisions.insert(id.to_owned(), answer.to_owned());
453 }
454 Ok(decisions)
455}
456
457fn render(out: Output, plan: &Plan, fresh: bool) {
461 out.result_line(format!(
462 "plan {} for {} toward release-kit {} ({}){}",
463 plan.identity.plan_id,
464 plan.observed_state.repository.target,
465 plan.desired_state.release.version,
466 plan.desired_state.release.venue,
467 if fresh { ", stored" } else { "" }
468 ));
469 out.result_line(format!("classification: {}", plan.classification.as_str()));
470 for finding in &plan.findings {
471 out.result_line(format!(" {}: {}", finding.code, finding.detail));
472 }
473 out.result_line(format!("readiness: {}", plan.readiness.as_str()));
474 let mut by_kind: BTreeMap<&str, usize> = BTreeMap::new();
475 for operation in &plan.operations {
476 *by_kind.entry(operation.kind()).or_default() += 1;
477 }
478 if by_kind.is_empty() {
479 out.result_line("operations: none");
480 } else {
481 out.result_line(format!(
482 "operations: {}",
483 by_kind
484 .iter()
485 .map(|(kind, count)| format!("{count} {kind}"))
486 .collect::<Vec<_>>()
487 .join(", ")
488 ));
489 for operation in &plan.operations {
490 out.result_line(format!(" {}", describe(operation)));
491 }
492 }
493 for precondition in plan.preconditions.iter().filter(|p| !p.evaluation.holds()) {
494 let reason = match &precondition.evaluation {
495 crate::plan::Evaluation::Satisfied => String::new(),
496 crate::plan::Evaluation::NotObserved { reason }
497 | crate::plan::Evaluation::Unsatisfied { reason } => reason.clone(),
498 };
499 out.result_line(format!(
500 "precondition {} ({}): {}: {reason}",
501 precondition.id,
502 precondition.requirement.as_str(),
503 precondition.evaluation.word()
504 ));
505 }
506 for decision in plan.decisions.iter().filter(|d| d.selected.is_none()) {
507 out.result_line(format!("decision {}: {}", decision.id, decision.question));
508 for choice in &decision.choices {
509 out.result_line(format!(" {}: {}", choice.answer, choice.consequence));
510 }
511 }
512 match &plan.release.guidance.coverage {
513 crate::plan::Coverage::NotNeeded => {}
514 coverage => {
515 let word = match coverage {
516 crate::plan::Coverage::Covered => "covered".to_owned(),
517 crate::plan::Coverage::Partial { since } => format!("partial above {since}"),
518 crate::plan::Coverage::Unavailable => "unavailable".to_owned(),
519 crate::plan::Coverage::NotNeeded => String::new(),
520 };
521 out.result_line(format!(
522 "guidance: {word}, {} step(s) for this target, {} excluded",
523 plan.release.guidance.steps.len(),
524 plan.release.guidance.excluded
525 ));
526 for step in &plan.release.guidance.steps {
527 out.result_line(format!(
528 " {} ({}): {} [{}]",
529 step.version,
530 step.action,
531 step.title,
532 step.destinations.join(", ")
533 ));
534 }
535 }
536 }
537 out.result_line(format!("fingerprint: {}", plan.input_fingerprint));
538 out.next(&next_lines(plan));
539}
540
541fn render_applied(out: Output, applied: &Applied) {
543 out.result_line(format!(
544 "applied plan {} to {}",
545 applied.plan_id, applied.target
546 ));
547 for result in &applied.operations {
548 out.result_line(format!(
549 " {} {}",
550 result.op,
551 result.path.as_deref().unwrap_or_default()
552 ));
553 }
554 for result in &applied.postconditions {
555 out.result_line(result.detail.as_ref().map_or_else(
556 || format!("postcondition {}: {}", result.check, result.status),
557 |detail| {
558 format!(
559 "postcondition {}: {} ({detail})",
560 result.check, result.status
561 )
562 },
563 ));
564 }
565 if let Some(run_id) = &applied.run_id {
566 out.result_line(format!("journal: run {run_id}"));
567 }
568 out.next(&applied.next);
569}
570
571pub(crate) fn describe(operation: &Operation) -> String {
573 match operation {
574 Operation::WriteFile { path, kind, .. } => format!("write-file {path} ({})", kind.as_str()),
575 Operation::SpliceBlock { path, .. } => format!("splice-block {path}"),
576 Operation::RemoveOwnedFile { path, .. } => format!("remove-owned-file {path}"),
577 Operation::WriteRecord { .. } => format!("write-record {}", manifest::MANIFEST_PATH),
578 Operation::UpdatePin {
579 manager,
580 before,
581 after,
582 } => format!("update-pin {manager} {before} -> {after}"),
583 }
584}
585
586fn next_lines(plan: &Plan) -> Vec<String> {
588 let target = &plan.observed_state.repository.target;
589 match plan.readiness {
590 Readiness::Blocked => {
591 vec!["resolve each unsatisfied required precondition above, then plan again".to_owned()]
592 }
593 Readiness::NeedsDecision => plan
594 .decisions
595 .iter()
596 .filter(|d| d.selected.is_none())
597 .map(|d| {
598 format!(
599 "rk reconcile plan --target {target} --decide {}=<{}> selects an answer",
600 d.id,
601 d.choices
602 .iter()
603 .map(|c| c.answer.as_str())
604 .collect::<Vec<_>>()
605 .join("|")
606 )
607 })
608 .collect(),
609 Readiness::Ready => match plan.classification {
610 Classification::Upgrade if plan.operations.is_empty() => {
611 vec!["nothing to take: the target is at this release".to_owned()]
612 }
613 Classification::Setup | Classification::Migration | Classification::Upgrade => {
614 vec![format!(
615 "rk reconcile apply {} executes exactly these operations",
616 plan.identity.plan_id
617 )]
618 }
619 Classification::Drift | Classification::Invalid => {
620 vec!["resolve the findings above, then plan again".to_owned()]
621 }
622 },
623 }
624}