Skip to main content

release_kit/commands/
reconcile.rs

1//! `rk reconcile`: the plan computed, stored, shown, and applied.
2//!
3//! `plan` observes the target, reads the embedded bundle, computes the
4//! plan, stores it under the state root, and prints it; it writes
5//! nothing into the target. `--to` with a version this binary does not
6//! carry reads the crates venue and says so. `--observe forge` opts into
7//! the one forge read. `show` renders a stored plan. `apply` executes
8//! one: it computes the same plan again over the same request, refuses
9//! on any difference in the fingerprint, writes through one staged
10//! transaction, runs the postconditions, and journals the run.
11
12use 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
34/// Dispatch one reconcile action.
35///
36/// # Errors
37///
38/// The planner's, the store's, and the apply's own failures.
39pub 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
48/// Compute, store, and print one plan.
49fn 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
84/// Render a stored plan.
85fn 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
92/// Execute a stored plan.
93fn apply_stored(args: &ApplyArgs) -> Result<(), RkError> {
94    let out = Output::new(args.json);
95    let stored = store::load(&args.plan_id)?;
96    // The same request at the same instant: a fresh landing's record
97    // carries the plan's instant, so recomputing at another one would
98    // read as the record moving when nothing did.
99    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/// The listing of stored plans.
123#[derive(Debug, Serialize)]
124struct ListReport {
125    /// The shape version of this document.
126    schema: &'static str,
127    /// Every stored plan, oldest first.
128    plans: Vec<ListRow>,
129}
130
131/// One stored plan's row.
132#[derive(Debug, Serialize)]
133struct ListRow {
134    /// The plan id.
135    plan_id: String,
136    /// The instant it was computed.
137    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/// The trace a front's report carries of the plan it applied.
162#[derive(Debug, Serialize)]
163pub struct Trace {
164    /// The plan that was applied.
165    pub plan_id: String,
166    /// The fingerprint the apply revalidated against.
167    pub input_fingerprint: crate::digest::Digest,
168    /// Whether the store took the plan.
169    pub stored: bool,
170    /// The journal entry, where the journal took one.
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub run_id: Option<String>,
173}
174
175impl FrontApplied {
176    /// The trace for a front's report.
177    #[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    /// The human line a front prints for the plan it applied.
188    #[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    /// The bytes an operation wrote at `path`, where one did.
197    #[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/// What a front's apply came back with: the engine's report and whether
216/// the store took the plan.
217#[derive(Debug)]
218pub struct FrontApplied {
219    /// The engine's report.
220    pub applied: Applied,
221    /// Whether the plan was stored; a front is one process with no review
222    /// window, so a store that cannot be written costs the record alone.
223    pub stored: bool,
224}
225
226/// One computed plan applied in the same process, which is what the
227/// fronts do on `--apply`: the store and the journal are best effort,
228/// and the execution path is the one `rk reconcile apply` takes.
229///
230/// # Errors
231///
232/// The apply's own refusals and failures.
233pub 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
259/// The whole computation, shared with the fronts.
260///
261/// # Errors
262///
263/// A selector the crates venue cannot resolve, a bundle the engine
264/// cannot read, and the gathering's own failures.
265pub 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    // The recorded release's bundle: the embedded one where the record
301    // names its payload, the cache where it holds the recorded version,
302    // the venue where `--fetch` allows it, and not observed otherwise.
303    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
367/// `<id>=<answer>` pairs into a map, refusing a malformed one.
368fn 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
386/// The human lines: the routing word, the readiness, the operations by
387/// kind, every precondition that does not hold, every decision that
388/// waits, and the fingerprint.
389fn 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
445/// The human lines of an apply.
446fn 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
475/// One operation as a human line.
476pub(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
490/// What plausibly follows, from the readiness.
491fn 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}