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, ResolvedRelease,
30    Verification,
31};
32use crate::release::declared;
33use crate::release::{CrateReleaseSource, EmbeddedReleaseSource, ReleaseSource};
34use crate::setup::journal::Journal;
35
36/// Dispatch one reconcile action.
37///
38/// # Errors
39///
40/// The planner's, the store's, and the apply's own failures.
41pub 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
50/// Compute, store, and print one plan.
51fn 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    .canonicalized()?;
81    let planned = compute(&request, &manifest::now())?;
82    store::persist(&planned, &request)?;
83    render(out, &planned.plan, true);
84    out.emit(&planned.plan)
85}
86
87/// Render a stored plan.
88fn show(args: &ShowArgs) -> Result<(), RkError> {
89    let out = Output::new(args.json);
90    let stored = store::load(&args.plan_id)?;
91    render(out, &stored.plan, false);
92    out.emit(&stored.plan)
93}
94
95/// Execute a stored plan.
96fn apply_stored(args: &ApplyArgs) -> Result<(), RkError> {
97    let out = Output::new(args.json);
98    let stored = store::load(&args.plan_id)?;
99    // The target is taken before it is observed, so the world the fresh
100    // plan describes is the world this apply goes on to write: another
101    // run committing between the observation and the first rename is
102    // what the lock exists to stop.
103    let _lock = crate::plan::lock::acquire(&stored.request.target)?;
104    // The same request at the same instant: a fresh landing's record
105    // carries the plan's instant, so recomputing at another one would
106    // read as the record moving when nothing did.
107    // The candidate is the release the plan froze, served from the cache:
108    // the selector was resolved once at plan time and is never resolved
109    // again.
110    let fresh = compute_frozen(
111        &stored.request,
112        &stored.plan.identity.created_at,
113        Some(&stored.plan.desired_state.release),
114    )?;
115    let journal = open_journal("reconcile apply", &stored.plan).map_err(|error| {
116        RkError::refusal(
117            Diagnostic::new(
118                Reason::JournalUnavailable,
119                format!("the run journal could not be created, and nothing was written: {error}"),
120            )
121            .expected("a writable state root for the journal")
122            .target_state("unchanged"),
123        )
124    })?;
125    let applied = apply::run_locked(
126        &stored.request.target,
127        &stored.plan,
128        &stored.blobs,
129        &fresh.plan,
130        Some(journal),
131    )?;
132    render_applied(out, &applied);
133    out.emit(&applied)?;
134    applied.failure().map_or(Ok(()), Err)
135}
136
137/// The listing of stored plans.
138#[derive(Debug, Serialize)]
139struct ListReport {
140    /// The shape version of this document.
141    schema: &'static str,
142    /// Every stored plan, oldest first.
143    plans: Vec<ListRow>,
144}
145
146/// One stored plan's row.
147#[derive(Debug, Serialize)]
148struct ListRow {
149    /// The plan id.
150    plan_id: String,
151    /// The instant it was computed.
152    created_at: String,
153}
154
155fn list(args: &ListArgs) -> Result<(), RkError> {
156    let out = Output::new(args.json);
157    let rows: Vec<ListRow> = store::list()
158        .into_iter()
159        .map(|(created_at, plan_id)| ListRow {
160            plan_id,
161            created_at,
162        })
163        .collect();
164    for row in &rows {
165        out.result_line(format!("{}  {}", row.plan_id, row.created_at));
166    }
167    if rows.is_empty() {
168        out.result_line("no plans are stored");
169    }
170    out.emit(&ListReport {
171        schema: "rk.reconcile-list/1",
172        plans: rows,
173    })
174}
175
176/// The trace a front's report carries of the plan it applied.
177#[derive(Debug, Serialize)]
178pub struct Trace {
179    /// The plan that was applied.
180    pub plan_id: String,
181    /// The fingerprint the apply revalidated against.
182    pub input_fingerprint: crate::digest::Digest,
183    /// Whether the store took the plan.
184    pub stored: bool,
185    /// The journal entry, where the journal took one.
186    #[serde(skip_serializing_if = "Option::is_none")]
187    pub run_id: Option<String>,
188}
189
190impl FrontApplied {
191    /// The trace for a front's report.
192    #[must_use]
193    pub fn trace(&self) -> Trace {
194        Trace {
195            plan_id: self.applied.plan_id.clone(),
196            input_fingerprint: self.applied.input_fingerprint.clone(),
197            stored: self.stored,
198            run_id: self.applied.run_id.clone(),
199        }
200    }
201
202    /// The human line a front prints for the plan it applied.
203    #[must_use]
204    pub fn line(&self) -> String {
205        self.applied.run_id.as_ref().map_or_else(
206            || format!("applied plan {}", self.applied.plan_id),
207            |run_id| format!("applied plan {} (run {run_id})", self.applied.plan_id),
208        )
209    }
210
211    /// The bytes an operation wrote at `path`, where one did.
212    #[must_use]
213    pub fn written<'a>(planned: &'a Planned, path: &str) -> Option<&'a [u8]> {
214        planned
215            .plan
216            .operations
217            .iter()
218            .find_map(|operation| match operation {
219                Operation::WriteFile { path: p, after, .. }
220                | Operation::SpliceBlock { path: p, after, .. }
221                    if p == path =>
222                {
223                    planned.blobs.get(after).map(Vec::as_slice)
224                }
225                _ => None,
226            })
227    }
228}
229
230/// What a front's apply came back with: the engine's report and whether
231/// the store took the plan.
232#[derive(Debug)]
233pub struct FrontApplied {
234    /// The engine's report.
235    pub applied: Applied,
236    /// Whether the plan was stored; a front is one process with no review
237    /// window, so a store that cannot be written costs the record alone.
238    pub stored: bool,
239}
240
241/// One computed plan applied in the same process, which is what the
242/// fronts do on `--apply`: the store and the journal are best effort,
243/// and the execution path is the one `rk reconcile apply` takes.
244///
245/// A front computes its plan before it renders anything, so the target
246/// is taken here and observed again under the lock, exactly as
247/// [`apply_stored`] does. Passing the front's own plan as the fresh one
248/// would make the revalidation compare a plan against itself: the
249/// destination digests would still catch a file that moved, and nothing
250/// would catch a planning input outside the destinations — the
251/// repository the target resolves to, a technology's version file, the
252/// committed configuration — that moved between the observation and the
253/// first rename.
254///
255/// # Errors
256///
257/// The acquisition's refusal, the recomputation's failures, and the
258/// apply's own refusals and failures.
259pub fn apply_in_process(
260    planned: &Planned,
261    request: &PlanRequest,
262    command: &str,
263) -> Result<FrontApplied, RkError> {
264    let _lock = crate::plan::lock::acquire(&request.target)?;
265    // The same request at the same instant over the release the plan
266    // resolved, so the recomputation reads the world rather than the
267    // registry, and a record's instant does not move for a plan that
268    // did not.
269    let fresh = compute_frozen(
270        request,
271        &planned.plan.identity.created_at,
272        Some(&planned.plan.desired_state.release),
273    )?;
274    let stored = store::persist(planned, request).is_ok();
275    let journal = open_journal(command, &planned.plan).ok();
276    let applied = apply::run_locked(
277        &request.target,
278        &planned.plan,
279        &planned.blobs,
280        &fresh.plan,
281        journal,
282    )?;
283    Ok(FrontApplied { applied, stored })
284}
285
286fn open_journal(command: &str, plan: &Plan) -> std::io::Result<Journal> {
287    let (forge, repo) = plan
288        .desired_state
289        .configuration
290        .as_ref()
291        .map_or(("", ""), |c| (c.forge.as_str(), c.repo.as_str()));
292    Journal::create(command, &plan.observed_state.repository.target, forge, repo)
293}
294
295/// The whole computation, shared with the fronts.
296///
297/// # Errors
298///
299/// A selector the crates venue cannot resolve, a bundle the engine
300/// cannot read, and the gathering's own failures.
301pub fn compute(request: &PlanRequest, clock: &str) -> Result<Planned, RkError> {
302    compute_frozen(request, clock, None)
303}
304
305/// The same computation over a release a stored plan froze: the exact
306/// version is served from the release cache and the selector is never
307/// resolved again, so an apply is offline once its plan exists.
308///
309/// # Errors
310///
311/// [`compute`]'s failures, and a `bundle-unverified` refusal when the
312/// cache no longer holds the frozen release.
313#[allow(
314    clippy::too_many_lines,
315    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"
316)]
317pub fn compute_frozen(
318    request: &PlanRequest,
319    clock: &str,
320    frozen: Option<&ResolvedRelease>,
321) -> Result<Planned, RkError> {
322    let target: &Utf8Path = &request.target;
323    let selector = request.selector.as_str();
324    let embedded = EmbeddedReleaseSource;
325    let crate_source = match frozen {
326        _ if selector == "embedded" => None,
327        Some(release) => {
328            let source = CrateReleaseSource::new(&release.version)?;
329            if !source.is_cached() {
330                return Err(RkError::refusal(
331                    Diagnostic::new(
332                        Reason::BundleUnverified,
333                        format!(
334                            "the plan froze release-kit {} ({}), and the release cache no longer holds that bundle; nothing was written",
335                            release.version, release.payload_sha256
336                        ),
337                    )
338                    .expected("the frozen release's verified bundle in the release cache")
339                    .action("rk reconcile plan --to <version> resolves and caches it again")
340                    .target_state("unchanged"),
341                ));
342            }
343            Some(source)
344        }
345        None => Some(CrateReleaseSource::new(selector)?),
346    };
347    let (candidate_source, venue, verification): (&dyn ReleaseSource, &str, Verification) =
348        match &crate_source {
349            None => (&embedded, "embedded", Verification::Embedded),
350            Some(source) => {
351                let resolved = source.resolve()?;
352                (
353                    source,
354                    "crates",
355                    Verification::RegistryChecksum {
356                        cksum: resolved.cksum.clone(),
357                    },
358                )
359            }
360        };
361    let candidate_manifest = candidate_source.manifest()?;
362    let declared = declared::compatibility(candidate_source, &candidate_manifest)?;
363    let guidance_files = declared::guidance(candidate_source, &candidate_manifest)?;
364    let carries_guidance = declared::carries_guidance(&candidate_manifest);
365    let extra_paths: Vec<String> = guidance_files
366        .iter()
367        .flat_map(|file| file.destinations.iter().cloned())
368        .collect();
369    let gather_request = Request {
370        target,
371        flags: &request.flags,
372        decisions: &request.decisions,
373        observe_forge: request.observe_forge,
374        clock,
375        source: candidate_source,
376        extra_paths: &extra_paths,
377    };
378    let mut observation = gather::observe(&gather_request)?;
379    let resolution = gather::resolve(&gather_request, &observation)?;
380    gather::observe_host_tools(
381        &mut observation,
382        resolution.params.as_ref().map(crate::landing::Params::tech),
383        resolution
384            .params
385            .as_ref()
386            .map(crate::landing::Params::forge),
387        clock,
388    );
389
390    // The recorded release's bundle: the embedded one where the record
391    // names its payload, the cache where it holds the recorded version,
392    // the venue where `--fetch` allows it, and not observed otherwise.
393    let recorded = match &observation.record {
394        RecordRead::Present { manifest, .. } => {
395            Some((manifest.rk_version.clone(), manifest.payload_sha256.clone()))
396        }
397        RecordRead::Absent | RecordRead::Invalid { .. } => None,
398    };
399    let baseline_crate = match &recorded {
400        Some((version, digest))
401            if *digest != EmbeddedReleaseSource::manifest_ref().payload_sha256
402                && *digest != candidate_manifest.payload_sha256 =>
403        {
404            let source = CrateReleaseSource::new(version)?;
405            if request.fetch || source.is_cached() {
406                Some(source)
407            } else {
408                None
409            }
410        }
411        _ => None,
412    };
413    let baseline = match &recorded {
414        None => Baseline::NotNeeded,
415        Some((_, digest)) if *digest == EmbeddedReleaseSource::manifest_ref().payload_sha256 => {
416            Baseline::Embedded(&embedded)
417        }
418        Some((version, digest)) if *digest == candidate_manifest.payload_sha256 => {
419            Baseline::Cached {
420                version: version.clone(),
421                source: candidate_source,
422            }
423        }
424        Some((version, _)) => cached_baseline(version, baseline_crate.as_ref()),
425    };
426    planner::plan(planner::Inputs {
427        intent: request.intent,
428        clock,
429        engine_version: env!("CARGO_PKG_VERSION"),
430        selector,
431        candidate: Candidate {
432            source: candidate_source,
433            manifest: candidate_manifest,
434            venue,
435            verification,
436        },
437        baseline,
438        observation,
439        resolution,
440        selected: &request.decisions,
441        declared: &declared,
442        guidance_files: &guidance_files,
443        carries_guidance,
444    })
445}
446
447/// The baseline for a recorded release the cache may hold.
448///
449/// A baseline that will not verify is an evidence gap, never a refusal.
450/// The candidate is what an apply writes and it refuses unverified; the
451/// recorded release only says what the target started from, so a plan
452/// that cannot read it says so and lets the readiness policy decide. A
453/// cache written before the seal existed is exactly this case, and it
454/// must still be able to plan.
455fn cached_baseline<'a>(version: &str, source: Option<&'a CrateReleaseSource>) -> Baseline<'a> {
456    let Some(source) = source else {
457        return Baseline::NotObserved {
458            reason: format!(
459                "the recorded release {version} is not in the release cache; --fetch reads it through the crates venue"
460            ),
461        };
462    };
463    match source.resolve() {
464        Ok(_) => Baseline::Cached {
465            version: version.to_owned(),
466            source,
467        },
468        Err(error) => Baseline::NotObserved {
469            reason: format!(
470                "the recorded release {version} is in the release cache and did not verify: {}",
471                error.diagnostic().message
472            ),
473        },
474    }
475}
476
477/// `<id>=<answer>` pairs into a map, refusing a malformed one.
478///
479/// # Errors
480///
481/// Returns [`RkError::Usage`] for an item without `=` or with an empty
482/// side.
483pub fn parse_decisions(raw: &[String]) -> Result<BTreeMap<String, String>, RkError> {
484    let mut decisions = BTreeMap::new();
485    for item in raw {
486        let Some((id, answer)) = item.split_once('=') else {
487            return Err(RkError::Usage(format!(
488                "--decide takes <id>=<answer>; '{item}' has no '='"
489            )));
490        };
491        if id.is_empty() || answer.is_empty() {
492            return Err(RkError::Usage(format!(
493                "--decide takes <id>=<answer>; '{item}' leaves one side empty"
494            )));
495        }
496        // A decision's choices are the whole of what answers it, so an
497        // unrecognized id or answer is refused where the operator typed
498        // it rather than read as a decision taken.
499        let Some(choices) = crate::plan::decision_choices(id) else {
500            let ids: Vec<&str> = crate::plan::DECISION_CHOICES
501                .iter()
502                .map(|(id, _)| *id)
503                .collect();
504            return Err(RkError::Usage(format!(
505                "--decide names no decision '{id}'; the decisions are: {}",
506                ids.join(", ")
507            )));
508        };
509        if !choices.contains(&answer) {
510            return Err(RkError::Usage(format!(
511                "--decide {id}={answer} is not an answer it takes; the answers are: {}",
512                choices.join(", ")
513            )));
514        }
515        decisions.insert(id.to_owned(), answer.to_owned());
516    }
517    Ok(decisions)
518}
519
520/// The human lines: the routing word, the readiness, the operations by
521/// kind, every precondition that does not hold, every decision that
522/// waits, and the fingerprint.
523fn render(out: Output, plan: &Plan, fresh: bool) {
524    out.result_line(format!(
525        "plan {} for {} toward release-kit {} ({}){}",
526        plan.identity.plan_id,
527        plan.observed_state.repository.target,
528        plan.desired_state.release.version,
529        plan.desired_state.release.venue,
530        if fresh { ", stored" } else { "" }
531    ));
532    out.result_line(format!("classification: {}", plan.classification.as_str()));
533    for finding in &plan.findings {
534        out.result_line(format!("  {}: {}", finding.code, finding.detail));
535    }
536    out.result_line(format!("readiness: {}", plan.readiness.as_str()));
537    let mut by_kind: BTreeMap<&str, usize> = BTreeMap::new();
538    for operation in &plan.operations {
539        *by_kind.entry(operation.kind()).or_default() += 1;
540    }
541    if by_kind.is_empty() {
542        out.result_line("operations: none");
543    } else {
544        out.result_line(format!(
545            "operations: {}",
546            by_kind
547                .iter()
548                .map(|(kind, count)| format!("{count} {kind}"))
549                .collect::<Vec<_>>()
550                .join(", ")
551        ));
552        for operation in &plan.operations {
553            out.result_line(format!("  {}", describe(operation)));
554        }
555    }
556    for precondition in plan.preconditions.iter().filter(|p| !p.evaluation.holds()) {
557        let reason = match &precondition.evaluation {
558            crate::plan::Evaluation::Satisfied => String::new(),
559            crate::plan::Evaluation::NotObserved { reason }
560            | crate::plan::Evaluation::Unsatisfied { reason } => reason.clone(),
561        };
562        out.result_line(format!(
563            "precondition {} ({}): {}: {reason}",
564            precondition.id,
565            precondition.requirement.as_str(),
566            precondition.evaluation.word()
567        ));
568    }
569    for decision in plan.decisions.iter().filter(|d| d.selected.is_none()) {
570        out.result_line(format!("decision {}: {}", decision.id, decision.question));
571        for choice in &decision.choices {
572            out.result_line(format!("  {}: {}", choice.answer, choice.consequence));
573        }
574    }
575    match &plan.release.guidance.coverage {
576        crate::plan::Coverage::NotNeeded => {}
577        coverage => {
578            let word = match coverage {
579                crate::plan::Coverage::Covered => "covered".to_owned(),
580                crate::plan::Coverage::Partial { since } => format!("partial above {since}"),
581                crate::plan::Coverage::Unavailable => "unavailable".to_owned(),
582                crate::plan::Coverage::NotNeeded => String::new(),
583            };
584            out.result_line(format!(
585                "guidance: {word}, {} step(s) for this target, {} excluded",
586                plan.release.guidance.steps.len(),
587                plan.release.guidance.excluded
588            ));
589            for step in &plan.release.guidance.steps {
590                out.result_line(format!(
591                    "  {} ({}): {} [{}]",
592                    step.version,
593                    step.action,
594                    step.title,
595                    step.destinations.join(", ")
596                ));
597            }
598        }
599    }
600    out.result_line(format!("fingerprint: {}", plan.input_fingerprint));
601    out.next(&next_lines(plan));
602}
603
604/// The human lines of an apply.
605fn render_applied(out: Output, applied: &Applied) {
606    out.result_line(format!(
607        "applied plan {} to {}",
608        applied.plan_id, applied.target
609    ));
610    for result in &applied.operations {
611        out.result_line(format!(
612            "  {} {}",
613            result.op,
614            result.path.as_deref().unwrap_or_default()
615        ));
616    }
617    for result in &applied.postconditions {
618        out.result_line(result.detail.as_ref().map_or_else(
619            || format!("postcondition {}: {}", result.check, result.status),
620            |detail| {
621                format!(
622                    "postcondition {}: {} ({detail})",
623                    result.check, result.status
624                )
625            },
626        ));
627    }
628    if let Some(run_id) = &applied.run_id {
629        out.result_line(format!("journal: run {run_id}"));
630    }
631    out.next(&applied.next);
632}
633
634/// One operation as a human line.
635pub(crate) fn describe(operation: &Operation) -> String {
636    match operation {
637        Operation::WriteFile { path, kind, .. } => format!("write-file {path} ({})", kind.as_str()),
638        Operation::SpliceBlock { path, .. } => format!("splice-block {path}"),
639        Operation::RemoveOwnedFile { path, .. } => format!("remove-owned-file {path}"),
640        Operation::WriteRecord { .. } => format!("write-record {}", manifest::MANIFEST_PATH),
641        Operation::UpdatePin {
642            manager,
643            before,
644            after,
645        } => format!("update-pin {manager} {before} -> {after}"),
646    }
647}
648
649/// What plausibly follows, from the readiness.
650fn next_lines(plan: &Plan) -> Vec<String> {
651    let target = &plan.observed_state.repository.target;
652    match plan.readiness {
653        Readiness::Blocked => {
654            vec!["resolve each unsatisfied required precondition above, then plan again".to_owned()]
655        }
656        Readiness::NeedsDecision => plan
657            .decisions
658            .iter()
659            .filter(|d| d.selected.is_none())
660            .map(|d| {
661                format!(
662                    "rk reconcile plan --target {target} --decide {}=<{}> selects an answer",
663                    d.id,
664                    d.choices
665                        .iter()
666                        .map(|c| c.answer.as_str())
667                        .collect::<Vec<_>>()
668                        .join("|")
669                )
670            })
671            .collect(),
672        Readiness::Ready => match plan.classification {
673            Classification::Upgrade if plan.operations.is_empty() => {
674                vec!["nothing to take: the target is at this release".to_owned()]
675            }
676            Classification::Setup | Classification::Migration | Classification::Upgrade => {
677                vec![format!(
678                    "rk reconcile apply {} executes exactly these operations",
679                    plan.identity.plan_id
680                )]
681            }
682            Classification::Drift | Classification::Invalid => {
683                vec!["resolve the findings above, then plan again".to_owned()]
684            }
685        },
686    }
687}