1pub mod converge;
103pub mod fingerprint;
104pub mod gates;
105pub mod hold;
106pub mod host;
107pub mod journal;
108pub mod preflight;
109pub mod record;
110pub mod rollback;
111pub mod run_store;
112
113use std::collections::{BTreeMap, BTreeSet};
114use std::path::{Path, PathBuf};
115
116use serde::Serialize;
117use serde_json::{Value, json};
118
119use crate::actions::backup::backup_download;
120use crate::actions::deploy::{
121 Declared, DeployPlanResult, PlanOptions, VerifyContext, VerifyOptions, deploy_plan,
122 deploy_verify_judged, gather_read_back,
123};
124use crate::actions::workspace::rfc3339_now_utc;
125use crate::client::backup::BackupType;
126use crate::client::deploy_api::scan_and_wait;
127use crate::client::scan::{LockVerdict, ScanKind, ScanOutcome};
128use crate::client::{DeployApi, GatewayApi};
129use crate::deploy::layout::{KeyCollection, RelPath, split_key};
130use crate::deploy::ledger::{Ledger, LedgerRoot, journal_location, ledger_location};
131use crate::deploy::pair::{BackupNode, BackupPolicy, BackupReport, effective_files, wall_clock_ms};
132use crate::deploy::plan::PlanStatus;
133use crate::deploy::target::NodeTarget;
134use crate::deploy::transport::batch::BUDGET_MAX_S;
135use crate::deploy::transport::{Scope, TreeReader};
136use crate::deploy::tree::sha256_hex;
137use crate::deploy::verify::{Expected, Finding, ReadBackBaseline, Written, compare_for};
138use crate::error::{CoreError, PreflightFailure, RollbackFailure};
139use crate::poll::PollConfig;
140
141use self::fingerprint::{LEDGER_KEY, fingerprint};
142use self::gates::GateFlags;
143use self::hold::{Clock, HeldLock, HoldModel, LOCK_MARGIN_S};
144use self::host::{ApplyHost, BatchSpec, DeleteOp, WriteOp};
145use self::journal::{Journal, JournalPhase, journal_on_box};
146use self::preflight::{DesignersPolicy, PreflightInput, run_preflights};
147use self::record::{RecordState, RunRecord, TouchedEntry};
148use self::run_store::{EventLog, RunDir, RunId};
149
150pub use self::rollback::{
151 InterruptedRun, RollbackChecked, RollbackContext, RollbackKind, RollbackOptions,
152 RollbackOutcome, check_rollback, restore_set, run_rollback,
153};
154
155const LOCKED_SAMPLE_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(5);
159
160pub const ROLLED_BACK_SLUG: &str = "deploy_rolled_back";
168
169pub const BACKUP_UNSYNCED_SLUG: &str = "deploy_backup_unsynced";
178
179const GWBK_CHECK: &str = "gwbk";
181
182const RESTART_UNAVAILABLE: &str = "restart_pending_unavailable";
185
186const EVENTS_UNWRITABLE: &str = "events_log_unwritable";
189
190const READ_BACK_BASELINE_FILE: &str = "read_back_baseline.json";
193
194#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
196#[serde(rename_all = "snake_case")]
197pub enum OutcomeKind {
198 Applied,
200 Noop,
202 RolledBack,
204 AppliedBackupUnsynced,
208}
209
210impl OutcomeKind {
211 pub fn as_str(self) -> &'static str {
213 match self {
214 OutcomeKind::Applied => "applied",
215 OutcomeKind::Noop => "noop",
216 OutcomeKind::RolledBack => "rolled_back",
217 OutcomeKind::AppliedBackupUnsynced => "applied_backup_unsynced",
218 }
219 }
220}
221
222#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
224#[serde(rename_all = "snake_case")]
225pub enum RollbackCause {
226 LockExpired,
228 WriteFailed,
230 ScanIncomplete,
232 VerifyFailed,
234}
235
236impl RollbackCause {
237 pub fn as_str(self) -> &'static str {
239 match self {
240 RollbackCause::LockExpired => "lock_expired",
241 RollbackCause::WriteFailed => "write_failed",
242 RollbackCause::ScanIncomplete => "scan_incomplete",
243 RollbackCause::VerifyFailed => "verify_failed",
244 }
245 }
246}
247
248#[derive(Debug, Clone, PartialEq, Eq)]
250pub struct WriteSet {
251 pub writes: Vec<WriteOp>,
253 pub deletes: Vec<DeleteOp>,
255}
256
257pub fn write_set(plan: &DeployPlanResult, declared: &Declared) -> Result<WriteSet, CoreError> {
271 let defect = |why: String| CoreError::Internal(format!("write set: {why}"));
272 let mut writes = Vec::new();
273 let mut live: BTreeMap<&str, (bool, Option<&str>)> = BTreeMap::new();
275 for entry in &plan.entries {
276 let in_mode = entry.live_in.iter().any(|c| c == "mode");
277 let in_own = |path: &str| match split_key(path).0 {
278 KeyCollection::Core => true,
279 KeyCollection::Mode => in_mode,
280 };
281 for file in &entry.files {
282 live.insert(
283 file.path.as_str(),
284 (in_own(&file.path), file.live_sha256.as_deref()),
285 );
286 }
287 if !matches!(entry.status, PlanStatus::Added | PlanStatus::Changed) {
288 continue;
289 }
290 for file in &entry.files {
291 let Some(desired) = &file.desired_sha256 else {
292 continue;
293 };
294 let path = RelPath::new(&file.path)
295 .map_err(|why| defect(format!("the plan names an invalid path ({why})")))?;
296 let rendered = RelPath::new(split_key(&file.path).1)
297 .map_err(|why| defect(format!("the plan names an invalid path ({why})")))?;
298 let bytes =
299 declared.outcome.tree.files.get(&rendered).ok_or_else(|| {
300 defect(format!("{path} is in the plan but not in the render"))
301 })?;
302 if sha256_hex(bytes) != *desired {
303 return Err(defect(format!(
304 "{path} was rendered differently from the plan"
305 )));
306 }
307 writes.push(WriteOp {
308 pre: if in_own(&file.path) {
309 file.live_sha256.clone()
310 } else {
311 None
312 },
313 path,
314 sha256: desired.clone(),
315 });
316 }
317 }
318 let mut deletes = Vec::new();
319 for path in &plan.delete_set.files {
320 let Some((true, Some(pre))) = live.get(path.as_str()) else {
321 return Err(defect(format!(
322 "{path} is in the delete set without a pre-image in the plan"
323 )));
324 };
325 deletes.push(DeleteOp {
326 path: path.clone(),
327 pre: (*pre).to_string(),
328 });
329 }
330 writes.sort_by(|a, b| a.path.as_str().as_bytes().cmp(b.path.as_str().as_bytes()));
331 deletes.sort_by(|a, b| a.path.as_str().as_bytes().cmp(b.path.as_str().as_bytes()));
332 Ok(WriteSet { writes, deletes })
333}
334
335fn new_ledger(
338 plan: &DeployPlanResult,
339 old: Option<&Ledger>,
340 set: &WriteSet,
341 run_id: &RunId,
342) -> Ledger {
343 let mut files: BTreeSet<String> = old
344 .map(|ledger| ledger.files.iter().cloned().collect())
345 .unwrap_or_default();
346 for op in &set.deletes {
347 files.remove(op.path.as_str());
348 }
349 for op in &set.writes {
350 files.insert(op.path.as_str().to_string());
351 }
352 Ledger {
353 schema_version: 1,
354 gateway: plan.gateway.clone(),
355 mode: plan.mode.clone(),
356 render_sha256: plan.render_sha256.clone(),
357 run_id: run_id.as_str().to_string(),
358 files: files.into_iter().collect(),
359 }
360}
361
362fn commit_budget_s(hold: &HoldModel) -> u32 {
369 let seconds = hold.hold_s.saturating_sub(LOCK_MARGIN_S as u64);
370 u32::try_from(seconds.clamp(1, u64::from(BUDGET_MAX_S))).unwrap_or(BUDGET_MAX_S)
371}
372
373fn batch_spec(
376 plan: &DeployPlanResult,
377 run_id: &RunId,
378 set: &WriteSet,
379 ledger_new: Vec<u8>,
380 ledger_pre: Option<String>,
381 commit_budget_s: u32,
382) -> BatchSpec {
383 let mut touched: Vec<TouchedEntry> = set
384 .writes
385 .iter()
386 .map(|w| TouchedEntry {
387 path: w.path.as_str().to_string(),
388 pre_sha256: w.pre.clone(),
389 post_sha256: Some(w.sha256.clone()),
390 })
391 .chain(set.deletes.iter().map(|d| TouchedEntry {
392 path: d.path.as_str().to_string(),
393 pre_sha256: Some(d.pre.clone()),
394 post_sha256: None,
395 }))
396 .collect();
397 touched.push(TouchedEntry {
398 path: LEDGER_KEY.to_string(),
399 pre_sha256: ledger_pre.clone(),
400 post_sha256: Some(sha256_hex(&ledger_new)),
401 });
402 touched.sort_by(|a, b| a.path.as_bytes().cmp(b.path.as_bytes()));
403 let pre_fingerprint = fingerprint(
404 &touched
405 .iter()
406 .map(|e| (e.path.clone(), e.pre_sha256.clone()))
407 .collect(),
408 );
409 let run = run_id.as_str().to_string();
410 BatchSpec {
411 run_id: run_id.clone(),
412 gateway: plan.gateway.clone(),
413 mode: plan.mode.clone(),
414 writes: set.writes.clone(),
415 deletes: set.deletes.clone(),
416 ledger_new: Some(ledger_new),
417 ledger_pre,
418 commit_budget_s: Some(commit_budget_s),
419 journal: Journal {
420 schema_version: 1,
421 run_id: run.clone(),
422 gateway: plan.gateway.clone(),
423 mode: plan.mode.clone(),
424 phase: JournalPhase::Snapshotting,
425 pre_fingerprint: pre_fingerprint.clone(),
426 touched: touched.iter().map(|e| e.path.clone()).collect(),
427 },
428 record: RunRecord {
429 schema_version: 1,
430 run_id: run,
431 gateway: plan.gateway.clone(),
432 mode: plan.mode.clone(),
433 render_sha256: plan.render_sha256.clone(),
434 plan_sha256: plan.plan_sha256.clone(),
435 state: RecordState::Applying,
436 rolled_back_by: None,
437 touched,
438 pre_fingerprint,
439 post_fingerprint: None,
440 },
441 }
442}
443
444#[derive(Debug, Clone, PartialEq, Eq)]
446pub enum ApplyEvent {
447 RunStarted {
449 run_id: String,
451 writes: usize,
453 deletes: usize,
455 },
456 Staged {
458 files: usize,
460 },
461 LockAcquired {
463 hold_s: u64,
465 },
466 Committed {
468 moved: usize,
470 deleted: usize,
472 },
473 LockVerdict {
475 held: bool,
477 },
478 ScanDone,
480 VerifyDone {
482 ok: bool,
484 },
485 Finalized,
487 CommitInterrupted {
491 code: String,
493 },
494 VerifyChecks {
498 checks: Vec<(String, String, Vec<Finding>)>,
500 },
501 RestoreStarted {
504 cause: Option<RollbackCause>,
506 },
507 LiveChangesDiscarded {
510 count: usize,
512 paths: Vec<String>,
514 },
515 Restored,
517 Cleared,
520 InterruptedRun {
524 phase: &'static str,
526 action: &'static str,
528 hashes: BTreeMap<String, Option<String>>,
530 },
531 PreflightChecks {
533 checks: Vec<(String, String)>,
535 },
536 DesignersPoll {
538 open: usize,
540 },
541 DesignersProceed {
543 sessions: Vec<preflight::DesignerSeen>,
545 },
546 GwbkDownloaded {
548 bytes: u64,
550 },
551 GwbkSkipped,
553 BackupSampled {
557 last_sync: Option<i64>,
559 },
560 BackupWaitStarted {
562 budget_s: u64,
564 },
565 BackupPoll {
567 peer_connected: bool,
569 sync_pending: bool,
571 advanced: bool,
573 },
574 BackupResync {
576 ok: bool,
578 code: Option<String>,
580 },
581 BackupVerdict {
583 converged: bool,
585 reason: Option<&'static str>,
587 },
588 Failed {
590 code: String,
592 },
593}
594
595impl ApplyEvent {
596 pub fn name(&self) -> &'static str {
598 match self {
599 ApplyEvent::RunStarted { .. } => "run_started",
600 ApplyEvent::Staged { .. } => "staged",
601 ApplyEvent::LockAcquired { .. } => "lock_acquired",
602 ApplyEvent::Committed { .. } => "committed",
603 ApplyEvent::LockVerdict { .. } => "lock_verdict",
604 ApplyEvent::ScanDone => "scan_done",
605 ApplyEvent::VerifyDone { .. } => "verify_done",
606 ApplyEvent::VerifyChecks { .. } => "verify_checks",
607 ApplyEvent::Finalized => "finalized",
608 ApplyEvent::CommitInterrupted { .. } => "commit_interrupted",
609 ApplyEvent::RestoreStarted { .. } => "restore_started",
610 ApplyEvent::Restored => "restored",
611 ApplyEvent::Cleared => "cleared",
612 ApplyEvent::InterruptedRun { .. } => "interrupted_run",
613 ApplyEvent::LiveChangesDiscarded { .. } => "live_changes_discarded",
614 ApplyEvent::PreflightChecks { .. } => "preflight_checks",
615 ApplyEvent::DesignersPoll { .. } => "designers_poll",
616 ApplyEvent::DesignersProceed { .. } => "designers_proceed",
617 ApplyEvent::GwbkDownloaded { .. } => "gwbk_downloaded",
618 ApplyEvent::GwbkSkipped => "gwbk_skipped",
619 ApplyEvent::BackupSampled { .. } => "backup_sampled",
620 ApplyEvent::BackupWaitStarted { .. } => "backup_wait_started",
621 ApplyEvent::BackupPoll { .. } => "backup_poll",
622 ApplyEvent::BackupResync { .. } => "backup_resync",
623 ApplyEvent::BackupVerdict { .. } => "backup_verdict",
624 ApplyEvent::Failed { .. } => "failed",
625 }
626 }
627
628 pub fn fields(&self) -> Value {
630 match self {
631 ApplyEvent::RunStarted {
632 run_id,
633 writes,
634 deletes,
635 } => json!({"run_id": run_id, "writes": writes, "deletes": deletes}),
636 ApplyEvent::Staged { files } => json!({"files": files}),
637 ApplyEvent::LockAcquired { hold_s } => json!({"hold_s": hold_s}),
638 ApplyEvent::Committed { moved, deleted } => {
639 json!({"moved": moved, "deleted": deleted})
640 }
641 ApplyEvent::LockVerdict { held } => json!({"held": held}),
642 ApplyEvent::VerifyDone { ok } => json!({"ok": ok}),
643 ApplyEvent::VerifyChecks { checks } => json!({
644 "checks": checks
645 .iter()
646 .map(|(id, status, findings)| {
647 json!({"id": id, "status": status, "findings": findings})
648 })
649 .collect::<Vec<_>>(),
650 }),
651 ApplyEvent::PreflightChecks { checks } => json!({
652 "checks": checks
653 .iter()
654 .map(|(id, status)| json!({"id": id, "status": status}))
655 .collect::<Vec<_>>(),
656 }),
657 ApplyEvent::DesignersPoll { open } => json!({"open": open}),
658 ApplyEvent::DesignersProceed { sessions } => json!({
659 "sessions": sessions
660 .iter()
661 .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
662 .collect::<Vec<_>>(),
663 }),
664 ApplyEvent::RestoreStarted { cause } => {
665 json!({"cause": cause.map(RollbackCause::as_str)})
666 }
667 ApplyEvent::LiveChangesDiscarded { count, paths } => {
668 json!({"count": count, "paths": paths})
669 }
670 ApplyEvent::InterruptedRun {
671 phase,
672 action,
673 hashes,
674 } => json!({
675 "phase": phase,
676 "action": action,
677 "touched": hashes.len(),
678 "hashes": hashes,
679 }),
680 ApplyEvent::GwbkDownloaded { bytes } => json!({"bytes": bytes}),
681 ApplyEvent::BackupSampled { last_sync } => json!({"last_sync": last_sync}),
682 ApplyEvent::BackupWaitStarted { budget_s } => json!({"budget_s": budget_s}),
683 ApplyEvent::BackupPoll {
684 peer_connected,
685 sync_pending,
686 advanced,
687 } => json!({
688 "peer_connected": peer_connected,
689 "sync_pending": sync_pending,
690 "advanced": advanced,
691 }),
692 ApplyEvent::BackupResync { ok, code } => json!({"ok": ok, "code": code}),
693 ApplyEvent::BackupVerdict { converged, reason } => {
694 json!({"converged": converged, "reason": reason})
695 }
696 ApplyEvent::CommitInterrupted { code } | ApplyEvent::Failed { code } => {
697 json!({"code": code})
698 }
699 ApplyEvent::ScanDone
700 | ApplyEvent::Finalized
701 | ApplyEvent::Restored
702 | ApplyEvent::Cleared
703 | ApplyEvent::GwbkSkipped => Value::Null,
704 }
705 }
706}
707
708pub trait ApplyObserver: Send + Sync {
710 fn event(&self, e: &ApplyEvent);
712}
713
714#[derive(Debug, Clone, Copy, Default)]
716pub struct NoObserver;
717
718impl ApplyObserver for NoObserver {
719 fn event(&self, _e: &ApplyEvent) {}
720}
721
722pub struct ApplyContext<'a> {
724 pub declared: &'a Declared,
726 pub repo: &'a std::path::Path,
728 pub reader: &'a dyn TreeReader,
730 pub host: &'a dyn ApplyHost,
732 pub deploy: &'a dyn DeployApi,
734 pub gateway: &'a dyn GatewayApi,
736 pub backup: Option<&'a BackupNode<'a>>,
739 pub clock: &'a dyn Clock,
741 pub observer: &'a dyn ApplyObserver,
743}
744
745impl<'a> ApplyContext<'a> {
746 fn restore_context(&'a self, opts: &'a ApplyOptions) -> RestoreContext<'a> {
748 RestoreContext {
749 env: &self.declared.env,
750 master: &self.declared.target.nodes.master,
751 host: self.host,
752 deploy: self.deploy,
753 clock: self.clock,
754 hold: &opts.hold,
755 poll: &opts.poll,
756 }
757 }
758}
759
760#[derive(Debug, Clone)]
762pub struct ApplyOptions {
763 pub base: Option<PathBuf>,
765 pub gates: GateFlags,
767 pub project: String,
769 pub hold: HoldModel,
771 pub poll: PollConfig,
773 pub verify: VerifyOptions,
775 pub designers: DesignersPolicy,
777 pub skip_gwbk: bool,
779 pub backup: BackupPolicy,
782}
783
784#[derive(Debug, Clone, PartialEq, Serialize)]
786pub struct ApplyOutcome {
787 #[serde(rename = "outcome")]
789 pub kind: OutcomeKind,
790 pub run_id: Option<String>,
792 pub run_dir: Option<String>,
794 pub cause: Option<RollbackCause>,
796 pub render_sha256: String,
798 pub plan_sha256: String,
800 pub written: Vec<String>,
802 pub deleted: Vec<String>,
804 pub post_fingerprint: Option<String>,
806 pub warnings: Vec<String>,
808 pub gwbk: Option<String>,
811 pub skip_gwbk: bool,
813 pub restart_pending: Vec<String>,
815 pub restart_pending_new: Vec<String>,
817 pub snapshot_sha256: BTreeMap<String, String>,
821 pub backup: Option<BackupReport>,
824}
825
826pub async fn run_apply(
843 ctx: &ApplyContext<'_>,
844 plan: &DeployPlanResult,
845 opts: &ApplyOptions,
846) -> Result<ApplyOutcome, CoreError> {
847 if let Some(refusal) = gates::unreadable_refusal(plan) {
848 return Err(refusal);
849 }
850 if let Some(refusal) =
851 gates::pair_tags_refusal(plan, gates::Redundancy::of(&ctx.declared.target))
852 {
853 return Err(refusal);
854 }
855 if !plan.summary.has_changes {
856 return Ok(ApplyOutcome {
857 kind: OutcomeKind::Noop,
858 run_id: None,
859 run_dir: None,
860 cause: None,
861 render_sha256: plan.render_sha256.clone(),
862 plan_sha256: plan.plan_sha256.clone(),
863 written: Vec::new(),
864 deleted: Vec::new(),
865 post_fingerprint: None,
866 warnings: Vec::new(),
867 gwbk: None,
868 skip_gwbk: false,
869 restart_pending: Vec::new(),
870 restart_pending_new: Vec::new(),
871 snapshot_sha256: BTreeMap::new(),
872 backup: None,
873 });
874 }
875 let (run_id, run_dir) = RunDir::create_fresh(ctx.repo, &plan.gateway)?;
876 let mut steps = Steps::new(None, ctx.observer);
877 let result = drive(ctx, plan, opts, &run_id, &run_dir, &mut steps).await;
878
879 if let Err(e) = &result {
881 let _ = steps.emit(ApplyEvent::Failed {
882 code: e.code().to_string(),
883 });
884 }
885 let doc = match &result {
886 Ok(outcome) => {
887 let mut doc = serde_json::to_value(outcome).unwrap_or(Value::Null);
888 doc["ok"] = Value::Bool(!matches!(
889 outcome.kind,
890 OutcomeKind::RolledBack | OutcomeKind::AppliedBackupUnsynced
891 ));
892 match outcome.kind {
893 OutcomeKind::RolledBack => {
894 doc["slug"] = Value::String(ROLLED_BACK_SLUG.to_string());
895 }
896 OutcomeKind::AppliedBackupUnsynced => {
897 doc["slug"] = Value::String(BACKUP_UNSYNCED_SLUG.to_string());
898 }
899 OutcomeKind::Applied | OutcomeKind::Noop => {}
900 }
901 doc
902 }
903 Err(e) => json!({
904 "ok": false,
905 "run_id": run_id.as_str(),
906 "error": {"code": e.code(), "message": e.to_string()},
907 }),
908 };
909 let written = run_dir.write_json("result.json", &doc);
910 match result {
911 Ok(mut outcome) => {
912 if written.is_err() {
913 outcome.warnings.push("result_json_unwritable".to_string());
914 }
915 Ok(outcome)
916 }
917 Err(e) => Err(e),
918 }
919}
920
921pub struct Steps<'a> {
924 log: Option<EventLog>,
925 observer: &'a dyn ApplyObserver,
926}
927
928impl<'a> Steps<'a> {
929 pub fn new(log: Option<EventLog>, observer: &'a dyn ApplyObserver) -> Self {
931 Steps { log, observer }
932 }
933
934 pub fn emit(&mut self, event: ApplyEvent) -> Result<(), CoreError> {
936 self.observer.event(&event);
937 match &mut self.log {
938 Some(log) => log.append(event.name(), event.fields()),
939 None => Ok(()),
940 }
941 }
942}
943
944fn with_env(err: CoreError, env: &str) -> CoreError {
946 match err {
947 CoreError::DeployJournalPresent {
948 run_id, location, ..
949 } => CoreError::DeployJournalPresent {
950 run_id,
951 env: env.to_string(),
952 location,
953 },
954 other => other,
955 }
956}
957
958enum BoxState {
961 Mine(Journal),
963 Other,
965 Absent,
967 Unknown,
969}
970
971async fn box_state(ctx: &ApplyContext<'_>, plan: &DeployPlanResult, run_id: &RunId) -> BoxState {
972 let master = &ctx.declared.target.nodes.master;
973 match journal_on_box(ctx.reader, master, &plan.gateway, &plan.mode).await {
974 Ok(Some(journal)) if journal.run_id == run_id.as_str() => BoxState::Mine(journal),
975 Ok(Some(_)) => BoxState::Other,
976 Ok(None) => BoxState::Absent,
977 Err(_) => BoxState::Unknown,
978 }
979}
980
981fn interrupted(
985 ctx: &ApplyContext<'_>,
986 plan: &DeployPlanResult,
987 steps: &mut Steps<'_>,
988 journal: &Journal,
989 err: &CoreError,
990) -> CoreError {
991 let _ = steps.emit(ApplyEvent::CommitInterrupted {
992 code: err.code().to_string(),
993 });
994 journal::journal_present(
995 journal,
996 &ctx.declared.env,
997 &ctx.declared.target.nodes.master,
998 &plan.gateway,
999 &plan.mode,
1000 )
1001}
1002
1003async fn commit_failed(
1019 ctx: &ApplyContext<'_>,
1020 run_id: &RunId,
1021 plan: &DeployPlanResult,
1022 steps: &mut Steps<'_>,
1023 err: CoreError,
1024) -> CoreError {
1025 if matches!(err, CoreError::DeployJournalPresent { .. }) {
1026 return err;
1027 }
1028 match box_state(ctx, plan, run_id).await {
1029 BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
1030 BoxState::Unknown => {
1031 let _ = steps.emit(ApplyEvent::CommitInterrupted {
1035 code: format!("{}; journal state unknown", err.code()),
1036 });
1037 recovery_pointer(ctx, plan, run_id, err)
1038 }
1039 BoxState::Other | BoxState::Absent => err,
1040 }
1041}
1042
1043async fn unfinished_after_green(
1050 ctx: &ApplyContext<'_>,
1051 run_id: &RunId,
1052 plan: &DeployPlanResult,
1053 steps: &mut Steps<'_>,
1054 err: CoreError,
1055) -> CoreError {
1056 match box_state(ctx, plan, run_id).await {
1057 BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
1058 _ => recovery_pointer(ctx, plan, run_id, err),
1059 }
1060}
1061
1062fn recovery_pointer(
1067 ctx: &ApplyContext<'_>,
1068 plan: &DeployPlanResult,
1069 run_id: &RunId,
1070 err: CoreError,
1071) -> CoreError {
1072 let env = &ctx.declared.env;
1073 match err {
1074 CoreError::TransportFailed {
1075 operation,
1076 node,
1077 detail,
1078 } => CoreError::TransportFailed {
1079 operation,
1080 node,
1081 detail: format!(
1082 "{detail}; this run's journal may be on the host: recover it with: \
1083 ign deploy rollback --run {run} --env {env}",
1084 run = run_id.as_str()
1085 ),
1086 },
1087 _ => CoreError::DeployJournalPresent {
1088 run_id: run_id.as_str().to_string(),
1089 env: env.clone(),
1090 location: journal_location(
1091 LedgerRoot::for_node(&ctx.declared.target.nodes.master),
1092 &plan.gateway,
1093 &plan.mode,
1094 ),
1095 },
1096 }
1097}
1098
1099async fn drive(
1103 ctx: &ApplyContext<'_>,
1104 plan: &DeployPlanResult,
1105 opts: &ApplyOptions,
1106 run_id: &RunId,
1107 run_dir: &RunDir,
1108 steps: &mut Steps<'_>,
1109) -> Result<ApplyOutcome, CoreError> {
1110 let declared = ctx.declared;
1111 let env = declared.env.as_str();
1112 let (gateway, mode) = (plan.gateway.as_str(), plan.mode.as_str());
1113
1114 let started_at = rfc3339_now_utc();
1115 steps.log = Some(EventLog::open(run_dir)?);
1116
1117 let set = write_set(plan, declared)?;
1118 steps.emit(ApplyEvent::RunStarted {
1119 run_id: run_id.as_str().to_string(),
1120 writes: set.writes.len(),
1121 deletes: set.deletes.len(),
1122 })?;
1123
1124 let touched_paths: Vec<String> = set
1128 .writes
1129 .iter()
1130 .map(|w| w.path.as_str().to_string())
1131 .chain(set.deletes.iter().map(|d| d.path.as_str().to_string()))
1132 .collect();
1133 let deleted_paths: Vec<String> = set
1134 .deletes
1135 .iter()
1136 .map(|d| d.path.as_str().to_string())
1137 .collect();
1138 let preflights = run_preflights(
1139 &PreflightInput {
1140 target: &declared.target,
1141 declared: Some(declared),
1142 master: ctx.deploy,
1143 backup: ctx.backup,
1144 gateway: ctx.gateway,
1145 reader: ctx.reader,
1146 host: ctx.host,
1147 touched_files: set.writes.len() + set.deletes.len(),
1148 touched_paths: &touched_paths,
1149 deleted_paths: &deleted_paths,
1150 hold: &opts.hold,
1151 designers: &opts.designers,
1152 clock: ctx.clock,
1153 },
1154 steps,
1155 )
1156 .await;
1157 let seen = preflights.as_ref().ok();
1158 run_dir.write_json(
1159 "run.json",
1160 &json!({
1161 "schema_version": 1,
1162 "run_id": run_id.as_str(),
1163 "env": env,
1164 "gateway": gateway,
1165 "mode": mode,
1166 "node": plan.node,
1167 "transport": plan.transport,
1168 "started_at": started_at,
1169 "run_dir_git": run_dir.git_status().as_record(),
1170 "render_sha256": plan.render_sha256,
1171 "plan_sha256": plan.plan_sha256,
1172 "hold_s": opts.hold.hold_s,
1173 "allow_destructive": opts.gates.allow_destructive,
1174 "allow_drift": opts.gates.allow_drift,
1175 "skip_gwbk": opts.skip_gwbk,
1176 "designers": opts.designers.mode.as_str(),
1177 "designers_timeout_s": opts.designers.timeout.as_secs(),
1178 "designers_seen": seen.map(|r| r.designers_seen
1179 .iter()
1180 .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
1181 .collect::<Vec<_>>()),
1182 "preflights": seen.map(|r| r.findings
1183 .iter()
1184 .map(|f| json!({"check": f.check.as_str(), "status": f.status.as_str()}))
1185 .collect::<Vec<_>>()),
1186 }),
1187 )?;
1188 run_dir.write_json("plan.json", plan)?;
1189 let preflights = preflights?;
1190 if !preflights.passed() {
1191 steps.emit(ApplyEvent::PreflightChecks {
1192 checks: preflights
1193 .findings
1194 .iter()
1195 .map(|f| (f.check.as_str().to_string(), f.status.as_str().to_string()))
1196 .collect(),
1197 })?;
1198 if !preflights.backup_unreadable.is_empty() {
1203 return Err(CoreError::DeployUnreadable {
1204 node: "backup".to_string(),
1205 keys: preflights.backup_unreadable,
1206 });
1207 }
1208 return Err(CoreError::DeployPreflightFailed {
1209 failures: preflights.failures().into(),
1210 });
1211 }
1212
1213 let mut backup_before = match ctx.backup {
1219 Some(backup) => {
1220 let sampled = backup.status().await?.last_sync_epoch_ms();
1221 steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
1222 sampled
1223 }
1224 None => None,
1225 };
1226 let mut backup_log_since_ms = wall_clock_ms();
1230
1231 let gwbk = take_gwbk(ctx, opts, run_id, run_dir, steps).await?;
1234
1235 let restart_before = ctx.deploy.restart_pending().await.ok();
1239
1240 let old_bytes = ctx.reader.read_ledger(gateway, mode).await?;
1242 let old_ledger = match &old_bytes {
1243 Some(bytes) => Some(Ledger::parse(
1244 &ledger_location(
1245 LedgerRoot::for_node(&declared.target.nodes.master),
1246 gateway,
1247 mode,
1248 ),
1249 bytes,
1250 gateway,
1251 mode,
1252 )?),
1253 None => None,
1254 };
1255 let ledger = new_ledger(plan, old_ledger.as_ref(), &set, run_id);
1256 let ledger_bytes = ledger.to_bytes();
1257 Ledger::parse(
1258 &ledger_location(
1259 LedgerRoot::for_node(&declared.target.nodes.master),
1260 gateway,
1261 mode,
1262 ),
1263 &ledger_bytes,
1264 gateway,
1265 mode,
1266 )?;
1267 let spec = batch_spec(
1268 plan,
1269 run_id,
1270 &set,
1271 ledger_bytes,
1272 old_bytes.as_deref().map(sha256_hex),
1273 commit_budget_s(&opts.hold),
1274 );
1275 if let Some(problem) = spec.problem() {
1276 return Err(CoreError::Internal(format!(
1277 "the batch the engine built is invalid: {problem}"
1278 )));
1279 }
1280
1281 let (baseline, backup_baseline) = read_back_baselines(ctx, plan, run_dir).await?;
1290
1291 let scope = RunScope {
1292 plan,
1293 run_id,
1294 run_dir,
1295 gwbk: gwbk.as_deref(),
1296 restart_before: restart_before.as_deref(),
1297 record: &spec.record,
1298 };
1299
1300 let prepared = ctx
1302 .host
1303 .prepare(&spec, &declared.outcome.tree)
1304 .await
1305 .map_err(|e| with_env(e, env))?;
1306 steps.emit(ApplyEvent::Staged {
1307 files: set.writes.len(),
1308 })?;
1309 let redundancy = gates::Redundancy::of(&declared.target);
1318 if redundancy == gates::Redundancy::Pair {
1319 let refused =
1320 match crate::actions::deploy::pair_mode_tag_dirs_of(ctx.reader, mode, &plan.entries)
1321 .await
1322 {
1323 Ok(dirs) => gates::pair_tags_refusal_with(plan, redundancy, &dirs),
1324 Err(e) => Some(e),
1325 };
1326 if let Some(err) = refused {
1327 let _ = ctx.host.discard_interrupted(run_id).await;
1328 return Err(err);
1329 }
1330 }
1331 let lock =
1332 match HeldLock::acquire(ctx.deploy, ScanKind::Config, opts.hold.hold_s, ctx.clock).await {
1333 Ok(lock) => lock,
1334 Err(refused) => {
1335 let _ = ctx.host.discard_interrupted(run_id).await;
1339 return Err(refused);
1340 }
1341 };
1342 steps.emit(ApplyEvent::LockAcquired {
1343 hold_s: opts.hold.hold_s,
1344 })?;
1345 if let Some(backup) = ctx.backup {
1357 let since_ms = wall_clock_ms();
1358 if let Ok(Ok(status)) = tokio::time::timeout(LOCKED_SAMPLE_TIMEOUT, backup.status()).await {
1359 let sampled = status.last_sync_epoch_ms();
1360 steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
1361 backup_before = sampled;
1362 backup_log_since_ms = since_ms;
1363 }
1364 }
1365 let report = match ctx.host.commit(&prepared).await {
1366 Ok(report) => report,
1367 Err(e) => return Err(commit_failed(ctx, run_id, plan, steps, with_env(e, env)).await),
1368 };
1369 if let Err(e) = steps.emit(ApplyEvent::Committed {
1370 moved: report.moved.len(),
1371 deleted: report.deleted.len(),
1372 }) {
1373 return Err(commit_failed(ctx, run_id, plan, steps, e).await);
1376 }
1377 if report.stale {
1378 return Err(plan_stale(ctx, plan, opts, run_id).await);
1379 }
1380 if report.failure.is_some() {
1381 let cause = if report.deadline {
1386 RollbackCause::LockExpired
1387 } else {
1388 RollbackCause::WriteFailed
1389 };
1390 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, Vec::new()).await;
1391 }
1392
1393 let mut warnings = match confirm(ctx, opts, &lock, &baseline, steps).await {
1396 Ok(Confirmed::Green { warnings }) => warnings,
1397 Ok(Confirmed::Abort { cause, warnings }) => {
1398 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, warnings).await;
1399 }
1400 Err(Unconfirmed::Restore { cause, step, error }) => {
1401 let note = format!("post_commit_error ({step}: {})", error.code());
1404 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, vec![note]).await;
1405 }
1406 Err(Unconfirmed::Green(error)) => {
1407 return Err(unfinished_after_green(ctx, run_id, plan, steps, error).await);
1408 }
1409 };
1410
1411 let post = match ctx.host.fingerprint(&spec.touched_paths()).await {
1413 Ok(post) => post,
1414 Err(e) => return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await),
1415 };
1416 let post_fingerprint = fingerprint(&post);
1417 let mut record = spec.record.clone();
1418 record.state = RecordState::Applied;
1419 record.post_fingerprint = Some(post_fingerprint.clone());
1420 if let Err(e) = ctx.host.finalize(&record).await {
1421 if !matches!(box_state(ctx, plan, run_id).await, BoxState::Absent) {
1424 return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await);
1425 }
1426 warnings.push(format!("finalize_reported_error ({})", e.code()));
1427 }
1428 if steps.emit(ApplyEvent::Finalized).is_err() {
1429 warnings.push(EVENTS_UNWRITABLE.to_string());
1430 }
1431
1432 let (restart_pending, restart_pending_new) =
1433 restart_report(ctx.deploy, restart_before.as_deref(), &mut warnings).await;
1434 let mut snapshot_sha256 = BTreeMap::new();
1435 match ctx.host.fetch_snapshot(run_id, run_dir.path()).await {
1436 Ok(hashes) => {
1437 warnings.extend(snapshot_warnings(&spec.record, &hashes));
1438 snapshot_sha256 = hashes;
1439 }
1440 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
1441 }
1442
1443 let backup_report = match ctx.backup {
1446 Some(backup) => Some(
1447 converge::converge_backup(
1448 ctx,
1449 backup,
1450 opts,
1451 &set,
1452 converge::Baseline {
1453 last_sync: backup_before,
1454 log_since_ms: backup_log_since_ms,
1455 read_back: backup_baseline,
1456 },
1457 steps,
1458 &mut warnings,
1459 )
1460 .await,
1461 ),
1462 None => None,
1463 };
1464 let kind = match &backup_report {
1465 Some(report) if !report.converged => OutcomeKind::AppliedBackupUnsynced,
1466 _ => OutcomeKind::Applied,
1467 };
1468 Ok(ApplyOutcome {
1469 kind,
1470 run_id: Some(run_id.as_str().to_string()),
1471 run_dir: Some(run_dir.path().to_string_lossy().into_owned()),
1472 cause: None,
1473 render_sha256: plan.render_sha256.clone(),
1474 plan_sha256: plan.plan_sha256.clone(),
1475 written: set
1476 .writes
1477 .iter()
1478 .map(|w| w.path.as_str().to_string())
1479 .collect(),
1480 deleted: set
1481 .deletes
1482 .iter()
1483 .map(|d| d.path.as_str().to_string())
1484 .collect(),
1485 post_fingerprint: Some(post_fingerprint),
1486 warnings,
1487 gwbk,
1488 skip_gwbk: opts.skip_gwbk,
1489 restart_pending,
1490 restart_pending_new,
1491 snapshot_sha256,
1492 backup: backup_report,
1493 })
1494}
1495
1496enum Confirmed {
1499 Green { warnings: Vec<String> },
1501 Abort {
1503 cause: RollbackCause,
1504 warnings: Vec<String>,
1505 },
1506}
1507
1508enum Unconfirmed {
1510 Restore {
1514 cause: RollbackCause,
1515 step: &'static str,
1516 error: CoreError,
1517 },
1518 Green(CoreError),
1520}
1521
1522async fn read_back_baselines(
1532 ctx: &ApplyContext<'_>,
1533 plan: &DeployPlanResult,
1534 run_dir: &RunDir,
1535) -> Result<(ReadBackBaseline, Option<ReadBackBaseline>), CoreError> {
1536 let declared = ctx.declared;
1537 let expected =
1538 Expected::from_render(&declared.target.mode, &declared.outcome, &declared.manifest);
1539 let status: BTreeMap<&str, PlanStatus> = plan
1540 .entries
1541 .iter()
1542 .map(|entry| (entry.key.as_str(), entry.status))
1543 .collect();
1544 let changed: Vec<_> = expected
1545 .resources
1546 .iter()
1547 .filter(|res| status.get(res.key.as_str()) == Some(&PlanStatus::Changed))
1548 .collect();
1549 let live = if changed.is_empty() {
1550 BTreeMap::new()
1551 } else {
1552 let types: BTreeSet<(String, String)> = changed
1553 .iter()
1554 .map(|res| (res.module.clone(), res.ty.clone()))
1555 .collect();
1556 effective_files(
1557 ctx.reader
1558 .read(&Scope {
1559 mode: plan.mode.clone(),
1560 types: types.into_iter().collect(),
1561 tag_providers: Vec::new(),
1562 })
1563 .await?,
1564 )
1565 };
1566 let written: BTreeMap<String, Written> = expected
1567 .resources
1568 .iter()
1569 .filter_map(|res| match status.get(res.key.as_str()) {
1570 Some(PlanStatus::Added) => Some((res.key.clone(), Written::Added)),
1571 Some(PlanStatus::Changed) => {
1572 let file = |name: &str| {
1573 RelPath::new(&format!("{}/{name}", res.key))
1574 .ok()
1575 .and_then(|path| live.get(&path))
1576 .map(|blob| blob.bytes.as_slice())
1577 };
1578 let compare = compare_for(&res.module, &res.ty, file).ok();
1579 Some((res.key.clone(), Written::Changed(compare)))
1580 }
1581 _ => None,
1582 })
1583 .collect();
1584
1585 let before = gather_read_back(ctx.deploy, &expected).await?;
1586 let master = ReadBackBaseline::build("master", &expected, &before, &written);
1587 let backup = match ctx.backup {
1588 Some(backup) => {
1589 let before = backup.read_back(&expected).await?;
1590 Some(ReadBackBaseline::build(
1591 "backup", &expected, &before, &written,
1592 ))
1593 }
1594 None => None,
1595 };
1596 let nodes: Vec<_> = std::iter::once(master.record())
1597 .chain(backup.as_ref().map(ReadBackBaseline::record))
1598 .collect();
1599 run_dir.write_json(
1600 READ_BACK_BASELINE_FILE,
1601 &json!({"schema_version": 1, "nodes": nodes}),
1602 )?;
1603 Ok((master, backup))
1604}
1605
1606async fn confirm(
1613 ctx: &ApplyContext<'_>,
1614 opts: &ApplyOptions,
1615 lock: &HeldLock,
1616 baseline: &ReadBackBaseline,
1617 steps: &mut Steps<'_>,
1618) -> Result<Confirmed, Unconfirmed> {
1619 fn undone(cause: RollbackCause, step: &'static str) -> impl FnOnce(CoreError) -> Unconfirmed {
1620 move |error| Unconfirmed::Restore { cause, step, error }
1621 }
1622 let abort = |cause, warnings| Ok(Confirmed::Abort { cause, warnings });
1623
1624 let verdict = lock
1626 .check(ctx.deploy, LOCK_MARGIN_S, ctx.clock)
1627 .await
1628 .map_err(undone(RollbackCause::LockExpired, "lock_check"))?;
1629 let held = matches!(verdict, LockVerdict::Held { .. });
1630 steps
1631 .emit(ApplyEvent::LockVerdict { held })
1632 .map_err(undone(RollbackCause::LockExpired, "events"))?;
1633 if !held {
1634 return abort(RollbackCause::LockExpired, Vec::new());
1636 }
1637 match scan_and_wait(ctx.deploy, ScanKind::Config, opts.poll.clone())
1639 .await
1640 .map_err(undone(RollbackCause::ScanIncomplete, "scan"))?
1641 {
1642 ScanOutcome::Complete(_) => steps
1643 .emit(ApplyEvent::ScanDone)
1644 .map_err(undone(RollbackCause::ScanIncomplete, "events"))?,
1645 ScanOutcome::NotComplete { .. } => {
1646 return abort(RollbackCause::ScanIncomplete, Vec::new());
1647 }
1648 }
1649
1650 let verify = deploy_verify_judged(
1651 ctx.declared,
1652 &opts.project,
1653 ctx.deploy,
1654 ctx.gateway,
1655 ctx.reader,
1656 &opts.verify,
1657 VerifyContext::PostApply(baseline),
1658 )
1659 .await
1660 .map_err(undone(RollbackCause::VerifyFailed, "verify"))?;
1661 if !verify.ok {
1662 steps
1663 .emit(ApplyEvent::VerifyDone { ok: false })
1664 .map_err(undone(RollbackCause::VerifyFailed, "events"))?;
1665 steps
1669 .emit(ApplyEvent::VerifyChecks {
1670 checks: verify
1671 .checks
1672 .iter()
1673 .map(|c| {
1674 (
1675 c.id.word().to_string(),
1676 c.status.word().to_string(),
1677 c.findings.clone(),
1678 )
1679 })
1680 .collect(),
1681 })
1682 .map_err(undone(RollbackCause::VerifyFailed, "events"))?;
1683 return abort(RollbackCause::VerifyFailed, verify.warnings);
1684 }
1685 steps
1686 .emit(ApplyEvent::VerifyDone { ok: true })
1687 .map_err(Unconfirmed::Green)?;
1688 Ok(Confirmed::Green {
1689 warnings: verify.warnings,
1690 })
1691}
1692
1693async fn restart_report(
1700 deploy: &dyn DeployApi,
1701 before: Option<&[String]>,
1702 warnings: &mut Vec<String>,
1703) -> (Vec<String>, Vec<String>) {
1704 let after = deploy.restart_pending().await.ok();
1705 if before.is_none() || after.is_none() {
1706 warnings.push(RESTART_UNAVAILABLE.to_string());
1707 }
1708 let pending = after.unwrap_or_default();
1709 let known = before.unwrap_or_default();
1710 let added = pending
1711 .iter()
1712 .filter(|entry| !known.contains(entry))
1713 .cloned()
1714 .collect();
1715 (pending, added)
1716}
1717
1718pub fn snapshot_slot(path: &str) -> String {
1722 match split_key(path) {
1723 (KeyCollection::Mode, bare) => format!("files/{bare}"),
1724 (KeyCollection::Core, rest) => format!("core/{rest}"),
1725 }
1726}
1727
1728fn snapshot_warnings(record: &RunRecord, copied: &BTreeMap<String, String>) -> Vec<String> {
1732 record
1733 .pre_map()
1734 .into_iter()
1735 .filter_map(|(path, pre)| {
1736 let pre = pre?;
1737 let key = if path == LEDGER_KEY {
1738 "ledger.json".to_string()
1739 } else {
1740 snapshot_slot(&path)
1741 };
1742 (copied.get(&key) != Some(&pre)).then(|| format!("snapshot_copy_differs ({path})"))
1743 })
1744 .collect()
1745}
1746
1747async fn take_gwbk(
1764 ctx: &ApplyContext<'_>,
1765 opts: &ApplyOptions,
1766 run_id: &RunId,
1767 run_dir: &RunDir,
1768 steps: &mut Steps<'_>,
1769) -> Result<Option<String>, CoreError> {
1770 if opts.skip_gwbk {
1771 steps.emit(ApplyEvent::GwbkSkipped)?;
1772 return Ok(None);
1773 }
1774 let refuse = |detail: String| CoreError::DeployPreflightFailed {
1775 failures: vec![PreflightFailure {
1776 check: GWBK_CHECK.to_string(),
1777 detail,
1778 }]
1779 .into(),
1780 };
1781 let name = format!("{}.gwbk", run_id.as_str());
1782 drop(run_dir.create_private_file(&name)?);
1783 let path = run_dir.path().join(&name);
1784 let verdict = match backup_download(ctx.gateway, Some(&path), "", BackupType::All).await {
1785 Ok(_) => validate_gwbk(&path),
1786 Err(e) => Err(format!("the .gwbk was not downloaded ({})", e.code())),
1787 };
1788 match verdict {
1789 Ok(bytes) => {
1790 steps.emit(ApplyEvent::GwbkDownloaded { bytes })?;
1791 Ok(Some(name))
1792 }
1793 Err(detail) => {
1794 let _ = std::fs::remove_file(&path);
1795 Err(refuse(detail))
1796 }
1797 }
1798}
1799
1800fn validate_gwbk(path: &Path) -> Result<u64, String> {
1803 let bytes = std::fs::metadata(path)
1804 .map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?
1805 .len();
1806 if bytes == 0 {
1807 return Err("the downloaded .gwbk is empty".to_string());
1808 }
1809 let file = std::fs::File::open(path)
1810 .map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?;
1811 let archive = zip::ZipArchive::new(file).map_err(|_| {
1812 "the downloaded .gwbk is not a readable ZIP (no central directory), so the download is incomplete"
1813 .to_string()
1814 })?;
1815 if archive.is_empty() {
1816 return Err("the downloaded .gwbk is a ZIP with no entries".to_string());
1817 }
1818 Ok(bytes)
1819}
1820
1821struct RunScope<'a> {
1823 plan: &'a DeployPlanResult,
1824 run_id: &'a RunId,
1825 run_dir: &'a RunDir,
1826 gwbk: Option<&'a str>,
1828 restart_before: Option<&'a [String]>,
1830 record: &'a RunRecord,
1833}
1834
1835async fn roll_back(
1838 ctx: &ApplyContext<'_>,
1839 opts: &ApplyOptions,
1840 scope: &RunScope<'_>,
1841 held: Option<&HeldLock>,
1842 cause: RollbackCause,
1843 steps: &mut Steps<'_>,
1844 mut warnings: Vec<String>,
1845) -> Result<ApplyOutcome, CoreError> {
1846 let restored = restore_snapshot(
1847 &ctx.restore_context(opts),
1848 &RestoreInput {
1849 run_id: scope.run_id,
1850 run_dir: scope.run_dir,
1851 record: scope.record,
1852 held,
1853 cause: Some(cause),
1854 },
1855 steps,
1856 )
1857 .await?;
1858 warnings.extend(restored.warnings);
1859 let (restart_pending, restart_pending_new) =
1860 restart_report(ctx.deploy, scope.restart_before, &mut warnings).await;
1861 Ok(ApplyOutcome {
1862 kind: OutcomeKind::RolledBack,
1863 run_id: Some(scope.run_id.as_str().to_string()),
1864 run_dir: Some(scope.run_dir.path().to_string_lossy().into_owned()),
1865 cause: Some(cause),
1866 render_sha256: scope.plan.render_sha256.clone(),
1867 plan_sha256: scope.plan.plan_sha256.clone(),
1868 written: Vec::new(),
1869 deleted: Vec::new(),
1870 post_fingerprint: Some(scope.record.pre_fingerprint.clone()),
1871 warnings,
1872 gwbk: scope.gwbk.map(str::to_string),
1873 skip_gwbk: opts.skip_gwbk,
1874 restart_pending,
1875 restart_pending_new,
1876 snapshot_sha256: restored.snapshot_sha256,
1877 backup: None,
1878 })
1879}
1880
1881pub struct RestoreInput<'a> {
1883 pub run_id: &'a RunId,
1887 pub run_dir: &'a RunDir,
1890 pub record: &'a RunRecord,
1892 pub held: Option<&'a HeldLock>,
1894 pub cause: Option<RollbackCause>,
1897}
1898
1899pub struct RestoreContext<'a> {
1902 pub env: &'a str,
1904 pub master: &'a NodeTarget,
1906 pub host: &'a dyn ApplyHost,
1908 pub deploy: &'a dyn DeployApi,
1910 pub clock: &'a dyn Clock,
1912 pub hold: &'a HoldModel,
1914 pub poll: &'a PollConfig,
1916}
1917
1918#[derive(Debug, Clone, PartialEq, Eq)]
1920pub struct RestoreReport {
1921 pub restored: usize,
1923 pub removed: usize,
1925 pub fresh_lock: bool,
1928 pub warnings: Vec<String>,
1930 pub snapshot_sha256: BTreeMap<String, String>,
1933}
1934
1935pub async fn restore_snapshot(
1959 ctx: &RestoreContext<'_>,
1960 input: &RestoreInput<'_>,
1961 steps: &mut Steps<'_>,
1962) -> Result<RestoreReport, CoreError> {
1963 let record = input.record;
1964 match restore_steps(ctx, input, steps).await {
1965 Ok(report) => Ok(report),
1966 Err(cause) => Err(CoreError::RollbackFailed(Box::new(RollbackFailure {
1967 run_id: record.run_id.clone(),
1968 env: ctx.env.to_string(),
1969 run_dir: Some(input.run_dir.path().to_string_lossy().into_owned()),
1970 journal: journal_location(
1971 LedgerRoot::for_node(ctx.master),
1972 &record.gateway,
1973 &record.mode,
1974 ),
1975 cause,
1976 }))),
1977 }
1978}
1979
1980async fn restore_steps(
1982 ctx: &RestoreContext<'_>,
1983 input: &RestoreInput<'_>,
1984 steps: &mut Steps<'_>,
1985) -> Result<RestoreReport, String> {
1986 let gw = |step: &'static str| move |e: CoreError| format!("{step}: {}", e.code());
1989 let host = |step: &'static str| move |e: CoreError| format!("{step}: {}", with_env(e, ctx.env));
1990 let event = |e: CoreError| format!("events: {}", e.code());
1991
1992 let record = input.record;
1993 let deploy = ctx.deploy;
1994 steps
1995 .emit(ApplyEvent::RestoreStarted { cause: input.cause })
1996 .map_err(event)?;
1997
1998 let need_s = ctx.hold.estimate_commit_s(record.touched.len()).ceil() as i64 + LOCK_MARGIN_S;
2000 let reuse = match input.held {
2001 Some(lock) => match lock
2002 .check_gateway(deploy, need_s)
2003 .await
2004 .map_err(gw("checking the scan-lock"))?
2005 {
2006 LockVerdict::Held { .. } => true,
2007 LockVerdict::Expiring { .. } => {
2008 lock.wait_for_lapse(deploy, ctx.poll.interval)
2009 .await
2010 .map_err(gw("waiting for the scan-lock to lapse"))?;
2011 false
2012 }
2013 LockVerdict::NotHeld => false,
2014 },
2015 None => false,
2016 };
2017 if !reuse {
2018 HeldLock::acquire(deploy, ScanKind::Config, ctx.hold.hold_s, ctx.clock)
2019 .await
2020 .map_err(gw("taking the scan-lock"))?;
2021 steps
2022 .emit(ApplyEvent::LockAcquired {
2023 hold_s: ctx.hold.hold_s,
2024 })
2025 .map_err(event)?;
2026 }
2027
2028 let report = ctx.host.restore(record).await.map_err(host("restore"))?;
2030 if let Some(failure) = &report.failure {
2031 return Err(format!("restore: {failure}"));
2032 }
2033 if report.stale {
2034 return Err("restore: the host reported a stale pre-image".to_string());
2035 }
2036 match scan_and_wait(deploy, ScanKind::Config, ctx.poll.clone())
2037 .await
2038 .map_err(gw("the scan after the restore"))?
2039 {
2040 ScanOutcome::Complete(_) => steps.emit(ApplyEvent::ScanDone).map_err(event)?,
2041 ScanOutcome::NotComplete { .. } => {
2042 return Err("the scan after the restore did not complete".to_string());
2043 }
2044 }
2045
2046 let touched: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
2048 let live = ctx
2049 .host
2050 .fingerprint(&touched)
2051 .await
2052 .map_err(host("reading the restored files"))?;
2053 let pre = record.pre_map();
2054 let differing: Vec<String> = pre
2055 .iter()
2056 .filter(|(path, hash)| live.get(*path) != Some(*hash))
2057 .map(|(path, _)| path.clone())
2058 .collect();
2059 if !differing.is_empty() {
2060 return Err(format!(
2061 "{} restored path(s) differ from the snapshot: {}",
2062 differing.len(),
2063 listed(&differing)
2064 ));
2065 }
2066 let mode = deploy
2067 .deployment_mode()
2068 .await
2069 .map_err(gw("reading the deployment mode"))?;
2070 if mode.as_deref() != Some(record.mode.as_str()) {
2071 return Err("the gateway's deployment mode is not the target's".to_string());
2072 }
2073
2074 let mut rolled_back = record.clone();
2076 rolled_back.state = RecordState::RolledBack;
2077 rolled_back.rolled_back_by = Some(input.run_id.as_str().to_string());
2078 rolled_back.post_fingerprint = Some(record.pre_fingerprint.clone());
2079 ctx.host
2080 .finalize(&rolled_back)
2081 .await
2082 .map_err(host("finalize"))?;
2083 steps.emit(ApplyEvent::Restored).map_err(event)?;
2084
2085 let mut warnings = Vec::new();
2086 let mut snapshot_sha256 = BTreeMap::new();
2087 match RunId::parse(&record.run_id) {
2088 Ok(restored_run) => {
2089 match ctx
2090 .host
2091 .fetch_snapshot(&restored_run, input.run_dir.path())
2092 .await
2093 {
2094 Ok(hashes) => {
2095 warnings.extend(snapshot_warnings(record, &hashes));
2096 snapshot_sha256 = hashes;
2097 }
2098 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
2099 }
2100 }
2101 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
2102 }
2103 Ok(RestoreReport {
2104 restored: report.moved.len(),
2105 removed: report.deleted.len(),
2106 fresh_lock: !reuse,
2107 warnings,
2108 snapshot_sha256,
2109 })
2110}
2111
2112fn listed(paths: &[String]) -> String {
2114 let shown = paths.len().min(crate::error::APPLY_LIST_SHOWN);
2115 let mut out = paths[..shown].join(", ");
2116 if paths.len() > shown {
2117 out.push_str(&format!(" and {} more", paths.len() - shown));
2118 }
2119 out
2120}
2121
2122async fn plan_stale(
2127 ctx: &ApplyContext<'_>,
2128 plan: &DeployPlanResult,
2129 opts: &ApplyOptions,
2130 run_id: &RunId,
2131) -> CoreError {
2132 let _ = ctx.host.discard_interrupted(run_id).await;
2135 let _ = ctx.deploy.scan_request(ScanKind::Config).await;
2136 let actual = match deploy_plan(
2137 ctx.declared,
2138 ctx.reader,
2139 &PlanOptions {
2140 base: opts.base.clone(),
2141 diff: false,
2142 },
2143 )
2144 .await
2145 {
2146 Ok(now) => now.plan_sha256,
2147 Err(_) => "unavailable".to_string(),
2148 };
2149 CoreError::PlanStale {
2150 expected: plan.plan_sha256.clone(),
2151 actual,
2152 detail: "a live file changed between the plan and the lock; nothing was written"
2153 .to_string(),
2154 }
2155}
2156
2157#[cfg(test)]
2158mod tests {
2159 use super::*;
2160
2161 fn record(touched: &[(&str, Option<&str>)]) -> RunRecord {
2162 let touched: Vec<TouchedEntry> = touched
2163 .iter()
2164 .map(|(path, pre)| TouchedEntry {
2165 path: (*path).to_string(),
2166 pre_sha256: pre.map(str::to_string),
2167 post_sha256: Some("f".repeat(64)),
2168 })
2169 .collect();
2170 RunRecord {
2171 schema_version: 1,
2172 run_id: "20261016T000000Z-aaaaaa".to_string(),
2173 gateway: "gw".to_string(),
2174 mode: "dev".to_string(),
2175 render_sha256: "a".repeat(64),
2176 plan_sha256: "b".repeat(64),
2177 state: RecordState::Applying,
2178 rolled_back_by: None,
2179 touched,
2180 pre_fingerprint: "c".repeat(64),
2181 post_fingerprint: None,
2182 }
2183 }
2184
2185 #[test]
2186 fn the_commit_budget_is_the_hold_less_its_margin_and_never_zero() {
2187 let hold = |hold_s| HoldModel {
2188 hold_s,
2189 ..HoldModel::MEASURED
2190 };
2191 assert_eq!(commit_budget_s(&hold(176)), 174);
2194 assert_eq!(commit_budget_s(&HoldModel::MEASURED), 174);
2195 assert_eq!(commit_budget_s(&hold(2)), 1);
2198 assert_eq!(commit_budget_s(&hold(0)), 1);
2199 assert_eq!(commit_budget_s(&hold(1_000_000)), BUDGET_MAX_S);
2200 }
2201
2202 #[test]
2203 fn snapshot_warnings_name_a_differing_or_missing_copy_and_skip_created_paths() {
2204 let (h1, h2) = ("1".repeat(64), "2".repeat(64));
2205 let rec = record(&[
2206 ("a/config.json", Some(&h1)),
2207 ("b/config.json", Some(&h1)),
2208 ("c/config.json", None),
2209 (LEDGER_KEY, Some(&h2)),
2210 ]);
2211 let mut copied = BTreeMap::new();
2212 copied.insert("files/a/config.json".to_string(), h1.clone());
2213 copied.insert("files/b/config.json".to_string(), h2.clone());
2214 copied.insert("ledger.json".to_string(), h2);
2215 assert_eq!(
2218 snapshot_warnings(&rec, &copied),
2219 ["snapshot_copy_differs (b/config.json)"]
2220 );
2221 copied.remove("files/a/config.json");
2223 assert_eq!(
2224 snapshot_warnings(&rec, &copied),
2225 [
2226 "snapshot_copy_differs (a/config.json)",
2227 "snapshot_copy_differs (b/config.json)"
2228 ]
2229 );
2230 }
2231
2232 fn shell_plan() -> DeployPlanResult {
2234 DeployPlanResult {
2235 env: "dev".to_string(),
2236 gateway: "gw".to_string(),
2237 mode: "dev".to_string(),
2238 node: "master".to_string(),
2239 transport: "local".to_string(),
2240 render_sha256: "a".repeat(64),
2241 plan_sha256: "b".repeat(64),
2242 summary: crate::deploy::plan::PlanSummary::default(),
2243 entries: Vec::new(),
2244 delete_set: crate::deploy::transport::DeleteSet::default(),
2245 base: None,
2246 warnings: Vec::new(),
2247 excluded_tag_providers: Vec::new(),
2248 unreadable: Vec::new(),
2249 pair_mode_tag_dirs: Vec::new(),
2250 }
2251 }
2252
2253 fn spec_of(writes: &[(&str, Option<&str>)], deletes: &[&str]) -> BatchSpec {
2254 let set = WriteSet {
2255 writes: writes
2256 .iter()
2257 .map(|(path, pre)| WriteOp {
2258 path: RelPath::new(path).expect("path"),
2259 sha256: "1".repeat(64),
2260 pre: pre.map(str::to_string),
2261 })
2262 .collect(),
2263 deletes: deletes
2264 .iter()
2265 .map(|path| DeleteOp {
2266 path: RelPath::new(path).expect("path"),
2267 pre: "2".repeat(64),
2268 })
2269 .collect(),
2270 };
2271 let run_id = RunId::parse("20261102T000000Z-aaaaaa").expect("run id");
2272 batch_spec(&shell_plan(), &run_id, &set, b"{}\n".to_vec(), None, 60)
2273 }
2274
2275 const CORE_TAGS: &str = ".core/ignition/tag-definition/default/Deploy/tags.json";
2276 const MODE_TAGS: &str = "ignition/tag-definition/default/Deploy/tags.json";
2277
2278 #[test]
2282 fn core_key_batch_spec_accepts_a_migration() {
2283 let spec = spec_of(&[(CORE_TAGS, None)], &[MODE_TAGS]);
2284 assert_eq!(spec.problem(), None);
2285 assert_eq!(spec.touched_paths(), [CORE_TAGS, MODE_TAGS, LEDGER_KEY]);
2286 assert_eq!(spec.journal.touched, spec.touched_paths());
2287 let clash = spec_of(&[(MODE_TAGS, None)], &[MODE_TAGS]);
2289 assert!(clash.problem().is_some());
2290 }
2291
2292 #[test]
2296 fn core_key_outside_tag_types_refused() {
2297 for outside in [
2298 ".core/ignition/tag-provider/p/config.json",
2299 ".core/ignition/database-translator/X/config.json",
2300 ] {
2301 let write = spec_of(&[(outside, None)], &[]);
2302 assert!(write.problem().is_some(), "write {outside}");
2303 let delete = spec_of(&[], &[outside]);
2304 assert!(delete.problem().is_some(), "delete {outside}");
2305 assert!(
2306 crate::deploy::transport::batch::WorkList::from_spec(&write).is_err(),
2307 "{outside}"
2308 );
2309 assert!(journal::check_touched([outside].into_iter()).is_err());
2310 }
2311 }
2312
2313 #[test]
2314 fn core_key_snapshot_slot_and_warnings() {
2315 assert_eq!(snapshot_slot(MODE_TAGS), format!("files/{MODE_TAGS}"));
2316 assert_eq!(
2317 snapshot_slot(CORE_TAGS),
2318 "core/ignition/tag-definition/default/Deploy/tags.json"
2319 );
2320 let h = "1".repeat(64);
2321 let rec = record(&[(CORE_TAGS, Some(&h)), (MODE_TAGS, Some(&h))]);
2322 let mut copied = BTreeMap::new();
2323 copied.insert(snapshot_slot(CORE_TAGS), h.clone());
2324 copied.insert(snapshot_slot(MODE_TAGS), h);
2325 assert!(snapshot_warnings(&rec, &copied).is_empty());
2326 }
2327}