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