1use 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
88const LOCAL_FILE_MAX_BYTES: u64 = 8_388_608;
90
91#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
93#[serde(rename_all = "snake_case")]
94pub enum RollbackKind {
95 Restored,
97 Cleared,
101}
102
103impl RollbackKind {
104 pub fn as_str(self) -> &'static str {
106 match self {
107 RollbackKind::Restored => "restored",
108 RollbackKind::Cleared => "cleared",
109 }
110 }
111}
112
113pub struct RollbackContext<'a> {
116 pub env: &'a str,
118 pub repo: &'a Path,
120 pub target_file: &'a str,
122 pub target: &'a Target,
124 pub reader: &'a dyn TreeReader,
126 pub host: &'a dyn ApplyHost,
128 pub deploy: &'a dyn DeployApi,
130 pub gateway: &'a dyn GatewayApi,
132 pub backup: Option<&'a BackupNode<'a>>,
134 pub clock: &'a dyn Clock,
136 pub observer: &'a dyn ApplyObserver,
138}
139
140#[derive(Debug, Clone)]
142pub struct RollbackOptions {
143 pub run_id: RunId,
146 pub discard_live_changes: bool,
148 pub designers: DesignersPolicy,
150 pub hold: HoldModel,
152 pub poll: PollConfig,
154}
155
156#[derive(Debug, Clone, PartialEq, Eq)]
159pub struct RollbackChecked {
160 pub record: Option<RunRecord>,
164 pub live_changed: Vec<String>,
167 pub interrupted: Option<InterruptedRun>,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq)]
174pub struct InterruptedRun {
175 pub journal: Journal,
177 pub live: BTreeMap<String, Option<String>>,
181 pub action: RecoveryAction,
183}
184
185#[derive(Debug, Clone, PartialEq, Serialize)]
187pub struct RollbackOutcome {
188 #[serde(rename = "outcome")]
190 pub kind: RollbackKind,
191 pub run_id: String,
193 pub target_run_id: String,
195 pub run_dir: String,
197 pub restored: Vec<String>,
199 pub deleted: Vec<String>,
201 pub discarded_live_changes: Vec<String>,
203 pub warnings: Vec<String>,
205 pub restart_pending: Vec<String>,
207 pub restart_pending_new: Vec<String>,
209 pub snapshot_sha256: BTreeMap<String, String>,
211}
212
213fn budget_files(record: &RunRecord) -> usize {
218 let (restored, deleted) = restore_set(record);
219 restored.len() + deleted.len()
220}
221
222pub 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
247pub 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 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 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 cross_check_local(repo, &record)?;
287
288 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
308fn pair_tags_restore_refusal(record: &RunRecord, target: &Target) -> Option<CoreError> {
318 target.nodes.backup.as_ref()?;
320 let mut keys: Vec<String> = record
321 .touched
322 .iter()
323 .filter(|e| e.pre_sha256.is_some() && is_pair_unsynced_tag_path(&e.path))
324 .map(|e| {
325 e.path
326 .rsplit_once('/')
327 .map_or_else(|| e.path.clone(), |(dir, _)| dir.to_string())
328 })
329 .collect();
330 if keys.is_empty() {
331 return None;
332 }
333 keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
334 keys.dedup();
335 Some(CoreError::DeployPairTagsRefused {
336 mode: record.mode.clone(),
337 keys,
338 })
339}
340
341async fn live_changes(host: &dyn ApplyHost, record: &RunRecord) -> Result<Vec<String>, CoreError> {
344 let touched: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
345 let live = host.fingerprint(&touched).await?;
346 Ok(record
347 .post_map()
348 .into_iter()
349 .filter(|(path, hash)| live.get(path) != Some(hash))
350 .map(|(path, _)| path)
351 .collect())
352}
353
354async fn read_run_record(
357 host: &dyn ApplyHost,
358 run_id: &RunId,
359) -> Result<Option<RunRecord>, CoreError> {
360 host.read_record(run_id).await.map_err(|e| match e {
361 CoreError::DeployRunInvalid { problem, .. } => invalid(run_id.as_str(), problem),
362 other => other,
363 })
364}
365
366async fn check_interrupted(
374 repo: &Path,
375 target: &Target,
376 host: &dyn ApplyHost,
377 run_id: &RunId,
378 journal: Journal,
379) -> Result<RollbackChecked, CoreError> {
380 let id = run_id.as_str();
381 let record = read_run_record(host, run_id).await?;
382 if let Some(record) = &record {
383 validate_identity(record, target, run_id)?;
384 validate_pre(record)?;
385 if journal.pre_fingerprint != record.pre_fingerprint
386 || journal.touched
387 != record
388 .touched
389 .iter()
390 .map(|e| e.path.clone())
391 .collect::<Vec<_>>()
392 {
393 return Err(invalid(
394 id,
395 "the journal and the record do not describe the same run",
396 ));
397 }
398 cross_check_local(repo, record)?;
399 }
400 let live = host.fingerprint(&journal.touched).await?;
401 let action = recovery_action(&journal, &live);
402 Ok(RollbackChecked {
403 record,
404 live_changed: Vec::new(),
405 interrupted: Some(InterruptedRun {
406 journal,
407 live,
408 action,
409 }),
410 })
411}
412
413fn validate_identity(record: &RunRecord, target: &Target, run_id: &RunId) -> Result<(), CoreError> {
415 let id = run_id.as_str();
416 if record.run_id != id {
417 return Err(invalid(id, "the record names a different run"));
418 }
419 if record.gateway != target.gateway {
420 return Err(invalid(
421 id,
422 "the record is for another gateway than the target's",
423 ));
424 }
425 if record.mode != target.mode {
426 return Err(invalid(
427 id,
428 "the record is for another mode than the target's",
429 ));
430 }
431 Ok(())
432}
433
434fn validate_pre(record: &RunRecord) -> Result<(), CoreError> {
436 if record.pre_fingerprint != fingerprint(&record.pre_map()) {
437 return Err(invalid(
438 &record.run_id,
439 "the record's pre_fingerprint is not the fingerprint of its touched set",
440 ));
441 }
442 Ok(())
443}
444
445fn validate_record(record: &RunRecord, target: &Target, run_id: &RunId) -> Result<(), CoreError> {
448 let id = run_id.as_str();
449 validate_identity(record, target, run_id)?;
450 match record.state {
451 RecordState::Applied => {}
452 RecordState::Applying => {
453 return Err(invalid(
454 id,
455 "the run did not finish and no journal is on the box, so there is nothing \
456 consistent to restore from; restore from the .gwbk backup or the \
457 run-directory snapshot",
458 ));
459 }
460 RecordState::RolledBack => {
461 let by = record.rolled_back_by.as_deref().unwrap_or("an unknown run");
462 return Err(invalid(id, format!("already rolled back by {by}")));
463 }
464 }
465 validate_pre(record)?;
466 if record.post_fingerprint.as_deref() != Some(fingerprint(&record.post_map()).as_str()) {
467 return Err(invalid(
468 id,
469 "the record's post_fingerprint is missing or is not the fingerprint of its touched set",
470 ));
471 }
472 Ok(())
473}
474
475fn read_local_json(
479 dir: &Path,
480 name: &str,
481 rel: &str,
482 id: &str,
483) -> Result<Option<Value>, CoreError> {
484 let path = dir.join(name);
485 let meta = match std::fs::symlink_metadata(&path) {
486 Ok(meta) => meta,
487 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
488 Err(_) => return Err(invalid(id, format!("{rel}/{name} cannot be inspected"))),
489 };
490 if !meta.file_type().is_file() {
491 return Err(invalid(
492 id,
493 format!("{rel}/{name} is not a regular file (a link is not followed)"),
494 ));
495 }
496 let mut bytes = Vec::new();
497 std::fs::File::open(&path)
498 .and_then(|file| file.take(LOCAL_FILE_MAX_BYTES + 1).read_to_end(&mut bytes))
499 .map_err(|_| invalid(id, format!("{rel}/{name} cannot be read")))?;
500 if bytes.len() as u64 > LOCAL_FILE_MAX_BYTES {
501 return Err(invalid(id, format!("{rel}/{name} is over the size cap")));
502 }
503 serde_json::from_slice(&bytes)
504 .map(Some)
505 .map_err(|_| invalid(id, format!("{rel}/{name} is not valid JSON")))
506}
507
508fn cross_check_local(repo: &Path, record: &RunRecord) -> Result<(), CoreError> {
512 let id = record.run_id.as_str();
513 let run_id = RunId::parse(id)?;
514 if component_problem(&record.gateway).is_some() || record.gateway.contains(':') {
515 return Err(invalid(
516 id,
517 "the record's gateway is not a plain directory name",
518 ));
519 }
520 let rel = run_dir_rel(&record.gateway, &run_id);
521 let dir = rel
522 .split('/')
523 .fold(repo.to_path_buf(), |acc, c| acc.join(c));
524 match std::fs::symlink_metadata(&dir) {
525 Ok(meta) if meta.file_type().is_dir() => {}
526 Ok(_) => {
527 return Err(invalid(
528 id,
529 format!("{rel} is not a plain directory (a link is not followed)"),
530 ));
531 }
532 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
533 Err(_) => return Err(invalid(id, format!("{rel} cannot be inspected"))),
534 }
535
536 let on_box: [(&str, &str); 4] = [
537 ("gateway", &record.gateway),
538 ("mode", &record.mode),
539 ("plan_sha256", &record.plan_sha256),
540 ("render_sha256", &record.render_sha256),
541 ];
542 let compare = |name: &str, with_run_id: bool| -> Result<(), CoreError> {
543 let Some(doc) = read_local_json(&dir, name, &rel, id)? else {
544 return Ok(());
545 };
546 if name == "run.json" && doc.get("kind").and_then(Value::as_str) == Some("rollback") {
547 return Err(invalid(
548 id,
549 format!("{rel}/{name} is the run directory of a rollback, not of a deployment"),
550 ));
551 }
552 let mut fields: Vec<(&str, &str)> = on_box.to_vec();
553 if with_run_id {
554 fields.insert(0, ("run_id", id));
555 }
556 for (field, expected) in fields {
557 if doc.get(field).and_then(Value::as_str) != Some(expected) {
558 return Err(invalid(
559 id,
560 format!("{rel}/{name} field `{field}` differs from the on-box record"),
561 ));
562 }
563 }
564 Ok(())
565 };
566 compare("run.json", true)?;
567 compare("plan.json", false)
568}
569
570pub async fn run_rollback(
577 ctx: &RollbackContext<'_>,
578 opts: &RollbackOptions,
579) -> Result<RollbackOutcome, CoreError> {
580 let checked = check_rollback(
581 ctx.env,
582 ctx.repo,
583 ctx.target,
584 ctx.reader,
585 ctx.host,
586 &opts.run_id,
587 opts.discard_live_changes,
588 )
589 .await?;
590 let (run_id, run_dir) = RunDir::create_fresh(ctx.repo, &ctx.target.gateway)?;
591 let mut steps = Steps::new(None, ctx.observer);
592 let result = drive(ctx, opts, &checked, &run_id, &run_dir, &mut steps).await;
593
594 if let Err(e) = &result {
596 let _ = steps.emit(ApplyEvent::Failed {
597 code: e.code().to_string(),
598 });
599 }
600 let doc = match &result {
601 Ok(outcome) => {
602 let mut doc = serde_json::to_value(outcome).unwrap_or(Value::Null);
603 doc["ok"] = Value::Bool(true);
604 doc["kind"] = Value::String("rollback".to_string());
605 doc
606 }
607 Err(e) => json!({
608 "ok": false,
609 "kind": "rollback",
610 "run_id": run_id.as_str(),
611 "target_run_id": opts.run_id.as_str(),
612 "error": {"code": e.code(), "message": e.to_string()},
613 }),
614 };
615 let written = run_dir.write_json("result.json", &doc);
616 match result {
617 Ok(mut outcome) => {
618 if written.is_err() {
619 outcome.warnings.push("result_json_unwritable".to_string());
620 }
621 Ok(outcome)
622 }
623 Err(e) => Err(e),
624 }
625}
626
627async fn drive(
628 ctx: &RollbackContext<'_>,
629 opts: &RollbackOptions,
630 checked: &RollbackChecked,
631 run_id: &RunId,
632 run_dir: &RunDir,
633 steps: &mut Steps<'_>,
634) -> Result<RollbackOutcome, CoreError> {
635 let target = ctx.target;
636
637 let started_at = rfc3339_now_utc();
638 steps.log = Some(EventLog::open(run_dir)?);
639 let (writes, deletes) = checked.record.as_ref().map(restore_set).unwrap_or_default();
642 steps.emit(ApplyEvent::RunStarted {
643 run_id: run_id.as_str().to_string(),
644 writes: writes.len(),
645 deletes: deletes.len(),
646 })?;
647 let interrupted = checked.interrupted.as_ref();
648 if let Some(found) = interrupted {
649 steps.emit(ApplyEvent::InterruptedRun {
651 phase: found.journal.phase.as_str(),
652 action: found.action.as_str(),
653 hashes: found.live.clone(),
654 })?;
655 if found.action == RecoveryAction::ClearWithoutRestore {
656 return clear_run(ctx, opts, checked, run_id, run_dir, steps, &started_at).await;
657 }
658 }
659
660 let record = match (interrupted, &checked.record) {
666 (Some(found), record) => {
667 snapshot_record(ctx, opts, run_dir, found, record.as_ref()).await?
668 }
669 (None, Some(record)) => record.clone(),
670 (None, None) => {
671 return Err(CoreError::Internal(
672 "a rollback of a finished run has no record".to_string(),
673 ));
674 }
675 };
676 let record = &record;
677 let (restored, deleted) = restore_set(record);
678
679 let touched_paths: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
680 let preflights = run_preflights(
681 &PreflightInput {
682 target,
683 declared: None,
684 master: ctx.deploy,
685 backup: ctx.backup,
686 gateway: ctx.gateway,
687 reader: ctx.reader,
688 host: ctx.host,
689 touched_files: budget_files(record),
690 touched_paths: &touched_paths,
691 deleted_paths: &[],
694 hold: &opts.hold,
695 designers: &opts.designers,
696 clock: ctx.clock,
697 },
698 steps,
699 )
700 .await;
701 let seen = preflights.as_ref().ok();
702 run_dir.write_json(
703 "run.json",
704 &json!({
705 "schema_version": 1,
706 "kind": "rollback",
707 "run_id": run_id.as_str(),
708 "target_run_id": opts.run_id.as_str(),
709 "env": ctx.env,
710 "target_file": ctx.target_file,
711 "gateway": target.gateway,
712 "mode": target.mode,
713 "node": "master",
714 "started_at": started_at,
715 "run_dir_git": run_dir.git_status().as_record(),
716 "render_sha256": record.render_sha256,
717 "plan_sha256": record.plan_sha256,
718 "hold_s": opts.hold.hold_s,
719 "discard_live_changes": opts.discard_live_changes,
720 "discarded_live_changes": checked.live_changed,
721 "interrupted_phase": interrupted.map(|i| i.journal.phase.as_str()),
722 "recovery": interrupted.map(|i| i.action.as_str()),
723 "designers": opts.designers.mode.as_str(),
724 "designers_timeout_s": opts.designers.timeout.as_secs(),
725 "designers_seen": seen.map(|r| r.designers_seen
726 .iter()
727 .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
728 .collect::<Vec<_>>()),
729 "preflights": seen.map(|r| r.findings
730 .iter()
731 .map(|f| json!({"check": f.check.as_str(), "status": f.status.as_str()}))
732 .collect::<Vec<_>>()),
733 }),
734 )?;
735 run_dir.write_json(
736 "plan.json",
737 &json!({
738 "schema_version": 1,
739 "kind": "rollback",
740 "target_run_id": opts.run_id.as_str(),
741 "gateway": target.gateway,
742 "mode": target.mode,
743 "plan_sha256": record.plan_sha256,
744 "render_sha256": record.render_sha256,
745 "restore": restored,
746 "delete": deleted,
747 "ledger": record
748 .touched
749 .iter()
750 .find(|e| e.path == LEDGER_KEY)
751 .map(|e| if e.pre_sha256.is_some() { "restore" } else { "delete" }),
752 }),
753 )?;
754 let preflights = preflights?;
755 if !preflights.passed() {
756 steps.emit(ApplyEvent::PreflightChecks {
757 checks: preflights
758 .findings
759 .iter()
760 .map(|f| (f.check.as_str().to_string(), f.status.as_str().to_string()))
761 .collect(),
762 })?;
763 return Err(CoreError::DeployPreflightFailed {
764 failures: preflights.failures().into(),
765 });
766 }
767 if !checked.live_changed.is_empty() {
768 steps.emit(ApplyEvent::LiveChangesDiscarded {
769 count: checked.live_changed.len(),
770 paths: checked
771 .live_changed
772 .iter()
773 .take(APPLY_LIST_SHOWN)
774 .cloned()
775 .collect(),
776 })?;
777 }
778
779 let restart_before = ctx.deploy.restart_pending().await.ok();
782
783 let lock = HeldLock::acquire(ctx.deploy, ScanKind::Config, opts.hold.hold_s, ctx.clock).await?;
787 steps.emit(ApplyEvent::LockAcquired {
788 hold_s: opts.hold.hold_s,
789 })?;
790
791 let mut live_changed = checked.live_changed.clone();
798 if interrupted.is_none() {
799 let now = live_changes(ctx.host, record).await?;
800 if now != live_changed {
801 if !opts.discard_live_changes {
802 let _ = ctx.deploy.scan_request(ScanKind::Config).await;
805 return Err(CoreError::DeployLiveChanged {
806 run_id: opts.run_id.as_str().to_string(),
807 paths: now.iter().take(APPLY_LIST_SHOWN).cloned().collect(),
808 count: now.len(),
809 });
810 }
811 steps.emit(ApplyEvent::LiveChangesDiscarded {
812 count: now.len(),
813 paths: now.iter().take(APPLY_LIST_SHOWN).cloned().collect(),
814 })?;
815 live_changed = now;
816 }
817 }
818 let report = restore_snapshot(
819 &RestoreContext {
820 env: ctx.env,
821 master: &target.nodes.master,
822 host: ctx.host,
823 deploy: ctx.deploy,
824 clock: ctx.clock,
825 hold: &opts.hold,
826 poll: &opts.poll,
827 },
828 &RestoreInput {
829 run_id,
830 run_dir,
831 record,
832 held: Some(&lock),
833 cause: None,
834 },
835 steps,
836 )
837 .await?;
838
839 let mut warnings = report.warnings;
840 let (restart_pending, restart_pending_new) =
841 restart_report(ctx.deploy, restart_before.as_deref(), &mut warnings).await;
842 Ok(RollbackOutcome {
843 kind: RollbackKind::Restored,
844 run_id: run_id.as_str().to_string(),
845 target_run_id: opts.run_id.as_str().to_string(),
846 run_dir: run_dir.path().to_string_lossy().into_owned(),
847 restored,
848 deleted,
849 discarded_live_changes: live_changed,
850 warnings,
851 restart_pending,
852 restart_pending_new,
853 snapshot_sha256: report.snapshot_sha256,
854 })
855}
856
857const UNKNOWN_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000";
860
861fn snapshot_key(path: &str) -> String {
864 if path == LEDGER_KEY {
865 LEDGER_KEY.to_string()
866 } else {
867 format!("files/{path}")
868 }
869}
870
871fn named(paths: &[String]) -> String {
873 let shown = paths.len().min(APPLY_LIST_SHOWN);
874 let mut out = paths[..shown].join(", ");
875 if paths.len() > shown {
876 out.push_str(&format!(" and {} more", paths.len() - shown));
877 }
878 out
879}
880
881async fn snapshot_record(
892 ctx: &RollbackContext<'_>,
893 opts: &RollbackOptions,
894 run_dir: &RunDir,
895 found: &InterruptedRun,
896 record: Option<&RunRecord>,
897) -> Result<RunRecord, CoreError> {
898 let target = ctx.target;
899 let (gateway, mode) = (target.gateway.as_str(), target.mode.as_str());
900 let fail = |cause: String| {
901 CoreError::RollbackFailed(Box::new(RollbackFailure {
902 run_id: opts.run_id.as_str().to_string(),
903 env: ctx.env.to_string(),
904 run_dir: Some(run_dir.path().to_string_lossy().into_owned()),
905 journal: journal_location(LedgerRoot::for_node(&target.nodes.master), gateway, mode),
906 cause,
907 }))
908 };
909
910 let scratch = run_dir.path().join("inspect");
911 std::fs::create_dir(&scratch)
912 .map_err(|e| fail(format!("cannot read the snapshot ({:?})", e.kind())))?;
913 let fetched = ctx.host.fetch_snapshot(&opts.run_id, &scratch).await;
914 let _ = std::fs::remove_dir_all(&scratch);
917 let hashes = fetched.map_err(|e| fail(format!("the snapshot cannot be read: {e}")))?;
918
919 if let Some(record) = record {
920 let bad: Vec<String> = record
921 .touched
922 .iter()
923 .filter_map(|entry| {
924 let pre = entry.pre_sha256.as_deref()?;
925 (hashes.get(&snapshot_key(&entry.path)).map(String::as_str) != Some(pre))
926 .then(|| entry.path.clone())
927 })
928 .collect();
929 if !bad.is_empty() {
930 return Err(fail(format!(
931 "the snapshot is missing or damaged for {} path(s): {}",
932 bad.len(),
933 named(&bad)
934 )));
935 }
936 return Ok(record.clone());
937 }
938
939 let pre: BTreeMap<String, Option<String>> = found
940 .journal
941 .touched
942 .iter()
943 .map(|path| (path.clone(), hashes.get(&snapshot_key(path)).cloned()))
944 .collect();
945 if fingerprint(&pre) != found.journal.pre_fingerprint {
946 return Err(fail(
947 "the run's record is missing and the snapshot is incomplete: its files do not \
948 match the journal's pre-run fingerprint"
949 .to_string(),
950 ));
951 }
952 Ok(RunRecord {
953 schema_version: 1,
954 run_id: found.journal.run_id.clone(),
955 gateway: found.journal.gateway.clone(),
956 mode: found.journal.mode.clone(),
957 render_sha256: UNKNOWN_SHA256.to_string(),
958 plan_sha256: UNKNOWN_SHA256.to_string(),
959 state: RecordState::Applying,
960 rolled_back_by: None,
961 touched: found
962 .journal
963 .touched
964 .iter()
965 .map(|path| TouchedEntry {
966 path: path.clone(),
967 pre_sha256: pre.get(path).cloned().flatten(),
968 post_sha256: None,
969 })
970 .collect(),
971 pre_fingerprint: found.journal.pre_fingerprint.clone(),
972 post_fingerprint: None,
973 })
974}
975
976async fn clear_run(
980 ctx: &RollbackContext<'_>,
981 opts: &RollbackOptions,
982 checked: &RollbackChecked,
983 run_id: &RunId,
984 run_dir: &RunDir,
985 steps: &mut Steps<'_>,
986 started_at: &str,
987) -> Result<RollbackOutcome, CoreError> {
988 let target = ctx.target;
989 let phase = checked
990 .interrupted
991 .as_ref()
992 .map(|i| i.journal.phase.as_str());
993 run_dir.write_json(
994 "run.json",
995 &json!({
996 "schema_version": 1,
997 "kind": "rollback",
998 "run_id": run_id.as_str(),
999 "target_run_id": opts.run_id.as_str(),
1000 "env": ctx.env,
1001 "target_file": ctx.target_file,
1002 "gateway": target.gateway,
1003 "mode": target.mode,
1004 "node": "master",
1005 "started_at": started_at,
1006 "run_dir_git": run_dir.git_status().as_record(),
1007 "discard_live_changes": opts.discard_live_changes,
1008 "discarded_live_changes": checked.live_changed,
1009 "interrupted_phase": phase,
1010 "recovery": RecoveryAction::ClearWithoutRestore.as_str(),
1011 }),
1012 )?;
1013 run_dir.write_json(
1014 "plan.json",
1015 &json!({
1016 "schema_version": 1,
1017 "kind": "rollback",
1018 "target_run_id": opts.run_id.as_str(),
1019 "gateway": target.gateway,
1020 "mode": target.mode,
1021 "restore": Vec::<String>::new(),
1022 "delete": Vec::<String>::new(),
1023 "ledger": Value::Null,
1024 "recovery": RecoveryAction::ClearWithoutRestore.as_str(),
1025 }),
1026 )?;
1027 ctx.host.discard_interrupted(&opts.run_id).await?;
1028 steps.emit(ApplyEvent::Cleared)?;
1029 Ok(RollbackOutcome {
1030 kind: RollbackKind::Cleared,
1031 run_id: run_id.as_str().to_string(),
1032 target_run_id: opts.run_id.as_str().to_string(),
1033 run_dir: run_dir.path().to_string_lossy().into_owned(),
1034 restored: Vec::new(),
1035 deleted: Vec::new(),
1036 discarded_live_changes: Vec::new(),
1037 warnings: Vec::new(),
1038 restart_pending: Vec::new(),
1039 restart_pending_new: Vec::new(),
1040 snapshot_sha256: BTreeMap::new(),
1041 })
1042}
1043
1044#[cfg(test)]
1045mod tests {
1046 use super::*;
1047 use crate::deploy::apply::preflight::{FindingStatus, hold_budget_finding};
1048
1049 fn record_of(files: usize) -> RunRecord {
1051 let mut touched: Vec<TouchedEntry> = (0..files)
1052 .map(|i| TouchedEntry {
1053 path: format!("u{i}/config.json"),
1054 pre_sha256: Some("a".repeat(64)),
1055 post_sha256: Some("b".repeat(64)),
1056 })
1057 .collect();
1058 touched.push(TouchedEntry {
1059 path: LEDGER_KEY.to_string(),
1060 pre_sha256: Some("c".repeat(64)),
1061 post_sha256: Some("d".repeat(64)),
1062 });
1063 RunRecord {
1064 schema_version: 1,
1065 run_id: "20261016T000000Z-aaaaaa".to_string(),
1066 gateway: "gw".to_string(),
1067 mode: "dev".to_string(),
1068 render_sha256: "e".repeat(64),
1069 plan_sha256: "f".repeat(64),
1070 state: RecordState::Applied,
1071 rolled_back_by: None,
1072 touched,
1073 pre_fingerprint: "0".repeat(64),
1074 post_fingerprint: None,
1075 }
1076 }
1077
1078 fn rollback_budget(files: usize) -> FindingStatus {
1080 let record = record_of(files);
1081 hold_budget_finding(&HoldModel::MEASURED, budget_files(&record)).status
1082 }
1083
1084 #[test]
1085 fn rollback_budget_counts_what_the_apply_counted() {
1086 assert_eq!(record_of(1527).touched.len(), 1528);
1090 assert_eq!(budget_files(&record_of(1527)), 1527);
1091 assert_eq!(rollback_budget(1527), FindingStatus::Pass);
1092 assert_eq!(budget_files(&record_of(1528)), 1528);
1094 assert_eq!(rollback_budget(1528), FindingStatus::Fail);
1095 }
1096
1097 fn target(pair: bool) -> Target {
1098 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();
1099 if pair {
1100 yaml.push_str(" backup:\n profile: b\n local:\n data_dir: /data/b\n");
1101 }
1102 Target::parse("targets/dev.yaml", yaml.as_bytes()).expect("target parses")
1103 }
1104
1105 fn touched(path: &str, pre: bool, post: bool) -> TouchedEntry {
1106 TouchedEntry {
1107 path: path.to_string(),
1108 pre_sha256: pre.then(|| "a".repeat(64)),
1109 post_sha256: post.then(|| "b".repeat(64)),
1110 }
1111 }
1112
1113 #[test]
1119 fn pair_rollback_refuses_restoring_tag_files_into_the_mode_collection() {
1120 let mut record = record_of(1);
1121 record.touched = vec![
1122 touched(
1123 "ignition/tag-definition/default/Deploy/tags.json",
1124 true,
1125 false,
1126 ),
1127 touched(
1128 "ignition/tag-definition/default/Deploy/unary-resource.json",
1129 true,
1130 false,
1131 ),
1132 touched(
1133 "ignition/tag-type-definition/default/T/udts.json",
1134 true,
1135 true,
1136 ),
1137 touched("ignition/tag-group/default/Fast/config.json", true, true),
1138 touched("ignition/tag-definition/default/New/tags.json", false, true),
1139 touched(LEDGER_KEY, true, true),
1140 ];
1141 let err = pair_tags_restore_refusal(&record, &target(true)).expect("refused");
1142 assert_eq!(err.code(), "deploy_pair_tags_refused");
1143 match err {
1144 CoreError::DeployPairTagsRefused { mode, keys } => {
1145 assert_eq!(mode, "dev");
1146 assert_eq!(
1147 keys,
1148 vec![
1149 "ignition/tag-definition/default/Deploy".to_string(),
1150 "ignition/tag-type-definition/default/T".to_string(),
1151 ]
1152 );
1153 }
1154 other => panic!("expected DeployPairTagsRefused, got {other:?}"),
1155 }
1156 assert!(pair_tags_restore_refusal(&record, &target(false)).is_none());
1157
1158 record.touched = vec![
1160 touched("ignition/tag-definition/default/New/tags.json", false, true),
1161 touched(
1162 "ignition/tag-definition/default/New/unary-resource.json",
1163 false,
1164 true,
1165 ),
1166 touched(
1167 "ignition/database-translator/POSTGRES/config.json",
1168 true,
1169 true,
1170 ),
1171 touched(LEDGER_KEY, false, true),
1172 ];
1173 assert!(pair_tags_restore_refusal(&record, &target(true)).is_none());
1174 }
1175}