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