1pub mod converge;
89pub mod fingerprint;
90pub mod gates;
91pub mod hold;
92pub mod host;
93pub mod journal;
94pub mod preflight;
95pub mod record;
96pub mod rollback;
97pub mod run_store;
98
99use std::collections::{BTreeMap, BTreeSet};
100use std::path::{Path, PathBuf};
101
102use serde::Serialize;
103use serde_json::{Value, json};
104
105use crate::actions::backup::backup_download;
106use crate::actions::deploy::{
107 Declared, DeployPlanResult, PlanOptions, VerifyOptions, deploy_plan, deploy_verify,
108};
109use crate::actions::workspace::rfc3339_now_utc;
110use crate::client::backup::BackupType;
111use crate::client::deploy_api::scan_and_wait;
112use crate::client::scan::{LockVerdict, ScanKind, ScanOutcome};
113use crate::client::{DeployApi, GatewayApi};
114use crate::deploy::layout::RelPath;
115use crate::deploy::ledger::{Ledger, LedgerRoot, journal_location, ledger_location};
116use crate::deploy::pair::{BackupNode, BackupPolicy, BackupReport, wall_clock_ms};
117use crate::deploy::plan::PlanStatus;
118use crate::deploy::target::NodeTarget;
119use crate::deploy::transport::TreeReader;
120use crate::deploy::transport::batch::BUDGET_MAX_S;
121use crate::deploy::tree::sha256_hex;
122use crate::error::{CoreError, PreflightFailure, RollbackFailure};
123use crate::poll::PollConfig;
124
125use self::fingerprint::{LEDGER_KEY, fingerprint};
126use self::gates::GateFlags;
127use self::hold::{Clock, HeldLock, HoldModel, LOCK_MARGIN_S};
128use self::host::{ApplyHost, BatchSpec, DeleteOp, WriteOp};
129use self::journal::{Journal, JournalPhase, journal_on_box};
130use self::preflight::{DesignersPolicy, PreflightInput, run_preflights};
131use self::record::{RecordState, RunRecord, TouchedEntry};
132use self::run_store::{EventLog, RunDir, RunId};
133
134pub use self::rollback::{
135 InterruptedRun, RollbackChecked, RollbackContext, RollbackKind, RollbackOptions,
136 RollbackOutcome, check_rollback, restore_set, run_rollback,
137};
138
139const LOCKED_SAMPLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
143
144pub const ROLLED_BACK_SLUG: &str = "deploy_rolled_back";
152
153pub const BACKUP_UNSYNCED_SLUG: &str = "deploy_backup_unsynced";
162
163const GWBK_CHECK: &str = "gwbk";
165
166const RESTART_UNAVAILABLE: &str = "restart_pending_unavailable";
169
170const EVENTS_UNWRITABLE: &str = "events_log_unwritable";
173
174#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
176#[serde(rename_all = "snake_case")]
177pub enum OutcomeKind {
178 Applied,
180 Noop,
182 RolledBack,
184 AppliedBackupUnsynced,
188}
189
190impl OutcomeKind {
191 pub fn as_str(self) -> &'static str {
193 match self {
194 OutcomeKind::Applied => "applied",
195 OutcomeKind::Noop => "noop",
196 OutcomeKind::RolledBack => "rolled_back",
197 OutcomeKind::AppliedBackupUnsynced => "applied_backup_unsynced",
198 }
199 }
200}
201
202#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
204#[serde(rename_all = "snake_case")]
205pub enum RollbackCause {
206 LockExpired,
208 WriteFailed,
210 ScanIncomplete,
212 VerifyFailed,
214}
215
216impl RollbackCause {
217 pub fn as_str(self) -> &'static str {
219 match self {
220 RollbackCause::LockExpired => "lock_expired",
221 RollbackCause::WriteFailed => "write_failed",
222 RollbackCause::ScanIncomplete => "scan_incomplete",
223 RollbackCause::VerifyFailed => "verify_failed",
224 }
225 }
226}
227
228#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct WriteSet {
231 pub writes: Vec<WriteOp>,
233 pub deletes: Vec<DeleteOp>,
235}
236
237pub fn write_set(plan: &DeployPlanResult, declared: &Declared) -> Result<WriteSet, CoreError> {
249 let defect = |why: String| CoreError::Internal(format!("write set: {why}"));
250 let mut writes = Vec::new();
251 let mut live: BTreeMap<&str, (bool, Option<&str>)> = BTreeMap::new();
253 for entry in &plan.entries {
254 let in_mode = entry.live_in.iter().any(|c| c == "mode");
255 for file in &entry.files {
256 live.insert(file.path.as_str(), (in_mode, file.live_sha256.as_deref()));
257 }
258 if !matches!(entry.status, PlanStatus::Added | PlanStatus::Changed) {
259 continue;
260 }
261 for file in &entry.files {
262 let Some(desired) = &file.desired_sha256 else {
263 continue;
264 };
265 let path = RelPath::new(&file.path)
266 .map_err(|why| defect(format!("the plan names an invalid path ({why})")))?;
267 let bytes =
268 declared.outcome.tree.files.get(&path).ok_or_else(|| {
269 defect(format!("{path} is in the plan but not in the render"))
270 })?;
271 if sha256_hex(bytes) != *desired {
272 return Err(defect(format!(
273 "{path} was rendered differently from the plan"
274 )));
275 }
276 writes.push(WriteOp {
277 path,
278 sha256: desired.clone(),
279 pre: if in_mode {
280 file.live_sha256.clone()
281 } else {
282 None
283 },
284 });
285 }
286 }
287 let mut deletes = Vec::new();
288 for path in &plan.delete_set.files {
289 let Some((true, Some(pre))) = live.get(path.as_str()) else {
290 return Err(defect(format!(
291 "{path} is in the delete set without a pre-image in the plan"
292 )));
293 };
294 deletes.push(DeleteOp {
295 path: path.clone(),
296 pre: (*pre).to_string(),
297 });
298 }
299 writes.sort_by(|a, b| a.path.as_str().as_bytes().cmp(b.path.as_str().as_bytes()));
300 deletes.sort_by(|a, b| a.path.as_str().as_bytes().cmp(b.path.as_str().as_bytes()));
301 Ok(WriteSet { writes, deletes })
302}
303
304fn new_ledger(
307 plan: &DeployPlanResult,
308 old: Option<&Ledger>,
309 set: &WriteSet,
310 run_id: &RunId,
311) -> Ledger {
312 let mut files: BTreeSet<String> = old
313 .map(|ledger| ledger.files.iter().cloned().collect())
314 .unwrap_or_default();
315 for op in &set.deletes {
316 files.remove(op.path.as_str());
317 }
318 for op in &set.writes {
319 files.insert(op.path.as_str().to_string());
320 }
321 Ledger {
322 schema_version: 1,
323 gateway: plan.gateway.clone(),
324 mode: plan.mode.clone(),
325 render_sha256: plan.render_sha256.clone(),
326 run_id: run_id.as_str().to_string(),
327 files: files.into_iter().collect(),
328 }
329}
330
331fn commit_budget_s(hold: &HoldModel) -> u32 {
338 let seconds = hold.hold_s.saturating_sub(LOCK_MARGIN_S as u64);
339 u32::try_from(seconds.clamp(1, u64::from(BUDGET_MAX_S))).unwrap_or(BUDGET_MAX_S)
340}
341
342fn batch_spec(
345 plan: &DeployPlanResult,
346 run_id: &RunId,
347 set: &WriteSet,
348 ledger_new: Vec<u8>,
349 ledger_pre: Option<String>,
350 commit_budget_s: u32,
351) -> BatchSpec {
352 let mut touched: Vec<TouchedEntry> = set
353 .writes
354 .iter()
355 .map(|w| TouchedEntry {
356 path: w.path.as_str().to_string(),
357 pre_sha256: w.pre.clone(),
358 post_sha256: Some(w.sha256.clone()),
359 })
360 .chain(set.deletes.iter().map(|d| TouchedEntry {
361 path: d.path.as_str().to_string(),
362 pre_sha256: Some(d.pre.clone()),
363 post_sha256: None,
364 }))
365 .collect();
366 touched.push(TouchedEntry {
367 path: LEDGER_KEY.to_string(),
368 pre_sha256: ledger_pre.clone(),
369 post_sha256: Some(sha256_hex(&ledger_new)),
370 });
371 touched.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
372 let pre_fingerprint = fingerprint(
373 &touched
374 .iter()
375 .map(|e| (e.path.clone(), e.pre_sha256.clone()))
376 .collect(),
377 );
378 let run = run_id.as_str().to_string();
379 BatchSpec {
380 run_id: run_id.clone(),
381 gateway: plan.gateway.clone(),
382 mode: plan.mode.clone(),
383 writes: set.writes.clone(),
384 deletes: set.deletes.clone(),
385 ledger_new: Some(ledger_new),
386 ledger_pre,
387 commit_budget_s: Some(commit_budget_s),
388 journal: Journal {
389 schema_version: 1,
390 run_id: run.clone(),
391 gateway: plan.gateway.clone(),
392 mode: plan.mode.clone(),
393 phase: JournalPhase::Snapshotting,
394 pre_fingerprint: pre_fingerprint.clone(),
395 touched: touched.iter().map(|e| e.path.clone()).collect(),
396 },
397 record: RunRecord {
398 schema_version: 1,
399 run_id: run,
400 gateway: plan.gateway.clone(),
401 mode: plan.mode.clone(),
402 render_sha256: plan.render_sha256.clone(),
403 plan_sha256: plan.plan_sha256.clone(),
404 state: RecordState::Applying,
405 rolled_back_by: None,
406 touched,
407 pre_fingerprint,
408 post_fingerprint: None,
409 },
410 }
411}
412
413#[derive(Debug, Clone, PartialEq, Eq)]
415pub enum ApplyEvent {
416 RunStarted {
418 run_id: String,
420 writes: usize,
422 deletes: usize,
424 },
425 Staged {
427 files: usize,
429 },
430 LockAcquired {
432 hold_s: u64,
434 },
435 Committed {
437 moved: usize,
439 deleted: usize,
441 },
442 LockVerdict {
444 held: bool,
446 },
447 ScanDone,
449 VerifyDone {
451 ok: bool,
453 },
454 Finalized,
456 CommitInterrupted {
460 code: String,
462 },
463 VerifyChecks {
465 checks: Vec<(String, String)>,
467 },
468 RestoreStarted {
471 cause: Option<RollbackCause>,
473 },
474 LiveChangesDiscarded {
477 count: usize,
479 paths: Vec<String>,
481 },
482 Restored,
484 Cleared,
487 InterruptedRun {
491 phase: &'static str,
493 action: &'static str,
495 hashes: BTreeMap<String, Option<String>>,
497 },
498 PreflightChecks {
500 checks: Vec<(String, String)>,
502 },
503 DesignersPoll {
505 open: usize,
507 },
508 DesignersProceed {
510 sessions: Vec<preflight::DesignerSeen>,
512 },
513 GwbkDownloaded {
515 bytes: u64,
517 },
518 GwbkSkipped,
520 BackupSampled {
524 last_sync: Option<i64>,
526 },
527 BackupWaitStarted {
529 budget_s: u64,
531 },
532 BackupPoll {
534 peer_connected: bool,
536 sync_pending: bool,
538 advanced: bool,
540 },
541 BackupResync {
543 ok: bool,
545 code: Option<String>,
547 },
548 BackupVerdict {
550 converged: bool,
552 reason: Option<&'static str>,
554 },
555 Failed {
557 code: String,
559 },
560}
561
562impl ApplyEvent {
563 pub fn name(&self) -> &'static str {
565 match self {
566 ApplyEvent::RunStarted { .. } => "run_started",
567 ApplyEvent::Staged { .. } => "staged",
568 ApplyEvent::LockAcquired { .. } => "lock_acquired",
569 ApplyEvent::Committed { .. } => "committed",
570 ApplyEvent::LockVerdict { .. } => "lock_verdict",
571 ApplyEvent::ScanDone => "scan_done",
572 ApplyEvent::VerifyDone { .. } => "verify_done",
573 ApplyEvent::VerifyChecks { .. } => "verify_checks",
574 ApplyEvent::Finalized => "finalized",
575 ApplyEvent::CommitInterrupted { .. } => "commit_interrupted",
576 ApplyEvent::RestoreStarted { .. } => "restore_started",
577 ApplyEvent::Restored => "restored",
578 ApplyEvent::Cleared => "cleared",
579 ApplyEvent::InterruptedRun { .. } => "interrupted_run",
580 ApplyEvent::LiveChangesDiscarded { .. } => "live_changes_discarded",
581 ApplyEvent::PreflightChecks { .. } => "preflight_checks",
582 ApplyEvent::DesignersPoll { .. } => "designers_poll",
583 ApplyEvent::DesignersProceed { .. } => "designers_proceed",
584 ApplyEvent::GwbkDownloaded { .. } => "gwbk_downloaded",
585 ApplyEvent::GwbkSkipped => "gwbk_skipped",
586 ApplyEvent::BackupSampled { .. } => "backup_sampled",
587 ApplyEvent::BackupWaitStarted { .. } => "backup_wait_started",
588 ApplyEvent::BackupPoll { .. } => "backup_poll",
589 ApplyEvent::BackupResync { .. } => "backup_resync",
590 ApplyEvent::BackupVerdict { .. } => "backup_verdict",
591 ApplyEvent::Failed { .. } => "failed",
592 }
593 }
594
595 pub fn fields(&self) -> Value {
597 match self {
598 ApplyEvent::RunStarted {
599 run_id,
600 writes,
601 deletes,
602 } => json!({"run_id": run_id, "writes": writes, "deletes": deletes}),
603 ApplyEvent::Staged { files } => json!({"files": files}),
604 ApplyEvent::LockAcquired { hold_s } => json!({"hold_s": hold_s}),
605 ApplyEvent::Committed { moved, deleted } => {
606 json!({"moved": moved, "deleted": deleted})
607 }
608 ApplyEvent::LockVerdict { held } => json!({"held": held}),
609 ApplyEvent::VerifyDone { ok } => json!({"ok": ok}),
610 ApplyEvent::VerifyChecks { checks } => json!({
611 "checks": checks
612 .iter()
613 .map(|(id, status)| json!({"id": id, "status": status}))
614 .collect::<Vec<_>>(),
615 }),
616 ApplyEvent::PreflightChecks { checks } => json!({
617 "checks": checks
618 .iter()
619 .map(|(id, status)| json!({"id": id, "status": status}))
620 .collect::<Vec<_>>(),
621 }),
622 ApplyEvent::DesignersPoll { open } => json!({"open": open}),
623 ApplyEvent::DesignersProceed { sessions } => json!({
624 "sessions": sessions
625 .iter()
626 .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
627 .collect::<Vec<_>>(),
628 }),
629 ApplyEvent::RestoreStarted { cause } => {
630 json!({"cause": cause.map(RollbackCause::as_str)})
631 }
632 ApplyEvent::LiveChangesDiscarded { count, paths } => {
633 json!({"count": count, "paths": paths})
634 }
635 ApplyEvent::InterruptedRun {
636 phase,
637 action,
638 hashes,
639 } => json!({
640 "phase": phase,
641 "action": action,
642 "touched": hashes.len(),
643 "hashes": hashes,
644 }),
645 ApplyEvent::GwbkDownloaded { bytes } => json!({"bytes": bytes}),
646 ApplyEvent::BackupSampled { last_sync } => json!({"last_sync": last_sync}),
647 ApplyEvent::BackupWaitStarted { budget_s } => json!({"budget_s": budget_s}),
648 ApplyEvent::BackupPoll {
649 peer_connected,
650 sync_pending,
651 advanced,
652 } => json!({
653 "peer_connected": peer_connected,
654 "sync_pending": sync_pending,
655 "advanced": advanced,
656 }),
657 ApplyEvent::BackupResync { ok, code } => json!({"ok": ok, "code": code}),
658 ApplyEvent::BackupVerdict { converged, reason } => {
659 json!({"converged": converged, "reason": reason})
660 }
661 ApplyEvent::CommitInterrupted { code } | ApplyEvent::Failed { code } => {
662 json!({"code": code})
663 }
664 ApplyEvent::ScanDone
665 | ApplyEvent::Finalized
666 | ApplyEvent::Restored
667 | ApplyEvent::Cleared
668 | ApplyEvent::GwbkSkipped => Value::Null,
669 }
670 }
671}
672
673pub trait ApplyObserver: Send + Sync {
675 fn event(&self, e: &ApplyEvent);
677}
678
679#[derive(Debug, Clone, Copy, Default)]
681pub struct NoObserver;
682
683impl ApplyObserver for NoObserver {
684 fn event(&self, _e: &ApplyEvent) {}
685}
686
687pub struct ApplyContext<'a> {
689 pub declared: &'a Declared,
691 pub repo: &'a std::path::Path,
693 pub reader: &'a dyn TreeReader,
695 pub host: &'a dyn ApplyHost,
697 pub deploy: &'a dyn DeployApi,
699 pub gateway: &'a dyn GatewayApi,
701 pub backup: Option<&'a BackupNode<'a>>,
704 pub clock: &'a dyn Clock,
706 pub observer: &'a dyn ApplyObserver,
708}
709
710impl<'a> ApplyContext<'a> {
711 fn restore_context(&'a self, opts: &'a ApplyOptions) -> RestoreContext<'a> {
713 RestoreContext {
714 env: &self.declared.env,
715 master: &self.declared.target.nodes.master,
716 host: self.host,
717 deploy: self.deploy,
718 clock: self.clock,
719 hold: &opts.hold,
720 poll: &opts.poll,
721 }
722 }
723}
724
725#[derive(Debug, Clone)]
727pub struct ApplyOptions {
728 pub base: Option<PathBuf>,
730 pub gates: GateFlags,
732 pub project: String,
734 pub hold: HoldModel,
736 pub poll: PollConfig,
738 pub verify: VerifyOptions,
740 pub designers: DesignersPolicy,
742 pub skip_gwbk: bool,
744 pub backup: BackupPolicy,
747}
748
749#[derive(Debug, Clone, PartialEq, Serialize)]
751pub struct ApplyOutcome {
752 #[serde(rename = "outcome")]
754 pub kind: OutcomeKind,
755 pub run_id: Option<String>,
757 pub run_dir: Option<String>,
759 pub cause: Option<RollbackCause>,
761 pub render_sha256: String,
763 pub plan_sha256: String,
765 pub written: Vec<String>,
767 pub deleted: Vec<String>,
769 pub post_fingerprint: Option<String>,
771 pub warnings: Vec<String>,
773 pub gwbk: Option<String>,
776 pub skip_gwbk: bool,
778 pub restart_pending: Vec<String>,
780 pub restart_pending_new: Vec<String>,
782 pub snapshot_sha256: BTreeMap<String, String>,
786 pub backup: Option<BackupReport>,
789}
790
791pub async fn run_apply(
802 ctx: &ApplyContext<'_>,
803 plan: &DeployPlanResult,
804 opts: &ApplyOptions,
805) -> Result<ApplyOutcome, CoreError> {
806 if let Some(refusal) = gates::unreadable_refusal(plan) {
807 return Err(refusal);
808 }
809 if !plan.summary.has_changes {
810 return Ok(ApplyOutcome {
811 kind: OutcomeKind::Noop,
812 run_id: None,
813 run_dir: None,
814 cause: None,
815 render_sha256: plan.render_sha256.clone(),
816 plan_sha256: plan.plan_sha256.clone(),
817 written: Vec::new(),
818 deleted: Vec::new(),
819 post_fingerprint: None,
820 warnings: Vec::new(),
821 gwbk: None,
822 skip_gwbk: false,
823 restart_pending: Vec::new(),
824 restart_pending_new: Vec::new(),
825 snapshot_sha256: BTreeMap::new(),
826 backup: None,
827 });
828 }
829 let (run_id, run_dir) = RunDir::create_fresh(ctx.repo, &plan.gateway)?;
830 let mut steps = Steps::new(None, ctx.observer);
831 let result = drive(ctx, plan, opts, &run_id, &run_dir, &mut steps).await;
832
833 if let Err(e) = &result {
835 let _ = steps.emit(ApplyEvent::Failed {
836 code: e.code().to_string(),
837 });
838 }
839 let doc = match &result {
840 Ok(outcome) => {
841 let mut doc = serde_json::to_value(outcome).unwrap_or(Value::Null);
842 doc["ok"] = Value::Bool(!matches!(
843 outcome.kind,
844 OutcomeKind::RolledBack | OutcomeKind::AppliedBackupUnsynced
845 ));
846 match outcome.kind {
847 OutcomeKind::RolledBack => {
848 doc["slug"] = Value::String(ROLLED_BACK_SLUG.to_string());
849 }
850 OutcomeKind::AppliedBackupUnsynced => {
851 doc["slug"] = Value::String(BACKUP_UNSYNCED_SLUG.to_string());
852 }
853 OutcomeKind::Applied | OutcomeKind::Noop => {}
854 }
855 doc
856 }
857 Err(e) => json!({
858 "ok": false,
859 "run_id": run_id.as_str(),
860 "error": {"code": e.code(), "message": e.to_string()},
861 }),
862 };
863 let written = run_dir.write_json("result.json", &doc);
864 match result {
865 Ok(mut outcome) => {
866 if written.is_err() {
867 outcome.warnings.push("result_json_unwritable".to_string());
868 }
869 Ok(outcome)
870 }
871 Err(e) => Err(e),
872 }
873}
874
875pub struct Steps<'a> {
878 log: Option<EventLog>,
879 observer: &'a dyn ApplyObserver,
880}
881
882impl<'a> Steps<'a> {
883 pub fn new(log: Option<EventLog>, observer: &'a dyn ApplyObserver) -> Self {
885 Steps { log, observer }
886 }
887
888 pub fn emit(&mut self, event: ApplyEvent) -> Result<(), CoreError> {
890 self.observer.event(&event);
891 match &mut self.log {
892 Some(log) => log.append(event.name(), event.fields()),
893 None => Ok(()),
894 }
895 }
896}
897
898fn with_env(err: CoreError, env: &str) -> CoreError {
900 match err {
901 CoreError::DeployJournalPresent {
902 run_id, location, ..
903 } => CoreError::DeployJournalPresent {
904 run_id,
905 env: env.to_string(),
906 location,
907 },
908 other => other,
909 }
910}
911
912enum BoxState {
915 Mine(Journal),
917 Other,
919 Absent,
921 Unknown,
923}
924
925async fn box_state(ctx: &ApplyContext<'_>, plan: &DeployPlanResult, run_id: &RunId) -> BoxState {
926 let master = &ctx.declared.target.nodes.master;
927 match journal_on_box(ctx.reader, master, &plan.gateway, &plan.mode).await {
928 Ok(Some(journal)) if journal.run_id == run_id.as_str() => BoxState::Mine(journal),
929 Ok(Some(_)) => BoxState::Other,
930 Ok(None) => BoxState::Absent,
931 Err(_) => BoxState::Unknown,
932 }
933}
934
935fn interrupted(
939 ctx: &ApplyContext<'_>,
940 plan: &DeployPlanResult,
941 steps: &mut Steps<'_>,
942 journal: &Journal,
943 err: &CoreError,
944) -> CoreError {
945 let _ = steps.emit(ApplyEvent::CommitInterrupted {
946 code: err.code().to_string(),
947 });
948 journal::journal_present(
949 journal,
950 &ctx.declared.env,
951 &ctx.declared.target.nodes.master,
952 &plan.gateway,
953 &plan.mode,
954 )
955}
956
957async fn commit_failed(
973 ctx: &ApplyContext<'_>,
974 run_id: &RunId,
975 plan: &DeployPlanResult,
976 steps: &mut Steps<'_>,
977 err: CoreError,
978) -> CoreError {
979 if matches!(err, CoreError::DeployJournalPresent { .. }) {
980 return err;
981 }
982 match box_state(ctx, plan, run_id).await {
983 BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
984 BoxState::Unknown => {
985 let _ = steps.emit(ApplyEvent::CommitInterrupted {
989 code: format!("{}; journal state unknown", err.code()),
990 });
991 recovery_pointer(ctx, plan, run_id, err)
992 }
993 BoxState::Other | BoxState::Absent => err,
994 }
995}
996
997async fn unfinished_after_green(
1004 ctx: &ApplyContext<'_>,
1005 run_id: &RunId,
1006 plan: &DeployPlanResult,
1007 steps: &mut Steps<'_>,
1008 err: CoreError,
1009) -> CoreError {
1010 match box_state(ctx, plan, run_id).await {
1011 BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
1012 _ => recovery_pointer(ctx, plan, run_id, err),
1013 }
1014}
1015
1016fn recovery_pointer(
1021 ctx: &ApplyContext<'_>,
1022 plan: &DeployPlanResult,
1023 run_id: &RunId,
1024 err: CoreError,
1025) -> CoreError {
1026 let env = &ctx.declared.env;
1027 match err {
1028 CoreError::TransportFailed {
1029 operation,
1030 node,
1031 detail,
1032 } => CoreError::TransportFailed {
1033 operation,
1034 node,
1035 detail: format!(
1036 "{detail}; this run's journal may be on the host: recover it with: \
1037 ign deploy rollback --run {run} --env {env}",
1038 run = run_id.as_str()
1039 ),
1040 },
1041 _ => CoreError::DeployJournalPresent {
1042 run_id: run_id.as_str().to_string(),
1043 env: env.clone(),
1044 location: journal_location(
1045 LedgerRoot::for_node(&ctx.declared.target.nodes.master),
1046 &plan.gateway,
1047 &plan.mode,
1048 ),
1049 },
1050 }
1051}
1052
1053async fn drive(
1057 ctx: &ApplyContext<'_>,
1058 plan: &DeployPlanResult,
1059 opts: &ApplyOptions,
1060 run_id: &RunId,
1061 run_dir: &RunDir,
1062 steps: &mut Steps<'_>,
1063) -> Result<ApplyOutcome, CoreError> {
1064 let declared = ctx.declared;
1065 let env = declared.env.as_str();
1066 let (gateway, mode) = (plan.gateway.as_str(), plan.mode.as_str());
1067
1068 let started_at = rfc3339_now_utc();
1069 steps.log = Some(EventLog::open(run_dir)?);
1070
1071 let set = write_set(plan, declared)?;
1072 steps.emit(ApplyEvent::RunStarted {
1073 run_id: run_id.as_str().to_string(),
1074 writes: set.writes.len(),
1075 deletes: set.deletes.len(),
1076 })?;
1077
1078 let touched_paths: Vec<String> = set
1082 .writes
1083 .iter()
1084 .map(|w| w.path.as_str().to_string())
1085 .chain(set.deletes.iter().map(|d| d.path.as_str().to_string()))
1086 .collect();
1087 let deleted_paths: Vec<String> = set
1088 .deletes
1089 .iter()
1090 .map(|d| d.path.as_str().to_string())
1091 .collect();
1092 let preflights = run_preflights(
1093 &PreflightInput {
1094 target: &declared.target,
1095 declared: Some(declared),
1096 master: ctx.deploy,
1097 backup: ctx.backup,
1098 gateway: ctx.gateway,
1099 reader: ctx.reader,
1100 host: ctx.host,
1101 touched_files: set.writes.len() + set.deletes.len(),
1102 touched_paths: &touched_paths,
1103 deleted_paths: &deleted_paths,
1104 hold: &opts.hold,
1105 designers: &opts.designers,
1106 clock: ctx.clock,
1107 },
1108 steps,
1109 )
1110 .await;
1111 let seen = preflights.as_ref().ok();
1112 run_dir.write_json(
1113 "run.json",
1114 &json!({
1115 "schema_version": 1,
1116 "run_id": run_id.as_str(),
1117 "env": env,
1118 "gateway": gateway,
1119 "mode": mode,
1120 "node": plan.node,
1121 "transport": plan.transport,
1122 "started_at": started_at,
1123 "run_dir_git": run_dir.git_status().as_record(),
1124 "render_sha256": plan.render_sha256,
1125 "plan_sha256": plan.plan_sha256,
1126 "hold_s": opts.hold.hold_s,
1127 "allow_destructive": opts.gates.allow_destructive,
1128 "allow_drift": opts.gates.allow_drift,
1129 "skip_gwbk": opts.skip_gwbk,
1130 "designers": opts.designers.mode.as_str(),
1131 "designers_timeout_s": opts.designers.timeout.as_secs(),
1132 "designers_seen": seen.map(|r| r.designers_seen
1133 .iter()
1134 .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
1135 .collect::<Vec<_>>()),
1136 "preflights": seen.map(|r| r.findings
1137 .iter()
1138 .map(|f| json!({"check": f.check.as_str(), "status": f.status.as_str()}))
1139 .collect::<Vec<_>>()),
1140 }),
1141 )?;
1142 run_dir.write_json("plan.json", plan)?;
1143 let preflights = preflights?;
1144 if !preflights.passed() {
1145 steps.emit(ApplyEvent::PreflightChecks {
1146 checks: preflights
1147 .findings
1148 .iter()
1149 .map(|f| (f.check.as_str().to_string(), f.status.as_str().to_string()))
1150 .collect(),
1151 })?;
1152 if !preflights.backup_unreadable.is_empty() {
1157 return Err(CoreError::DeployUnreadable {
1158 node: "backup".to_string(),
1159 keys: preflights.backup_unreadable,
1160 });
1161 }
1162 return Err(CoreError::DeployPreflightFailed {
1163 failures: preflights.failures().into(),
1164 });
1165 }
1166
1167 let mut backup_before = match ctx.backup {
1173 Some(backup) => {
1174 let sampled = backup.status().await?.last_sync_epoch_ms();
1175 steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
1176 sampled
1177 }
1178 None => None,
1179 };
1180 let mut backup_log_since_ms = wall_clock_ms();
1184
1185 let gwbk = take_gwbk(ctx, opts, run_id, run_dir, steps).await?;
1188
1189 let restart_before = ctx.deploy.restart_pending().await.ok();
1193
1194 let old_bytes = ctx.reader.read_ledger(gateway, mode).await?;
1196 let old_ledger = match &old_bytes {
1197 Some(bytes) => Some(Ledger::parse(
1198 &ledger_location(
1199 LedgerRoot::for_node(&declared.target.nodes.master),
1200 gateway,
1201 mode,
1202 ),
1203 bytes,
1204 gateway,
1205 mode,
1206 )?),
1207 None => None,
1208 };
1209 let ledger = new_ledger(plan, old_ledger.as_ref(), &set, run_id);
1210 let ledger_bytes = ledger.to_bytes();
1211 Ledger::parse(
1212 &ledger_location(
1213 LedgerRoot::for_node(&declared.target.nodes.master),
1214 gateway,
1215 mode,
1216 ),
1217 &ledger_bytes,
1218 gateway,
1219 mode,
1220 )?;
1221 let spec = batch_spec(
1222 plan,
1223 run_id,
1224 &set,
1225 ledger_bytes,
1226 old_bytes.as_deref().map(sha256_hex),
1227 commit_budget_s(&opts.hold),
1228 );
1229 if let Some(problem) = spec.problem() {
1230 return Err(CoreError::Internal(format!(
1231 "the batch the engine built is invalid: {problem}"
1232 )));
1233 }
1234
1235 let scope = RunScope {
1236 plan,
1237 run_id,
1238 run_dir,
1239 gwbk: gwbk.as_deref(),
1240 restart_before: restart_before.as_deref(),
1241 record: &spec.record,
1242 };
1243
1244 let prepared = ctx
1246 .host
1247 .prepare(&spec, &declared.outcome.tree)
1248 .await
1249 .map_err(|e| with_env(e, env))?;
1250 steps.emit(ApplyEvent::Staged {
1251 files: set.writes.len(),
1252 })?;
1253 let lock =
1254 match HeldLock::acquire(ctx.deploy, ScanKind::Config, opts.hold.hold_s, ctx.clock).await {
1255 Ok(lock) => lock,
1256 Err(refused) => {
1257 let _ = ctx.host.discard_interrupted(run_id).await;
1261 return Err(refused);
1262 }
1263 };
1264 steps.emit(ApplyEvent::LockAcquired {
1265 hold_s: opts.hold.hold_s,
1266 })?;
1267 if let Some(backup) = ctx.backup {
1279 let since_ms = wall_clock_ms();
1280 if let Ok(Ok(status)) = tokio::time::timeout(LOCKED_SAMPLE_TIMEOUT, backup.status()).await {
1281 let sampled = status.last_sync_epoch_ms();
1282 steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
1283 backup_before = sampled;
1284 backup_log_since_ms = since_ms;
1285 }
1286 }
1287 let report = match ctx.host.commit(&prepared).await {
1288 Ok(report) => report,
1289 Err(e) => return Err(commit_failed(ctx, run_id, plan, steps, with_env(e, env)).await),
1290 };
1291 if let Err(e) = steps.emit(ApplyEvent::Committed {
1292 moved: report.moved.len(),
1293 deleted: report.deleted.len(),
1294 }) {
1295 return Err(commit_failed(ctx, run_id, plan, steps, e).await);
1298 }
1299 if report.stale {
1300 return Err(plan_stale(ctx, plan, opts, run_id).await);
1301 }
1302 if report.failure.is_some() {
1303 let cause = if report.deadline {
1308 RollbackCause::LockExpired
1309 } else {
1310 RollbackCause::WriteFailed
1311 };
1312 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, Vec::new()).await;
1313 }
1314
1315 let mut warnings = match confirm(ctx, opts, &lock, steps).await {
1318 Ok(Confirmed::Green { warnings }) => warnings,
1319 Ok(Confirmed::Abort { cause, warnings }) => {
1320 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, warnings).await;
1321 }
1322 Err(Unconfirmed::Restore { cause, step, error }) => {
1323 let note = format!("post_commit_error ({step}: {})", error.code());
1326 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, vec![note]).await;
1327 }
1328 Err(Unconfirmed::Green(error)) => {
1329 return Err(unfinished_after_green(ctx, run_id, plan, steps, error).await);
1330 }
1331 };
1332
1333 let post = match ctx.host.fingerprint(&spec.touched_paths()).await {
1335 Ok(post) => post,
1336 Err(e) => return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await),
1337 };
1338 let post_fingerprint = fingerprint(&post);
1339 let mut record = spec.record.clone();
1340 record.state = RecordState::Applied;
1341 record.post_fingerprint = Some(post_fingerprint.clone());
1342 if let Err(e) = ctx.host.finalize(&record).await {
1343 if !matches!(box_state(ctx, plan, run_id).await, BoxState::Absent) {
1346 return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await);
1347 }
1348 warnings.push(format!("finalize_reported_error ({})", e.code()));
1349 }
1350 if steps.emit(ApplyEvent::Finalized).is_err() {
1351 warnings.push(EVENTS_UNWRITABLE.to_string());
1352 }
1353
1354 let (restart_pending, restart_pending_new) =
1355 restart_report(ctx.deploy, restart_before.as_deref(), &mut warnings).await;
1356 let mut snapshot_sha256 = BTreeMap::new();
1357 match ctx.host.fetch_snapshot(run_id, run_dir.path()).await {
1358 Ok(hashes) => {
1359 warnings.extend(snapshot_warnings(&spec.record, &hashes));
1360 snapshot_sha256 = hashes;
1361 }
1362 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
1363 }
1364
1365 let backup_report = match ctx.backup {
1368 Some(backup) => Some(
1369 converge::converge_backup(
1370 ctx,
1371 backup,
1372 opts,
1373 &set,
1374 converge::Baseline {
1375 last_sync: backup_before,
1376 log_since_ms: backup_log_since_ms,
1377 },
1378 steps,
1379 &mut warnings,
1380 )
1381 .await,
1382 ),
1383 None => None,
1384 };
1385 let kind = match &backup_report {
1386 Some(report) if !report.converged => OutcomeKind::AppliedBackupUnsynced,
1387 _ => OutcomeKind::Applied,
1388 };
1389 Ok(ApplyOutcome {
1390 kind,
1391 run_id: Some(run_id.as_str().to_string()),
1392 run_dir: Some(run_dir.path().to_string_lossy().into_owned()),
1393 cause: None,
1394 render_sha256: plan.render_sha256.clone(),
1395 plan_sha256: plan.plan_sha256.clone(),
1396 written: set
1397 .writes
1398 .iter()
1399 .map(|w| w.path.as_str().to_string())
1400 .collect(),
1401 deleted: set
1402 .deletes
1403 .iter()
1404 .map(|d| d.path.as_str().to_string())
1405 .collect(),
1406 post_fingerprint: Some(post_fingerprint),
1407 warnings,
1408 gwbk,
1409 skip_gwbk: opts.skip_gwbk,
1410 restart_pending,
1411 restart_pending_new,
1412 snapshot_sha256,
1413 backup: backup_report,
1414 })
1415}
1416
1417enum Confirmed {
1420 Green { warnings: Vec<String> },
1422 Abort {
1424 cause: RollbackCause,
1425 warnings: Vec<String>,
1426 },
1427}
1428
1429enum Unconfirmed {
1431 Restore {
1435 cause: RollbackCause,
1436 step: &'static str,
1437 error: CoreError,
1438 },
1439 Green(CoreError),
1441}
1442
1443async fn confirm(
1450 ctx: &ApplyContext<'_>,
1451 opts: &ApplyOptions,
1452 lock: &HeldLock,
1453 steps: &mut Steps<'_>,
1454) -> Result<Confirmed, Unconfirmed> {
1455 fn undone(cause: RollbackCause, step: &'static str) -> impl FnOnce(CoreError) -> Unconfirmed {
1456 move |error| Unconfirmed::Restore { cause, step, error }
1457 }
1458 let abort = |cause, warnings| Ok(Confirmed::Abort { cause, warnings });
1459
1460 let verdict = lock
1462 .check(ctx.deploy, LOCK_MARGIN_S, ctx.clock)
1463 .await
1464 .map_err(undone(RollbackCause::LockExpired, "lock_check"))?;
1465 let held = matches!(verdict, LockVerdict::Held { .. });
1466 steps
1467 .emit(ApplyEvent::LockVerdict { held })
1468 .map_err(undone(RollbackCause::LockExpired, "events"))?;
1469 if !held {
1470 return abort(RollbackCause::LockExpired, Vec::new());
1472 }
1473 match scan_and_wait(ctx.deploy, ScanKind::Config, opts.poll.clone())
1475 .await
1476 .map_err(undone(RollbackCause::ScanIncomplete, "scan"))?
1477 {
1478 ScanOutcome::Complete(_) => steps
1479 .emit(ApplyEvent::ScanDone)
1480 .map_err(undone(RollbackCause::ScanIncomplete, "events"))?,
1481 ScanOutcome::NotComplete { .. } => {
1482 return abort(RollbackCause::ScanIncomplete, Vec::new());
1483 }
1484 }
1485
1486 let verify = deploy_verify(
1487 ctx.declared,
1488 &opts.project,
1489 ctx.deploy,
1490 ctx.gateway,
1491 ctx.reader,
1492 &opts.verify,
1493 )
1494 .await
1495 .map_err(undone(RollbackCause::VerifyFailed, "verify"))?;
1496 if !verify.ok {
1497 steps
1498 .emit(ApplyEvent::VerifyDone { ok: false })
1499 .map_err(undone(RollbackCause::VerifyFailed, "events"))?;
1500 steps
1502 .emit(ApplyEvent::VerifyChecks {
1503 checks: verify
1504 .checks
1505 .iter()
1506 .map(|c| (c.id.word().to_string(), c.status.word().to_string()))
1507 .collect(),
1508 })
1509 .map_err(undone(RollbackCause::VerifyFailed, "events"))?;
1510 return abort(RollbackCause::VerifyFailed, verify.warnings);
1511 }
1512 steps
1513 .emit(ApplyEvent::VerifyDone { ok: true })
1514 .map_err(Unconfirmed::Green)?;
1515 Ok(Confirmed::Green {
1516 warnings: verify.warnings,
1517 })
1518}
1519
1520async fn restart_report(
1527 deploy: &dyn DeployApi,
1528 before: Option<&[String]>,
1529 warnings: &mut Vec<String>,
1530) -> (Vec<String>, Vec<String>) {
1531 let after = deploy.restart_pending().await.ok();
1532 if before.is_none() || after.is_none() {
1533 warnings.push(RESTART_UNAVAILABLE.to_string());
1534 }
1535 let pending = after.unwrap_or_default();
1536 let known = before.unwrap_or_default();
1537 let added = pending
1538 .iter()
1539 .filter(|entry| !known.contains(entry))
1540 .cloned()
1541 .collect();
1542 (pending, added)
1543}
1544
1545fn snapshot_warnings(record: &RunRecord, copied: &BTreeMap<String, String>) -> Vec<String> {
1549 record
1550 .pre_map()
1551 .into_iter()
1552 .filter_map(|(path, pre)| {
1553 let pre = pre?;
1554 let key = if path == LEDGER_KEY {
1555 "ledger.json".to_string()
1556 } else {
1557 format!("files/{path}")
1558 };
1559 (copied.get(&key) != Some(&pre)).then(|| format!("snapshot_copy_differs ({path})"))
1560 })
1561 .collect()
1562}
1563
1564async fn take_gwbk(
1581 ctx: &ApplyContext<'_>,
1582 opts: &ApplyOptions,
1583 run_id: &RunId,
1584 run_dir: &RunDir,
1585 steps: &mut Steps<'_>,
1586) -> Result<Option<String>, CoreError> {
1587 if opts.skip_gwbk {
1588 steps.emit(ApplyEvent::GwbkSkipped)?;
1589 return Ok(None);
1590 }
1591 let refuse = |detail: String| CoreError::DeployPreflightFailed {
1592 failures: vec![PreflightFailure {
1593 check: GWBK_CHECK.to_string(),
1594 detail,
1595 }]
1596 .into(),
1597 };
1598 let name = format!("{}.gwbk", run_id.as_str());
1599 drop(run_dir.create_private_file(&name)?);
1600 let path = run_dir.path().join(&name);
1601 let verdict = match backup_download(ctx.gateway, Some(&path), "", BackupType::All).await {
1602 Ok(_) => validate_gwbk(&path),
1603 Err(e) => Err(format!("the .gwbk was not downloaded ({})", e.code())),
1604 };
1605 match verdict {
1606 Ok(bytes) => {
1607 steps.emit(ApplyEvent::GwbkDownloaded { bytes })?;
1608 Ok(Some(name))
1609 }
1610 Err(detail) => {
1611 let _ = std::fs::remove_file(&path);
1612 Err(refuse(detail))
1613 }
1614 }
1615}
1616
1617fn validate_gwbk(path: &Path) -> Result<u64, String> {
1620 let bytes = std::fs::metadata(path)
1621 .map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?
1622 .len();
1623 if bytes == 0 {
1624 return Err("the downloaded .gwbk is empty".to_string());
1625 }
1626 let file = std::fs::File::open(path)
1627 .map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?;
1628 let archive = zip::ZipArchive::new(file).map_err(|_| {
1629 "the downloaded .gwbk is not a readable ZIP (no central directory), so the download is incomplete"
1630 .to_string()
1631 })?;
1632 if archive.is_empty() {
1633 return Err("the downloaded .gwbk is a ZIP with no entries".to_string());
1634 }
1635 Ok(bytes)
1636}
1637
1638struct RunScope<'a> {
1640 plan: &'a DeployPlanResult,
1641 run_id: &'a RunId,
1642 run_dir: &'a RunDir,
1643 gwbk: Option<&'a str>,
1645 restart_before: Option<&'a [String]>,
1647 record: &'a RunRecord,
1650}
1651
1652async fn roll_back(
1655 ctx: &ApplyContext<'_>,
1656 opts: &ApplyOptions,
1657 scope: &RunScope<'_>,
1658 held: Option<&HeldLock>,
1659 cause: RollbackCause,
1660 steps: &mut Steps<'_>,
1661 mut warnings: Vec<String>,
1662) -> Result<ApplyOutcome, CoreError> {
1663 let restored = restore_snapshot(
1664 &ctx.restore_context(opts),
1665 &RestoreInput {
1666 run_id: scope.run_id,
1667 run_dir: scope.run_dir,
1668 record: scope.record,
1669 held,
1670 cause: Some(cause),
1671 },
1672 steps,
1673 )
1674 .await?;
1675 warnings.extend(restored.warnings);
1676 let (restart_pending, restart_pending_new) =
1677 restart_report(ctx.deploy, scope.restart_before, &mut warnings).await;
1678 Ok(ApplyOutcome {
1679 kind: OutcomeKind::RolledBack,
1680 run_id: Some(scope.run_id.as_str().to_string()),
1681 run_dir: Some(scope.run_dir.path().to_string_lossy().into_owned()),
1682 cause: Some(cause),
1683 render_sha256: scope.plan.render_sha256.clone(),
1684 plan_sha256: scope.plan.plan_sha256.clone(),
1685 written: Vec::new(),
1686 deleted: Vec::new(),
1687 post_fingerprint: Some(scope.record.pre_fingerprint.clone()),
1688 warnings,
1689 gwbk: scope.gwbk.map(str::to_string),
1690 skip_gwbk: opts.skip_gwbk,
1691 restart_pending,
1692 restart_pending_new,
1693 snapshot_sha256: restored.snapshot_sha256,
1694 backup: None,
1695 })
1696}
1697
1698pub struct RestoreInput<'a> {
1700 pub run_id: &'a RunId,
1704 pub run_dir: &'a RunDir,
1707 pub record: &'a RunRecord,
1709 pub held: Option<&'a HeldLock>,
1711 pub cause: Option<RollbackCause>,
1714}
1715
1716pub struct RestoreContext<'a> {
1719 pub env: &'a str,
1721 pub master: &'a NodeTarget,
1723 pub host: &'a dyn ApplyHost,
1725 pub deploy: &'a dyn DeployApi,
1727 pub clock: &'a dyn Clock,
1729 pub hold: &'a HoldModel,
1731 pub poll: &'a PollConfig,
1733}
1734
1735#[derive(Debug, Clone, PartialEq, Eq)]
1737pub struct RestoreReport {
1738 pub restored: usize,
1740 pub removed: usize,
1742 pub fresh_lock: bool,
1745 pub warnings: Vec<String>,
1747 pub snapshot_sha256: BTreeMap<String, String>,
1750}
1751
1752pub async fn restore_snapshot(
1776 ctx: &RestoreContext<'_>,
1777 input: &RestoreInput<'_>,
1778 steps: &mut Steps<'_>,
1779) -> Result<RestoreReport, CoreError> {
1780 let record = input.record;
1781 match restore_steps(ctx, input, steps).await {
1782 Ok(report) => Ok(report),
1783 Err(cause) => Err(CoreError::RollbackFailed(Box::new(RollbackFailure {
1784 run_id: record.run_id.clone(),
1785 env: ctx.env.to_string(),
1786 run_dir: Some(input.run_dir.path().to_string_lossy().into_owned()),
1787 journal: journal_location(
1788 LedgerRoot::for_node(ctx.master),
1789 &record.gateway,
1790 &record.mode,
1791 ),
1792 cause,
1793 }))),
1794 }
1795}
1796
1797async fn restore_steps(
1799 ctx: &RestoreContext<'_>,
1800 input: &RestoreInput<'_>,
1801 steps: &mut Steps<'_>,
1802) -> Result<RestoreReport, String> {
1803 let gw = |step: &'static str| move |e: CoreError| format!("{step}: {}", e.code());
1806 let host = |step: &'static str| move |e: CoreError| format!("{step}: {}", with_env(e, ctx.env));
1807 let event = |e: CoreError| format!("events: {}", e.code());
1808
1809 let record = input.record;
1810 let deploy = ctx.deploy;
1811 steps
1812 .emit(ApplyEvent::RestoreStarted { cause: input.cause })
1813 .map_err(event)?;
1814
1815 let need_s = ctx.hold.estimate_commit_s(record.touched.len()).ceil() as i64 + LOCK_MARGIN_S;
1817 let reuse = match input.held {
1818 Some(lock) => match lock
1819 .check_gateway(deploy, need_s)
1820 .await
1821 .map_err(gw("checking the scan-lock"))?
1822 {
1823 LockVerdict::Held { .. } => true,
1824 LockVerdict::Expiring { .. } => {
1825 lock.wait_for_lapse(deploy, ctx.poll.interval)
1826 .await
1827 .map_err(gw("waiting for the scan-lock to lapse"))?;
1828 false
1829 }
1830 LockVerdict::NotHeld => false,
1831 },
1832 None => false,
1833 };
1834 if !reuse {
1835 HeldLock::acquire(deploy, ScanKind::Config, ctx.hold.hold_s, ctx.clock)
1836 .await
1837 .map_err(gw("taking the scan-lock"))?;
1838 steps
1839 .emit(ApplyEvent::LockAcquired {
1840 hold_s: ctx.hold.hold_s,
1841 })
1842 .map_err(event)?;
1843 }
1844
1845 let report = ctx.host.restore(record).await.map_err(host("restore"))?;
1847 if let Some(failure) = &report.failure {
1848 return Err(format!("restore: {failure}"));
1849 }
1850 if report.stale {
1851 return Err("restore: the host reported a stale pre-image".to_string());
1852 }
1853 match scan_and_wait(deploy, ScanKind::Config, ctx.poll.clone())
1854 .await
1855 .map_err(gw("the scan after the restore"))?
1856 {
1857 ScanOutcome::Complete(_) => steps.emit(ApplyEvent::ScanDone).map_err(event)?,
1858 ScanOutcome::NotComplete { .. } => {
1859 return Err("the scan after the restore did not complete".to_string());
1860 }
1861 }
1862
1863 let touched: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
1865 let live = ctx
1866 .host
1867 .fingerprint(&touched)
1868 .await
1869 .map_err(host("reading the restored files"))?;
1870 let pre = record.pre_map();
1871 let differing: Vec<String> = pre
1872 .iter()
1873 .filter(|(path, hash)| live.get(*path) != Some(*hash))
1874 .map(|(path, _)| path.clone())
1875 .collect();
1876 if !differing.is_empty() {
1877 return Err(format!(
1878 "{} restored path(s) differ from the snapshot: {}",
1879 differing.len(),
1880 listed(&differing)
1881 ));
1882 }
1883 let mode = deploy
1884 .deployment_mode()
1885 .await
1886 .map_err(gw("reading the deployment mode"))?;
1887 if mode.as_deref() != Some(record.mode.as_str()) {
1888 return Err("the gateway's deployment mode is not the target's".to_string());
1889 }
1890
1891 let mut rolled_back = record.clone();
1893 rolled_back.state = RecordState::RolledBack;
1894 rolled_back.rolled_back_by = Some(input.run_id.as_str().to_string());
1895 rolled_back.post_fingerprint = Some(record.pre_fingerprint.clone());
1896 ctx.host
1897 .finalize(&rolled_back)
1898 .await
1899 .map_err(host("finalize"))?;
1900 steps.emit(ApplyEvent::Restored).map_err(event)?;
1901
1902 let mut warnings = Vec::new();
1903 let mut snapshot_sha256 = BTreeMap::new();
1904 match RunId::parse(&record.run_id) {
1905 Ok(restored_run) => {
1906 match ctx
1907 .host
1908 .fetch_snapshot(&restored_run, input.run_dir.path())
1909 .await
1910 {
1911 Ok(hashes) => {
1912 warnings.extend(snapshot_warnings(record, &hashes));
1913 snapshot_sha256 = hashes;
1914 }
1915 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
1916 }
1917 }
1918 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
1919 }
1920 Ok(RestoreReport {
1921 restored: report.moved.len(),
1922 removed: report.deleted.len(),
1923 fresh_lock: !reuse,
1924 warnings,
1925 snapshot_sha256,
1926 })
1927}
1928
1929fn listed(paths: &[String]) -> String {
1931 let shown = paths.len().min(crate::error::APPLY_LIST_SHOWN);
1932 let mut out = paths[..shown].join(", ");
1933 if paths.len() > shown {
1934 out.push_str(&format!(" and {} more", paths.len() - shown));
1935 }
1936 out
1937}
1938
1939async fn plan_stale(
1944 ctx: &ApplyContext<'_>,
1945 plan: &DeployPlanResult,
1946 opts: &ApplyOptions,
1947 run_id: &RunId,
1948) -> CoreError {
1949 let _ = ctx.host.discard_interrupted(run_id).await;
1952 let _ = ctx.deploy.scan_request(ScanKind::Config).await;
1953 let actual = match deploy_plan(
1954 ctx.declared,
1955 ctx.reader,
1956 &PlanOptions {
1957 base: opts.base.clone(),
1958 diff: false,
1959 },
1960 )
1961 .await
1962 {
1963 Ok(now) => now.plan_sha256,
1964 Err(_) => "unavailable".to_string(),
1965 };
1966 CoreError::PlanStale {
1967 expected: plan.plan_sha256.clone(),
1968 actual,
1969 detail: "a live file changed between the plan and the lock; nothing was written"
1970 .to_string(),
1971 }
1972}
1973
1974#[cfg(test)]
1975mod tests {
1976 use super::*;
1977
1978 fn record(touched: &[(&str, Option<&str>)]) -> RunRecord {
1979 let touched: Vec<TouchedEntry> = touched
1980 .iter()
1981 .map(|(path, pre)| TouchedEntry {
1982 path: (*path).to_string(),
1983 pre_sha256: pre.map(str::to_string),
1984 post_sha256: Some("f".repeat(64)),
1985 })
1986 .collect();
1987 RunRecord {
1988 schema_version: 1,
1989 run_id: "20261016T000000Z-aaaaaa".to_string(),
1990 gateway: "gw".to_string(),
1991 mode: "dev".to_string(),
1992 render_sha256: "a".repeat(64),
1993 plan_sha256: "b".repeat(64),
1994 state: RecordState::Applying,
1995 rolled_back_by: None,
1996 touched,
1997 pre_fingerprint: "c".repeat(64),
1998 post_fingerprint: None,
1999 }
2000 }
2001
2002 #[test]
2003 fn the_commit_budget_is_the_hold_less_its_margin_and_never_zero() {
2004 let hold = |hold_s| HoldModel {
2005 hold_s,
2006 ..HoldModel::MEASURED
2007 };
2008 assert_eq!(commit_budget_s(&hold(176)), 174);
2011 assert_eq!(commit_budget_s(&HoldModel::MEASURED), 174);
2012 assert_eq!(commit_budget_s(&hold(2)), 1);
2015 assert_eq!(commit_budget_s(&hold(0)), 1);
2016 assert_eq!(commit_budget_s(&hold(1_000_000)), BUDGET_MAX_S);
2017 }
2018
2019 #[test]
2020 fn snapshot_warnings_name_a_differing_or_missing_copy_and_skip_created_paths() {
2021 let (h1, h2) = ("1".repeat(64), "2".repeat(64));
2022 let rec = record(&[
2023 ("a/config.json", Some(&h1)),
2024 ("b/config.json", Some(&h1)),
2025 ("c/config.json", None),
2026 (LEDGER_KEY, Some(&h2)),
2027 ]);
2028 let mut copied = BTreeMap::new();
2029 copied.insert("files/a/config.json".to_string(), h1.clone());
2030 copied.insert("files/b/config.json".to_string(), h2.clone());
2031 copied.insert("ledger.json".to_string(), h2);
2032 assert_eq!(
2035 snapshot_warnings(&rec, &copied),
2036 ["snapshot_copy_differs (b/config.json)"]
2037 );
2038 copied.remove("files/a/config.json");
2040 assert_eq!(
2041 snapshot_warnings(&rec, &copied),
2042 [
2043 "snapshot_copy_differs (a/config.json)",
2044 "snapshot_copy_differs (b/config.json)"
2045 ]
2046 );
2047 }
2048}