1use std::collections::BTreeMap;
54use std::io::Read as _;
55use std::path::Path;
56
57use serde::Serialize;
58use serde_json::{Value, json};
59
60use super::fingerprint::{LEDGER_KEY, fingerprint};
61use super::hold::{Clock, HeldLock, HoldModel};
62use super::host::ApplyHost;
63use super::journal::{
64 Journal, RecoveryAction, journal_location, journal_on_box, journal_present, recovery_action,
65};
66use super::preflight::{DesignersPolicy, PreflightInput, run_preflights};
67use super::record::{RECORD_FILE, RecordState, RunRecord, TouchedEntry};
68use super::run_store::{EventLog, RunDir, RunId, run_dir_rel};
69use super::{
70 ApplyEvent, ApplyObserver, RestoreContext, RestoreInput, Steps, restart_report,
71 restore_snapshot,
72};
73use crate::actions::workspace::rfc3339_now_utc;
74use crate::client::scan::ScanKind;
75use crate::client::{DeployApi, GatewayApi};
76use crate::deploy::component_problem;
77use crate::deploy::ledger::LedgerRoot;
78use crate::deploy::pair::BackupNode;
79use crate::deploy::target::Target;
80use crate::deploy::transport::TreeReader;
81use crate::error::{APPLY_LIST_SHOWN, CoreError, RollbackFailure};
82use crate::poll::PollConfig;
83
84const LOCAL_FILE_MAX_BYTES: u64 = 8_388_608;
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
89#[serde(rename_all = "snake_case")]
90pub enum RollbackKind {
91 Restored,
93 Cleared,
97}
98
99impl RollbackKind {
100 pub fn as_str(self) -> &'static str {
102 match self {
103 RollbackKind::Restored => "restored",
104 RollbackKind::Cleared => "cleared",
105 }
106 }
107}
108
109pub struct RollbackContext<'a> {
112 pub env: &'a str,
114 pub repo: &'a Path,
116 pub target_file: &'a str,
118 pub target: &'a Target,
120 pub reader: &'a dyn TreeReader,
122 pub host: &'a dyn ApplyHost,
124 pub deploy: &'a dyn DeployApi,
126 pub gateway: &'a dyn GatewayApi,
128 pub backup: Option<&'a BackupNode<'a>>,
130 pub clock: &'a dyn Clock,
132 pub observer: &'a dyn ApplyObserver,
134}
135
136#[derive(Debug, Clone)]
138pub struct RollbackOptions {
139 pub run_id: RunId,
142 pub discard_live_changes: bool,
144 pub designers: DesignersPolicy,
146 pub hold: HoldModel,
148 pub poll: PollConfig,
150}
151
152#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct RollbackChecked {
156 pub record: Option<RunRecord>,
160 pub live_changed: Vec<String>,
163 pub interrupted: Option<InterruptedRun>,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct InterruptedRun {
171 pub journal: Journal,
173 pub live: BTreeMap<String, Option<String>>,
177 pub action: RecoveryAction,
179}
180
181#[derive(Debug, Clone, PartialEq, Serialize)]
183pub struct RollbackOutcome {
184 #[serde(rename = "outcome")]
186 pub kind: RollbackKind,
187 pub run_id: String,
189 pub target_run_id: String,
191 pub run_dir: String,
193 pub restored: Vec<String>,
195 pub deleted: Vec<String>,
197 pub discarded_live_changes: Vec<String>,
199 pub warnings: Vec<String>,
201 pub restart_pending: Vec<String>,
203 pub restart_pending_new: Vec<String>,
205 pub snapshot_sha256: BTreeMap<String, String>,
207}
208
209fn budget_files(record: &RunRecord) -> usize {
214 let (restored, deleted) = restore_set(record);
215 restored.len() + deleted.len()
216}
217
218pub fn restore_set(record: &RunRecord) -> (Vec<String>, Vec<String>) {
221 let mut restored = Vec::new();
222 let mut deleted = Vec::new();
223 for entry in &record.touched {
224 if entry.path == LEDGER_KEY {
225 continue;
226 }
227 if entry.pre_sha256.is_some() {
228 restored.push(entry.path.clone());
229 } else {
230 deleted.push(entry.path.clone());
231 }
232 }
233 (restored, deleted)
234}
235
236fn invalid(run_id: &str, problem: impl Into<String>) -> CoreError {
237 CoreError::DeployRunInvalid {
238 run_id: run_id.to_string(),
239 problem: problem.into(),
240 }
241}
242
243pub async fn check_rollback(
248 env: &str,
249 repo: &Path,
250 target: &Target,
251 reader: &dyn TreeReader,
252 host: &dyn ApplyHost,
253 run_id: &RunId,
254 discard_live_changes: bool,
255) -> Result<RollbackChecked, CoreError> {
256 let (gateway, mode) = (target.gateway.as_str(), target.mode.as_str());
257 let id = run_id.as_str();
258
259 let master = &target.nodes.master;
262 if let Some(journal) = journal_on_box(reader, master, gateway, mode).await? {
263 if journal.run_id != id {
264 return Err(journal_present(&journal, env, master, gateway, mode));
265 }
266 return check_interrupted(repo, target, host, run_id, journal).await;
267 }
268
269 let Some(record) = read_run_record(host, run_id).await? else {
271 return Err(invalid(
272 id,
273 format!("the host has no {RECORD_FILE} for this run (snapshots/{id}/{RECORD_FILE})"),
274 ));
275 };
276 validate_record(&record, target, run_id)?;
277
278 cross_check_local(repo, &record)?;
280
281 let live_changed = live_changes(host, &record).await?;
283 if !live_changed.is_empty() && !discard_live_changes {
284 return Err(CoreError::DeployLiveChanged {
285 run_id: id.to_string(),
286 paths: live_changed
287 .iter()
288 .take(APPLY_LIST_SHOWN)
289 .cloned()
290 .collect(),
291 count: live_changed.len(),
292 });
293 }
294 Ok(RollbackChecked {
295 record: Some(record),
296 live_changed,
297 interrupted: None,
298 })
299}
300
301async fn live_changes(host: &dyn ApplyHost, record: &RunRecord) -> Result<Vec<String>, CoreError> {
304 let touched: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
305 let live = host.fingerprint(&touched).await?;
306 Ok(record
307 .post_map()
308 .into_iter()
309 .filter(|(path, hash)| live.get(path) != Some(hash))
310 .map(|(path, _)| path)
311 .collect())
312}
313
314async fn read_run_record(
317 host: &dyn ApplyHost,
318 run_id: &RunId,
319) -> Result<Option<RunRecord>, CoreError> {
320 host.read_record(run_id).await.map_err(|e| match e {
321 CoreError::DeployRunInvalid { problem, .. } => invalid(run_id.as_str(), problem),
322 other => other,
323 })
324}
325
326async fn check_interrupted(
334 repo: &Path,
335 target: &Target,
336 host: &dyn ApplyHost,
337 run_id: &RunId,
338 journal: Journal,
339) -> Result<RollbackChecked, CoreError> {
340 let id = run_id.as_str();
341 let record = read_run_record(host, run_id).await?;
342 if let Some(record) = &record {
343 validate_identity(record, target, run_id)?;
344 validate_pre(record)?;
345 if journal.pre_fingerprint != record.pre_fingerprint
346 || journal.touched
347 != record
348 .touched
349 .iter()
350 .map(|e| e.path.clone())
351 .collect::<Vec<_>>()
352 {
353 return Err(invalid(
354 id,
355 "the journal and the record do not describe the same run",
356 ));
357 }
358 cross_check_local(repo, record)?;
359 }
360 let live = host.fingerprint(&journal.touched).await?;
361 let action = recovery_action(&journal, &live);
362 Ok(RollbackChecked {
363 record,
364 live_changed: Vec::new(),
365 interrupted: Some(InterruptedRun {
366 journal,
367 live,
368 action,
369 }),
370 })
371}
372
373fn validate_identity(record: &RunRecord, target: &Target, run_id: &RunId) -> Result<(), CoreError> {
375 let id = run_id.as_str();
376 if record.run_id != id {
377 return Err(invalid(id, "the record names a different run"));
378 }
379 if record.gateway != target.gateway {
380 return Err(invalid(
381 id,
382 "the record is for another gateway than the target's",
383 ));
384 }
385 if record.mode != target.mode {
386 return Err(invalid(
387 id,
388 "the record is for another mode than the target's",
389 ));
390 }
391 Ok(())
392}
393
394fn validate_pre(record: &RunRecord) -> Result<(), CoreError> {
396 if record.pre_fingerprint != fingerprint(&record.pre_map()) {
397 return Err(invalid(
398 &record.run_id,
399 "the record's pre_fingerprint is not the fingerprint of its touched set",
400 ));
401 }
402 Ok(())
403}
404
405fn validate_record(record: &RunRecord, target: &Target, run_id: &RunId) -> Result<(), CoreError> {
408 let id = run_id.as_str();
409 validate_identity(record, target, run_id)?;
410 match record.state {
411 RecordState::Applied => {}
412 RecordState::Applying => {
413 return Err(invalid(
414 id,
415 "the run did not finish and no journal is on the box, so there is nothing \
416 consistent to restore from; restore from the .gwbk backup or the \
417 run-directory snapshot",
418 ));
419 }
420 RecordState::RolledBack => {
421 let by = record.rolled_back_by.as_deref().unwrap_or("an unknown run");
422 return Err(invalid(id, format!("already rolled back by {by}")));
423 }
424 }
425 validate_pre(record)?;
426 if record.post_fingerprint.as_deref() != Some(fingerprint(&record.post_map()).as_str()) {
427 return Err(invalid(
428 id,
429 "the record's post_fingerprint is missing or is not the fingerprint of its touched set",
430 ));
431 }
432 Ok(())
433}
434
435fn read_local_json(
439 dir: &Path,
440 name: &str,
441 rel: &str,
442 id: &str,
443) -> Result<Option<Value>, CoreError> {
444 let path = dir.join(name);
445 let meta = match std::fs::symlink_metadata(&path) {
446 Ok(meta) => meta,
447 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
448 Err(_) => return Err(invalid(id, format!("{rel}/{name} cannot be inspected"))),
449 };
450 if !meta.file_type().is_file() {
451 return Err(invalid(
452 id,
453 format!("{rel}/{name} is not a regular file (a link is not followed)"),
454 ));
455 }
456 let mut bytes = Vec::new();
457 std::fs::File::open(&path)
458 .and_then(|file| file.take(LOCAL_FILE_MAX_BYTES + 1).read_to_end(&mut bytes))
459 .map_err(|_| invalid(id, format!("{rel}/{name} cannot be read")))?;
460 if bytes.len() as u64 > LOCAL_FILE_MAX_BYTES {
461 return Err(invalid(id, format!("{rel}/{name} is over the size cap")));
462 }
463 serde_json::from_slice(&bytes)
464 .map(Some)
465 .map_err(|_| invalid(id, format!("{rel}/{name} is not valid JSON")))
466}
467
468fn cross_check_local(repo: &Path, record: &RunRecord) -> Result<(), CoreError> {
472 let id = record.run_id.as_str();
473 let run_id = RunId::parse(id)?;
474 if component_problem(&record.gateway).is_some() || record.gateway.contains(':') {
475 return Err(invalid(
476 id,
477 "the record's gateway is not a plain directory name",
478 ));
479 }
480 let rel = run_dir_rel(&record.gateway, &run_id);
481 let dir = rel
482 .split('/')
483 .fold(repo.to_path_buf(), |acc, c| acc.join(c));
484 match std::fs::symlink_metadata(&dir) {
485 Ok(meta) if meta.file_type().is_dir() => {}
486 Ok(_) => {
487 return Err(invalid(
488 id,
489 format!("{rel} is not a plain directory (a link is not followed)"),
490 ));
491 }
492 Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(()),
493 Err(_) => return Err(invalid(id, format!("{rel} cannot be inspected"))),
494 }
495
496 let on_box: [(&str, &str); 4] = [
497 ("gateway", &record.gateway),
498 ("mode", &record.mode),
499 ("plan_sha256", &record.plan_sha256),
500 ("render_sha256", &record.render_sha256),
501 ];
502 let compare = |name: &str, with_run_id: bool| -> Result<(), CoreError> {
503 let Some(doc) = read_local_json(&dir, name, &rel, id)? else {
504 return Ok(());
505 };
506 if name == "run.json" && doc.get("kind").and_then(Value::as_str) == Some("rollback") {
507 return Err(invalid(
508 id,
509 format!("{rel}/{name} is the run directory of a rollback, not of a deployment"),
510 ));
511 }
512 let mut fields: Vec<(&str, &str)> = on_box.to_vec();
513 if with_run_id {
514 fields.insert(0, ("run_id", id));
515 }
516 for (field, expected) in fields {
517 if doc.get(field).and_then(Value::as_str) != Some(expected) {
518 return Err(invalid(
519 id,
520 format!("{rel}/{name} field `{field}` differs from the on-box record"),
521 ));
522 }
523 }
524 Ok(())
525 };
526 compare("run.json", true)?;
527 compare("plan.json", false)
528}
529
530pub async fn run_rollback(
537 ctx: &RollbackContext<'_>,
538 opts: &RollbackOptions,
539) -> Result<RollbackOutcome, CoreError> {
540 let checked = check_rollback(
541 ctx.env,
542 ctx.repo,
543 ctx.target,
544 ctx.reader,
545 ctx.host,
546 &opts.run_id,
547 opts.discard_live_changes,
548 )
549 .await?;
550 let (run_id, run_dir) = RunDir::create_fresh(ctx.repo, &ctx.target.gateway)?;
551 let mut steps = Steps::new(None, ctx.observer);
552 let result = drive(ctx, opts, &checked, &run_id, &run_dir, &mut steps).await;
553
554 if let Err(e) = &result {
556 let _ = steps.emit(ApplyEvent::Failed {
557 code: e.code().to_string(),
558 });
559 }
560 let doc = match &result {
561 Ok(outcome) => {
562 let mut doc = serde_json::to_value(outcome).unwrap_or(Value::Null);
563 doc["ok"] = Value::Bool(true);
564 doc["kind"] = Value::String("rollback".to_string());
565 doc
566 }
567 Err(e) => json!({
568 "ok": false,
569 "kind": "rollback",
570 "run_id": run_id.as_str(),
571 "target_run_id": opts.run_id.as_str(),
572 "error": {"code": e.code(), "message": e.to_string()},
573 }),
574 };
575 let written = run_dir.write_json("result.json", &doc);
576 match result {
577 Ok(mut outcome) => {
578 if written.is_err() {
579 outcome.warnings.push("result_json_unwritable".to_string());
580 }
581 Ok(outcome)
582 }
583 Err(e) => Err(e),
584 }
585}
586
587async fn drive(
588 ctx: &RollbackContext<'_>,
589 opts: &RollbackOptions,
590 checked: &RollbackChecked,
591 run_id: &RunId,
592 run_dir: &RunDir,
593 steps: &mut Steps<'_>,
594) -> Result<RollbackOutcome, CoreError> {
595 let target = ctx.target;
596
597 let started_at = rfc3339_now_utc();
598 steps.log = Some(EventLog::open(run_dir)?);
599 let (writes, deletes) = checked.record.as_ref().map(restore_set).unwrap_or_default();
602 steps.emit(ApplyEvent::RunStarted {
603 run_id: run_id.as_str().to_string(),
604 writes: writes.len(),
605 deletes: deletes.len(),
606 })?;
607 let interrupted = checked.interrupted.as_ref();
608 if let Some(found) = interrupted {
609 steps.emit(ApplyEvent::InterruptedRun {
611 phase: found.journal.phase.as_str(),
612 action: found.action.as_str(),
613 hashes: found.live.clone(),
614 })?;
615 if found.action == RecoveryAction::ClearWithoutRestore {
616 return clear_run(ctx, opts, checked, run_id, run_dir, steps, &started_at).await;
617 }
618 }
619
620 let record = match (interrupted, &checked.record) {
626 (Some(found), record) => {
627 snapshot_record(ctx, opts, run_dir, found, record.as_ref()).await?
628 }
629 (None, Some(record)) => record.clone(),
630 (None, None) => {
631 return Err(CoreError::Internal(
632 "a rollback of a finished run has no record".to_string(),
633 ));
634 }
635 };
636 let record = &record;
637 let (restored, deleted) = restore_set(record);
638
639 let touched_paths: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
640 let preflights = run_preflights(
641 &PreflightInput {
642 target,
643 declared: None,
644 master: ctx.deploy,
645 backup: ctx.backup,
646 gateway: ctx.gateway,
647 reader: ctx.reader,
648 host: ctx.host,
649 touched_files: budget_files(record),
650 touched_paths: &touched_paths,
651 deleted_paths: &[],
654 hold: &opts.hold,
655 designers: &opts.designers,
656 clock: ctx.clock,
657 },
658 steps,
659 )
660 .await;
661 let seen = preflights.as_ref().ok();
662 run_dir.write_json(
663 "run.json",
664 &json!({
665 "schema_version": 1,
666 "kind": "rollback",
667 "run_id": run_id.as_str(),
668 "target_run_id": opts.run_id.as_str(),
669 "env": ctx.env,
670 "target_file": ctx.target_file,
671 "gateway": target.gateway,
672 "mode": target.mode,
673 "node": "master",
674 "started_at": started_at,
675 "run_dir_git": run_dir.git_status().as_record(),
676 "render_sha256": record.render_sha256,
677 "plan_sha256": record.plan_sha256,
678 "hold_s": opts.hold.hold_s,
679 "discard_live_changes": opts.discard_live_changes,
680 "discarded_live_changes": checked.live_changed,
681 "interrupted_phase": interrupted.map(|i| i.journal.phase.as_str()),
682 "recovery": interrupted.map(|i| i.action.as_str()),
683 "designers": opts.designers.mode.as_str(),
684 "designers_timeout_s": opts.designers.timeout.as_secs(),
685 "designers_seen": seen.map(|r| r.designers_seen
686 .iter()
687 .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
688 .collect::<Vec<_>>()),
689 "preflights": seen.map(|r| r.findings
690 .iter()
691 .map(|f| json!({"check": f.check.as_str(), "status": f.status.as_str()}))
692 .collect::<Vec<_>>()),
693 }),
694 )?;
695 run_dir.write_json(
696 "plan.json",
697 &json!({
698 "schema_version": 1,
699 "kind": "rollback",
700 "target_run_id": opts.run_id.as_str(),
701 "gateway": target.gateway,
702 "mode": target.mode,
703 "plan_sha256": record.plan_sha256,
704 "render_sha256": record.render_sha256,
705 "restore": restored,
706 "delete": deleted,
707 "ledger": record
708 .touched
709 .iter()
710 .find(|e| e.path == LEDGER_KEY)
711 .map(|e| if e.pre_sha256.is_some() { "restore" } else { "delete" }),
712 }),
713 )?;
714 let preflights = preflights?;
715 if !preflights.passed() {
716 steps.emit(ApplyEvent::PreflightChecks {
717 checks: preflights
718 .findings
719 .iter()
720 .map(|f| (f.check.as_str().to_string(), f.status.as_str().to_string()))
721 .collect(),
722 })?;
723 return Err(CoreError::DeployPreflightFailed {
724 failures: preflights.failures().into(),
725 });
726 }
727 if !checked.live_changed.is_empty() {
728 steps.emit(ApplyEvent::LiveChangesDiscarded {
729 count: checked.live_changed.len(),
730 paths: checked
731 .live_changed
732 .iter()
733 .take(APPLY_LIST_SHOWN)
734 .cloned()
735 .collect(),
736 })?;
737 }
738
739 let restart_before = ctx.deploy.restart_pending().await.ok();
742
743 let lock = HeldLock::acquire(ctx.deploy, ScanKind::Config, opts.hold.hold_s, ctx.clock).await?;
747 steps.emit(ApplyEvent::LockAcquired {
748 hold_s: opts.hold.hold_s,
749 })?;
750
751 let mut live_changed = checked.live_changed.clone();
758 if interrupted.is_none() {
759 let now = live_changes(ctx.host, record).await?;
760 if now != live_changed {
761 if !opts.discard_live_changes {
762 let _ = ctx.deploy.scan_request(ScanKind::Config).await;
765 return Err(CoreError::DeployLiveChanged {
766 run_id: opts.run_id.as_str().to_string(),
767 paths: now.iter().take(APPLY_LIST_SHOWN).cloned().collect(),
768 count: now.len(),
769 });
770 }
771 steps.emit(ApplyEvent::LiveChangesDiscarded {
772 count: now.len(),
773 paths: now.iter().take(APPLY_LIST_SHOWN).cloned().collect(),
774 })?;
775 live_changed = now;
776 }
777 }
778 let report = restore_snapshot(
779 &RestoreContext {
780 env: ctx.env,
781 master: &target.nodes.master,
782 host: ctx.host,
783 deploy: ctx.deploy,
784 clock: ctx.clock,
785 hold: &opts.hold,
786 poll: &opts.poll,
787 },
788 &RestoreInput {
789 run_id,
790 run_dir,
791 record,
792 held: Some(&lock),
793 cause: None,
794 },
795 steps,
796 )
797 .await?;
798
799 let mut warnings = report.warnings;
800 let (restart_pending, restart_pending_new) =
801 restart_report(ctx.deploy, restart_before.as_deref(), &mut warnings).await;
802 Ok(RollbackOutcome {
803 kind: RollbackKind::Restored,
804 run_id: run_id.as_str().to_string(),
805 target_run_id: opts.run_id.as_str().to_string(),
806 run_dir: run_dir.path().to_string_lossy().into_owned(),
807 restored,
808 deleted,
809 discarded_live_changes: live_changed,
810 warnings,
811 restart_pending,
812 restart_pending_new,
813 snapshot_sha256: report.snapshot_sha256,
814 })
815}
816
817const UNKNOWN_SHA256: &str = "0000000000000000000000000000000000000000000000000000000000000000";
820
821fn snapshot_key(path: &str) -> String {
824 if path == LEDGER_KEY {
825 LEDGER_KEY.to_string()
826 } else {
827 format!("files/{path}")
828 }
829}
830
831fn named(paths: &[String]) -> String {
833 let shown = paths.len().min(APPLY_LIST_SHOWN);
834 let mut out = paths[..shown].join(", ");
835 if paths.len() > shown {
836 out.push_str(&format!(" and {} more", paths.len() - shown));
837 }
838 out
839}
840
841async fn snapshot_record(
852 ctx: &RollbackContext<'_>,
853 opts: &RollbackOptions,
854 run_dir: &RunDir,
855 found: &InterruptedRun,
856 record: Option<&RunRecord>,
857) -> Result<RunRecord, CoreError> {
858 let target = ctx.target;
859 let (gateway, mode) = (target.gateway.as_str(), target.mode.as_str());
860 let fail = |cause: String| {
861 CoreError::RollbackFailed(Box::new(RollbackFailure {
862 run_id: opts.run_id.as_str().to_string(),
863 env: ctx.env.to_string(),
864 run_dir: Some(run_dir.path().to_string_lossy().into_owned()),
865 journal: journal_location(LedgerRoot::for_node(&target.nodes.master), gateway, mode),
866 cause,
867 }))
868 };
869
870 let scratch = run_dir.path().join("inspect");
871 std::fs::create_dir(&scratch)
872 .map_err(|e| fail(format!("cannot read the snapshot ({:?})", e.kind())))?;
873 let fetched = ctx.host.fetch_snapshot(&opts.run_id, &scratch).await;
874 let _ = std::fs::remove_dir_all(&scratch);
877 let hashes = fetched.map_err(|e| fail(format!("the snapshot cannot be read: {e}")))?;
878
879 if let Some(record) = record {
880 let bad: Vec<String> = record
881 .touched
882 .iter()
883 .filter_map(|entry| {
884 let pre = entry.pre_sha256.as_deref()?;
885 (hashes.get(&snapshot_key(&entry.path)).map(String::as_str) != Some(pre))
886 .then(|| entry.path.clone())
887 })
888 .collect();
889 if !bad.is_empty() {
890 return Err(fail(format!(
891 "the snapshot is missing or damaged for {} path(s): {}",
892 bad.len(),
893 named(&bad)
894 )));
895 }
896 return Ok(record.clone());
897 }
898
899 let pre: BTreeMap<String, Option<String>> = found
900 .journal
901 .touched
902 .iter()
903 .map(|path| (path.clone(), hashes.get(&snapshot_key(path)).cloned()))
904 .collect();
905 if fingerprint(&pre) != found.journal.pre_fingerprint {
906 return Err(fail(
907 "the run's record is missing and the snapshot is incomplete: its files do not \
908 match the journal's pre-run fingerprint"
909 .to_string(),
910 ));
911 }
912 Ok(RunRecord {
913 schema_version: 1,
914 run_id: found.journal.run_id.clone(),
915 gateway: found.journal.gateway.clone(),
916 mode: found.journal.mode.clone(),
917 render_sha256: UNKNOWN_SHA256.to_string(),
918 plan_sha256: UNKNOWN_SHA256.to_string(),
919 state: RecordState::Applying,
920 rolled_back_by: None,
921 touched: found
922 .journal
923 .touched
924 .iter()
925 .map(|path| TouchedEntry {
926 path: path.clone(),
927 pre_sha256: pre.get(path).cloned().flatten(),
928 post_sha256: None,
929 })
930 .collect(),
931 pre_fingerprint: found.journal.pre_fingerprint.clone(),
932 post_fingerprint: None,
933 })
934}
935
936async fn clear_run(
940 ctx: &RollbackContext<'_>,
941 opts: &RollbackOptions,
942 checked: &RollbackChecked,
943 run_id: &RunId,
944 run_dir: &RunDir,
945 steps: &mut Steps<'_>,
946 started_at: &str,
947) -> Result<RollbackOutcome, CoreError> {
948 let target = ctx.target;
949 let phase = checked
950 .interrupted
951 .as_ref()
952 .map(|i| i.journal.phase.as_str());
953 run_dir.write_json(
954 "run.json",
955 &json!({
956 "schema_version": 1,
957 "kind": "rollback",
958 "run_id": run_id.as_str(),
959 "target_run_id": opts.run_id.as_str(),
960 "env": ctx.env,
961 "target_file": ctx.target_file,
962 "gateway": target.gateway,
963 "mode": target.mode,
964 "node": "master",
965 "started_at": started_at,
966 "run_dir_git": run_dir.git_status().as_record(),
967 "discard_live_changes": opts.discard_live_changes,
968 "discarded_live_changes": checked.live_changed,
969 "interrupted_phase": phase,
970 "recovery": RecoveryAction::ClearWithoutRestore.as_str(),
971 }),
972 )?;
973 run_dir.write_json(
974 "plan.json",
975 &json!({
976 "schema_version": 1,
977 "kind": "rollback",
978 "target_run_id": opts.run_id.as_str(),
979 "gateway": target.gateway,
980 "mode": target.mode,
981 "restore": Vec::<String>::new(),
982 "delete": Vec::<String>::new(),
983 "ledger": Value::Null,
984 "recovery": RecoveryAction::ClearWithoutRestore.as_str(),
985 }),
986 )?;
987 ctx.host.discard_interrupted(&opts.run_id).await?;
988 steps.emit(ApplyEvent::Cleared)?;
989 Ok(RollbackOutcome {
990 kind: RollbackKind::Cleared,
991 run_id: run_id.as_str().to_string(),
992 target_run_id: opts.run_id.as_str().to_string(),
993 run_dir: run_dir.path().to_string_lossy().into_owned(),
994 restored: Vec::new(),
995 deleted: Vec::new(),
996 discarded_live_changes: Vec::new(),
997 warnings: Vec::new(),
998 restart_pending: Vec::new(),
999 restart_pending_new: Vec::new(),
1000 snapshot_sha256: BTreeMap::new(),
1001 })
1002}
1003
1004#[cfg(test)]
1005mod tests {
1006 use super::*;
1007 use crate::deploy::apply::preflight::{FindingStatus, hold_budget_finding};
1008
1009 fn record_of(files: usize) -> RunRecord {
1011 let mut touched: Vec<TouchedEntry> = (0..files)
1012 .map(|i| TouchedEntry {
1013 path: format!("u{i}/config.json"),
1014 pre_sha256: Some("a".repeat(64)),
1015 post_sha256: Some("b".repeat(64)),
1016 })
1017 .collect();
1018 touched.push(TouchedEntry {
1019 path: LEDGER_KEY.to_string(),
1020 pre_sha256: Some("c".repeat(64)),
1021 post_sha256: Some("d".repeat(64)),
1022 });
1023 RunRecord {
1024 schema_version: 1,
1025 run_id: "20261016T000000Z-aaaaaa".to_string(),
1026 gateway: "gw".to_string(),
1027 mode: "dev".to_string(),
1028 render_sha256: "e".repeat(64),
1029 plan_sha256: "f".repeat(64),
1030 state: RecordState::Applied,
1031 rolled_back_by: None,
1032 touched,
1033 pre_fingerprint: "0".repeat(64),
1034 post_fingerprint: None,
1035 }
1036 }
1037
1038 fn rollback_budget(files: usize) -> FindingStatus {
1040 let record = record_of(files);
1041 hold_budget_finding(&HoldModel::MEASURED, budget_files(&record)).status
1042 }
1043
1044 #[test]
1045 fn rollback_budget_counts_what_the_apply_counted() {
1046 assert_eq!(record_of(1527).touched.len(), 1528);
1050 assert_eq!(budget_files(&record_of(1527)), 1527);
1051 assert_eq!(rollback_budget(1527), FindingStatus::Pass);
1052 assert_eq!(budget_files(&record_of(1528)), 1528);
1054 assert_eq!(rollback_budget(1528), FindingStatus::Fail);
1055 }
1056}