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::declared;
32use crate::release::{CrateReleaseSource, EmbeddedReleaseSource, ReleaseSource};
33use crate::setup::journal::Journal;
34
35/// Dispatch one reconcile action.
36///
37/// # Errors
38///
39/// The planner's, the store's, and the apply's own failures.
40pub 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
49/// Compute, store, and print one plan.
50fn 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
85/// Render a stored plan.
86fn 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
93/// Execute a stored plan.
94fn apply_stored(args: &ApplyArgs) -> Result<(), RkError> {
95    let out = Output::new(args.json);
96    let stored = store::load(&args.plan_id)?;
97    // The same request at the same instant: a fresh landing's record
98    // carries the plan's instant, so recomputing at another one would
99    // read as the record moving when nothing did.
100    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/// The listing of stored plans.
124#[derive(Debug, Serialize)]
125struct ListReport {
126    /// The shape version of this document.
127    schema: &'static str,
128    /// Every stored plan, oldest first.
129    plans: Vec<ListRow>,
130}
131
132/// One stored plan's row.
133#[derive(Debug, Serialize)]
134struct ListRow {
135    /// The plan id.
136    plan_id: String,
137    /// The instant it was computed.
138    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/// The trace a front's report carries of the plan it applied.
163#[derive(Debug, Serialize)]
164pub struct Trace {
165    /// The plan that was applied.
166    pub plan_id: String,
167    /// The fingerprint the apply revalidated against.
168    pub input_fingerprint: crate::digest::Digest,
169    /// Whether the store took the plan.
170    pub stored: bool,
171    /// The journal entry, where the journal took one.
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub run_id: Option<String>,
174}
175
176impl FrontApplied {
177    /// The trace for a front's report.
178    #[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    /// The human line a front prints for the plan it applied.
189    #[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    /// The bytes an operation wrote at `path`, where one did.
198    #[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/// What a front's apply came back with: the engine's report and whether
217/// the store took the plan.
218#[derive(Debug)]
219pub struct FrontApplied {
220    /// The engine's report.
221    pub applied: Applied,
222    /// Whether the plan was stored; a front is one process with no review
223    /// window, so a store that cannot be written costs the record alone.
224    pub stored: bool,
225}
226
227/// One computed plan applied in the same process, which is what the
228/// fronts do on `--apply`: the store and the journal are best effort,
229/// and the execution path is the one `rk reconcile apply` takes.
230///
231/// # Errors
232///
233/// The apply's own refusals and failures.
234pub 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/// The whole computation, shared with the fronts.
261///
262/// # Errors
263///
264/// A selector the crates venue cannot resolve, a bundle the engine
265/// cannot read, and the gathering's own failures.
266#[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    // The recorded release's bundle: the embedded one where the record
323    // names its payload, the cache where it holds the recorded version,
324    // the venue where `--fetch` allows it, and not observed otherwise.
325    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
392/// `<id>=<answer>` pairs into a map, refusing a malformed one.
393///
394/// # Errors
395///
396/// Returns [`RkError::Usage`] for an item without `=` or with an empty
397/// side.
398pub 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
416/// The human lines: the routing word, the readiness, the operations by
417/// kind, every precondition that does not hold, every decision that
418/// waits, and the fingerprint.
419fn 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
500/// The human lines of an apply.
501fn 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
530/// One operation as a human line.
531pub(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
545/// What plausibly follows, from the readiness.
546fn 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}