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            // A rollback renders nothing, so `backup_read` is skipped and
652            // compares no path.
653            deleted_paths: &[],
654            hold: &opts.hold,
655            designers: &opts.designers,
656            clock: ctx.clock,
657        },
658        steps,
659    )
660    .await;
661    let seen = preflights.as_ref().ok();
662    run_dir.write_json(
663        "run.json",
664        &json!({
665            "schema_version": 1,
666            "kind": "rollback",
667            "run_id": run_id.as_str(),
668            "target_run_id": opts.run_id.as_str(),
669            "env": ctx.env,
670            "target_file": ctx.target_file,
671            "gateway": target.gateway,
672            "mode": target.mode,
673            "node": "master",
674            "started_at": started_at,
675            "run_dir_git": run_dir.git_status().as_record(),
676            "render_sha256": record.render_sha256,
677            "plan_sha256": record.plan_sha256,
678            "hold_s": opts.hold.hold_s,
679            "discard_live_changes": opts.discard_live_changes,
680            "discarded_live_changes": checked.live_changed,
681            "interrupted_phase": interrupted.map(|i| i.journal.phase.as_str()),
682            "recovery": interrupted.map(|i| i.action.as_str()),
683            "designers": opts.designers.mode.as_str(),
684            "designers_timeout_s": opts.designers.timeout.as_secs(),
685            "designers_seen": seen.map(|r| r.designers_seen
686                .iter()
687                .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
688                .collect::<Vec<_>>()),
689            "preflights": seen.map(|r| r.findings
690                .iter()
691                .map(|f| json!({"check": f.check.as_str(), "status": f.status.as_str()}))
692                .collect::<Vec<_>>()),
693        }),
694    )?;
695    run_dir.write_json(
696        "plan.json",
697        &json!({
698            "schema_version": 1,
699            "kind": "rollback",
700            "target_run_id": opts.run_id.as_str(),
701            "gateway": target.gateway,
702            "mode": target.mode,
703            "plan_sha256": record.plan_sha256,
704            "render_sha256": record.render_sha256,
705            "restore": restored,
706            "delete": deleted,
707            "ledger": record
708                .touched
709                .iter()
710                .find(|e| e.path == LEDGER_KEY)
711                .map(|e| if e.pre_sha256.is_some() { "restore" } else { "delete" }),
712        }),
713    )?;
714    let preflights = preflights?;
715    if !preflights.passed() {
716        steps.emit(ApplyEvent::PreflightChecks {
717            checks: preflights
718                .findings
719                .iter()
720                .map(|f| (f.check.as_str().to_string(), f.status.as_str().to_string()))
721                .collect(),
722        })?;
723        return Err(CoreError::DeployPreflightFailed {
724            failures: preflights.failures().into(),
725        });
726    }
727    if !checked.live_changed.is_empty() {
728        steps.emit(ApplyEvent::LiveChangesDiscarded {
729            count: checked.live_changed.len(),
730            paths: checked
731                .live_changed
732                .iter()
733                .take(APPLY_LIST_SHOWN)
734                .cloned()
735                .collect(),
736        })?;
737    }
738
739    // What is already waiting for a restart, so the rollback's own additions
740    // can be told apart afterwards. Unreadable is `None`.
741    let restart_before = ctx.deploy.restart_pending().await.ok();
742
743    // The lock is taken here, so that another actor's hold is the typed
744    // `scan_lock_held` before anything is written; `restore_snapshot` then
745    // reuses it.
746    let lock = HeldLock::acquire(ctx.deploy, ScanKind::Config, opts.hold.hold_s, ctx.clock).await?;
747    steps.emit(ApplyEvent::LockAcquired {
748        hold_s: opts.hold.hold_s,
749    })?;
750
751    // The check above ran before the preflights, which can wait for Designer
752    // sessions for hours, and the restore itself does not compare the live
753    // file with the post-run hash. So the live hashes are read again under
754    // the lock (review WR-01): an edit that landed since is refused, or named
755    // when the caller discards live changes. A recovery has no post-run state
756    // to compare with (D-11).
757    let mut live_changed = checked.live_changed.clone();
758    if interrupted.is_none() {
759        let now = live_changes(ctx.host, record).await?;
760        if now != live_changed {
761            if !opts.discard_live_changes {
762                // Nothing was written. One no-op scan releases the lock (D12),
763                // best effort: an unreleased lock lapses by itself.
764                let _ = ctx.deploy.scan_request(ScanKind::Config).await;
765                return Err(CoreError::DeployLiveChanged {
766                    run_id: opts.run_id.as_str().to_string(),
767                    paths: now.iter().take(APPLY_LIST_SHOWN).cloned().collect(),
768                    count: now.len(),
769                });
770            }
771            steps.emit(ApplyEvent::LiveChangesDiscarded {
772                count: now.len(),
773                paths: now.iter().take(APPLY_LIST_SHOWN).cloned().collect(),
774            })?;
775            live_changed = now;
776        }
777    }
778    let report = restore_snapshot(
779        &RestoreContext {
780            env: ctx.env,
781            master: &target.nodes.master,
782            host: ctx.host,
783            deploy: ctx.deploy,
784            clock: ctx.clock,
785            hold: &opts.hold,
786            poll: &opts.poll,
787        },
788        &RestoreInput {
789            run_id,
790            run_dir,
791            record,
792            held: Some(&lock),
793            cause: None,
794        },
795        steps,
796    )
797    .await?;
798
799    let mut warnings = report.warnings;
800    let (restart_pending, restart_pending_new) =
801        restart_report(ctx.deploy, restart_before.as_deref(), &mut warnings).await;
802    Ok(RollbackOutcome {
803        kind: RollbackKind::Restored,
804        run_id: run_id.as_str().to_string(),
805        target_run_id: opts.run_id.as_str().to_string(),
806        run_dir: run_dir.path().to_string_lossy().into_owned(),
807        restored,
808        deleted,
809        discarded_live_changes: live_changed,
810        warnings,
811        restart_pending,
812        restart_pending_new,
813        snapshot_sha256: report.snapshot_sha256,
814    })
815}
816
817/// The zero hash: what a recovery writes for the hashes of a plan and
818/// a render it never saw (the record was lost).
819const UNKNOWN_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000";
820
821/// How the snapshot spells `path`: the ledger is `ledger.json`, a collection
822/// file is `files/<path>`.
823fn snapshot_key(path: &str) -> String {
824    if path == LEDGER_KEY {
825        LEDGER_KEY.to_string()
826    } else {
827        format!("files/{path}")
828    }
829}
830
831/// `a, b and N more`, at most [`APPLY_LIST_SHOWN`] named.
832fn named(paths: &[String]) -> String {
833    let shown = paths.len().min(APPLY_LIST_SHOWN);
834    let mut out = paths[..shown].join(", ");
835    if paths.len() > shown {
836        out.push_str(&format!(" and {} more", paths.len() - shown));
837    }
838    out
839}
840
841/// Read the interrupted run's snapshot from the host and return the record to
842/// restore from.
843///
844/// The snapshot is copied into a scratch directory below this run's directory
845/// (private, removed straight away) and every file hashed. With a record, each
846/// pre-image the record lists must be there with its hash. Without one, the
847/// pre-run state is what the snapshot holds, and it must hash to the journal's
848/// `pre_fingerprint`, which is how an incomplete snapshot is told from a
849/// created file. A snapshot that is missing, incomplete or fails its hashes is
850/// `rollback_failed` (D-12): the journal stays and nothing is restored.
851async fn snapshot_record(
852    ctx: &RollbackContext<'_>,
853    opts: &RollbackOptions,
854    run_dir: &RunDir,
855    found: &InterruptedRun,
856    record: Option<&RunRecord>,
857) -> Result<RunRecord, CoreError> {
858    let target = ctx.target;
859    let (gateway, mode) = (target.gateway.as_str(), target.mode.as_str());
860    let fail = |cause: String| {
861        CoreError::RollbackFailed(Box::new(RollbackFailure {
862            run_id: opts.run_id.as_str().to_string(),
863            env: ctx.env.to_string(),
864            run_dir: Some(run_dir.path().to_string_lossy().into_owned()),
865            journal: journal_location(LedgerRoot::for_node(&target.nodes.master), gateway, mode),
866            cause,
867        }))
868    };
869
870    let scratch = run_dir.path().join("inspect");
871    std::fs::create_dir(&scratch)
872        .map_err(|e| fail(format!("cannot read the snapshot ({:?})", e.kind())))?;
873    let fetched = ctx.host.fetch_snapshot(&opts.run_id, &scratch).await;
874    // The copies were only for hashing; the run directory keeps the one the
875    // restore itself downloads.
876    let _ = std::fs::remove_dir_all(&scratch);
877    let hashes = fetched.map_err(|e| fail(format!("the snapshot cannot be read: {e}")))?;
878
879    if let Some(record) = record {
880        let bad: Vec<String> = record
881            .touched
882            .iter()
883            .filter_map(|entry| {
884                let pre = entry.pre_sha256.as_deref()?;
885                (hashes.get(&snapshot_key(&entry.path)).map(String::as_str) != Some(pre))
886                    .then(|| entry.path.clone())
887            })
888            .collect();
889        if !bad.is_empty() {
890            return Err(fail(format!(
891                "the snapshot is missing or damaged for {} path(s): {}",
892                bad.len(),
893                named(&bad)
894            )));
895        }
896        return Ok(record.clone());
897    }
898
899    let pre: BTreeMap<String, Option<String>> = found
900        .journal
901        .touched
902        .iter()
903        .map(|path| (path.clone(), hashes.get(&snapshot_key(path)).cloned()))
904        .collect();
905    if fingerprint(&pre) != found.journal.pre_fingerprint {
906        return Err(fail(
907            "the run's record is missing and the snapshot is incomplete: its files do not \
908             match the journal's pre-run fingerprint"
909                .to_string(),
910        ));
911    }
912    Ok(RunRecord {
913        schema_version: 1,
914        run_id: found.journal.run_id.clone(),
915        gateway: found.journal.gateway.clone(),
916        mode: found.journal.mode.clone(),
917        render_sha256: UNKNOWN_SHA256.to_string(),
918        plan_sha256: UNKNOWN_SHA256.to_string(),
919        state: RecordState::Applying,
920        rolled_back_by: None,
921        touched: found
922            .journal
923            .touched
924            .iter()
925            .map(|path| TouchedEntry {
926                path: path.clone(),
927                pre_sha256: pre.get(path).cloned().flatten(),
928                post_sha256: None,
929            })
930            .collect(),
931        pre_fingerprint: found.journal.pre_fingerprint.clone(),
932        post_fingerprint: None,
933    })
934}
935
936/// D-10: the run died before any move and the touched set is as it was. Drop
937/// the journal, the partial snapshot and the staging; no lock, no restore, no
938/// scan. The run directory still gets its `run.json` and `plan.json`.
939async fn clear_run(
940    ctx: &RollbackContext<'_>,
941    opts: &RollbackOptions,
942    checked: &RollbackChecked,
943    run_id: &RunId,
944    run_dir: &RunDir,
945    steps: &mut Steps<'_>,
946    started_at: &str,
947) -> Result<RollbackOutcome, CoreError> {
948    let target = ctx.target;
949    let phase = checked
950        .interrupted
951        .as_ref()
952        .map(|i| i.journal.phase.as_str());
953    run_dir.write_json(
954        "run.json",
955        &json!({
956            "schema_version": 1,
957            "kind": "rollback",
958            "run_id": run_id.as_str(),
959            "target_run_id": opts.run_id.as_str(),
960            "env": ctx.env,
961            "target_file": ctx.target_file,
962            "gateway": target.gateway,
963            "mode": target.mode,
964            "node": "master",
965            "started_at": started_at,
966            "run_dir_git": run_dir.git_status().as_record(),
967            "discard_live_changes": opts.discard_live_changes,
968            "discarded_live_changes": checked.live_changed,
969            "interrupted_phase": phase,
970            "recovery": RecoveryAction::ClearWithoutRestore.as_str(),
971        }),
972    )?;
973    run_dir.write_json(
974        "plan.json",
975        &json!({
976            "schema_version": 1,
977            "kind": "rollback",
978            "target_run_id": opts.run_id.as_str(),
979            "gateway": target.gateway,
980            "mode": target.mode,
981            "restore": Vec::<String>::new(),
982            "delete": Vec::<String>::new(),
983            "ledger": Value::Null,
984            "recovery": RecoveryAction::ClearWithoutRestore.as_str(),
985        }),
986    )?;
987    ctx.host.discard_interrupted(&opts.run_id).await?;
988    steps.emit(ApplyEvent::Cleared)?;
989    Ok(RollbackOutcome {
990        kind: RollbackKind::Cleared,
991        run_id: run_id.as_str().to_string(),
992        target_run_id: opts.run_id.as_str().to_string(),
993        run_dir: run_dir.path().to_string_lossy().into_owned(),
994        restored: Vec::new(),
995        deleted: Vec::new(),
996        discarded_live_changes: Vec::new(),
997        warnings: Vec::new(),
998        restart_pending: Vec::new(),
999        restart_pending_new: Vec::new(),
1000        snapshot_sha256: BTreeMap::new(),
1001    })
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006    use super::*;
1007    use crate::deploy::apply::preflight::{FindingStatus, hold_budget_finding};
1008
1009    /// A record touching `files` collection paths and the ledger.
1010    fn record_of(files: usize) -> RunRecord {
1011        let mut touched: Vec<TouchedEntry> = (0..files)
1012            .map(|i| TouchedEntry {
1013                path: format!("u{i}/config.json"),
1014                pre_sha256: Some("a".repeat(64)),
1015                post_sha256: Some("b".repeat(64)),
1016            })
1017            .collect();
1018        touched.push(TouchedEntry {
1019            path: LEDGER_KEY.to_string(),
1020            pre_sha256: Some("c".repeat(64)),
1021            post_sha256: Some("d".repeat(64)),
1022        });
1023        RunRecord {
1024            schema_version: 1,
1025            run_id: "20261016T000000Z-aaaaaa".to_string(),
1026            gateway: "gw".to_string(),
1027            mode: "dev".to_string(),
1028            render_sha256: "e".repeat(64),
1029            plan_sha256: "f".repeat(64),
1030            state: RecordState::Applied,
1031            rolled_back_by: None,
1032            touched,
1033            pre_fingerprint: "0".repeat(64),
1034            post_fingerprint: None,
1035        }
1036    }
1037
1038    /// The `hold_budget` verdict of a rollback of a run that touched `files`.
1039    fn rollback_budget(files: usize) -> FindingStatus {
1040        let record = record_of(files);
1041        hold_budget_finding(&HoldModel::MEASURED, budget_files(&record)).status
1042    }
1043
1044    #[test]
1045    fn rollback_budget_counts_what_the_apply_counted() {
1046        // An apply of 1,527 files fits the hold (hold_model_fits_the_measured
1047        // _largest_tree pins 1,527 and 1,528); its rollback has 1,528 touched
1048        // entries with the ledger and must fit too.
1049        assert_eq!(record_of(1527).touched.len(), 1528);
1050        assert_eq!(budget_files(&record_of(1527)), 1527);
1051        assert_eq!(rollback_budget(1527), FindingStatus::Pass);
1052        // One more file does not fit, for the apply and the rollback alike.
1053        assert_eq!(budget_files(&record_of(1528)), 1528);
1054        assert_eq!(rollback_budget(1528), FindingStatus::Fail);
1055    }
1056}