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> {
324 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
347async 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
360async 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
372async 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
419fn 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
440fn 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
451fn 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
481fn 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
514fn 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
576pub 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 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 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 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 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 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 let restart_before = ctx.deploy.restart_pending().await.ok();
788
789 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 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 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
863const UNKNOWN_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000";
866
867fn 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
878fn 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
888async 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 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
983async 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 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 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 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 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 #[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 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 #[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 #[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}