Skip to main content

release_kit/commands/
upgrade.rs

1//! `rk upgrade`: a landed target takes a newer payload.
2//!
3//! A front over the engine: the plan is computed with the upgrade
4//! intent, and on `--apply` the engine executes exactly its operations
5//! through one staged transaction with the record last. Three digests
6//! decide each file: the baseline the record keeps — the payload as it
7//! stood at landing — the bytes on disk now, and this binary's candidate,
8//! rendered under the recorded parameters. A `rendered` file nobody
9//! touched is rewritten; one the target edited is a conflict, and every
10//! conflict is collected before the whole upgrade refuses in one run.
11//! There is no merge: the two outcomes are a clean write and a refusal,
12//! because a wrong guess in a release workflow is discovered at the next
13//! release.
14
15use serde::Serialize;
16
17use crate::cli::upgrade::UpgradeArgs;
18use crate::commands::reconcile::{self, FrontApplied, Trace};
19use crate::diagnostic::{Diagnostic, Reason};
20use crate::embedded;
21use crate::error::RkError;
22use crate::landing::manifest::{self, Alignment, Manifest, Style, Workflow};
23use crate::landing::{self, Kind};
24use crate::output::Output;
25use crate::plan::gather::Flags;
26use crate::plan::{Disposition, Intent, PlanRequest, Planned};
27use crate::release::EmbeddedReleaseSource;
28
29/// One destination and what the upgrade decided for it.
30#[derive(Debug, Serialize)]
31struct FileEntry {
32    /// The destination, relative to the target.
33    path: String,
34    /// The kind this payload declares for it.
35    kind: &'static str,
36    /// `updated`, `unchanged`, `added`, `drift`, `kept`, `dropped`,
37    /// `state`, or `conflict`.
38    action: &'static str,
39}
40
41/// The machine form of an upgrade report.
42#[derive(Debug, Serialize)]
43struct Report {
44    /// The shape version of this document.
45    schema: &'static str,
46    /// `preview` or `apply`.
47    mode: &'static str,
48    /// The target directory.
49    target: String,
50    /// The recorded technology.
51    tech: String,
52    /// The recorded forge.
53    forge: String,
54    /// The version the record came from.
55    from_version: String,
56    /// This binary's version.
57    to_version: &'static str,
58    /// The working-copy mode the rewritten record carries — the recorded
59    /// mode, or the `--workflow` override this run applies.
60    workflow: &'static str,
61    style: &'static str,
62    /// Whether the rewritten record carries the Nix capability.
63    nix: bool,
64    /// The Nix destinations this target could not take, each with why;
65    /// absent where nothing was withheld.
66    #[serde(skip_serializing_if = "Option::is_none")]
67    withheld: Option<Vec<landing::Withheld>>,
68    config: crate::config::Plan,
69    /// Every destination, with its action.
70    files: Vec<FileEntry>,
71    /// The plan the apply executed; absent in a preview.
72    #[serde(skip_serializing_if = "Option::is_none")]
73    plan: Option<Trace>,
74    /// What plausibly follows.
75    next: Vec<String>,
76}
77
78/// One decided destination, read off the plan's outcomes.
79struct Decision {
80    path: String,
81    kind: Kind,
82    action: &'static str,
83}
84
85/// Upgrade the landed target to this binary's payload.
86///
87/// # Errors
88///
89/// Returns a refusal for a missing record, an unknown record schema, a
90/// record from a newer binary, a `rendered` destination that is not a
91/// regular file, and — on apply — any collected conflict; and
92/// [`RkError::Io`] on filesystem failure.
93#[allow(
94    clippy::too_many_lines,
95    reason = "one upgrade run is one linear sequence from the record to the report, and cutting it would separate a refusal from the order it is reported in"
96)]
97pub fn run(args: &UpgradeArgs) -> Result<(), RkError> {
98    let out = Output::new(args.json);
99    let recorded = load_upgradable(&args.target)?;
100    let existing = crate::config::load(args.target.as_std_path())?;
101    let params = resolve_params(args, &recorded, existing.as_ref())?;
102    let style = params
103        .style()
104        .ok_or_else(|| RkError::Usage("landing style is unresolved".into()))?;
105    let request = PlanRequest {
106        target: args.target.clone(),
107        intent: Intent::Upgrade,
108        selector: "embedded".into(),
109        fetch: false,
110        observe_forge: false,
111        flags: Flags {
112            tech: Some(params.tech().to_owned()),
113            forge: Some(params.forge().to_owned()),
114            repo: Some(params.repo().to_owned()),
115            workflow: Some(params.workflow().as_str().to_owned()),
116            style: Some(style.as_str().to_owned()),
117            nix: Some(params.nix()),
118        },
119        decisions: reconcile::parse_decisions(&args.decide)?,
120    };
121    refuse_non_regular(
122        &args.target,
123        &landing::projection(&EmbeddedReleaseSource, &params)?,
124    )?;
125    let planned = reconcile::compute(&request, &manifest::now())?;
126    let config = planned
127        .config
128        .clone()
129        .ok_or_else(|| RkError::Usage("landing parameters are unresolved".into()))?;
130    for key in &config.changes {
131        out.result_line(format!("configuration changes {key}"));
132    }
133    out.result_line(format!("{} {}", config.action, crate::config::CONFIG_PATH));
134
135    let hooks_defect = planned
136        .plan
137        .preconditions
138        .iter()
139        .any(|p| p.id == "hooks-file-spliceable" && !p.evaluation.holds());
140    let (decisions, conflicts) = decide_all(&planned, hooks_defect);
141    // A file this payload stops shipping is a file the target owns from
142    // that moment: left in place, named, and dropped from the record.
143    let dropped: Vec<String> = planned
144        .plan
145        .observed_state
146        .installation
147        .destinations
148        .iter()
149        .filter(|destination| destination.recorded_kind.is_some())
150        .filter(|destination| {
151            !planned
152                .outcomes
153                .iter()
154                .any(|outcome| outcome.path == destination.path)
155        })
156        .map(|destination| destination.path.clone())
157        .collect();
158
159    if args.apply && !conflicts.is_empty() {
160        return Err(refuse_conflicts(&conflicts));
161    }
162
163    let applied = if args.apply {
164        Some(reconcile::apply_in_process(&planned, &request, "upgrade")?)
165    } else {
166        None
167    };
168    let mut sentinels: Vec<String> = Vec::new();
169    for decision in &decisions {
170        if args.apply && matches!(decision.action, "updated" | "added") {
171            if let Some(bytes) = FrontApplied::written(&planned, &decision.path) {
172                collect_sentinels(&decision.path, bytes, &mut sentinels);
173            }
174        }
175        out.result_line(describe(decision));
176    }
177    for path in &dropped {
178        out.result_line(format!(
179            "dropped {path} (no longer shipped; now target-owned)"
180        ));
181    }
182    for entry in &planned.withheld {
183        out.result_line(format!("withheld {}: {}", entry.path, entry.reason));
184    }
185
186    if let Some(applied) = &applied {
187        out.result_line(format!("rewrote {}", manifest::MANIFEST_PATH));
188        out.result_line(applied.line());
189        for sentinel in &sentinels {
190            out.result_line(format!("fill this sentinel: {sentinel}"));
191        }
192    }
193
194    let next = next_lines(args, conflicts.is_empty());
195    out.next(&next);
196    out.emit(&Report {
197        schema: "rk.upgrade/6",
198        config,
199        mode: if args.apply { "apply" } else { "preview" },
200        target: args.target.to_string(),
201        tech: params.tech().into(),
202        forge: params.forge().into(),
203        from_version: recorded.rk_version,
204        to_version: env!("CARGO_PKG_VERSION"),
205        workflow: params.workflow().as_str(),
206        style: style.as_str(),
207        nix: params.nix(),
208        withheld: (!planned.withheld.is_empty()).then(|| planned.withheld.clone()),
209        files: decisions
210            .iter()
211            .map(|decision| FileEntry {
212                path: decision.path.clone(),
213                kind: decision.kind.as_str(),
214                action: decision.action,
215            })
216            .chain(dropped.iter().map(|path| FileEntry {
217                path: path.clone(),
218                kind: "dropped",
219                action: "dropped",
220            }))
221            .collect(),
222        plan: applied.as_ref().map(FrontApplied::trace),
223        next,
224    })?;
225    applied
226        .and_then(|applied| applied.applied.failure())
227        .map_or(Ok(()), Err)
228}
229
230fn describe(decision: &Decision) -> String {
231    match decision.action {
232        "drift" => format!("drift {} (seeded, target-owned)", decision.path),
233        "kept" => format!("kept {} (target-owned)", decision.path),
234        "conflict" => format!("conflict {} (edited, release-kit-owned)", decision.path),
235        action => format!("{action} {}", decision.path),
236    }
237}
238
239fn resolve_params(
240    args: &UpgradeArgs,
241    recorded: &Manifest,
242    existing: Option<&crate::config::Config>,
243) -> Result<landing::Params, RkError> {
244    let nix = match args.nix.as_deref() {
245        None => None,
246        Some("on") => Some(true),
247        Some("off") => Some(false),
248        Some(other) => {
249            return Err(RkError::Usage(format!(
250                "unknown --nix value '{other}'; the values are: on, off"
251            )));
252        }
253    };
254    landing::Params::resolve(
255        &EmbeddedReleaseSource,
256        &args.target,
257        &landing::Inputs {
258            tech: args.tech.as_deref(),
259            forge: args.forge.as_deref(),
260            repo: args.repo.as_deref(),
261            workflow: args.workflow.as_deref().map(Workflow::parse).transpose()?,
262            style: args.style.as_deref().map(Style::parse).transpose()?,
263            nix,
264        },
265        existing,
266        Some(recorded),
267        landing::Purpose::Upgrade,
268    )
269}
270
271/// The collect-then-refuse conflict answer: the whole list in one run, so
272/// an operator resolves everything and re-runs once.
273fn refuse_conflicts(conflicts: &[String]) -> RkError {
274    RkError::refusal(
275        Diagnostic::new(
276            Reason::StateDrift,
277            format!(
278                "these files release-kit owns were edited, and nothing was written: {}",
279                conflicts.join(", ")
280            ),
281        )
282        .expected("every rendered file as the record left it")
283        .action("resolve each, or re-land it, then run 'rk upgrade' again")
284        .target_state("unchanged"),
285    )
286}
287
288/// The `Next:` lines for each outcome. A behavior-defining flag the
289/// preview was run with rides into the follow-up command, so following
290/// it applies the decision that was previewed, never a different one.
291fn next_lines(args: &UpgradeArgs, clean: bool) -> Vec<String> {
292    let identity_flags: String = [
293        ("tech", args.tech.as_deref()),
294        ("forge", args.forge.as_deref()),
295        ("repo", args.repo.as_deref()),
296    ]
297    .into_iter()
298    .filter_map(|(key, value)| value.map(|value| format!(" --{key} {value}")))
299    .collect();
300    let workflow_flag = args
301        .workflow
302        .as_deref()
303        .map_or_else(String::new, |mode| format!(" --workflow {mode}"));
304    let style_flag = args
305        .style
306        .as_deref()
307        .map_or_else(String::new, |style| format!(" --style {style}"));
308    let nix_flag = args
309        .nix
310        .as_deref()
311        .map_or_else(String::new, |value| format!(" --nix {value}"));
312    if args.apply {
313        vec![
314            "commit the upgraded files, the record included".to_owned(),
315            format!("rk status --target {} reports the result", args.target),
316        ]
317    } else if clean {
318        vec![format!(
319            "rk upgrade{identity_flags}{workflow_flag}{style_flag}{nix_flag} --target {} --apply writes",
320            args.target
321        )]
322    } else {
323        vec![format!(
324            "resolve each conflict above; rk upgrade{identity_flags}{workflow_flag}{style_flag}{nix_flag} --target {} --apply refuses until then",
325            args.target
326        )]
327    }
328}
329
330/// The record an upgrade may act on: present, at a known schema, and not
331/// from a newer binary than this one.
332fn load_upgradable(target: &camino::Utf8Path) -> Result<Manifest, RkError> {
333    let Some(recorded) = manifest::load(target)? else {
334        return Err(RkError::refusal(
335            Diagnostic::new(
336                Reason::StateDrift,
337                format!(
338                    "no {} at {target}: there is no baseline to upgrade against",
339                    manifest::MANIFEST_PATH
340                ),
341            )
342            .expected("a recorded landing")
343            .action(
344                "rk init lands a first landing; rk adopt records one made before the record existed",
345            )
346            .target_state("unchanged"),
347        ));
348    };
349    if manifest::alignment(&recorded.rk_version, env!("CARGO_PKG_VERSION"))
350        == Alignment::TargetNewer
351    {
352        return Err(RkError::refusal(
353            Diagnostic::new(
354                Reason::StateDrift,
355                format!(
356                    "this landing came from rk {}, newer than this binary's {}; downgrading a target is not an upgrade",
357                    recorded.rk_version,
358                    env!("CARGO_PKG_VERSION")
359                ),
360            )
361            .expected("a binary at or above the recorded rk_version")
362            .action(format!("install release-kit {} or newer", recorded.rk_version))
363            .target_state("unchanged"),
364        ));
365    }
366    Ok(recorded)
367}
368
369/// Every destination decided off the plan's outcomes, with the collected
370/// conflicts. An ill-formed hook file is a conflict in preview and apply
371/// alike: its first block may match while a duplicate still executes,
372/// so the per-entry comparison cannot see it, and the refusal names each
373/// conflict once.
374fn decide_all(planned: &Planned, hooks_defect: bool) -> (Vec<Decision>, Vec<String>) {
375    let mut conflicts: Vec<String> = Vec::new();
376    if hooks_defect {
377        conflicts.push(landing::HOOKS_DESTINATION.to_owned());
378    }
379    let mut decisions = Vec::new();
380    for outcome in &planned.outcomes {
381        let decided = match outcome.disposition {
382            Disposition::Write if outcome.recorded => "updated",
383            Disposition::Write => "added",
384            Disposition::Unchanged => "unchanged",
385            Disposition::Kept => "kept",
386            Disposition::Drift => "drift",
387            Disposition::State => "state",
388            Disposition::Conflict | Disposition::Missing => {
389                conflicts.push(outcome.path.clone());
390                "conflict"
391            }
392        };
393        let action = if outcome.path == landing::HOOKS_DESTINATION && hooks_defect {
394            "conflict"
395        } else {
396            decided
397        };
398        decisions.push(Decision {
399            path: outcome.path.clone(),
400            kind: outcome.kind,
401            action,
402        });
403    }
404    let mut seen = std::collections::HashSet::new();
405    conflicts.retain(|conflict| seen.insert(conflict.clone()));
406    (decisions, conflicts)
407}
408
409/// A `rendered` destination that exists and is not a regular file refuses
410/// before anything is read.
411fn refuse_non_regular(
412    target: &camino::Utf8Path,
413    entries: &[landing::Entry],
414) -> Result<(), RkError> {
415    for entry in entries {
416        if entry.kind != Kind::Rendered {
417            continue;
418        }
419        let path = target.join(&entry.destination);
420        if let Ok(meta) = std::fs::symlink_metadata(&path) {
421            if !meta.is_file() {
422                return Err(RkError::refusal(
423                    Diagnostic::new(
424                        Reason::StateDrift,
425                        format!("{path} exists and is not a regular file; nothing was written"),
426                    )
427                    .expected("every rendered destination a regular file")
428                    .target_state("unchanged"),
429                ));
430            }
431        }
432    }
433    Ok(())
434}
435
436/// The judgment sentinels a newly written file carries.
437fn collect_sentinels(destination: &str, bytes: &[u8], found: &mut Vec<String>) {
438    let text = String::from_utf8_lossy(bytes);
439    for (idx, line) in text.lines().enumerate() {
440        if line.contains(embedded::SENTINEL) {
441            found.push(format!("{destination}:{}: {}", idx + 1, line.trim()));
442        }
443    }
444}
445
446#[cfg(test)]
447mod tests {
448    use super::{FileEntry, Report};
449
450    /// The complete `rk.upgrade/6` shape, held by snapshot.
451    #[test]
452    fn the_upgrade_report_schema_snapshot_holds() {
453        let report = Report {
454            schema: "rk.upgrade/6",
455            config: crate::config::Plan {
456                action: "added",
457                changes: vec![],
458                content: "schema_version = 1\n".into(),
459            },
460            mode: "preview",
461            target: "/tmp/t".into(),
462            tech: "rust".into(),
463            forge: "github".into(),
464            from_version: "0.1.0".into(),
465            to_version: "0.2.0",
466            workflow: "branches",
467            style: "trunk",
468            nix: false,
469            withheld: None,
470            files: vec![FileEntry {
471                path: "release-plz.toml".into(),
472                kind: "seeded",
473                action: "drift",
474            }],
475            plan: None,
476            next: vec!["rk upgrade --target /tmp/t --apply writes".into()],
477        };
478        assert_eq!(
479            serde_json::to_string(&report).expect("a report serializes"),
480            r#"{"schema":"rk.upgrade/6","mode":"preview","target":"/tmp/t","tech":"rust","forge":"github","from_version":"0.1.0","to_version":"0.2.0","workflow":"branches","style":"trunk","nix":false,"config":{"action":"added","changes":[],"content":"schema_version = 1\n"},"files":[{"path":"release-plz.toml","kind":"seeded","action":"drift"}],"next":["rk upgrade --target /tmp/t --apply writes"]}"#
481        );
482    }
483}