Skip to main content

ignition_core/deploy/apply/
rollback.rs

1//! Manual rollback of a past run (Phase 23, DAPL-08, D-07).
2//!
3//! `rollback --run <id>` needs the target file and the run id and nothing
4//! else: no manifest, no render, no local run directory. The run's pre-images
5//! and its record live on the gateway host (`snapshots/<id>/`, 23-03), so an
6//! ephemeral runner can undo a run it did not perform. The restore itself is
7//! [`restore_snapshot`], the same path an abort and an auto-rollback take.
8//!
9//! # What is checked, in this order, before anything is written
10//!
11//! [`check_rollback`] (the same function answers the early
12//! `deploy_rollback_prepare` and, again, the run itself, so a change between
13//! the two is seen):
14//!
15//! 1. the run id is a valid [`RunId`] (checked by the caller before any host
16//!    contact; the id selects a path on the box);
17//! 2. no journal of another run is on the box: such a journal is
18//!    `deploy_journal_present` naming its run and the recovery command. The
19//!    run's own journal is not a refusal: this is its recovery (23-12, D-09),
20//!    which skips the staleness check of step 5 (D-11);
21//! 3. the on-box record exists and parses (`RunRecord::parse`, strict) for
22//!    this target's gateway and mode; it is at state `applied` (a record at
23//!    `applying` without a journal and a record already `rolled_back` are
24//!    `deploy_run_invalid`), and its fingerprints agree with its touched set;
25//! 4. when the run's local run directory exists, its `run.json` and
26//!    `plan.json` agree with the record on run id, gateway, mode,
27//!    `plan_sha256` and `render_sha256`; a difference names the field;
28//! 5. the live hashes of the touched set equal the record's post-run hashes.
29//!    A difference is `deploy_live_changed` naming the paths, unless the
30//!    caller discards them (`--discard-live-changes`), in which case they are
31//!    recorded in the rollback run's `run.json` and events and the restore
32//!    goes ahead. Files outside the touched set never count. The run reads
33//!    the live hashes once more under the scan-lock, right before the
34//!    restore, because the preflights can wait for hours and the host's
35//!    restore does not compare the live file itself (review WR-01).
36//!
37//! # The run
38//!
39//! A rollback is a run of its own: a fresh run id and run directory
40//! (`run.json` with `kind: "rollback"` and the target run id, `plan.json`
41//! holding the restore set, `events.ndjson`, `result.json`), the preflights
42//! of an apply (`mode_master`, `mode_backup`, `wrong_target`, `write_rest`,
43//! `write_host`, `designers`, `hold_budget`), the scan-lock (refused as
44//! `scan_lock_held` before any write), [`restore_snapshot`] with the held
45//! lock, which scans once and checks the restore by hashes and mode (never
46//! `deploy_verify`), and the record marked `rolled_back` with `rolled_back_by`
47//! this run's id. The outcome is `restored`.
48//!
49//! The pending-restart list is read before the lock and after the restore, as
50//! an apply does, and reported as `restart_pending` and
51//! `restart_pending_new`. Reading is all it does (SF-3).
52
53use std::collections::BTreeMap;
54use std::io::Read as _;
55use std::path::Path;
56
57use serde::Serialize;
58use serde_json::{Value, json};
59
60use super::fingerprint::{LEDGER_KEY, fingerprint};
61use super::hold::{Clock, HeldLock, HoldModel};
62use super::host::ApplyHost;
63use super::journal::{
64    Journal, RecoveryAction, journal_location, journal_on_box, journal_present, recovery_action,
65};
66use super::preflight::{DesignersPolicy, PreflightInput, run_preflights};
67use super::record::{RECORD_FILE, RecordState, RunRecord, TouchedEntry};
68use super::run_store::{EventLog, RunDir, RunId, run_dir_rel};
69use super::{
70    ApplyEvent, ApplyObserver, RestoreContext, RestoreInput, Steps, restart_report,
71    restore_snapshot,
72};
73use crate::actions::workspace::rfc3339_now_utc;
74use crate::client::scan::ScanKind;
75use crate::client::{DeployApi, GatewayApi};
76use crate::deploy::component_problem;
77use crate::deploy::ledger::LedgerRoot;
78use crate::deploy::pair::BackupNode;
79use crate::deploy::target::Target;
80use crate::deploy::transport::TreeReader;
81use crate::error::{APPLY_LIST_SHOWN, CoreError, RollbackFailure};
82use crate::poll::PollConfig;
83
84/// The largest local `run.json` or `plan.json` read for the cross-check.
85const LOCAL_FILE_MAX_BYTES: u64 = 8_388_608;
86
87/// How a rollback ended.
88#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "snake_case")]
90pub enum RollbackKind {
91    /// The run's pre-apply files are back and the gateway was scanned.
92    Restored,
93    /// The recovered run had not changed anything (D-10): its journal, partial
94    /// snapshot and staging were removed, nothing was restored and no scan
95    /// was made.
96    Cleared,
97}
98
99impl RollbackKind {
100    /// The word `data.outcome` carries (the serde form).
101    pub fn as_str(self) -> &'static str {
102        match self {
103            RollbackKind::Restored => "restored",
104            RollbackKind::Cleared => "cleared",
105        }
106    }
107}
108
109/// What a rollback drives. The target file names the gateway, the mode and
110/// the master node; nothing else of the declared repo is read.
111pub struct RollbackContext<'a> {
112    /// The `--env` value.
113    pub env: &'a str,
114    /// The repo root (the run directories live below it).
115    pub repo: &'a Path,
116    /// Repo-relative path of the target file (`targets/<env>.yaml`).
117    pub target_file: &'a str,
118    /// The parsed target.
119    pub target: &'a Target,
120    /// The read half of the master's host.
121    pub reader: &'a dyn TreeReader,
122    /// The write seam of the master's host.
123    pub host: &'a dyn ApplyHost,
124    /// The gateway REST surface (lock, scan, mode, restart list).
125    pub deploy: &'a dyn DeployApi,
126    /// The gateway surface the preflights read (identity, Designers).
127    pub gateway: &'a dyn GatewayApi,
128    /// The backup node (a read-only handle), when the target has one.
129    pub backup: Option<&'a BackupNode<'a>>,
130    /// The monotonic clock the lock is judged by.
131    pub clock: &'a dyn Clock,
132    /// Hears each step.
133    pub observer: &'a dyn ApplyObserver,
134}
135
136/// What a rollback needs besides its context.
137#[derive(Debug, Clone)]
138pub struct RollbackOptions {
139    /// The run to roll back (`--run`). Not this rollback's own id, which is
140    /// generated.
141    pub run_id: RunId,
142    /// `--discard-live-changes`: restore over files changed since the run.
143    pub discard_live_changes: bool,
144    /// What to do about open Designer sessions (D-14, D-15).
145    pub designers: DesignersPolicy,
146    /// The hold the lock is requested with.
147    pub hold: HoldModel,
148    /// How the scan wait polls.
149    pub poll: PollConfig,
150}
151
152/// What the checks found: the record to restore from and the touched paths
153/// that changed since the run (empty unless discarding).
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct RollbackChecked {
156    /// The on-box record of the run: at state `applied` for a rollback, at
157    /// any state for the recovery of an interrupted run, and `None` only for
158    /// an interrupted run whose record was never written or is lost.
159    pub record: Option<RunRecord>,
160    /// Touched paths whose live hash is not the record's post-run hash,
161    /// byte-sorted. Only ever non-empty when the caller discards them.
162    pub live_changed: Vec<String>,
163    /// Set when the run's own journal is on the box: this is a recovery, not
164    /// a rollback of a completed run.
165    pub interrupted: Option<InterruptedRun>,
166}
167
168/// What a recovery found on the box (23-12, D-09 to D-11).
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct InterruptedRun {
171    /// The run's journal.
172    pub journal: Journal,
173    /// The live hash of each path in the journal's touched list when the
174    /// recovery looked (`None` when absent): the torn state, recorded in the
175    /// recovery run's events (D-11).
176    pub live: BTreeMap<String, Option<String>>,
177    /// What recovery will do with it (D-10).
178    pub action: RecoveryAction,
179}
180
181/// How a manual rollback ended and what it did.
182#[derive(Debug, Clone, PartialEq, Serialize)]
183pub struct RollbackOutcome {
184    /// `restored`.
185    #[serde(rename = "outcome")]
186    pub kind: RollbackKind,
187    /// This rollback's own run id.
188    pub run_id: String,
189    /// The run that was rolled back.
190    pub target_run_id: String,
191    /// This rollback's run directory.
192    pub run_dir: String,
193    /// Collection paths put back from the snapshot.
194    pub restored: Vec<String>,
195    /// Collection paths the rolled-back run had created, now removed.
196    pub deleted: Vec<String>,
197    /// Touched paths that had changed since the run and were overwritten.
198    pub discarded_live_changes: Vec<String>,
199    /// Non-failing notes of the engine.
200    pub warnings: Vec<String>,
201    /// The pending restarts after the rollback (`[]` when none or unreadable).
202    pub restart_pending: Vec<String>,
203    /// The pending restarts the rollback added: those not listed before it.
204    pub restart_pending_new: Vec<String>,
205    /// SHA-256 of each file copied into the run directory's `snapshot/`.
206    pub snapshot_sha256: BTreeMap<String, String>,
207}
208
209/// The file count the `hold_budget` preflight judges a rollback of `record`
210/// by: the paths it puts back plus those it removes, the ledger excluded. It is
211/// the count the apply was judged by (`writes + deletes`), so an apply that fit
212/// the hold has a rollback that fits (review WR-03).
213fn budget_files(record: &RunRecord) -> usize {
214    let (restored, deleted) = restore_set(record);
215    restored.len() + deleted.len()
216}
217
218/// The collection paths a restore of `record` puts back, and those it
219/// removes (the run created them). The ledger is not listed.
220pub fn restore_set(record: &RunRecord) -> (Vec<String>, Vec<String>) {
221    let mut restored = Vec::new();
222    let mut deleted = Vec::new();
223    for entry in &record.touched {
224        if entry.path == LEDGER_KEY {
225            continue;
226        }
227        if entry.pre_sha256.is_some() {
228            restored.push(entry.path.clone());
229        } else {
230            deleted.push(entry.path.clone());
231        }
232    }
233    (restored, deleted)
234}
235
236fn invalid(run_id: &str, problem: impl Into<String>) -> CoreError {
237    CoreError::DeployRunInvalid {
238        run_id: run_id.to_string(),
239        problem: problem.into(),
240    }
241}
242
243/// Run the checks of the module doc against the host.
244///
245/// Reads the host and the local run directory and writes nothing. `env` is
246/// named in a journal refusal (the recovery command needs it).
247pub async fn check_rollback(
248    env: &str,
249    repo: &Path,
250    target: &Target,
251    reader: &dyn TreeReader,
252    host: &dyn ApplyHost,
253    run_id: &RunId,
254    discard_live_changes: bool,
255) -> Result<RollbackChecked, CoreError> {
256    let (gateway, mode) = (target.gateway.as_str(), target.mode.as_str());
257    let id = run_id.as_str();
258
259    // 2. A journal on the box means a run is unfinished: only the rollback of
260    // that very run goes on, as its recovery.
261    let master = &target.nodes.master;
262    if let Some(journal) = journal_on_box(reader, master, gateway, mode).await? {
263        if journal.run_id != id {
264            return Err(journal_present(&journal, env, master, gateway, mode));
265        }
266        return check_interrupted(repo, target, host, run_id, journal).await;
267    }
268
269    // 3. The record.
270    let Some(record) = read_run_record(host, run_id).await? else {
271        return Err(invalid(
272            id,
273            format!("the host has no {RECORD_FILE} for this run (snapshots/{id}/{RECORD_FILE})"),
274        ));
275    };
276    validate_record(&record, target, run_id)?;
277
278    // 4. The local run directory, when there is one.
279    cross_check_local(repo, &record)?;
280
281    // 5. Live state against the record's post-run hashes.
282    let live_changed = live_changes(host, &record).await?;
283    if !live_changed.is_empty() && !discard_live_changes {
284        return Err(CoreError::DeployLiveChanged {
285            run_id: id.to_string(),
286            paths: live_changed
287                .iter()
288                .take(APPLY_LIST_SHOWN)
289                .cloned()
290                .collect(),
291            count: live_changed.len(),
292        });
293    }
294    Ok(RollbackChecked {
295        record: Some(record),
296        live_changed,
297        interrupted: None,
298    })
299}
300
301/// The touched paths whose live hash is not the record's post-run hash
302/// (DAPL-08), byte-sorted.
303async fn live_changes(host: &dyn ApplyHost, record: &RunRecord) -> Result<Vec<String>, CoreError> {
304    let touched: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
305    let live = host.fingerprint(&touched).await?;
306    Ok(record
307        .post_map()
308        .into_iter()
309        .filter(|(path, hash)| live.get(path) != Some(hash))
310        .map(|(path, _)| path)
311        .collect())
312}
313
314/// The on-box record of `run_id`; `None` when the host has none. A record too
315/// damaged to name its run is still this run's problem.
316async fn read_run_record(
317    host: &dyn ApplyHost,
318    run_id: &RunId,
319) -> Result<Option<RunRecord>, CoreError> {
320    host.read_record(run_id).await.map_err(|e| match e {
321        CoreError::DeployRunInvalid { problem, .. } => invalid(run_id.as_str(), problem),
322        other => other,
323    })
324}
325
326/// The recovery of the run whose own journal is on the box (D-09 to D-11).
327///
328/// The tree may be partial, so the post-apply staleness check is skipped:
329/// there is no post-apply fingerprint to compare with and nothing is refused
330/// for live changes. The record may be at any state, or absent (Pitfall 14);
331/// when it is there it must agree with the journal. Recovery never depends on
332/// the record's post part.
333async fn check_interrupted(
334    repo: &Path,
335    target: &Target,
336    host: &dyn ApplyHost,
337    run_id: &RunId,
338    journal: Journal,
339) -> Result<RollbackChecked, CoreError> {
340    let id = run_id.as_str();
341    let record = read_run_record(host, run_id).await?;
342    if let Some(record) = &record {
343        validate_identity(record, target, run_id)?;
344        validate_pre(record)?;
345        if journal.pre_fingerprint != record.pre_fingerprint
346            || journal.touched
347                != record
348                    .touched
349                    .iter()
350                    .map(|e| e.path.clone())
351                    .collect::<Vec<_>>()
352        {
353            return Err(invalid(
354                id,
355                "the journal and the record do not describe the same run",
356            ));
357        }
358        cross_check_local(repo, record)?;
359    }
360    let live = host.fingerprint(&journal.touched).await?;
361    let action = recovery_action(&journal, &live);
362    Ok(RollbackChecked {
363        record,
364        live_changed: Vec::new(),
365        interrupted: Some(InterruptedRun {
366            journal,
367            live,
368            action,
369        }),
370    })
371}
372
373/// The record is for this run, this gateway and this mode.
374fn validate_identity(record: &RunRecord, target: &Target, run_id: &RunId) -> Result<(), CoreError> {
375    let id = run_id.as_str();
376    if record.run_id != id {
377        return Err(invalid(id, "the record names a different run"));
378    }
379    if record.gateway != target.gateway {
380        return Err(invalid(
381            id,
382            "the record is for another gateway than the target's",
383        ));
384    }
385    if record.mode != target.mode {
386        return Err(invalid(
387            id,
388            "the record is for another mode than the target's",
389        ));
390    }
391    Ok(())
392}
393
394/// The record's pre-run fingerprint is the fingerprint of its pre-images.
395fn validate_pre(record: &RunRecord) -> Result<(), CoreError> {
396    if record.pre_fingerprint != fingerprint(&record.pre_map()) {
397        return Err(invalid(
398            &record.run_id,
399            "the record's pre_fingerprint is not the fingerprint of its touched set",
400        ));
401    }
402    Ok(())
403}
404
405/// The record is for this run, this gateway and mode, is at state `applied`
406/// and agrees with itself. Names the problem and never a content.
407fn validate_record(record: &RunRecord, target: &Target, run_id: &RunId) -> Result<(), CoreError> {
408    let id = run_id.as_str();
409    validate_identity(record, target, run_id)?;
410    match record.state {
411        RecordState::Applied => {}
412        RecordState::Applying => {
413            return Err(invalid(
414                id,
415                "the run did not finish and no journal is on the box, so there is nothing \
416                 consistent to restore from; restore from the .gwbk backup or the \
417                 run-directory snapshot",
418            ));
419        }
420        RecordState::RolledBack => {
421            let by = record.rolled_back_by.as_deref().unwrap_or("an unknown run");
422            return Err(invalid(id, format!("already rolled back by {by}")));
423        }
424    }
425    validate_pre(record)?;
426    if record.post_fingerprint.as_deref() != Some(fingerprint(&record.post_map()).as_str()) {
427        return Err(invalid(
428            id,
429            "the record's post_fingerprint is missing or is not the fingerprint of its touched set",
430        ));
431    }
432    Ok(())
433}
434
435/// Read one local run-directory file as JSON. `None` when it is absent; a
436/// symlink, a non-file, a file over the cap or invalid JSON is
437/// `deploy_run_invalid`. A symlink is never followed.
438fn read_local_json(
439    dir: &Path,
440    name: &str,
441    rel: &str,
442    id: &str,
443) -> Result<Option<Value>, CoreError> {
444    let path = dir.join(name);
445    let meta = match std::fs::symlink_metadata(&path) {
446        Ok(meta) => meta,
447        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
448        Err(_) => return Err(invalid(id, format!("{rel}/{name} cannot be inspected"))),
449    };
450    if !meta.file_type().is_file() {
451        return Err(invalid(
452            id,
453            format!("{rel}/{name} is not a regular file (a link is not followed)"),
454        ));
455    }
456    let mut bytes = Vec::new();
457    std::fs::File::open(&path)
458        .and_then(|file| file.take(LOCAL_FILE_MAX_BYTES + 1).read_to_end(&mut bytes))
459        .map_err(|_| invalid(id, format!("{rel}/{name} cannot be read")))?;
460    if bytes.len() as u64 > LOCAL_FILE_MAX_BYTES {
461        return Err(invalid(id, format!("{rel}/{name} is over the size cap")));
462    }
463    serde_json::from_slice(&bytes)
464        .map(Some)
465        .map_err(|_| invalid(id, format!("{rel}/{name} is not valid JSON")))
466}
467
468/// When the local run directory of the run exists, its `run.json` and
469/// `plan.json` must agree with the on-box record. Neither side wins; a
470/// difference names the field.
471fn cross_check_local(repo: &Path, record: &RunRecord) -> Result<(), CoreError> {
472    let id = record.run_id.as_str();
473    let run_id = RunId::parse(id)?;
474    if component_problem(&record.gateway).is_some() || record.gateway.contains(':') {
475        return Err(invalid(
476            id,
477            "the record's gateway is not a plain directory name",
478        ));
479    }
480    let rel = run_dir_rel(&record.gateway, &run_id);
481    let dir = rel
482        .split('/')
483        .fold(repo.to_path_buf(), |acc, c| acc.join(c));
484    match std::fs::symlink_metadata(&dir) {
485        Ok(meta) if meta.file_type().is_dir() => {}
486        Ok(_) => {
487            return Err(invalid(
488                id,
489                format!("{rel} is not a plain directory (a link is not followed)"),
490            ));
491        }
492        Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
493        Err(_) => return Err(invalid(id, format!("{rel} cannot be inspected"))),
494    }
495
496    let on_box: [(&str, &str); 4] = [
497        ("gateway", &record.gateway),
498        ("mode", &record.mode),
499        ("plan_sha256", &record.plan_sha256),
500        ("render_sha256", &record.render_sha256),
501    ];
502    let compare = |name: &str, with_run_id: bool| -> Result<(), CoreError> {
503        let Some(doc) = read_local_json(&dir, name, &rel, id)? else {
504            return Ok(());
505        };
506        if name == "run.json" && doc.get("kind").and_then(Value::as_str) == Some("rollback") {
507            return Err(invalid(
508                id,
509                format!("{rel}/{name} is the run directory of a rollback, not of a deployment"),
510            ));
511        }
512        let mut fields: Vec<(&str, &str)> = on_box.to_vec();
513        if with_run_id {
514            fields.insert(0, ("run_id", id));
515        }
516        for (field, expected) in fields {
517            if doc.get(field).and_then(Value::as_str) != Some(expected) {
518                return Err(invalid(
519                    id,
520                    format!("{rel}/{name} field `{field}` differs from the on-box record"),
521                ));
522            }
523        }
524        Ok(())
525    };
526    compare("run.json", true)?;
527    compare("plan.json", false)
528}
529
530/// Roll `opts.run_id` back (see the module doc). Re-runs [`check_rollback`]
531/// first, so a journal, a changed file or a second rollback that appeared
532/// after `deploy_rollback_prepare` is still refused.
533///
534/// Once the rollback's run directory exists, every way out passes through
535/// the one exit point that writes `result.json` once.
536pub async fn run_rollback(
537    ctx: &RollbackContext<'_>,
538    opts: &RollbackOptions,
539) -> Result<RollbackOutcome, CoreError> {
540    let checked = check_rollback(
541        ctx.env,
542        ctx.repo,
543        ctx.target,
544        ctx.reader,
545        ctx.host,
546        &opts.run_id,
547        opts.discard_live_changes,
548    )
549    .await?;
550    let (run_id, run_dir) = RunDir::create_fresh(ctx.repo, &ctx.target.gateway)?;
551    let mut steps = Steps::new(None, ctx.observer);
552    let result = drive(ctx, opts, &checked, &run_id, &run_dir, &mut steps).await;
553
554    // The one exit point.
555    if let Err(e) = &result {
556        let _ = steps.emit(ApplyEvent::Failed {
557            code: e.code().to_string(),
558        });
559    }
560    let doc = match &result {
561        Ok(outcome) => {
562            let mut doc = serde_json::to_value(outcome).unwrap_or(Value::Null);
563            doc["ok"] = Value::Bool(true);
564            doc["kind"] = Value::String("rollback".to_string());
565            doc
566        }
567        Err(e) => json!({
568            "ok": false,
569            "kind": "rollback",
570            "run_id": run_id.as_str(),
571            "target_run_id": opts.run_id.as_str(),
572            "error": {"code": e.code(), "message": e.to_string()},
573        }),
574    };
575    let written = run_dir.write_json("result.json", &doc);
576    match result {
577        Ok(mut outcome) => {
578            if written.is_err() {
579                outcome.warnings.push("result_json_unwritable".to_string());
580            }
581            Ok(outcome)
582        }
583        Err(e) => Err(e),
584    }
585}
586
587async fn drive(
588    ctx: &RollbackContext<'_>,
589    opts: &RollbackOptions,
590    checked: &RollbackChecked,
591    run_id: &RunId,
592    run_dir: &RunDir,
593    steps: &mut Steps<'_>,
594) -> Result<RollbackOutcome, CoreError> {
595    let target = ctx.target;
596
597    let started_at = rfc3339_now_utc();
598    steps.log = Some(EventLog::open(run_dir)?);
599    // The counts of the record; a recovery whose record is lost does not know
600    // them yet and reports zero until the snapshot has been read.
601    let (writes, deletes) = checked.record.as_ref().map(restore_set).unwrap_or_default();
602    steps.emit(ApplyEvent::RunStarted {
603        run_id: run_id.as_str().to_string(),
604        writes: writes.len(),
605        deletes: deletes.len(),
606    })?;
607    let interrupted = checked.interrupted.as_ref();
608    if let Some(found) = interrupted {
609        // The torn state the recovery found, by path and hash (D-11).
610        steps.emit(ApplyEvent::InterruptedRun {
611            phase: found.journal.phase.as_str(),
612            action: found.action.as_str(),
613            hashes: found.live.clone(),
614        })?;
615        if found.action == RecoveryAction::ClearWithoutRestore {
616            return clear_run(ctx, opts, checked, run_id, run_dir, steps, &started_at).await;
617        }
618    }
619
620    // What the restore puts back: the record when there is one; otherwise (a
621    // recovery whose record is lost) the snapshot itself, checked against the
622    // journal's pre-run fingerprint. A recovery reads the snapshot before it
623    // restores anything, so a lost or damaged one is `rollback_failed` with
624    // nothing half restored (D-12).
625    let record = match (interrupted, &checked.record) {
626        (Some(found), record) => {
627            snapshot_record(ctx, opts, run_dir, found, record.as_ref()).await?
628        }
629        (None, Some(record)) => record.clone(),
630        (None, None) => {
631            return Err(CoreError::Internal(
632                "a rollback of a finished run has no record".to_string(),
633            ));
634        }
635    };
636    let record = &record;
637    let (restored, deleted) = restore_set(record);
638
639    let touched_paths: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
640    let preflights = run_preflights(
641        &PreflightInput {
642            target,
643            declared: None,
644            master: ctx.deploy,
645            backup: ctx.backup,
646            gateway: ctx.gateway,
647            reader: ctx.reader,
648            host: ctx.host,
649            touched_files: budget_files(record),
650            touched_paths: &touched_paths,
651            hold: &opts.hold,
652            designers: &opts.designers,
653            clock: ctx.clock,
654        },
655        steps,
656    )
657    .await;
658    let seen = preflights.as_ref().ok();
659    run_dir.write_json(
660        "run.json",
661        &json!({
662            "schema_version": 1,
663            "kind": "rollback",
664            "run_id": run_id.as_str(),
665            "target_run_id": opts.run_id.as_str(),
666            "env": ctx.env,
667            "target_file": ctx.target_file,
668            "gateway": target.gateway,
669            "mode": target.mode,
670            "node": "master",
671            "started_at": started_at,
672            "run_dir_git": run_dir.git_status().as_record(),
673            "render_sha256": record.render_sha256,
674            "plan_sha256": record.plan_sha256,
675            "hold_s": opts.hold.hold_s,
676            "discard_live_changes": opts.discard_live_changes,
677            "discarded_live_changes": checked.live_changed,
678            "interrupted_phase": interrupted.map(|i| i.journal.phase.as_str()),
679            "recovery": interrupted.map(|i| i.action.as_str()),
680            "designers": opts.designers.mode.as_str(),
681            "designers_timeout_s": opts.designers.timeout.as_secs(),
682            "designers_seen": seen.map(|r| r.designers_seen
683                .iter()
684                .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
685                .collect::<Vec<_>>()),
686            "preflights": seen.map(|r| r.findings
687                .iter()
688                .map(|f| json!({"check": f.check.as_str(), "status": f.status.as_str()}))
689                .collect::<Vec<_>>()),
690        }),
691    )?;
692    run_dir.write_json(
693        "plan.json",
694        &json!({
695            "schema_version": 1,
696            "kind": "rollback",
697            "target_run_id": opts.run_id.as_str(),
698            "gateway": target.gateway,
699            "mode": target.mode,
700            "plan_sha256": record.plan_sha256,
701            "render_sha256": record.render_sha256,
702            "restore": restored,
703            "delete": deleted,
704            "ledger": record
705                .touched
706                .iter()
707                .find(|e| e.path == LEDGER_KEY)
708                .map(|e| if e.pre_sha256.is_some() { "restore" } else { "delete" }),
709        }),
710    )?;
711    let preflights = preflights?;
712    if !preflights.passed() {
713        steps.emit(ApplyEvent::PreflightChecks {
714            checks: preflights
715                .findings
716                .iter()
717                .map(|f| (f.check.as_str().to_string(), f.status.as_str().to_string()))
718                .collect(),
719        })?;
720        return Err(CoreError::DeployPreflightFailed {
721            failures: preflights.failures().into(),
722        });
723    }
724    if !checked.live_changed.is_empty() {
725        steps.emit(ApplyEvent::LiveChangesDiscarded {
726            count: checked.live_changed.len(),
727            paths: checked
728                .live_changed
729                .iter()
730                .take(APPLY_LIST_SHOWN)
731                .cloned()
732                .collect(),
733        })?;
734    }
735
736    // What is already waiting for a restart, so the rollback's own additions
737    // can be told apart afterwards. Unreadable is `None`.
738    let restart_before = ctx.deploy.restart_pending().await.ok();
739
740    // The lock is taken here, so that another actor's hold is the typed
741    // `scan_lock_held` before anything is written; `restore_snapshot` then
742    // reuses it.
743    let lock = HeldLock::acquire(ctx.deploy, ScanKind::Config, opts.hold.hold_s, ctx.clock).await?;
744    steps.emit(ApplyEvent::LockAcquired {
745        hold_s: opts.hold.hold_s,
746    })?;
747
748    // The check above ran before the preflights, which can wait for Designer
749    // sessions for hours, and the restore itself does not compare the live
750    // file with the post-run hash. So the live hashes are read again under
751    // the lock (review WR-01): an edit that landed since is refused, or named
752    // when the caller discards live changes. A recovery has no post-run state
753    // to compare with (D-11).
754    let mut live_changed = checked.live_changed.clone();
755    if interrupted.is_none() {
756        let now = live_changes(ctx.host, record).await?;
757        if now != live_changed {
758            if !opts.discard_live_changes {
759                // Nothing was written. One no-op scan releases the lock (D12),
760                // best effort: an unreleased lock lapses by itself.
761                let _ = ctx.deploy.scan_request(ScanKind::Config).await;
762                return Err(CoreError::DeployLiveChanged {
763                    run_id: opts.run_id.as_str().to_string(),
764                    paths: now.iter().take(APPLY_LIST_SHOWN).cloned().collect(),
765                    count: now.len(),
766                });
767            }
768            steps.emit(ApplyEvent::LiveChangesDiscarded {
769                count: now.len(),
770                paths: now.iter().take(APPLY_LIST_SHOWN).cloned().collect(),
771            })?;
772            live_changed = now;
773        }
774    }
775    let report = restore_snapshot(
776        &RestoreContext {
777            env: ctx.env,
778            master: &target.nodes.master,
779            host: ctx.host,
780            deploy: ctx.deploy,
781            clock: ctx.clock,
782            hold: &opts.hold,
783            poll: &opts.poll,
784        },
785        &RestoreInput {
786            run_id,
787            run_dir,
788            record,
789            held: Some(&lock),
790            cause: None,
791        },
792        steps,
793    )
794    .await?;
795
796    let mut warnings = report.warnings;
797    let (restart_pending, restart_pending_new) =
798        restart_report(ctx.deploy, restart_before.as_deref(), &mut warnings).await;
799    Ok(RollbackOutcome {
800        kind: RollbackKind::Restored,
801        run_id: run_id.as_str().to_string(),
802        target_run_id: opts.run_id.as_str().to_string(),
803        run_dir: run_dir.path().to_string_lossy().into_owned(),
804        restored,
805        deleted,
806        discarded_live_changes: live_changed,
807        warnings,
808        restart_pending,
809        restart_pending_new,
810        snapshot_sha256: report.snapshot_sha256,
811    })
812}
813
814/// The zero hash: what a recovery writes for the hashes of a plan and
815/// a render it never saw (the record was lost).
816const UNKNOWN_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000";
817
818/// How the snapshot spells `path`: the ledger is `ledger.json`, a collection
819/// file is `files/<path>`.
820fn snapshot_key(path: &str) -> String {
821    if path == LEDGER_KEY {
822        LEDGER_KEY.to_string()
823    } else {
824        format!("files/{path}")
825    }
826}
827
828/// `a, b and N more`, at most [`APPLY_LIST_SHOWN`] named.
829fn named(paths: &[String]) -> String {
830    let shown = paths.len().min(APPLY_LIST_SHOWN);
831    let mut out = paths[..shown].join(", ");
832    if paths.len() > shown {
833        out.push_str(&format!(" and {} more", paths.len() - shown));
834    }
835    out
836}
837
838/// Read the interrupted run's snapshot from the host and return the record to
839/// restore from.
840///
841/// The snapshot is copied into a scratch directory below this run's directory
842/// (private, removed straight away) and every file hashed. With a record, each
843/// pre-image the record lists must be there with its hash. Without one, the
844/// pre-run state is what the snapshot holds, and it must hash to the journal's
845/// `pre_fingerprint`, which is how an incomplete snapshot is told from a
846/// created file. A snapshot that is missing, incomplete or fails its hashes is
847/// `rollback_failed` (D-12): the journal stays and nothing is restored.
848async fn snapshot_record(
849    ctx: &RollbackContext<'_>,
850    opts: &RollbackOptions,
851    run_dir: &RunDir,
852    found: &InterruptedRun,
853    record: Option<&RunRecord>,
854) -> Result<RunRecord, CoreError> {
855    let target = ctx.target;
856    let (gateway, mode) = (target.gateway.as_str(), target.mode.as_str());
857    let fail = |cause: String| {
858        CoreError::RollbackFailed(Box::new(RollbackFailure {
859            run_id: opts.run_id.as_str().to_string(),
860            env: ctx.env.to_string(),
861            run_dir: Some(run_dir.path().to_string_lossy().into_owned()),
862            journal: journal_location(LedgerRoot::for_node(&target.nodes.master), gateway, mode),
863            cause,
864        }))
865    };
866
867    let scratch = run_dir.path().join("inspect");
868    std::fs::create_dir(&scratch)
869        .map_err(|e| fail(format!("cannot read the snapshot ({:?})", e.kind())))?;
870    let fetched = ctx.host.fetch_snapshot(&opts.run_id, &scratch).await;
871    // The copies were only for hashing; the run directory keeps the one the
872    // restore itself downloads.
873    let _ = std::fs::remove_dir_all(&scratch);
874    let hashes = fetched.map_err(|e| fail(format!("the snapshot cannot be read: {e}")))?;
875
876    if let Some(record) = record {
877        let bad: Vec<String> = record
878            .touched
879            .iter()
880            .filter_map(|entry| {
881                let pre = entry.pre_sha256.as_deref()?;
882                (hashes.get(&snapshot_key(&entry.path)).map(String::as_str) != Some(pre))
883                    .then(|| entry.path.clone())
884            })
885            .collect();
886        if !bad.is_empty() {
887            return Err(fail(format!(
888                "the snapshot is missing or damaged for {} path(s): {}",
889                bad.len(),
890                named(&bad)
891            )));
892        }
893        return Ok(record.clone());
894    }
895
896    let pre: BTreeMap<String, Option<String>> = found
897        .journal
898        .touched
899        .iter()
900        .map(|path| (path.clone(), hashes.get(&snapshot_key(path)).cloned()))
901        .collect();
902    if fingerprint(&pre) != found.journal.pre_fingerprint {
903        return Err(fail(
904            "the run's record is missing and the snapshot is incomplete: its files do not \
905             match the journal's pre-run fingerprint"
906                .to_string(),
907        ));
908    }
909    Ok(RunRecord {
910        schema_version: 1,
911        run_id: found.journal.run_id.clone(),
912        gateway: found.journal.gateway.clone(),
913        mode: found.journal.mode.clone(),
914        render_sha256: UNKNOWN_SHA256.to_string(),
915        plan_sha256: UNKNOWN_SHA256.to_string(),
916        state: RecordState::Applying,
917        rolled_back_by: None,
918        touched: found
919            .journal
920            .touched
921            .iter()
922            .map(|path| TouchedEntry {
923                path: path.clone(),
924                pre_sha256: pre.get(path).cloned().flatten(),
925                post_sha256: None,
926            })
927            .collect(),
928        pre_fingerprint: found.journal.pre_fingerprint.clone(),
929        post_fingerprint: None,
930    })
931}
932
933/// D-10: the run died before any move and the touched set is as it was. Drop
934/// the journal, the partial snapshot and the staging; no lock, no restore, no
935/// scan. The run directory still gets its `run.json` and `plan.json`.
936async fn clear_run(
937    ctx: &RollbackContext<'_>,
938    opts: &RollbackOptions,
939    checked: &RollbackChecked,
940    run_id: &RunId,
941    run_dir: &RunDir,
942    steps: &mut Steps<'_>,
943    started_at: &str,
944) -> Result<RollbackOutcome, CoreError> {
945    let target = ctx.target;
946    let phase = checked
947        .interrupted
948        .as_ref()
949        .map(|i| i.journal.phase.as_str());
950    run_dir.write_json(
951        "run.json",
952        &json!({
953            "schema_version": 1,
954            "kind": "rollback",
955            "run_id": run_id.as_str(),
956            "target_run_id": opts.run_id.as_str(),
957            "env": ctx.env,
958            "target_file": ctx.target_file,
959            "gateway": target.gateway,
960            "mode": target.mode,
961            "node": "master",
962            "started_at": started_at,
963            "run_dir_git": run_dir.git_status().as_record(),
964            "discard_live_changes": opts.discard_live_changes,
965            "discarded_live_changes": checked.live_changed,
966            "interrupted_phase": phase,
967            "recovery": RecoveryAction::ClearWithoutRestore.as_str(),
968        }),
969    )?;
970    run_dir.write_json(
971        "plan.json",
972        &json!({
973            "schema_version": 1,
974            "kind": "rollback",
975            "target_run_id": opts.run_id.as_str(),
976            "gateway": target.gateway,
977            "mode": target.mode,
978            "restore": Vec::<String>::new(),
979            "delete": Vec::<String>::new(),
980            "ledger": Value::Null,
981            "recovery": RecoveryAction::ClearWithoutRestore.as_str(),
982        }),
983    )?;
984    ctx.host.discard_interrupted(&opts.run_id).await?;
985    steps.emit(ApplyEvent::Cleared)?;
986    Ok(RollbackOutcome {
987        kind: RollbackKind::Cleared,
988        run_id: run_id.as_str().to_string(),
989        target_run_id: opts.run_id.as_str().to_string(),
990        run_dir: run_dir.path().to_string_lossy().into_owned(),
991        restored: Vec::new(),
992        deleted: Vec::new(),
993        discarded_live_changes: Vec::new(),
994        warnings: Vec::new(),
995        restart_pending: Vec::new(),
996        restart_pending_new: Vec::new(),
997        snapshot_sha256: BTreeMap::new(),
998    })
999}
1000
1001#[cfg(test)]
1002mod tests {
1003    use super::*;
1004    use crate::deploy::apply::preflight::{FindingStatus, hold_budget_finding};
1005
1006    /// A record touching `files` collection paths and the ledger.
1007    fn record_of(files: usize) -> RunRecord {
1008        let mut touched: Vec<TouchedEntry> = (0..files)
1009            .map(|i| TouchedEntry {
1010                path: format!("u{i}/config.json"),
1011                pre_sha256: Some("a".repeat(64)),
1012                post_sha256: Some("b".repeat(64)),
1013            })
1014            .collect();
1015        touched.push(TouchedEntry {
1016            path: LEDGER_KEY.to_string(),
1017            pre_sha256: Some("c".repeat(64)),
1018            post_sha256: Some("d".repeat(64)),
1019        });
1020        RunRecord {
1021            schema_version: 1,
1022            run_id: "20261016T000000Z-aaaaaa".to_string(),
1023            gateway: "gw".to_string(),
1024            mode: "dev".to_string(),
1025            render_sha256: "e".repeat(64),
1026            plan_sha256: "f".repeat(64),
1027            state: RecordState::Applied,
1028            rolled_back_by: None,
1029            touched,
1030            pre_fingerprint: "0".repeat(64),
1031            post_fingerprint: None,
1032        }
1033    }
1034
1035    /// The `hold_budget` verdict of a rollback of a run that touched `files`.
1036    fn rollback_budget(files: usize) -> FindingStatus {
1037        let record = record_of(files);
1038        hold_budget_finding(&HoldModel::MEASURED, budget_files(&record)).status
1039    }
1040
1041    #[test]
1042    fn rollback_budget_counts_what_the_apply_counted() {
1043        // An apply of 1,527 files fits the hold (hold_model_fits_the_measured
1044        // _largest_tree pins 1,527 and 1,528); its rollback has 1,528 touched
1045        // entries with the ledger and must fit too.
1046        assert_eq!(record_of(1527).touched.len(), 1528);
1047        assert_eq!(budget_files(&record_of(1527)), 1527);
1048        assert_eq!(rollback_budget(1527), FindingStatus::Pass);
1049        // One more file does not fit, for the apply and the rollback alike.
1050        assert_eq!(budget_files(&record_of(1528)), 1528);
1051        assert_eq!(rollback_budget(1528), FindingStatus::Fail);
1052    }
1053}