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, Verification,
30};
31use crate::release::declared;
32use crate::release::{CrateReleaseSource, EmbeddedReleaseSource, ReleaseSource};
33use crate::setup::journal::Journal;
34
35pub fn run(args: &ReconcileArgs) -> Result<(), RkError> {
41 match &args.action {
42 ReconcileAction::Plan(plan_args) => plan(plan_args),
43 ReconcileAction::Show(show_args) => show(show_args),
44 ReconcileAction::Apply(apply_args) => apply_stored(apply_args),
45 ReconcileAction::List(list_args) => list(list_args),
46 }
47}
48
49fn plan(args: &PlanArgs) -> Result<(), RkError> {
51 let out = Output::new(args.json);
52 let decisions = parse_decisions(&args.decide)?;
53 let nix = match args.nix.as_deref() {
54 None => None,
55 Some("on") => Some(true),
56 Some("off") => Some(false),
57 Some(other) => {
58 return Err(RkError::Usage(format!(
59 "unknown --nix value '{other}'; the values are: on, off"
60 )));
61 }
62 };
63 let request = PlanRequest {
64 target: args.target.clone(),
65 intent: Intent::Reconcile,
66 selector: args.to.clone(),
67 fetch: args.fetch,
68 observe_forge: args.observe.contains(&Observe::Forge),
69 flags: Flags {
70 tech: args.tech.clone(),
71 forge: args.forge.clone(),
72 repo: args.repo.clone(),
73 workflow: args.workflow.clone(),
74 style: args.style.clone(),
75 nix,
76 },
77 decisions,
78 };
79 let planned = compute(&request, &manifest::now())?;
80 store::persist(&planned, &request)?;
81 render(out, &planned.plan, true);
82 out.emit(&planned.plan)
83}
84
85fn show(args: &ShowArgs) -> Result<(), RkError> {
87 let out = Output::new(args.json);
88 let stored = store::load(&args.plan_id)?;
89 render(out, &stored.plan, false);
90 out.emit(&stored.plan)
91}
92
93fn apply_stored(args: &ApplyArgs) -> Result<(), RkError> {
95 let out = Output::new(args.json);
96 let stored = store::load(&args.plan_id)?;
97 let fresh = compute(&stored.request, &stored.plan.identity.created_at)?;
101 let journal = open_journal("reconcile apply", &stored.plan).map_err(|error| {
102 RkError::refusal(
103 Diagnostic::new(
104 Reason::JournalUnavailable,
105 format!("the run journal could not be created, and nothing was written: {error}"),
106 )
107 .expected("a writable state root for the journal")
108 .target_state("unchanged"),
109 )
110 })?;
111 let applied = apply::run(
112 &stored.request.target,
113 &stored.plan,
114 &stored.blobs,
115 &fresh.plan,
116 Some(journal),
117 )?;
118 render_applied(out, &applied);
119 out.emit(&applied)?;
120 applied.failure().map_or(Ok(()), Err)
121}
122
123#[derive(Debug, Serialize)]
125struct ListReport {
126 schema: &'static str,
128 plans: Vec<ListRow>,
130}
131
132#[derive(Debug, Serialize)]
134struct ListRow {
135 plan_id: String,
137 created_at: String,
139}
140
141fn list(args: &ListArgs) -> Result<(), RkError> {
142 let out = Output::new(args.json);
143 let rows: Vec<ListRow> = store::list()
144 .into_iter()
145 .map(|(created_at, plan_id)| ListRow {
146 plan_id,
147 created_at,
148 })
149 .collect();
150 for row in &rows {
151 out.result_line(format!("{} {}", row.plan_id, row.created_at));
152 }
153 if rows.is_empty() {
154 out.result_line("no plans are stored");
155 }
156 out.emit(&ListReport {
157 schema: "rk.reconcile-list/1",
158 plans: rows,
159 })
160}
161
162#[derive(Debug, Serialize)]
164pub struct Trace {
165 pub plan_id: String,
167 pub input_fingerprint: crate::digest::Digest,
169 pub stored: bool,
171 #[serde(skip_serializing_if = "Option::is_none")]
173 pub run_id: Option<String>,
174}
175
176impl FrontApplied {
177 #[must_use]
179 pub fn trace(&self) -> Trace {
180 Trace {
181 plan_id: self.applied.plan_id.clone(),
182 input_fingerprint: self.applied.input_fingerprint.clone(),
183 stored: self.stored,
184 run_id: self.applied.run_id.clone(),
185 }
186 }
187
188 #[must_use]
190 pub fn line(&self) -> String {
191 self.applied.run_id.as_ref().map_or_else(
192 || format!("applied plan {}", self.applied.plan_id),
193 |run_id| format!("applied plan {} (run {run_id})", self.applied.plan_id),
194 )
195 }
196
197 #[must_use]
199 pub fn written<'a>(planned: &'a Planned, path: &str) -> Option<&'a [u8]> {
200 planned
201 .plan
202 .operations
203 .iter()
204 .find_map(|operation| match operation {
205 Operation::WriteFile { path: p, after, .. }
206 | Operation::SpliceBlock { path: p, after, .. }
207 if p == path =>
208 {
209 planned.blobs.get(after).map(Vec::as_slice)
210 }
211 _ => None,
212 })
213 }
214}
215
216#[derive(Debug)]
219pub struct FrontApplied {
220 pub applied: Applied,
222 pub stored: bool,
225}
226
227pub fn apply_in_process(
235 planned: &Planned,
236 request: &PlanRequest,
237 command: &str,
238) -> Result<FrontApplied, RkError> {
239 let stored = store::persist(planned, request).is_ok();
240 let journal = open_journal(command, &planned.plan).ok();
241 let applied = apply::run(
242 &request.target,
243 &planned.plan,
244 &planned.blobs,
245 &planned.plan,
246 journal,
247 )?;
248 Ok(FrontApplied { applied, stored })
249}
250
251fn open_journal(command: &str, plan: &Plan) -> std::io::Result<Journal> {
252 let (forge, repo) = plan
253 .desired_state
254 .configuration
255 .as_ref()
256 .map_or(("", ""), |c| (c.forge.as_str(), c.repo.as_str()));
257 Journal::create(command, &plan.observed_state.repository.target, forge, repo)
258}
259
260#[allow(
267 clippy::too_many_lines,
268 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"
269)]
270pub fn compute(request: &PlanRequest, clock: &str) -> Result<Planned, RkError> {
271 let target: &Utf8Path = &request.target;
272 let selector = request.selector.as_str();
273 let embedded = EmbeddedReleaseSource;
274 let crate_source = if selector == "embedded" {
275 None
276 } else {
277 Some(CrateReleaseSource::new(selector)?)
278 };
279 let (candidate_source, venue, verification): (&dyn ReleaseSource, &str, Verification) =
280 match &crate_source {
281 None => (&embedded, "embedded", Verification::Embedded),
282 Some(source) => {
283 let resolved = source.resolve()?;
284 (
285 source,
286 "crates",
287 Verification::RegistryChecksum {
288 cksum: resolved.cksum.clone(),
289 },
290 )
291 }
292 };
293 let candidate_manifest = candidate_source.manifest()?;
294 let declared = declared::compatibility(candidate_source, &candidate_manifest)?;
295 let guidance_files = declared::guidance(candidate_source, &candidate_manifest)?;
296 let carries_guidance = declared::carries_guidance(&candidate_manifest);
297 let extra_paths: Vec<String> = guidance_files
298 .iter()
299 .flat_map(|file| file.destinations.iter().cloned())
300 .collect();
301 let gather_request = Request {
302 target,
303 flags: &request.flags,
304 decisions: &request.decisions,
305 observe_forge: request.observe_forge,
306 clock,
307 source: candidate_source,
308 extra_paths: &extra_paths,
309 };
310 let mut observation = gather::observe(&gather_request)?;
311 let resolution = gather::resolve(&gather_request, &observation)?;
312 gather::observe_host_tools(
313 &mut observation,
314 resolution.params.as_ref().map(crate::landing::Params::tech),
315 resolution
316 .params
317 .as_ref()
318 .map(crate::landing::Params::forge),
319 clock,
320 );
321
322 let recorded = match &observation.record {
326 RecordRead::Present { manifest, .. } => {
327 Some((manifest.rk_version.clone(), manifest.payload_sha256.clone()))
328 }
329 RecordRead::Absent | RecordRead::Invalid { .. } => None,
330 };
331 let baseline_crate = match &recorded {
332 Some((version, digest))
333 if *digest != EmbeddedReleaseSource::manifest_ref().payload_sha256
334 && *digest != candidate_manifest.payload_sha256 =>
335 {
336 let source = CrateReleaseSource::new(version)?;
337 if request.fetch || source.is_cached() {
338 Some(source)
339 } else {
340 None
341 }
342 }
343 _ => None,
344 };
345 let baseline = match &recorded {
346 None => Baseline::NotNeeded,
347 Some((_, digest)) if *digest == EmbeddedReleaseSource::manifest_ref().payload_sha256 => {
348 Baseline::Embedded(&embedded)
349 }
350 Some((version, digest)) if *digest == candidate_manifest.payload_sha256 => {
351 Baseline::Cached {
352 version: version.clone(),
353 source: candidate_source,
354 }
355 }
356 Some((version, _)) => match &baseline_crate {
357 Some(source) => {
358 source.resolve()?;
359 Baseline::Cached {
360 version: version.clone(),
361 source,
362 }
363 }
364 None => Baseline::NotObserved {
365 reason: format!(
366 "the recorded release {version} is not in the release cache; --fetch reads it through the crates venue"
367 ),
368 },
369 },
370 };
371 planner::plan(planner::Inputs {
372 intent: request.intent,
373 clock,
374 engine_version: env!("CARGO_PKG_VERSION"),
375 selector,
376 candidate: Candidate {
377 source: candidate_source,
378 manifest: candidate_manifest,
379 venue,
380 verification,
381 },
382 baseline,
383 observation,
384 resolution,
385 selected: &request.decisions,
386 declared: &declared,
387 guidance_files: &guidance_files,
388 carries_guidance,
389 })
390}
391
392pub fn parse_decisions(raw: &[String]) -> Result<BTreeMap<String, String>, RkError> {
399 let mut decisions = BTreeMap::new();
400 for item in raw {
401 let Some((id, answer)) = item.split_once('=') else {
402 return Err(RkError::Usage(format!(
403 "--decide takes <id>=<answer>; '{item}' has no '='"
404 )));
405 };
406 if id.is_empty() || answer.is_empty() {
407 return Err(RkError::Usage(format!(
408 "--decide takes <id>=<answer>; '{item}' leaves one side empty"
409 )));
410 }
411 decisions.insert(id.to_owned(), answer.to_owned());
412 }
413 Ok(decisions)
414}
415
416fn render(out: Output, plan: &Plan, fresh: bool) {
420 out.result_line(format!(
421 "plan {} for {} toward release-kit {} ({}){}",
422 plan.identity.plan_id,
423 plan.observed_state.repository.target,
424 plan.desired_state.release.version,
425 plan.desired_state.release.venue,
426 if fresh { ", stored" } else { "" }
427 ));
428 out.result_line(format!("classification: {}", plan.classification.as_str()));
429 for finding in &plan.findings {
430 out.result_line(format!(" {}: {}", finding.code, finding.detail));
431 }
432 out.result_line(format!("readiness: {}", plan.readiness.as_str()));
433 let mut by_kind: BTreeMap<&str, usize> = BTreeMap::new();
434 for operation in &plan.operations {
435 *by_kind.entry(operation.kind()).or_default() += 1;
436 }
437 if by_kind.is_empty() {
438 out.result_line("operations: none");
439 } else {
440 out.result_line(format!(
441 "operations: {}",
442 by_kind
443 .iter()
444 .map(|(kind, count)| format!("{count} {kind}"))
445 .collect::<Vec<_>>()
446 .join(", ")
447 ));
448 for operation in &plan.operations {
449 out.result_line(format!(" {}", describe(operation)));
450 }
451 }
452 for precondition in plan.preconditions.iter().filter(|p| !p.evaluation.holds()) {
453 let reason = match &precondition.evaluation {
454 crate::plan::Evaluation::Satisfied => String::new(),
455 crate::plan::Evaluation::NotObserved { reason }
456 | crate::plan::Evaluation::Unsatisfied { reason } => reason.clone(),
457 };
458 out.result_line(format!(
459 "precondition {} ({}): {}: {reason}",
460 precondition.id,
461 precondition.requirement.as_str(),
462 precondition.evaluation.word()
463 ));
464 }
465 for decision in plan.decisions.iter().filter(|d| d.selected.is_none()) {
466 out.result_line(format!("decision {}: {}", decision.id, decision.question));
467 for choice in &decision.choices {
468 out.result_line(format!(" {}: {}", choice.answer, choice.consequence));
469 }
470 }
471 match &plan.release.guidance.coverage {
472 crate::plan::Coverage::NotNeeded => {}
473 coverage => {
474 let word = match coverage {
475 crate::plan::Coverage::Covered => "covered".to_owned(),
476 crate::plan::Coverage::Partial { since } => format!("partial above {since}"),
477 crate::plan::Coverage::Unavailable => "unavailable".to_owned(),
478 crate::plan::Coverage::NotNeeded => String::new(),
479 };
480 out.result_line(format!(
481 "guidance: {word}, {} step(s) for this target, {} excluded",
482 plan.release.guidance.steps.len(),
483 plan.release.guidance.excluded
484 ));
485 for step in &plan.release.guidance.steps {
486 out.result_line(format!(
487 " {} ({}): {} [{}]",
488 step.version,
489 step.action,
490 step.title,
491 step.destinations.join(", ")
492 ));
493 }
494 }
495 }
496 out.result_line(format!("fingerprint: {}", plan.input_fingerprint));
497 out.next(&next_lines(plan));
498}
499
500fn render_applied(out: Output, applied: &Applied) {
502 out.result_line(format!(
503 "applied plan {} to {}",
504 applied.plan_id, applied.target
505 ));
506 for result in &applied.operations {
507 out.result_line(format!(
508 " {} {}",
509 result.op,
510 result.path.as_deref().unwrap_or_default()
511 ));
512 }
513 for result in &applied.postconditions {
514 out.result_line(result.detail.as_ref().map_or_else(
515 || format!("postcondition {}: {}", result.check, result.status),
516 |detail| {
517 format!(
518 "postcondition {}: {} ({detail})",
519 result.check, result.status
520 )
521 },
522 ));
523 }
524 if let Some(run_id) = &applied.run_id {
525 out.result_line(format!("journal: run {run_id}"));
526 }
527 out.next(&applied.next);
528}
529
530pub(crate) fn describe(operation: &Operation) -> String {
532 match operation {
533 Operation::WriteFile { path, kind, .. } => format!("write-file {path} ({})", kind.as_str()),
534 Operation::SpliceBlock { path, .. } => format!("splice-block {path}"),
535 Operation::RemoveOwnedFile { path, .. } => format!("remove-owned-file {path}"),
536 Operation::WriteRecord { .. } => format!("write-record {}", manifest::MANIFEST_PATH),
537 Operation::UpdatePin {
538 manager,
539 before,
540 after,
541 } => format!("update-pin {manager} {before} -> {after}"),
542 }
543}
544
545fn next_lines(plan: &Plan) -> Vec<String> {
547 let target = &plan.observed_state.repository.target;
548 match plan.readiness {
549 Readiness::Blocked => {
550 vec!["resolve each unsatisfied required precondition above, then plan again".to_owned()]
551 }
552 Readiness::NeedsDecision => plan
553 .decisions
554 .iter()
555 .filter(|d| d.selected.is_none())
556 .map(|d| {
557 format!(
558 "rk reconcile plan --target {target} --decide {}=<{}> selects an answer",
559 d.id,
560 d.choices
561 .iter()
562 .map(|c| c.answer.as_str())
563 .collect::<Vec<_>>()
564 .join("|")
565 )
566 })
567 .collect(),
568 Readiness::Ready => match plan.classification {
569 Classification::Upgrade if plan.operations.is_empty() => {
570 vec!["nothing to take: the target is at this release".to_owned()]
571 }
572 Classification::Setup | Classification::Migration | Classification::Upgrade => {
573 vec![format!(
574 "rk reconcile apply {} executes exactly these operations",
575 plan.identity.plan_id
576 )]
577 }
578 Classification::Drift | Classification::Invalid => {
579 vec!["resolve the findings above, then plan again".to_owned()]
580 }
581 },
582 }
583}