1pub mod converge;
99pub mod fingerprint;
100pub mod gates;
101pub mod hold;
102pub mod host;
103pub mod journal;
104pub mod preflight;
105pub mod record;
106pub mod rollback;
107pub mod run_store;
108
109use std::collections::{BTreeMap, BTreeSet};
110use std::path::{Path, PathBuf};
111
112use serde::Serialize;
113use serde_json::{Value, json};
114
115use crate::actions::backup::backup_download;
116use crate::actions::deploy::{
117 Declared, DeployPlanResult, PlanOptions, VerifyContext, VerifyOptions, deploy_plan,
118 deploy_verify_judged, gather_read_back,
119};
120use crate::actions::workspace::rfc3339_now_utc;
121use crate::client::backup::BackupType;
122use crate::client::deploy_api::scan_and_wait;
123use crate::client::scan::{LockVerdict, ScanKind, ScanOutcome};
124use crate::client::{DeployApi, GatewayApi};
125use crate::deploy::layout::RelPath;
126use crate::deploy::ledger::{Ledger, LedgerRoot, journal_location, ledger_location};
127use crate::deploy::pair::{BackupNode, BackupPolicy, BackupReport, effective_files, wall_clock_ms};
128use crate::deploy::plan::PlanStatus;
129use crate::deploy::target::NodeTarget;
130use crate::deploy::transport::batch::BUDGET_MAX_S;
131use crate::deploy::transport::{Scope, TreeReader};
132use crate::deploy::tree::sha256_hex;
133use crate::deploy::verify::{Expected, Finding, ReadBackBaseline, Written, compare_for};
134use crate::error::{CoreError, PreflightFailure, RollbackFailure};
135use crate::poll::PollConfig;
136
137use self::fingerprint::{LEDGER_KEY, fingerprint};
138use self::gates::GateFlags;
139use self::hold::{Clock, HeldLock, HoldModel, LOCK_MARGIN_S};
140use self::host::{ApplyHost, BatchSpec, DeleteOp, WriteOp};
141use self::journal::{Journal, JournalPhase, journal_on_box};
142use self::preflight::{DesignersPolicy, PreflightInput, run_preflights};
143use self::record::{RecordState, RunRecord, TouchedEntry};
144use self::run_store::{EventLog, RunDir, RunId};
145
146pub use self::rollback::{
147 InterruptedRun, RollbackChecked, RollbackContext, RollbackKind, RollbackOptions,
148 RollbackOutcome, check_rollback, restore_set, run_rollback,
149};
150
151const LOCKED_SAMPLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
155
156pub const ROLLED_BACK_SLUG: &str = "deploy_rolled_back";
164
165pub const BACKUP_UNSYNCED_SLUG: &str = "deploy_backup_unsynced";
174
175const GWBK_CHECK: &str = "gwbk";
177
178const RESTART_UNAVAILABLE: &str = "restart_pending_unavailable";
181
182const EVENTS_UNWRITABLE: &str = "events_log_unwritable";
185
186const READ_BACK_BASELINE_FILE: &str = "read_back_baseline.json";
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
192#[serde(rename_all = "snake_case")]
193pub enum OutcomeKind {
194 Applied,
196 Noop,
198 RolledBack,
200 AppliedBackupUnsynced,
204}
205
206impl OutcomeKind {
207 pub fn as_str(self) -> &'static str {
209 match self {
210 OutcomeKind::Applied => "applied",
211 OutcomeKind::Noop => "noop",
212 OutcomeKind::RolledBack => "rolled_back",
213 OutcomeKind::AppliedBackupUnsynced => "applied_backup_unsynced",
214 }
215 }
216}
217
218#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
220#[serde(rename_all = "snake_case")]
221pub enum RollbackCause {
222 LockExpired,
224 WriteFailed,
226 ScanIncomplete,
228 VerifyFailed,
230}
231
232impl RollbackCause {
233 pub fn as_str(self) -> &'static str {
235 match self {
236 RollbackCause::LockExpired => "lock_expired",
237 RollbackCause::WriteFailed => "write_failed",
238 RollbackCause::ScanIncomplete => "scan_incomplete",
239 RollbackCause::VerifyFailed => "verify_failed",
240 }
241 }
242}
243
244#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct WriteSet {
247 pub writes: Vec<WriteOp>,
249 pub deletes: Vec<DeleteOp>,
251}
252
253pub fn write_set(plan: &DeployPlanResult, declared: &Declared) -> Result<WriteSet, CoreError> {
265 let defect = |why: String| CoreError::Internal(format!("write set: {why}"));
266 let mut writes = Vec::new();
267 let mut live: BTreeMap<&str, (bool, Option<&str>)> = BTreeMap::new();
269 for entry in &plan.entries {
270 let in_mode = entry.live_in.iter().any(|c| c == "mode");
271 for file in &entry.files {
272 live.insert(file.path.as_str(), (in_mode, file.live_sha256.as_deref()));
273 }
274 if !matches!(entry.status, PlanStatus::Added | PlanStatus::Changed) {
275 continue;
276 }
277 for file in &entry.files {
278 let Some(desired) = &file.desired_sha256 else {
279 continue;
280 };
281 let path = RelPath::new(&file.path)
282 .map_err(|why| defect(format!("the plan names an invalid path ({why})")))?;
283 let bytes =
284 declared.outcome.tree.files.get(&path).ok_or_else(|| {
285 defect(format!("{path} is in the plan but not in the render"))
286 })?;
287 if sha256_hex(bytes) != *desired {
288 return Err(defect(format!(
289 "{path} was rendered differently from the plan"
290 )));
291 }
292 writes.push(WriteOp {
293 path,
294 sha256: desired.clone(),
295 pre: if in_mode {
296 file.live_sha256.clone()
297 } else {
298 None
299 },
300 });
301 }
302 }
303 let mut deletes = Vec::new();
304 for path in &plan.delete_set.files {
305 let Some((true, Some(pre))) = live.get(path.as_str()) else {
306 return Err(defect(format!(
307 "{path} is in the delete set without a pre-image in the plan"
308 )));
309 };
310 deletes.push(DeleteOp {
311 path: path.clone(),
312 pre: (*pre).to_string(),
313 });
314 }
315 writes.sort_by(|a, b| a.path.as_str().as_bytes().cmp(b.path.as_str().as_bytes()));
316 deletes.sort_by(|a, b| a.path.as_str().as_bytes().cmp(b.path.as_str().as_bytes()));
317 Ok(WriteSet { writes, deletes })
318}
319
320fn new_ledger(
323 plan: &DeployPlanResult,
324 old: Option<&Ledger>,
325 set: &WriteSet,
326 run_id: &RunId,
327) -> Ledger {
328 let mut files: BTreeSet<String> = old
329 .map(|ledger| ledger.files.iter().cloned().collect())
330 .unwrap_or_default();
331 for op in &set.deletes {
332 files.remove(op.path.as_str());
333 }
334 for op in &set.writes {
335 files.insert(op.path.as_str().to_string());
336 }
337 Ledger {
338 schema_version: 1,
339 gateway: plan.gateway.clone(),
340 mode: plan.mode.clone(),
341 render_sha256: plan.render_sha256.clone(),
342 run_id: run_id.as_str().to_string(),
343 files: files.into_iter().collect(),
344 }
345}
346
347fn commit_budget_s(hold: &HoldModel) -> u32 {
354 let seconds = hold.hold_s.saturating_sub(LOCK_MARGIN_S as u64);
355 u32::try_from(seconds.clamp(1, u64::from(BUDGET_MAX_S))).unwrap_or(BUDGET_MAX_S)
356}
357
358fn batch_spec(
361 plan: &DeployPlanResult,
362 run_id: &RunId,
363 set: &WriteSet,
364 ledger_new: Vec<u8>,
365 ledger_pre: Option<String>,
366 commit_budget_s: u32,
367) -> BatchSpec {
368 let mut touched: Vec<TouchedEntry> = set
369 .writes
370 .iter()
371 .map(|w| TouchedEntry {
372 path: w.path.as_str().to_string(),
373 pre_sha256: w.pre.clone(),
374 post_sha256: Some(w.sha256.clone()),
375 })
376 .chain(set.deletes.iter().map(|d| TouchedEntry {
377 path: d.path.as_str().to_string(),
378 pre_sha256: Some(d.pre.clone()),
379 post_sha256: None,
380 }))
381 .collect();
382 touched.push(TouchedEntry {
383 path: LEDGER_KEY.to_string(),
384 pre_sha256: ledger_pre.clone(),
385 post_sha256: Some(sha256_hex(&ledger_new)),
386 });
387 touched.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
388 let pre_fingerprint = fingerprint(
389 &touched
390 .iter()
391 .map(|e| (e.path.clone(), e.pre_sha256.clone()))
392 .collect(),
393 );
394 let run = run_id.as_str().to_string();
395 BatchSpec {
396 run_id: run_id.clone(),
397 gateway: plan.gateway.clone(),
398 mode: plan.mode.clone(),
399 writes: set.writes.clone(),
400 deletes: set.deletes.clone(),
401 ledger_new: Some(ledger_new),
402 ledger_pre,
403 commit_budget_s: Some(commit_budget_s),
404 journal: Journal {
405 schema_version: 1,
406 run_id: run.clone(),
407 gateway: plan.gateway.clone(),
408 mode: plan.mode.clone(),
409 phase: JournalPhase::Snapshotting,
410 pre_fingerprint: pre_fingerprint.clone(),
411 touched: touched.iter().map(|e| e.path.clone()).collect(),
412 },
413 record: RunRecord {
414 schema_version: 1,
415 run_id: run,
416 gateway: plan.gateway.clone(),
417 mode: plan.mode.clone(),
418 render_sha256: plan.render_sha256.clone(),
419 plan_sha256: plan.plan_sha256.clone(),
420 state: RecordState::Applying,
421 rolled_back_by: None,
422 touched,
423 pre_fingerprint,
424 post_fingerprint: None,
425 },
426 }
427}
428
429#[derive(Debug, Clone, PartialEq, Eq)]
431pub enum ApplyEvent {
432 RunStarted {
434 run_id: String,
436 writes: usize,
438 deletes: usize,
440 },
441 Staged {
443 files: usize,
445 },
446 LockAcquired {
448 hold_s: u64,
450 },
451 Committed {
453 moved: usize,
455 deleted: usize,
457 },
458 LockVerdict {
460 held: bool,
462 },
463 ScanDone,
465 VerifyDone {
467 ok: bool,
469 },
470 Finalized,
472 CommitInterrupted {
476 code: String,
478 },
479 VerifyChecks {
483 checks: Vec<(String, String, Vec<Finding>)>,
485 },
486 RestoreStarted {
489 cause: Option<RollbackCause>,
491 },
492 LiveChangesDiscarded {
495 count: usize,
497 paths: Vec<String>,
499 },
500 Restored,
502 Cleared,
505 InterruptedRun {
509 phase: &'static str,
511 action: &'static str,
513 hashes: BTreeMap<String, Option<String>>,
515 },
516 PreflightChecks {
518 checks: Vec<(String, String)>,
520 },
521 DesignersPoll {
523 open: usize,
525 },
526 DesignersProceed {
528 sessions: Vec<preflight::DesignerSeen>,
530 },
531 GwbkDownloaded {
533 bytes: u64,
535 },
536 GwbkSkipped,
538 BackupSampled {
542 last_sync: Option<i64>,
544 },
545 BackupWaitStarted {
547 budget_s: u64,
549 },
550 BackupPoll {
552 peer_connected: bool,
554 sync_pending: bool,
556 advanced: bool,
558 },
559 BackupResync {
561 ok: bool,
563 code: Option<String>,
565 },
566 BackupVerdict {
568 converged: bool,
570 reason: Option<&'static str>,
572 },
573 Failed {
575 code: String,
577 },
578}
579
580impl ApplyEvent {
581 pub fn name(&self) -> &'static str {
583 match self {
584 ApplyEvent::RunStarted { .. } => "run_started",
585 ApplyEvent::Staged { .. } => "staged",
586 ApplyEvent::LockAcquired { .. } => "lock_acquired",
587 ApplyEvent::Committed { .. } => "committed",
588 ApplyEvent::LockVerdict { .. } => "lock_verdict",
589 ApplyEvent::ScanDone => "scan_done",
590 ApplyEvent::VerifyDone { .. } => "verify_done",
591 ApplyEvent::VerifyChecks { .. } => "verify_checks",
592 ApplyEvent::Finalized => "finalized",
593 ApplyEvent::CommitInterrupted { .. } => "commit_interrupted",
594 ApplyEvent::RestoreStarted { .. } => "restore_started",
595 ApplyEvent::Restored => "restored",
596 ApplyEvent::Cleared => "cleared",
597 ApplyEvent::InterruptedRun { .. } => "interrupted_run",
598 ApplyEvent::LiveChangesDiscarded { .. } => "live_changes_discarded",
599 ApplyEvent::PreflightChecks { .. } => "preflight_checks",
600 ApplyEvent::DesignersPoll { .. } => "designers_poll",
601 ApplyEvent::DesignersProceed { .. } => "designers_proceed",
602 ApplyEvent::GwbkDownloaded { .. } => "gwbk_downloaded",
603 ApplyEvent::GwbkSkipped => "gwbk_skipped",
604 ApplyEvent::BackupSampled { .. } => "backup_sampled",
605 ApplyEvent::BackupWaitStarted { .. } => "backup_wait_started",
606 ApplyEvent::BackupPoll { .. } => "backup_poll",
607 ApplyEvent::BackupResync { .. } => "backup_resync",
608 ApplyEvent::BackupVerdict { .. } => "backup_verdict",
609 ApplyEvent::Failed { .. } => "failed",
610 }
611 }
612
613 pub fn fields(&self) -> Value {
615 match self {
616 ApplyEvent::RunStarted {
617 run_id,
618 writes,
619 deletes,
620 } => json!({"run_id": run_id, "writes": writes, "deletes": deletes}),
621 ApplyEvent::Staged { files } => json!({"files": files}),
622 ApplyEvent::LockAcquired { hold_s } => json!({"hold_s": hold_s}),
623 ApplyEvent::Committed { moved, deleted } => {
624 json!({"moved": moved, "deleted": deleted})
625 }
626 ApplyEvent::LockVerdict { held } => json!({"held": held}),
627 ApplyEvent::VerifyDone { ok } => json!({"ok": ok}),
628 ApplyEvent::VerifyChecks { checks } => json!({
629 "checks": checks
630 .iter()
631 .map(|(id, status, findings)| {
632 json!({"id": id, "status": status, "findings": findings})
633 })
634 .collect::<Vec<_>>(),
635 }),
636 ApplyEvent::PreflightChecks { checks } => json!({
637 "checks": checks
638 .iter()
639 .map(|(id, status)| json!({"id": id, "status": status}))
640 .collect::<Vec<_>>(),
641 }),
642 ApplyEvent::DesignersPoll { open } => json!({"open": open}),
643 ApplyEvent::DesignersProceed { sessions } => json!({
644 "sessions": sessions
645 .iter()
646 .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
647 .collect::<Vec<_>>(),
648 }),
649 ApplyEvent::RestoreStarted { cause } => {
650 json!({"cause": cause.map(RollbackCause::as_str)})
651 }
652 ApplyEvent::LiveChangesDiscarded { count, paths } => {
653 json!({"count": count, "paths": paths})
654 }
655 ApplyEvent::InterruptedRun {
656 phase,
657 action,
658 hashes,
659 } => json!({
660 "phase": phase,
661 "action": action,
662 "touched": hashes.len(),
663 "hashes": hashes,
664 }),
665 ApplyEvent::GwbkDownloaded { bytes } => json!({"bytes": bytes}),
666 ApplyEvent::BackupSampled { last_sync } => json!({"last_sync": last_sync}),
667 ApplyEvent::BackupWaitStarted { budget_s } => json!({"budget_s": budget_s}),
668 ApplyEvent::BackupPoll {
669 peer_connected,
670 sync_pending,
671 advanced,
672 } => json!({
673 "peer_connected": peer_connected,
674 "sync_pending": sync_pending,
675 "advanced": advanced,
676 }),
677 ApplyEvent::BackupResync { ok, code } => json!({"ok": ok, "code": code}),
678 ApplyEvent::BackupVerdict { converged, reason } => {
679 json!({"converged": converged, "reason": reason})
680 }
681 ApplyEvent::CommitInterrupted { code } | ApplyEvent::Failed { code } => {
682 json!({"code": code})
683 }
684 ApplyEvent::ScanDone
685 | ApplyEvent::Finalized
686 | ApplyEvent::Restored
687 | ApplyEvent::Cleared
688 | ApplyEvent::GwbkSkipped => Value::Null,
689 }
690 }
691}
692
693pub trait ApplyObserver: Send + Sync {
695 fn event(&self, e: &ApplyEvent);
697}
698
699#[derive(Debug, Clone, Copy, Default)]
701pub struct NoObserver;
702
703impl ApplyObserver for NoObserver {
704 fn event(&self, _e: &ApplyEvent) {}
705}
706
707pub struct ApplyContext<'a> {
709 pub declared: &'a Declared,
711 pub repo: &'a std::path::Path,
713 pub reader: &'a dyn TreeReader,
715 pub host: &'a dyn ApplyHost,
717 pub deploy: &'a dyn DeployApi,
719 pub gateway: &'a dyn GatewayApi,
721 pub backup: Option<&'a BackupNode<'a>>,
724 pub clock: &'a dyn Clock,
726 pub observer: &'a dyn ApplyObserver,
728}
729
730impl<'a> ApplyContext<'a> {
731 fn restore_context(&'a self, opts: &'a ApplyOptions) -> RestoreContext<'a> {
733 RestoreContext {
734 env: &self.declared.env,
735 master: &self.declared.target.nodes.master,
736 host: self.host,
737 deploy: self.deploy,
738 clock: self.clock,
739 hold: &opts.hold,
740 poll: &opts.poll,
741 }
742 }
743}
744
745#[derive(Debug, Clone)]
747pub struct ApplyOptions {
748 pub base: Option<PathBuf>,
750 pub gates: GateFlags,
752 pub project: String,
754 pub hold: HoldModel,
756 pub poll: PollConfig,
758 pub verify: VerifyOptions,
760 pub designers: DesignersPolicy,
762 pub skip_gwbk: bool,
764 pub backup: BackupPolicy,
767}
768
769#[derive(Debug, Clone, PartialEq, Serialize)]
771pub struct ApplyOutcome {
772 #[serde(rename = "outcome")]
774 pub kind: OutcomeKind,
775 pub run_id: Option<String>,
777 pub run_dir: Option<String>,
779 pub cause: Option<RollbackCause>,
781 pub render_sha256: String,
783 pub plan_sha256: String,
785 pub written: Vec<String>,
787 pub deleted: Vec<String>,
789 pub post_fingerprint: Option<String>,
791 pub warnings: Vec<String>,
793 pub gwbk: Option<String>,
796 pub skip_gwbk: bool,
798 pub restart_pending: Vec<String>,
800 pub restart_pending_new: Vec<String>,
802 pub snapshot_sha256: BTreeMap<String, String>,
806 pub backup: Option<BackupReport>,
809}
810
811pub async fn run_apply(
824 ctx: &ApplyContext<'_>,
825 plan: &DeployPlanResult,
826 opts: &ApplyOptions,
827) -> Result<ApplyOutcome, CoreError> {
828 if let Some(refusal) = gates::unreadable_refusal(plan) {
829 return Err(refusal);
830 }
831 if let Some(refusal) =
832 gates::pair_tags_refusal(plan, gates::Redundancy::of(&ctx.declared.target))
833 {
834 return Err(refusal);
835 }
836 if !plan.summary.has_changes {
837 return Ok(ApplyOutcome {
838 kind: OutcomeKind::Noop,
839 run_id: None,
840 run_dir: None,
841 cause: None,
842 render_sha256: plan.render_sha256.clone(),
843 plan_sha256: plan.plan_sha256.clone(),
844 written: Vec::new(),
845 deleted: Vec::new(),
846 post_fingerprint: None,
847 warnings: Vec::new(),
848 gwbk: None,
849 skip_gwbk: false,
850 restart_pending: Vec::new(),
851 restart_pending_new: Vec::new(),
852 snapshot_sha256: BTreeMap::new(),
853 backup: None,
854 });
855 }
856 let (run_id, run_dir) = RunDir::create_fresh(ctx.repo, &plan.gateway)?;
857 let mut steps = Steps::new(None, ctx.observer);
858 let result = drive(ctx, plan, opts, &run_id, &run_dir, &mut steps).await;
859
860 if let Err(e) = &result {
862 let _ = steps.emit(ApplyEvent::Failed {
863 code: e.code().to_string(),
864 });
865 }
866 let doc = match &result {
867 Ok(outcome) => {
868 let mut doc = serde_json::to_value(outcome).unwrap_or(Value::Null);
869 doc["ok"] = Value::Bool(!matches!(
870 outcome.kind,
871 OutcomeKind::RolledBack | OutcomeKind::AppliedBackupUnsynced
872 ));
873 match outcome.kind {
874 OutcomeKind::RolledBack => {
875 doc["slug"] = Value::String(ROLLED_BACK_SLUG.to_string());
876 }
877 OutcomeKind::AppliedBackupUnsynced => {
878 doc["slug"] = Value::String(BACKUP_UNSYNCED_SLUG.to_string());
879 }
880 OutcomeKind::Applied | OutcomeKind::Noop => {}
881 }
882 doc
883 }
884 Err(e) => json!({
885 "ok": false,
886 "run_id": run_id.as_str(),
887 "error": {"code": e.code(), "message": e.to_string()},
888 }),
889 };
890 let written = run_dir.write_json("result.json", &doc);
891 match result {
892 Ok(mut outcome) => {
893 if written.is_err() {
894 outcome.warnings.push("result_json_unwritable".to_string());
895 }
896 Ok(outcome)
897 }
898 Err(e) => Err(e),
899 }
900}
901
902pub struct Steps<'a> {
905 log: Option<EventLog>,
906 observer: &'a dyn ApplyObserver,
907}
908
909impl<'a> Steps<'a> {
910 pub fn new(log: Option<EventLog>, observer: &'a dyn ApplyObserver) -> Self {
912 Steps { log, observer }
913 }
914
915 pub fn emit(&mut self, event: ApplyEvent) -> Result<(), CoreError> {
917 self.observer.event(&event);
918 match &mut self.log {
919 Some(log) => log.append(event.name(), event.fields()),
920 None => Ok(()),
921 }
922 }
923}
924
925fn with_env(err: CoreError, env: &str) -> CoreError {
927 match err {
928 CoreError::DeployJournalPresent {
929 run_id, location, ..
930 } => CoreError::DeployJournalPresent {
931 run_id,
932 env: env.to_string(),
933 location,
934 },
935 other => other,
936 }
937}
938
939enum BoxState {
942 Mine(Journal),
944 Other,
946 Absent,
948 Unknown,
950}
951
952async fn box_state(ctx: &ApplyContext<'_>, plan: &DeployPlanResult, run_id: &RunId) -> BoxState {
953 let master = &ctx.declared.target.nodes.master;
954 match journal_on_box(ctx.reader, master, &plan.gateway, &plan.mode).await {
955 Ok(Some(journal)) if journal.run_id == run_id.as_str() => BoxState::Mine(journal),
956 Ok(Some(_)) => BoxState::Other,
957 Ok(None) => BoxState::Absent,
958 Err(_) => BoxState::Unknown,
959 }
960}
961
962fn interrupted(
966 ctx: &ApplyContext<'_>,
967 plan: &DeployPlanResult,
968 steps: &mut Steps<'_>,
969 journal: &Journal,
970 err: &CoreError,
971) -> CoreError {
972 let _ = steps.emit(ApplyEvent::CommitInterrupted {
973 code: err.code().to_string(),
974 });
975 journal::journal_present(
976 journal,
977 &ctx.declared.env,
978 &ctx.declared.target.nodes.master,
979 &plan.gateway,
980 &plan.mode,
981 )
982}
983
984async fn commit_failed(
1000 ctx: &ApplyContext<'_>,
1001 run_id: &RunId,
1002 plan: &DeployPlanResult,
1003 steps: &mut Steps<'_>,
1004 err: CoreError,
1005) -> CoreError {
1006 if matches!(err, CoreError::DeployJournalPresent { .. }) {
1007 return err;
1008 }
1009 match box_state(ctx, plan, run_id).await {
1010 BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
1011 BoxState::Unknown => {
1012 let _ = steps.emit(ApplyEvent::CommitInterrupted {
1016 code: format!("{}; journal state unknown", err.code()),
1017 });
1018 recovery_pointer(ctx, plan, run_id, err)
1019 }
1020 BoxState::Other | BoxState::Absent => err,
1021 }
1022}
1023
1024async fn unfinished_after_green(
1031 ctx: &ApplyContext<'_>,
1032 run_id: &RunId,
1033 plan: &DeployPlanResult,
1034 steps: &mut Steps<'_>,
1035 err: CoreError,
1036) -> CoreError {
1037 match box_state(ctx, plan, run_id).await {
1038 BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
1039 _ => recovery_pointer(ctx, plan, run_id, err),
1040 }
1041}
1042
1043fn recovery_pointer(
1048 ctx: &ApplyContext<'_>,
1049 plan: &DeployPlanResult,
1050 run_id: &RunId,
1051 err: CoreError,
1052) -> CoreError {
1053 let env = &ctx.declared.env;
1054 match err {
1055 CoreError::TransportFailed {
1056 operation,
1057 node,
1058 detail,
1059 } => CoreError::TransportFailed {
1060 operation,
1061 node,
1062 detail: format!(
1063 "{detail}; this run's journal may be on the host: recover it with: \
1064 ign deploy rollback --run {run} --env {env}",
1065 run = run_id.as_str()
1066 ),
1067 },
1068 _ => CoreError::DeployJournalPresent {
1069 run_id: run_id.as_str().to_string(),
1070 env: env.clone(),
1071 location: journal_location(
1072 LedgerRoot::for_node(&ctx.declared.target.nodes.master),
1073 &plan.gateway,
1074 &plan.mode,
1075 ),
1076 },
1077 }
1078}
1079
1080async fn drive(
1084 ctx: &ApplyContext<'_>,
1085 plan: &DeployPlanResult,
1086 opts: &ApplyOptions,
1087 run_id: &RunId,
1088 run_dir: &RunDir,
1089 steps: &mut Steps<'_>,
1090) -> Result<ApplyOutcome, CoreError> {
1091 let declared = ctx.declared;
1092 let env = declared.env.as_str();
1093 let (gateway, mode) = (plan.gateway.as_str(), plan.mode.as_str());
1094
1095 let started_at = rfc3339_now_utc();
1096 steps.log = Some(EventLog::open(run_dir)?);
1097
1098 let set = write_set(plan, declared)?;
1099 steps.emit(ApplyEvent::RunStarted {
1100 run_id: run_id.as_str().to_string(),
1101 writes: set.writes.len(),
1102 deletes: set.deletes.len(),
1103 })?;
1104
1105 let touched_paths: Vec<String> = set
1109 .writes
1110 .iter()
1111 .map(|w| w.path.as_str().to_string())
1112 .chain(set.deletes.iter().map(|d| d.path.as_str().to_string()))
1113 .collect();
1114 let deleted_paths: Vec<String> = set
1115 .deletes
1116 .iter()
1117 .map(|d| d.path.as_str().to_string())
1118 .collect();
1119 let preflights = run_preflights(
1120 &PreflightInput {
1121 target: &declared.target,
1122 declared: Some(declared),
1123 master: ctx.deploy,
1124 backup: ctx.backup,
1125 gateway: ctx.gateway,
1126 reader: ctx.reader,
1127 host: ctx.host,
1128 touched_files: set.writes.len() + set.deletes.len(),
1129 touched_paths: &touched_paths,
1130 deleted_paths: &deleted_paths,
1131 hold: &opts.hold,
1132 designers: &opts.designers,
1133 clock: ctx.clock,
1134 },
1135 steps,
1136 )
1137 .await;
1138 let seen = preflights.as_ref().ok();
1139 run_dir.write_json(
1140 "run.json",
1141 &json!({
1142 "schema_version": 1,
1143 "run_id": run_id.as_str(),
1144 "env": env,
1145 "gateway": gateway,
1146 "mode": mode,
1147 "node": plan.node,
1148 "transport": plan.transport,
1149 "started_at": started_at,
1150 "run_dir_git": run_dir.git_status().as_record(),
1151 "render_sha256": plan.render_sha256,
1152 "plan_sha256": plan.plan_sha256,
1153 "hold_s": opts.hold.hold_s,
1154 "allow_destructive": opts.gates.allow_destructive,
1155 "allow_drift": opts.gates.allow_drift,
1156 "skip_gwbk": opts.skip_gwbk,
1157 "designers": opts.designers.mode.as_str(),
1158 "designers_timeout_s": opts.designers.timeout.as_secs(),
1159 "designers_seen": seen.map(|r| r.designers_seen
1160 .iter()
1161 .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
1162 .collect::<Vec<_>>()),
1163 "preflights": seen.map(|r| r.findings
1164 .iter()
1165 .map(|f| json!({"check": f.check.as_str(), "status": f.status.as_str()}))
1166 .collect::<Vec<_>>()),
1167 }),
1168 )?;
1169 run_dir.write_json("plan.json", plan)?;
1170 let preflights = preflights?;
1171 if !preflights.passed() {
1172 steps.emit(ApplyEvent::PreflightChecks {
1173 checks: preflights
1174 .findings
1175 .iter()
1176 .map(|f| (f.check.as_str().to_string(), f.status.as_str().to_string()))
1177 .collect(),
1178 })?;
1179 if !preflights.backup_unreadable.is_empty() {
1184 return Err(CoreError::DeployUnreadable {
1185 node: "backup".to_string(),
1186 keys: preflights.backup_unreadable,
1187 });
1188 }
1189 return Err(CoreError::DeployPreflightFailed {
1190 failures: preflights.failures().into(),
1191 });
1192 }
1193
1194 let mut backup_before = match ctx.backup {
1200 Some(backup) => {
1201 let sampled = backup.status().await?.last_sync_epoch_ms();
1202 steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
1203 sampled
1204 }
1205 None => None,
1206 };
1207 let mut backup_log_since_ms = wall_clock_ms();
1211
1212 let gwbk = take_gwbk(ctx, opts, run_id, run_dir, steps).await?;
1215
1216 let restart_before = ctx.deploy.restart_pending().await.ok();
1220
1221 let old_bytes = ctx.reader.read_ledger(gateway, mode).await?;
1223 let old_ledger = match &old_bytes {
1224 Some(bytes) => Some(Ledger::parse(
1225 &ledger_location(
1226 LedgerRoot::for_node(&declared.target.nodes.master),
1227 gateway,
1228 mode,
1229 ),
1230 bytes,
1231 gateway,
1232 mode,
1233 )?),
1234 None => None,
1235 };
1236 let ledger = new_ledger(plan, old_ledger.as_ref(), &set, run_id);
1237 let ledger_bytes = ledger.to_bytes();
1238 Ledger::parse(
1239 &ledger_location(
1240 LedgerRoot::for_node(&declared.target.nodes.master),
1241 gateway,
1242 mode,
1243 ),
1244 &ledger_bytes,
1245 gateway,
1246 mode,
1247 )?;
1248 let spec = batch_spec(
1249 plan,
1250 run_id,
1251 &set,
1252 ledger_bytes,
1253 old_bytes.as_deref().map(sha256_hex),
1254 commit_budget_s(&opts.hold),
1255 );
1256 if let Some(problem) = spec.problem() {
1257 return Err(CoreError::Internal(format!(
1258 "the batch the engine built is invalid: {problem}"
1259 )));
1260 }
1261
1262 let (baseline, backup_baseline) = read_back_baselines(ctx, plan, run_dir).await?;
1271
1272 let scope = RunScope {
1273 plan,
1274 run_id,
1275 run_dir,
1276 gwbk: gwbk.as_deref(),
1277 restart_before: restart_before.as_deref(),
1278 record: &spec.record,
1279 };
1280
1281 let prepared = ctx
1283 .host
1284 .prepare(&spec, &declared.outcome.tree)
1285 .await
1286 .map_err(|e| with_env(e, env))?;
1287 steps.emit(ApplyEvent::Staged {
1288 files: set.writes.len(),
1289 })?;
1290 let lock =
1291 match HeldLock::acquire(ctx.deploy, ScanKind::Config, opts.hold.hold_s, ctx.clock).await {
1292 Ok(lock) => lock,
1293 Err(refused) => {
1294 let _ = ctx.host.discard_interrupted(run_id).await;
1298 return Err(refused);
1299 }
1300 };
1301 steps.emit(ApplyEvent::LockAcquired {
1302 hold_s: opts.hold.hold_s,
1303 })?;
1304 if let Some(backup) = ctx.backup {
1316 let since_ms = wall_clock_ms();
1317 if let Ok(Ok(status)) = tokio::time::timeout(LOCKED_SAMPLE_TIMEOUT, backup.status()).await {
1318 let sampled = status.last_sync_epoch_ms();
1319 steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
1320 backup_before = sampled;
1321 backup_log_since_ms = since_ms;
1322 }
1323 }
1324 let report = match ctx.host.commit(&prepared).await {
1325 Ok(report) => report,
1326 Err(e) => return Err(commit_failed(ctx, run_id, plan, steps, with_env(e, env)).await),
1327 };
1328 if let Err(e) = steps.emit(ApplyEvent::Committed {
1329 moved: report.moved.len(),
1330 deleted: report.deleted.len(),
1331 }) {
1332 return Err(commit_failed(ctx, run_id, plan, steps, e).await);
1335 }
1336 if report.stale {
1337 return Err(plan_stale(ctx, plan, opts, run_id).await);
1338 }
1339 if report.failure.is_some() {
1340 let cause = if report.deadline {
1345 RollbackCause::LockExpired
1346 } else {
1347 RollbackCause::WriteFailed
1348 };
1349 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, Vec::new()).await;
1350 }
1351
1352 let mut warnings = match confirm(ctx, opts, &lock, &baseline, steps).await {
1355 Ok(Confirmed::Green { warnings }) => warnings,
1356 Ok(Confirmed::Abort { cause, warnings }) => {
1357 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, warnings).await;
1358 }
1359 Err(Unconfirmed::Restore { cause, step, error }) => {
1360 let note = format!("post_commit_error ({step}: {})", error.code());
1363 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, vec![note]).await;
1364 }
1365 Err(Unconfirmed::Green(error)) => {
1366 return Err(unfinished_after_green(ctx, run_id, plan, steps, error).await);
1367 }
1368 };
1369
1370 let post = match ctx.host.fingerprint(&spec.touched_paths()).await {
1372 Ok(post) => post,
1373 Err(e) => return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await),
1374 };
1375 let post_fingerprint = fingerprint(&post);
1376 let mut record = spec.record.clone();
1377 record.state = RecordState::Applied;
1378 record.post_fingerprint = Some(post_fingerprint.clone());
1379 if let Err(e) = ctx.host.finalize(&record).await {
1380 if !matches!(box_state(ctx, plan, run_id).await, BoxState::Absent) {
1383 return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await);
1384 }
1385 warnings.push(format!("finalize_reported_error ({})", e.code()));
1386 }
1387 if steps.emit(ApplyEvent::Finalized).is_err() {
1388 warnings.push(EVENTS_UNWRITABLE.to_string());
1389 }
1390
1391 let (restart_pending, restart_pending_new) =
1392 restart_report(ctx.deploy, restart_before.as_deref(), &mut warnings).await;
1393 let mut snapshot_sha256 = BTreeMap::new();
1394 match ctx.host.fetch_snapshot(run_id, run_dir.path()).await {
1395 Ok(hashes) => {
1396 warnings.extend(snapshot_warnings(&spec.record, &hashes));
1397 snapshot_sha256 = hashes;
1398 }
1399 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
1400 }
1401
1402 let backup_report = match ctx.backup {
1405 Some(backup) => Some(
1406 converge::converge_backup(
1407 ctx,
1408 backup,
1409 opts,
1410 &set,
1411 converge::Baseline {
1412 last_sync: backup_before,
1413 log_since_ms: backup_log_since_ms,
1414 read_back: backup_baseline,
1415 },
1416 steps,
1417 &mut warnings,
1418 )
1419 .await,
1420 ),
1421 None => None,
1422 };
1423 let kind = match &backup_report {
1424 Some(report) if !report.converged => OutcomeKind::AppliedBackupUnsynced,
1425 _ => OutcomeKind::Applied,
1426 };
1427 Ok(ApplyOutcome {
1428 kind,
1429 run_id: Some(run_id.as_str().to_string()),
1430 run_dir: Some(run_dir.path().to_string_lossy().into_owned()),
1431 cause: None,
1432 render_sha256: plan.render_sha256.clone(),
1433 plan_sha256: plan.plan_sha256.clone(),
1434 written: set
1435 .writes
1436 .iter()
1437 .map(|w| w.path.as_str().to_string())
1438 .collect(),
1439 deleted: set
1440 .deletes
1441 .iter()
1442 .map(|d| d.path.as_str().to_string())
1443 .collect(),
1444 post_fingerprint: Some(post_fingerprint),
1445 warnings,
1446 gwbk,
1447 skip_gwbk: opts.skip_gwbk,
1448 restart_pending,
1449 restart_pending_new,
1450 snapshot_sha256,
1451 backup: backup_report,
1452 })
1453}
1454
1455enum Confirmed {
1458 Green { warnings: Vec<String> },
1460 Abort {
1462 cause: RollbackCause,
1463 warnings: Vec<String>,
1464 },
1465}
1466
1467enum Unconfirmed {
1469 Restore {
1473 cause: RollbackCause,
1474 step: &'static str,
1475 error: CoreError,
1476 },
1477 Green(CoreError),
1479}
1480
1481async fn read_back_baselines(
1491 ctx: &ApplyContext<'_>,
1492 plan: &DeployPlanResult,
1493 run_dir: &RunDir,
1494) -> Result<(ReadBackBaseline, Option<ReadBackBaseline>), CoreError> {
1495 let declared = ctx.declared;
1496 let expected =
1497 Expected::from_render(&declared.target.mode, &declared.outcome, &declared.manifest);
1498 let status: BTreeMap<&str, PlanStatus> = plan
1499 .entries
1500 .iter()
1501 .map(|entry| (entry.key.as_str(), entry.status))
1502 .collect();
1503 let changed: Vec<_> = expected
1504 .resources
1505 .iter()
1506 .filter(|res| status.get(res.key.as_str()) == Some(&PlanStatus::Changed))
1507 .collect();
1508 let live = if changed.is_empty() {
1509 BTreeMap::new()
1510 } else {
1511 let types: BTreeSet<(String, String)> = changed
1512 .iter()
1513 .map(|res| (res.module.clone(), res.ty.clone()))
1514 .collect();
1515 effective_files(
1516 ctx.reader
1517 .read(&Scope {
1518 mode: plan.mode.clone(),
1519 types: types.into_iter().collect(),
1520 tag_providers: Vec::new(),
1521 })
1522 .await?,
1523 )
1524 };
1525 let written: BTreeMap<String, Written> = expected
1526 .resources
1527 .iter()
1528 .filter_map(|res| match status.get(res.key.as_str()) {
1529 Some(PlanStatus::Added) => Some((res.key.clone(), Written::Added)),
1530 Some(PlanStatus::Changed) => {
1531 let file = |name: &str| {
1532 RelPath::new(&format!("{}/{name}", res.key))
1533 .ok()
1534 .and_then(|path| live.get(&path))
1535 .map(|blob| blob.bytes.as_slice())
1536 };
1537 let compare = compare_for(&res.module, &res.ty, file).ok();
1538 Some((res.key.clone(), Written::Changed(compare)))
1539 }
1540 _ => None,
1541 })
1542 .collect();
1543
1544 let before = gather_read_back(ctx.deploy, &expected).await?;
1545 let master = ReadBackBaseline::build("master", &expected, &before, &written);
1546 let backup = match ctx.backup {
1547 Some(backup) => {
1548 let before = backup.read_back(&expected).await?;
1549 Some(ReadBackBaseline::build(
1550 "backup", &expected, &before, &written,
1551 ))
1552 }
1553 None => None,
1554 };
1555 let nodes: Vec<_> = std::iter::once(master.record())
1556 .chain(backup.as_ref().map(ReadBackBaseline::record))
1557 .collect();
1558 run_dir.write_json(
1559 READ_BACK_BASELINE_FILE,
1560 &json!({"schema_version": 1, "nodes": nodes}),
1561 )?;
1562 Ok((master, backup))
1563}
1564
1565async fn confirm(
1572 ctx: &ApplyContext<'_>,
1573 opts: &ApplyOptions,
1574 lock: &HeldLock,
1575 baseline: &ReadBackBaseline,
1576 steps: &mut Steps<'_>,
1577) -> Result<Confirmed, Unconfirmed> {
1578 fn undone(cause: RollbackCause, step: &'static str) -> impl FnOnce(CoreError) -> Unconfirmed {
1579 move |error| Unconfirmed::Restore { cause, step, error }
1580 }
1581 let abort = |cause, warnings| Ok(Confirmed::Abort { cause, warnings });
1582
1583 let verdict = lock
1585 .check(ctx.deploy, LOCK_MARGIN_S, ctx.clock)
1586 .await
1587 .map_err(undone(RollbackCause::LockExpired, "lock_check"))?;
1588 let held = matches!(verdict, LockVerdict::Held { .. });
1589 steps
1590 .emit(ApplyEvent::LockVerdict { held })
1591 .map_err(undone(RollbackCause::LockExpired, "events"))?;
1592 if !held {
1593 return abort(RollbackCause::LockExpired, Vec::new());
1595 }
1596 match scan_and_wait(ctx.deploy, ScanKind::Config, opts.poll.clone())
1598 .await
1599 .map_err(undone(RollbackCause::ScanIncomplete, "scan"))?
1600 {
1601 ScanOutcome::Complete(_) => steps
1602 .emit(ApplyEvent::ScanDone)
1603 .map_err(undone(RollbackCause::ScanIncomplete, "events"))?,
1604 ScanOutcome::NotComplete { .. } => {
1605 return abort(RollbackCause::ScanIncomplete, Vec::new());
1606 }
1607 }
1608
1609 let verify = deploy_verify_judged(
1610 ctx.declared,
1611 &opts.project,
1612 ctx.deploy,
1613 ctx.gateway,
1614 ctx.reader,
1615 &opts.verify,
1616 VerifyContext::PostApply(baseline),
1617 )
1618 .await
1619 .map_err(undone(RollbackCause::VerifyFailed, "verify"))?;
1620 if !verify.ok {
1621 steps
1622 .emit(ApplyEvent::VerifyDone { ok: false })
1623 .map_err(undone(RollbackCause::VerifyFailed, "events"))?;
1624 steps
1628 .emit(ApplyEvent::VerifyChecks {
1629 checks: verify
1630 .checks
1631 .iter()
1632 .map(|c| {
1633 (
1634 c.id.word().to_string(),
1635 c.status.word().to_string(),
1636 c.findings.clone(),
1637 )
1638 })
1639 .collect(),
1640 })
1641 .map_err(undone(RollbackCause::VerifyFailed, "events"))?;
1642 return abort(RollbackCause::VerifyFailed, verify.warnings);
1643 }
1644 steps
1645 .emit(ApplyEvent::VerifyDone { ok: true })
1646 .map_err(Unconfirmed::Green)?;
1647 Ok(Confirmed::Green {
1648 warnings: verify.warnings,
1649 })
1650}
1651
1652async fn restart_report(
1659 deploy: &dyn DeployApi,
1660 before: Option<&[String]>,
1661 warnings: &mut Vec<String>,
1662) -> (Vec<String>, Vec<String>) {
1663 let after = deploy.restart_pending().await.ok();
1664 if before.is_none() || after.is_none() {
1665 warnings.push(RESTART_UNAVAILABLE.to_string());
1666 }
1667 let pending = after.unwrap_or_default();
1668 let known = before.unwrap_or_default();
1669 let added = pending
1670 .iter()
1671 .filter(|entry| !known.contains(entry))
1672 .cloned()
1673 .collect();
1674 (pending, added)
1675}
1676
1677fn snapshot_warnings(record: &RunRecord, copied: &BTreeMap<String, String>) -> Vec<String> {
1681 record
1682 .pre_map()
1683 .into_iter()
1684 .filter_map(|(path, pre)| {
1685 let pre = pre?;
1686 let key = if path == LEDGER_KEY {
1687 "ledger.json".to_string()
1688 } else {
1689 format!("files/{path}")
1690 };
1691 (copied.get(&key) != Some(&pre)).then(|| format!("snapshot_copy_differs ({path})"))
1692 })
1693 .collect()
1694}
1695
1696async fn take_gwbk(
1713 ctx: &ApplyContext<'_>,
1714 opts: &ApplyOptions,
1715 run_id: &RunId,
1716 run_dir: &RunDir,
1717 steps: &mut Steps<'_>,
1718) -> Result<Option<String>, CoreError> {
1719 if opts.skip_gwbk {
1720 steps.emit(ApplyEvent::GwbkSkipped)?;
1721 return Ok(None);
1722 }
1723 let refuse = |detail: String| CoreError::DeployPreflightFailed {
1724 failures: vec![PreflightFailure {
1725 check: GWBK_CHECK.to_string(),
1726 detail,
1727 }]
1728 .into(),
1729 };
1730 let name = format!("{}.gwbk", run_id.as_str());
1731 drop(run_dir.create_private_file(&name)?);
1732 let path = run_dir.path().join(&name);
1733 let verdict = match backup_download(ctx.gateway, Some(&path), "", BackupType::All).await {
1734 Ok(_) => validate_gwbk(&path),
1735 Err(e) => Err(format!("the .gwbk was not downloaded ({})", e.code())),
1736 };
1737 match verdict {
1738 Ok(bytes) => {
1739 steps.emit(ApplyEvent::GwbkDownloaded { bytes })?;
1740 Ok(Some(name))
1741 }
1742 Err(detail) => {
1743 let _ = std::fs::remove_file(&path);
1744 Err(refuse(detail))
1745 }
1746 }
1747}
1748
1749fn validate_gwbk(path: &Path) -> Result<u64, String> {
1752 let bytes = std::fs::metadata(path)
1753 .map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?
1754 .len();
1755 if bytes == 0 {
1756 return Err("the downloaded .gwbk is empty".to_string());
1757 }
1758 let file = std::fs::File::open(path)
1759 .map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?;
1760 let archive = zip::ZipArchive::new(file).map_err(|_| {
1761 "the downloaded .gwbk is not a readable ZIP (no central directory), so the download is incomplete"
1762 .to_string()
1763 })?;
1764 if archive.is_empty() {
1765 return Err("the downloaded .gwbk is a ZIP with no entries".to_string());
1766 }
1767 Ok(bytes)
1768}
1769
1770struct RunScope<'a> {
1772 plan: &'a DeployPlanResult,
1773 run_id: &'a RunId,
1774 run_dir: &'a RunDir,
1775 gwbk: Option<&'a str>,
1777 restart_before: Option<&'a [String]>,
1779 record: &'a RunRecord,
1782}
1783
1784async fn roll_back(
1787 ctx: &ApplyContext<'_>,
1788 opts: &ApplyOptions,
1789 scope: &RunScope<'_>,
1790 held: Option<&HeldLock>,
1791 cause: RollbackCause,
1792 steps: &mut Steps<'_>,
1793 mut warnings: Vec<String>,
1794) -> Result<ApplyOutcome, CoreError> {
1795 let restored = restore_snapshot(
1796 &ctx.restore_context(opts),
1797 &RestoreInput {
1798 run_id: scope.run_id,
1799 run_dir: scope.run_dir,
1800 record: scope.record,
1801 held,
1802 cause: Some(cause),
1803 },
1804 steps,
1805 )
1806 .await?;
1807 warnings.extend(restored.warnings);
1808 let (restart_pending, restart_pending_new) =
1809 restart_report(ctx.deploy, scope.restart_before, &mut warnings).await;
1810 Ok(ApplyOutcome {
1811 kind: OutcomeKind::RolledBack,
1812 run_id: Some(scope.run_id.as_str().to_string()),
1813 run_dir: Some(scope.run_dir.path().to_string_lossy().into_owned()),
1814 cause: Some(cause),
1815 render_sha256: scope.plan.render_sha256.clone(),
1816 plan_sha256: scope.plan.plan_sha256.clone(),
1817 written: Vec::new(),
1818 deleted: Vec::new(),
1819 post_fingerprint: Some(scope.record.pre_fingerprint.clone()),
1820 warnings,
1821 gwbk: scope.gwbk.map(str::to_string),
1822 skip_gwbk: opts.skip_gwbk,
1823 restart_pending,
1824 restart_pending_new,
1825 snapshot_sha256: restored.snapshot_sha256,
1826 backup: None,
1827 })
1828}
1829
1830pub struct RestoreInput<'a> {
1832 pub run_id: &'a RunId,
1836 pub run_dir: &'a RunDir,
1839 pub record: &'a RunRecord,
1841 pub held: Option<&'a HeldLock>,
1843 pub cause: Option<RollbackCause>,
1846}
1847
1848pub struct RestoreContext<'a> {
1851 pub env: &'a str,
1853 pub master: &'a NodeTarget,
1855 pub host: &'a dyn ApplyHost,
1857 pub deploy: &'a dyn DeployApi,
1859 pub clock: &'a dyn Clock,
1861 pub hold: &'a HoldModel,
1863 pub poll: &'a PollConfig,
1865}
1866
1867#[derive(Debug, Clone, PartialEq, Eq)]
1869pub struct RestoreReport {
1870 pub restored: usize,
1872 pub removed: usize,
1874 pub fresh_lock: bool,
1877 pub warnings: Vec<String>,
1879 pub snapshot_sha256: BTreeMap<String, String>,
1882}
1883
1884pub async fn restore_snapshot(
1908 ctx: &RestoreContext<'_>,
1909 input: &RestoreInput<'_>,
1910 steps: &mut Steps<'_>,
1911) -> Result<RestoreReport, CoreError> {
1912 let record = input.record;
1913 match restore_steps(ctx, input, steps).await {
1914 Ok(report) => Ok(report),
1915 Err(cause) => Err(CoreError::RollbackFailed(Box::new(RollbackFailure {
1916 run_id: record.run_id.clone(),
1917 env: ctx.env.to_string(),
1918 run_dir: Some(input.run_dir.path().to_string_lossy().into_owned()),
1919 journal: journal_location(
1920 LedgerRoot::for_node(ctx.master),
1921 &record.gateway,
1922 &record.mode,
1923 ),
1924 cause,
1925 }))),
1926 }
1927}
1928
1929async fn restore_steps(
1931 ctx: &RestoreContext<'_>,
1932 input: &RestoreInput<'_>,
1933 steps: &mut Steps<'_>,
1934) -> Result<RestoreReport, String> {
1935 let gw = |step: &'static str| move |e: CoreError| format!("{step}: {}", e.code());
1938 let host = |step: &'static str| move |e: CoreError| format!("{step}: {}", with_env(e, ctx.env));
1939 let event = |e: CoreError| format!("events: {}", e.code());
1940
1941 let record = input.record;
1942 let deploy = ctx.deploy;
1943 steps
1944 .emit(ApplyEvent::RestoreStarted { cause: input.cause })
1945 .map_err(event)?;
1946
1947 let need_s = ctx.hold.estimate_commit_s(record.touched.len()).ceil() as i64 + LOCK_MARGIN_S;
1949 let reuse = match input.held {
1950 Some(lock) => match lock
1951 .check_gateway(deploy, need_s)
1952 .await
1953 .map_err(gw("checking the scan-lock"))?
1954 {
1955 LockVerdict::Held { .. } => true,
1956 LockVerdict::Expiring { .. } => {
1957 lock.wait_for_lapse(deploy, ctx.poll.interval)
1958 .await
1959 .map_err(gw("waiting for the scan-lock to lapse"))?;
1960 false
1961 }
1962 LockVerdict::NotHeld => false,
1963 },
1964 None => false,
1965 };
1966 if !reuse {
1967 HeldLock::acquire(deploy, ScanKind::Config, ctx.hold.hold_s, ctx.clock)
1968 .await
1969 .map_err(gw("taking the scan-lock"))?;
1970 steps
1971 .emit(ApplyEvent::LockAcquired {
1972 hold_s: ctx.hold.hold_s,
1973 })
1974 .map_err(event)?;
1975 }
1976
1977 let report = ctx.host.restore(record).await.map_err(host("restore"))?;
1979 if let Some(failure) = &report.failure {
1980 return Err(format!("restore: {failure}"));
1981 }
1982 if report.stale {
1983 return Err("restore: the host reported a stale pre-image".to_string());
1984 }
1985 match scan_and_wait(deploy, ScanKind::Config, ctx.poll.clone())
1986 .await
1987 .map_err(gw("the scan after the restore"))?
1988 {
1989 ScanOutcome::Complete(_) => steps.emit(ApplyEvent::ScanDone).map_err(event)?,
1990 ScanOutcome::NotComplete { .. } => {
1991 return Err("the scan after the restore did not complete".to_string());
1992 }
1993 }
1994
1995 let touched: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
1997 let live = ctx
1998 .host
1999 .fingerprint(&touched)
2000 .await
2001 .map_err(host("reading the restored files"))?;
2002 let pre = record.pre_map();
2003 let differing: Vec<String> = pre
2004 .iter()
2005 .filter(|(path, hash)| live.get(*path) != Some(*hash))
2006 .map(|(path, _)| path.clone())
2007 .collect();
2008 if !differing.is_empty() {
2009 return Err(format!(
2010 "{} restored path(s) differ from the snapshot: {}",
2011 differing.len(),
2012 listed(&differing)
2013 ));
2014 }
2015 let mode = deploy
2016 .deployment_mode()
2017 .await
2018 .map_err(gw("reading the deployment mode"))?;
2019 if mode.as_deref() != Some(record.mode.as_str()) {
2020 return Err("the gateway's deployment mode is not the target's".to_string());
2021 }
2022
2023 let mut rolled_back = record.clone();
2025 rolled_back.state = RecordState::RolledBack;
2026 rolled_back.rolled_back_by = Some(input.run_id.as_str().to_string());
2027 rolled_back.post_fingerprint = Some(record.pre_fingerprint.clone());
2028 ctx.host
2029 .finalize(&rolled_back)
2030 .await
2031 .map_err(host("finalize"))?;
2032 steps.emit(ApplyEvent::Restored).map_err(event)?;
2033
2034 let mut warnings = Vec::new();
2035 let mut snapshot_sha256 = BTreeMap::new();
2036 match RunId::parse(&record.run_id) {
2037 Ok(restored_run) => {
2038 match ctx
2039 .host
2040 .fetch_snapshot(&restored_run, input.run_dir.path())
2041 .await
2042 {
2043 Ok(hashes) => {
2044 warnings.extend(snapshot_warnings(record, &hashes));
2045 snapshot_sha256 = hashes;
2046 }
2047 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
2048 }
2049 }
2050 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
2051 }
2052 Ok(RestoreReport {
2053 restored: report.moved.len(),
2054 removed: report.deleted.len(),
2055 fresh_lock: !reuse,
2056 warnings,
2057 snapshot_sha256,
2058 })
2059}
2060
2061fn listed(paths: &[String]) -> String {
2063 let shown = paths.len().min(crate::error::APPLY_LIST_SHOWN);
2064 let mut out = paths[..shown].join(", ");
2065 if paths.len() > shown {
2066 out.push_str(&format!(" and {} more", paths.len() - shown));
2067 }
2068 out
2069}
2070
2071async fn plan_stale(
2076 ctx: &ApplyContext<'_>,
2077 plan: &DeployPlanResult,
2078 opts: &ApplyOptions,
2079 run_id: &RunId,
2080) -> CoreError {
2081 let _ = ctx.host.discard_interrupted(run_id).await;
2084 let _ = ctx.deploy.scan_request(ScanKind::Config).await;
2085 let actual = match deploy_plan(
2086 ctx.declared,
2087 ctx.reader,
2088 &PlanOptions {
2089 base: opts.base.clone(),
2090 diff: false,
2091 },
2092 )
2093 .await
2094 {
2095 Ok(now) => now.plan_sha256,
2096 Err(_) => "unavailable".to_string(),
2097 };
2098 CoreError::PlanStale {
2099 expected: plan.plan_sha256.clone(),
2100 actual,
2101 detail: "a live file changed between the plan and the lock; nothing was written"
2102 .to_string(),
2103 }
2104}
2105
2106#[cfg(test)]
2107mod tests {
2108 use super::*;
2109
2110 fn record(touched: &[(&str, Option<&str>)]) -> RunRecord {
2111 let touched: Vec<TouchedEntry> = touched
2112 .iter()
2113 .map(|(path, pre)| TouchedEntry {
2114 path: (*path).to_string(),
2115 pre_sha256: pre.map(str::to_string),
2116 post_sha256: Some("f".repeat(64)),
2117 })
2118 .collect();
2119 RunRecord {
2120 schema_version: 1,
2121 run_id: "20261016T000000Z-aaaaaa".to_string(),
2122 gateway: "gw".to_string(),
2123 mode: "dev".to_string(),
2124 render_sha256: "a".repeat(64),
2125 plan_sha256: "b".repeat(64),
2126 state: RecordState::Applying,
2127 rolled_back_by: None,
2128 touched,
2129 pre_fingerprint: "c".repeat(64),
2130 post_fingerprint: None,
2131 }
2132 }
2133
2134 #[test]
2135 fn the_commit_budget_is_the_hold_less_its_margin_and_never_zero() {
2136 let hold = |hold_s| HoldModel {
2137 hold_s,
2138 ..HoldModel::MEASURED
2139 };
2140 assert_eq!(commit_budget_s(&hold(176)), 174);
2143 assert_eq!(commit_budget_s(&HoldModel::MEASURED), 174);
2144 assert_eq!(commit_budget_s(&hold(2)), 1);
2147 assert_eq!(commit_budget_s(&hold(0)), 1);
2148 assert_eq!(commit_budget_s(&hold(1_000_000)), BUDGET_MAX_S);
2149 }
2150
2151 #[test]
2152 fn snapshot_warnings_name_a_differing_or_missing_copy_and_skip_created_paths() {
2153 let (h1, h2) = ("1".repeat(64), "2".repeat(64));
2154 let rec = record(&[
2155 ("a/config.json", Some(&h1)),
2156 ("b/config.json", Some(&h1)),
2157 ("c/config.json", None),
2158 (LEDGER_KEY, Some(&h2)),
2159 ]);
2160 let mut copied = BTreeMap::new();
2161 copied.insert("files/a/config.json".to_string(), h1.clone());
2162 copied.insert("files/b/config.json".to_string(), h2.clone());
2163 copied.insert("ledger.json".to_string(), h2);
2164 assert_eq!(
2167 snapshot_warnings(&rec, &copied),
2168 ["snapshot_copy_differs (b/config.json)"]
2169 );
2170 copied.remove("files/a/config.json");
2172 assert_eq!(
2173 snapshot_warnings(&rec, &copied),
2174 [
2175 "snapshot_copy_differs (a/config.json)",
2176 "snapshot_copy_differs (b/config.json)"
2177 ]
2178 );
2179 }
2180}