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