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, RollbackScope};
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>,
825}
826
827pub async fn run_apply(
844 ctx: &ApplyContext<'_>,
845 plan: &DeployPlanResult,
846 opts: &ApplyOptions,
847) -> Result<ApplyOutcome, CoreError> {
848 if let Some(refusal) = gates::unreadable_refusal(plan) {
849 return Err(refusal);
850 }
851 if let Some(refusal) =
852 gates::pair_tags_refusal(plan, gates::Redundancy::of(&ctx.declared.target))
853 {
854 return Err(refusal);
855 }
856 if !plan.summary.has_changes {
857 return Ok(ApplyOutcome {
858 kind: OutcomeKind::Noop,
859 run_id: None,
860 run_dir: None,
861 cause: None,
862 render_sha256: plan.render_sha256.clone(),
863 plan_sha256: plan.plan_sha256.clone(),
864 written: Vec::new(),
865 deleted: Vec::new(),
866 post_fingerprint: None,
867 warnings: Vec::new(),
868 gwbk: None,
869 skip_gwbk: false,
870 restart_pending: Vec::new(),
871 restart_pending_new: Vec::new(),
872 snapshot_sha256: BTreeMap::new(),
873 backup: None,
874 });
875 }
876 let (run_id, run_dir) = RunDir::create_fresh(ctx.repo, &plan.gateway)?;
877 let mut steps = Steps::new(None, ctx.observer);
878 let result = drive(ctx, plan, opts, &run_id, &run_dir, &mut steps).await;
879
880 if let Err(e) = &result {
882 let _ = steps.emit(ApplyEvent::Failed {
883 code: e.code().to_string(),
884 });
885 }
886 let doc = match &result {
887 Ok(outcome) => {
888 let mut doc = serde_json::to_value(outcome).unwrap_or(Value::Null);
889 doc["ok"] = Value::Bool(!matches!(
890 outcome.kind,
891 OutcomeKind::RolledBack | OutcomeKind::AppliedBackupUnsynced
892 ));
893 match outcome.kind {
894 OutcomeKind::RolledBack => {
895 doc["slug"] = Value::String(ROLLED_BACK_SLUG.to_string());
896 }
897 OutcomeKind::AppliedBackupUnsynced => {
898 doc["slug"] = Value::String(BACKUP_UNSYNCED_SLUG.to_string());
899 }
900 OutcomeKind::Applied | OutcomeKind::Noop => {}
901 }
902 doc
903 }
904 Err(e) => json!({
905 "ok": false,
906 "run_id": run_id.as_str(),
907 "error": {"code": e.code(), "message": e.to_string()},
908 }),
909 };
910 let written = run_dir.write_json("result.json", &doc);
911 match result {
912 Ok(mut outcome) => {
913 if written.is_err() {
914 outcome.warnings.push("result_json_unwritable".to_string());
915 }
916 Ok(outcome)
917 }
918 Err(e) => Err(e),
919 }
920}
921
922pub struct Steps<'a> {
925 log: Option<EventLog>,
926 observer: &'a dyn ApplyObserver,
927}
928
929impl<'a> Steps<'a> {
930 pub fn new(log: Option<EventLog>, observer: &'a dyn ApplyObserver) -> Self {
932 Steps { log, observer }
933 }
934
935 pub fn emit(&mut self, event: ApplyEvent) -> Result<(), CoreError> {
937 self.observer.event(&event);
938 match &mut self.log {
939 Some(log) => log.append(event.name(), event.fields()),
940 None => Ok(()),
941 }
942 }
943}
944
945fn with_env(err: CoreError, env: &str) -> CoreError {
947 match err {
948 CoreError::DeployJournalPresent {
949 run_id,
950 location,
951 recover_with,
952 ..
953 } => CoreError::DeployJournalPresent {
954 run_id,
955 env: env.to_string(),
956 location,
957 recover_with,
958 },
959 other => other,
960 }
961}
962
963enum BoxState {
966 Mine(Journal),
968 Other,
970 Absent,
972 Unknown,
974}
975
976async fn box_state(ctx: &ApplyContext<'_>, plan: &DeployPlanResult, run_id: &RunId) -> BoxState {
977 let master = &ctx.declared.target.nodes.master;
978 match journal_on_box(ctx.reader, master, &plan.gateway, &plan.mode).await {
979 Ok(Some(journal)) if journal.run_id == run_id.as_str() => BoxState::Mine(journal),
980 Ok(Some(_)) => BoxState::Other,
981 Ok(None) => BoxState::Absent,
982 Err(_) => BoxState::Unknown,
983 }
984}
985
986fn interrupted(
990 ctx: &ApplyContext<'_>,
991 plan: &DeployPlanResult,
992 steps: &mut Steps<'_>,
993 journal: &Journal,
994 err: &CoreError,
995) -> CoreError {
996 let _ = steps.emit(ApplyEvent::CommitInterrupted {
997 code: err.code().to_string(),
998 });
999 journal::journal_present(
1000 journal,
1001 &ctx.declared.env,
1002 &ctx.declared.target.nodes.master,
1003 &plan.gateway,
1004 &plan.mode,
1005 )
1006}
1007
1008async fn commit_failed(
1024 ctx: &ApplyContext<'_>,
1025 run_id: &RunId,
1026 plan: &DeployPlanResult,
1027 steps: &mut Steps<'_>,
1028 err: CoreError,
1029) -> CoreError {
1030 if matches!(err, CoreError::DeployJournalPresent { .. }) {
1031 return err;
1032 }
1033 match box_state(ctx, plan, run_id).await {
1034 BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
1035 BoxState::Unknown => {
1036 let _ = steps.emit(ApplyEvent::CommitInterrupted {
1040 code: format!("{}; journal state unknown", err.code()),
1041 });
1042 recovery_pointer(ctx, plan, run_id, err)
1043 }
1044 BoxState::Other | BoxState::Absent => err,
1045 }
1046}
1047
1048async fn unfinished_after_green(
1055 ctx: &ApplyContext<'_>,
1056 run_id: &RunId,
1057 plan: &DeployPlanResult,
1058 steps: &mut Steps<'_>,
1059 err: CoreError,
1060) -> CoreError {
1061 match box_state(ctx, plan, run_id).await {
1062 BoxState::Mine(journal) => interrupted(ctx, plan, steps, &journal, &err),
1063 _ => recovery_pointer(ctx, plan, run_id, err),
1064 }
1065}
1066
1067fn recovery_pointer(
1072 ctx: &ApplyContext<'_>,
1073 plan: &DeployPlanResult,
1074 run_id: &RunId,
1075 err: CoreError,
1076) -> CoreError {
1077 let env = &ctx.declared.env;
1078 match err {
1079 CoreError::TransportFailed {
1080 operation,
1081 node,
1082 detail,
1083 } => CoreError::TransportFailed {
1084 operation,
1085 node,
1086 detail: format!(
1087 "{detail}; this run's journal may be on the host: recover it with: \
1088 ign deploy rollback --run {run} --env {env}",
1089 run = run_id.as_str()
1090 ),
1091 },
1092 _ => CoreError::DeployJournalPresent {
1093 run_id: run_id.as_str().to_string(),
1094 env: env.clone(),
1095 location: journal_location(
1096 LedgerRoot::for_node(&ctx.declared.target.nodes.master),
1097 &plan.gateway,
1098 &plan.mode,
1099 ),
1100 recover_with: None,
1101 },
1102 }
1103}
1104
1105async fn drive(
1109 ctx: &ApplyContext<'_>,
1110 plan: &DeployPlanResult,
1111 opts: &ApplyOptions,
1112 run_id: &RunId,
1113 run_dir: &RunDir,
1114 steps: &mut Steps<'_>,
1115) -> Result<ApplyOutcome, CoreError> {
1116 let declared = ctx.declared;
1117 let env = declared.env.as_str();
1118 let (gateway, mode) = (plan.gateway.as_str(), plan.mode.as_str());
1119
1120 let started_at = rfc3339_now_utc();
1121 steps.log = Some(EventLog::open(run_dir)?);
1122
1123 let set = write_set(plan, declared)?;
1124 steps.emit(ApplyEvent::RunStarted {
1125 run_id: run_id.as_str().to_string(),
1126 writes: set.writes.len(),
1127 deletes: set.deletes.len(),
1128 })?;
1129
1130 let touched_paths: Vec<String> = set
1134 .writes
1135 .iter()
1136 .map(|w| w.path.as_str().to_string())
1137 .chain(set.deletes.iter().map(|d| d.path.as_str().to_string()))
1138 .collect();
1139 let deleted_paths: Vec<String> = set
1140 .deletes
1141 .iter()
1142 .map(|d| d.path.as_str().to_string())
1143 .collect();
1144 let preflights = run_preflights(
1145 &PreflightInput {
1146 target: &declared.target,
1147 declared: Some(declared),
1148 master: ctx.deploy,
1149 backup: ctx.backup,
1150 gateway: ctx.gateway,
1151 reader: ctx.reader,
1152 host: ctx.host,
1153 touched_files: set.writes.len() + set.deletes.len(),
1154 touched_paths: &touched_paths,
1155 deleted_paths: &deleted_paths,
1156 hold: &opts.hold,
1157 designers: &opts.designers,
1158 clock: ctx.clock,
1159 },
1160 steps,
1161 )
1162 .await;
1163 let seen = preflights.as_ref().ok();
1164 run_dir.write_json(
1165 "run.json",
1166 &json!({
1167 "schema_version": 1,
1168 "run_id": run_id.as_str(),
1169 "env": env,
1170 "gateway": gateway,
1171 "mode": mode,
1172 "node": plan.node,
1173 "transport": plan.transport,
1174 "started_at": started_at,
1175 "run_dir_git": run_dir.git_status().as_record(),
1176 "render_sha256": plan.render_sha256,
1177 "plan_sha256": plan.plan_sha256,
1178 "hold_s": opts.hold.hold_s,
1179 "allow_destructive": opts.gates.allow_destructive,
1180 "allow_drift": opts.gates.allow_drift,
1181 "skip_gwbk": opts.skip_gwbk,
1182 "designers": opts.designers.mode.as_str(),
1183 "designers_timeout_s": opts.designers.timeout.as_secs(),
1184 "designers_seen": seen.map(|r| r.designers_seen
1185 .iter()
1186 .map(|(user, project, host)| json!({"user": user, "project": project, "host": host}))
1187 .collect::<Vec<_>>()),
1188 "preflights": seen.map(|r| r.findings
1189 .iter()
1190 .map(|f| json!({"check": f.check.as_str(), "status": f.status.as_str()}))
1191 .collect::<Vec<_>>()),
1192 }),
1193 )?;
1194 run_dir.write_json("plan.json", plan)?;
1195 let preflights = preflights?;
1196 if !preflights.passed() {
1197 steps.emit(ApplyEvent::PreflightChecks {
1198 checks: preflights
1199 .findings
1200 .iter()
1201 .map(|f| (f.check.as_str().to_string(), f.status.as_str().to_string()))
1202 .collect(),
1203 })?;
1204 if !preflights.backup_unreadable.is_empty() {
1209 return Err(CoreError::DeployUnreadable {
1210 node: "backup".to_string(),
1211 keys: preflights.backup_unreadable,
1212 });
1213 }
1214 return Err(CoreError::DeployPreflightFailed {
1215 failures: preflights.failures().into(),
1216 });
1217 }
1218
1219 let mut backup_before = match ctx.backup {
1225 Some(backup) => {
1226 let sampled = backup.status().await?.last_sync_epoch_ms();
1227 steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
1228 sampled
1229 }
1230 None => None,
1231 };
1232 let mut backup_log_since_ms = wall_clock_ms();
1236
1237 let gwbk = take_gwbk(ctx, opts, run_id, run_dir, steps).await?;
1240
1241 let restart_before = ctx.deploy.restart_pending().await.ok();
1245
1246 let old_bytes = ctx.reader.read_ledger(gateway, mode).await?;
1248 let old_ledger = match &old_bytes {
1249 Some(bytes) => Some(Ledger::parse(
1250 &ledger_location(
1251 LedgerRoot::for_node(&declared.target.nodes.master),
1252 gateway,
1253 mode,
1254 ),
1255 bytes,
1256 gateway,
1257 mode,
1258 )?),
1259 None => None,
1260 };
1261 let ledger = new_ledger(plan, old_ledger.as_ref(), &set, run_id);
1262 let ledger_bytes = ledger.to_bytes();
1263 Ledger::parse(
1264 &ledger_location(
1265 LedgerRoot::for_node(&declared.target.nodes.master),
1266 gateway,
1267 mode,
1268 ),
1269 &ledger_bytes,
1270 gateway,
1271 mode,
1272 )?;
1273 let spec = batch_spec(
1274 plan,
1275 run_id,
1276 &set,
1277 ledger_bytes,
1278 old_bytes.as_deref().map(sha256_hex),
1279 commit_budget_s(&opts.hold),
1280 );
1281 if let Some(problem) = spec.problem() {
1282 return Err(CoreError::Internal(format!(
1283 "the batch the engine built is invalid: {problem}"
1284 )));
1285 }
1286
1287 let (baseline, backup_baseline) = read_back_baselines(ctx, plan, run_dir).await?;
1296
1297 let scope = RunScope {
1298 plan,
1299 run_id,
1300 run_dir,
1301 gwbk: gwbk.as_deref(),
1302 restart_before: restart_before.as_deref(),
1303 record: &spec.record,
1304 };
1305
1306 let prepared = ctx
1308 .host
1309 .prepare(&spec, &declared.outcome.tree)
1310 .await
1311 .map_err(|e| with_env(e, env))?;
1312 steps.emit(ApplyEvent::Staged {
1313 files: set.writes.len(),
1314 })?;
1315 let redundancy = gates::Redundancy::of(&declared.target);
1324 if redundancy == gates::Redundancy::Pair {
1325 let refused =
1326 match crate::actions::deploy::pair_mode_tag_dirs_of(ctx.reader, mode, &plan.entries)
1327 .await
1328 {
1329 Ok(dirs) => gates::pair_tags_refusal_with(plan, redundancy, &dirs),
1330 Err(e) => Some(e),
1331 };
1332 if let Some(err) = refused {
1333 let _ = ctx.host.discard_interrupted(run_id).await;
1334 return Err(err);
1335 }
1336 }
1337 let lock =
1338 match HeldLock::acquire(ctx.deploy, ScanKind::Config, opts.hold.hold_s, ctx.clock).await {
1339 Ok(lock) => lock,
1340 Err(refused) => {
1341 let _ = ctx.host.discard_interrupted(run_id).await;
1345 return Err(refused);
1346 }
1347 };
1348 steps.emit(ApplyEvent::LockAcquired {
1349 hold_s: opts.hold.hold_s,
1350 })?;
1351 if let Some(backup) = ctx.backup {
1363 let since_ms = wall_clock_ms();
1364 if let Ok(Ok(status)) = tokio::time::timeout(LOCKED_SAMPLE_TIMEOUT, backup.status()).await {
1365 let sampled = status.last_sync_epoch_ms();
1366 steps.emit(ApplyEvent::BackupSampled { last_sync: sampled })?;
1367 backup_before = sampled;
1368 backup_log_since_ms = since_ms;
1369 }
1370 }
1371 let report = match ctx.host.commit(&prepared).await {
1372 Ok(report) => report,
1373 Err(e) => return Err(commit_failed(ctx, run_id, plan, steps, with_env(e, env)).await),
1374 };
1375 if let Err(e) = steps.emit(ApplyEvent::Committed {
1376 moved: report.moved.len(),
1377 deleted: report.deleted.len(),
1378 }) {
1379 return Err(commit_failed(ctx, run_id, plan, steps, e).await);
1382 }
1383 if report.stale {
1384 return Err(plan_stale(ctx, plan, opts, run_id).await);
1385 }
1386 if report.failure.is_some() {
1387 let cause = if report.deadline {
1392 RollbackCause::LockExpired
1393 } else {
1394 RollbackCause::WriteFailed
1395 };
1396 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, Vec::new()).await;
1397 }
1398
1399 let mut warnings = match confirm(ctx, opts, &lock, &baseline, steps).await {
1402 Ok(Confirmed::Green { warnings }) => warnings,
1403 Ok(Confirmed::Abort { cause, warnings }) => {
1404 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, warnings).await;
1405 }
1406 Err(Unconfirmed::Restore { cause, step, error }) => {
1407 let note = format!("post_commit_error ({step}: {})", error.code());
1410 return roll_back(ctx, opts, &scope, Some(&lock), cause, steps, vec![note]).await;
1411 }
1412 Err(Unconfirmed::Green(error)) => {
1413 return Err(unfinished_after_green(ctx, run_id, plan, steps, error).await);
1414 }
1415 };
1416
1417 let post = match ctx.host.fingerprint(&spec.touched_paths()).await {
1419 Ok(post) => post,
1420 Err(e) => return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await),
1421 };
1422 let post_fingerprint = fingerprint(&post);
1423 let mut record = spec.record.clone();
1424 record.state = RecordState::Applied;
1425 record.post_fingerprint = Some(post_fingerprint.clone());
1426 if let Err(e) = ctx.host.finalize(&record).await {
1427 if !matches!(box_state(ctx, plan, run_id).await, BoxState::Absent) {
1430 return Err(unfinished_after_green(ctx, run_id, plan, steps, e).await);
1431 }
1432 warnings.push(format!("finalize_reported_error ({})", e.code()));
1433 }
1434 if steps.emit(ApplyEvent::Finalized).is_err() {
1435 warnings.push(EVENTS_UNWRITABLE.to_string());
1436 }
1437
1438 let (restart_pending, restart_pending_new) =
1439 restart_report(ctx.deploy, restart_before.as_deref(), &mut warnings).await;
1440 let mut snapshot_sha256 = BTreeMap::new();
1441 match ctx.host.fetch_snapshot(run_id, run_dir.path()).await {
1442 Ok(hashes) => {
1443 warnings.extend(snapshot_warnings(&spec.record, &hashes));
1444 snapshot_sha256 = hashes;
1445 }
1446 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
1447 }
1448
1449 let backup_report = match ctx.backup {
1452 Some(backup) => Some(
1453 converge::converge_backup(
1454 ctx,
1455 backup,
1456 opts,
1457 &set,
1458 converge::Baseline {
1459 last_sync: backup_before,
1460 log_since_ms: backup_log_since_ms,
1461 read_back: backup_baseline,
1462 },
1463 steps,
1464 &mut warnings,
1465 )
1466 .await,
1467 ),
1468 None => None,
1469 };
1470 let kind = match &backup_report {
1471 Some(report) if !report.converged => OutcomeKind::AppliedBackupUnsynced,
1472 _ => OutcomeKind::Applied,
1473 };
1474 Ok(ApplyOutcome {
1475 kind,
1476 run_id: Some(run_id.as_str().to_string()),
1477 run_dir: Some(run_dir.path().to_string_lossy().into_owned()),
1478 cause: None,
1479 render_sha256: plan.render_sha256.clone(),
1480 plan_sha256: plan.plan_sha256.clone(),
1481 written: set
1482 .writes
1483 .iter()
1484 .map(|w| w.path.as_str().to_string())
1485 .collect(),
1486 deleted: set
1487 .deletes
1488 .iter()
1489 .map(|d| d.path.as_str().to_string())
1490 .collect(),
1491 post_fingerprint: Some(post_fingerprint),
1492 warnings,
1493 gwbk,
1494 skip_gwbk: opts.skip_gwbk,
1495 restart_pending,
1496 restart_pending_new,
1497 snapshot_sha256,
1498 backup: backup_report,
1499 })
1500}
1501
1502enum Confirmed {
1505 Green { warnings: Vec<String> },
1507 Abort {
1509 cause: RollbackCause,
1510 warnings: Vec<String>,
1511 },
1512}
1513
1514enum Unconfirmed {
1516 Restore {
1520 cause: RollbackCause,
1521 step: &'static str,
1522 error: CoreError,
1523 },
1524 Green(CoreError),
1526}
1527
1528async fn read_back_baselines(
1538 ctx: &ApplyContext<'_>,
1539 plan: &DeployPlanResult,
1540 run_dir: &RunDir,
1541) -> Result<(ReadBackBaseline, Option<ReadBackBaseline>), CoreError> {
1542 let declared = ctx.declared;
1543 let expected =
1544 Expected::from_render(&declared.target.mode, &declared.outcome, &declared.manifest);
1545 let status: BTreeMap<&str, PlanStatus> = plan
1546 .entries
1547 .iter()
1548 .map(|entry| (entry.key.as_str(), entry.status))
1549 .collect();
1550 let changed: Vec<_> = expected
1551 .resources
1552 .iter()
1553 .filter(|res| status.get(res.key.as_str()) == Some(&PlanStatus::Changed))
1554 .collect();
1555 let live = if changed.is_empty() {
1556 BTreeMap::new()
1557 } else {
1558 let types: BTreeSet<(String, String)> = changed
1559 .iter()
1560 .map(|res| (res.module.clone(), res.ty.clone()))
1561 .collect();
1562 effective_files(
1563 ctx.reader
1564 .read(&Scope {
1565 mode: plan.mode.clone(),
1566 types: types.into_iter().collect(),
1567 tag_providers: Vec::new(),
1568 })
1569 .await?,
1570 )
1571 };
1572 let written: BTreeMap<String, Written> = expected
1573 .resources
1574 .iter()
1575 .filter_map(|res| match status.get(res.key.as_str()) {
1576 Some(PlanStatus::Added) => Some((res.key.clone(), Written::Added)),
1577 Some(PlanStatus::Changed) => {
1578 let file = |name: &str| {
1579 RelPath::new(&format!("{}/{name}", res.key))
1580 .ok()
1581 .and_then(|path| live.get(&path))
1582 .map(|blob| blob.bytes.as_slice())
1583 };
1584 let compare = compare_for(&res.module, &res.ty, file).ok();
1585 Some((res.key.clone(), Written::Changed(compare)))
1586 }
1587 _ => None,
1588 })
1589 .collect();
1590
1591 let before = gather_read_back(ctx.deploy, &expected).await?;
1592 let master = ReadBackBaseline::build("master", &expected, &before, &written);
1593 let backup = match ctx.backup {
1594 Some(backup) => {
1595 let before = backup.read_back(&expected).await?;
1596 Some(ReadBackBaseline::build(
1597 "backup", &expected, &before, &written,
1598 ))
1599 }
1600 None => None,
1601 };
1602 let nodes: Vec<_> = std::iter::once(master.record())
1603 .chain(backup.as_ref().map(ReadBackBaseline::record))
1604 .collect();
1605 run_dir.write_json(
1606 READ_BACK_BASELINE_FILE,
1607 &json!({"schema_version": 1, "nodes": nodes}),
1608 )?;
1609 Ok((master, backup))
1610}
1611
1612async fn confirm(
1619 ctx: &ApplyContext<'_>,
1620 opts: &ApplyOptions,
1621 lock: &HeldLock,
1622 baseline: &ReadBackBaseline,
1623 steps: &mut Steps<'_>,
1624) -> Result<Confirmed, Unconfirmed> {
1625 fn undone(cause: RollbackCause, step: &'static str) -> impl FnOnce(CoreError) -> Unconfirmed {
1626 move |error| Unconfirmed::Restore { cause, step, error }
1627 }
1628 let abort = |cause, warnings| Ok(Confirmed::Abort { cause, warnings });
1629
1630 let verdict = lock
1632 .check(ctx.deploy, LOCK_MARGIN_S, ctx.clock)
1633 .await
1634 .map_err(undone(RollbackCause::LockExpired, "lock_check"))?;
1635 let held = matches!(verdict, LockVerdict::Held { .. });
1636 steps
1637 .emit(ApplyEvent::LockVerdict { held })
1638 .map_err(undone(RollbackCause::LockExpired, "events"))?;
1639 if !held {
1640 return abort(RollbackCause::LockExpired, Vec::new());
1642 }
1643 match scan_and_wait(ctx.deploy, ScanKind::Config, opts.poll.clone())
1645 .await
1646 .map_err(undone(RollbackCause::ScanIncomplete, "scan"))?
1647 {
1648 ScanOutcome::Complete(_) => steps
1649 .emit(ApplyEvent::ScanDone)
1650 .map_err(undone(RollbackCause::ScanIncomplete, "events"))?,
1651 ScanOutcome::NotComplete { .. } => {
1652 return abort(RollbackCause::ScanIncomplete, Vec::new());
1653 }
1654 }
1655
1656 let verify = deploy_verify_judged(
1657 ctx.declared,
1658 &opts.project,
1659 ctx.deploy,
1660 ctx.gateway,
1661 ctx.reader,
1662 &opts.verify,
1663 VerifyContext::PostApply(baseline),
1664 )
1665 .await
1666 .map_err(undone(RollbackCause::VerifyFailed, "verify"))?;
1667 if !verify.ok {
1668 steps
1669 .emit(ApplyEvent::VerifyDone { ok: false })
1670 .map_err(undone(RollbackCause::VerifyFailed, "events"))?;
1671 steps
1675 .emit(ApplyEvent::VerifyChecks {
1676 checks: verify
1677 .checks
1678 .iter()
1679 .map(|c| {
1680 (
1681 c.id.word().to_string(),
1682 c.status.word().to_string(),
1683 c.findings.clone(),
1684 )
1685 })
1686 .collect(),
1687 })
1688 .map_err(undone(RollbackCause::VerifyFailed, "events"))?;
1689 return abort(RollbackCause::VerifyFailed, verify.warnings);
1690 }
1691 steps
1692 .emit(ApplyEvent::VerifyDone { ok: true })
1693 .map_err(Unconfirmed::Green)?;
1694 Ok(Confirmed::Green {
1695 warnings: verify.warnings,
1696 })
1697}
1698
1699async fn restart_report(
1706 deploy: &dyn DeployApi,
1707 before: Option<&[String]>,
1708 warnings: &mut Vec<String>,
1709) -> (Vec<String>, Vec<String>) {
1710 let after = deploy.restart_pending().await.ok();
1711 if before.is_none() || after.is_none() {
1712 warnings.push(RESTART_UNAVAILABLE.to_string());
1713 }
1714 let pending = after.unwrap_or_default();
1715 let known = before.unwrap_or_default();
1716 let added = pending
1717 .iter()
1718 .filter(|entry| !known.contains(entry))
1719 .cloned()
1720 .collect();
1721 (pending, added)
1722}
1723
1724pub fn snapshot_slot(path: &str) -> String {
1728 match split_key(path) {
1729 (KeyCollection::Mode, bare) => format!("files/{bare}"),
1730 (KeyCollection::Core, rest) => format!("core/{rest}"),
1731 }
1732}
1733
1734fn snapshot_warnings(record: &RunRecord, copied: &BTreeMap<String, String>) -> Vec<String> {
1738 record
1739 .pre_map()
1740 .into_iter()
1741 .filter_map(|(path, pre)| {
1742 let pre = pre?;
1743 let key = if path == LEDGER_KEY {
1744 "ledger.json".to_string()
1745 } else {
1746 snapshot_slot(&path)
1747 };
1748 (copied.get(&key) != Some(&pre)).then(|| format!("snapshot_copy_differs ({path})"))
1749 })
1750 .collect()
1751}
1752
1753async fn take_gwbk(
1770 ctx: &ApplyContext<'_>,
1771 opts: &ApplyOptions,
1772 run_id: &RunId,
1773 run_dir: &RunDir,
1774 steps: &mut Steps<'_>,
1775) -> Result<Option<String>, CoreError> {
1776 if opts.skip_gwbk {
1777 steps.emit(ApplyEvent::GwbkSkipped)?;
1778 return Ok(None);
1779 }
1780 let refuse = |detail: String| CoreError::DeployPreflightFailed {
1781 failures: vec![PreflightFailure {
1782 check: GWBK_CHECK.to_string(),
1783 detail,
1784 }]
1785 .into(),
1786 };
1787 let name = format!("{}.gwbk", run_id.as_str());
1788 drop(run_dir.create_private_file(&name)?);
1789 let path = run_dir.path().join(&name);
1790 let verdict = match backup_download(ctx.gateway, Some(&path), "", BackupType::All).await {
1791 Ok(_) => validate_gwbk(&path),
1792 Err(e) => Err(format!("the .gwbk was not downloaded ({})", e.code())),
1793 };
1794 match verdict {
1795 Ok(bytes) => {
1796 steps.emit(ApplyEvent::GwbkDownloaded { bytes })?;
1797 Ok(Some(name))
1798 }
1799 Err(detail) => {
1800 let _ = std::fs::remove_file(&path);
1801 Err(refuse(detail))
1802 }
1803 }
1804}
1805
1806fn validate_gwbk(path: &Path) -> Result<u64, String> {
1809 let bytes = std::fs::metadata(path)
1810 .map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?
1811 .len();
1812 if bytes == 0 {
1813 return Err("the downloaded .gwbk is empty".to_string());
1814 }
1815 let file = std::fs::File::open(path)
1816 .map_err(|_| "the downloaded .gwbk cannot be read back".to_string())?;
1817 let archive = zip::ZipArchive::new(file).map_err(|_| {
1818 "the downloaded .gwbk is not a readable ZIP (no central directory), so the download is incomplete"
1819 .to_string()
1820 })?;
1821 if archive.is_empty() {
1822 return Err("the downloaded .gwbk is a ZIP with no entries".to_string());
1823 }
1824 Ok(bytes)
1825}
1826
1827struct RunScope<'a> {
1829 plan: &'a DeployPlanResult,
1830 run_id: &'a RunId,
1831 run_dir: &'a RunDir,
1832 gwbk: Option<&'a str>,
1834 restart_before: Option<&'a [String]>,
1836 record: &'a RunRecord,
1839}
1840
1841async fn roll_back(
1844 ctx: &ApplyContext<'_>,
1845 opts: &ApplyOptions,
1846 scope: &RunScope<'_>,
1847 held: Option<&HeldLock>,
1848 cause: RollbackCause,
1849 steps: &mut Steps<'_>,
1850 mut warnings: Vec<String>,
1851) -> Result<ApplyOutcome, CoreError> {
1852 let backup_baseline = match ctx.backup {
1856 Some(backup) => Some(restore_baseline(backup).await),
1857 None => None,
1858 };
1859 let restored = restore_snapshot(
1860 &ctx.restore_context(opts),
1861 &RestoreInput {
1862 run_id: scope.run_id,
1863 run_dir: scope.run_dir,
1864 record: scope.record,
1865 held,
1866 cause: Some(cause),
1867 },
1868 steps,
1869 )
1870 .await?;
1871 warnings.extend(restored.warnings);
1872 let (restart_pending, restart_pending_new) =
1873 restart_report(ctx.deploy, scope.restart_before, &mut warnings).await;
1874 let backup = match (ctx.backup, backup_baseline) {
1877 (Some(backup), Some(baseline)) => Some(
1878 restore_backup_phase(
1879 converge::RestoreSurfaces {
1880 deploy: ctx.deploy,
1881 reader: ctx.reader,
1882 clock: ctx.clock,
1883 backup,
1884 },
1885 scope.record,
1886 &ctx.declared.target.mode,
1887 opts.backup,
1888 baseline,
1889 steps,
1890 &mut warnings,
1891 )
1892 .await,
1893 ),
1894 _ => None,
1895 };
1896 Ok(ApplyOutcome {
1897 kind: OutcomeKind::RolledBack,
1898 run_id: Some(scope.run_id.as_str().to_string()),
1899 run_dir: Some(scope.run_dir.path().to_string_lossy().into_owned()),
1900 cause: Some(cause),
1901 render_sha256: scope.plan.render_sha256.clone(),
1902 plan_sha256: scope.plan.plan_sha256.clone(),
1903 written: Vec::new(),
1904 deleted: Vec::new(),
1905 post_fingerprint: Some(scope.record.pre_fingerprint.clone()),
1906 warnings,
1907 gwbk: scope.gwbk.map(str::to_string),
1908 skip_gwbk: opts.skip_gwbk,
1909 restart_pending,
1910 restart_pending_new,
1911 snapshot_sha256: restored.snapshot_sha256,
1912 backup,
1913 })
1914}
1915
1916pub(crate) async fn restore_baseline(backup: &BackupNode<'_>) -> converge::RestoreBaseline {
1920 let log_since_ms = wall_clock_ms();
1921 let last_sync = match tokio::time::timeout(LOCKED_SAMPLE_TIMEOUT, backup.status()).await {
1922 Ok(Ok(status)) => status.last_sync_epoch_ms(),
1923 _ => None,
1924 };
1925 converge::RestoreBaseline {
1926 last_sync,
1927 log_since_ms,
1928 }
1929}
1930
1931#[allow(clippy::too_many_arguments)]
1934pub(crate) async fn restore_backup_phase(
1935 surfaces: converge::RestoreSurfaces<'_, '_>,
1936 record: &RunRecord,
1937 mode: &str,
1938 policy: BackupPolicy,
1939 baseline: converge::RestoreBaseline,
1940 steps: &mut Steps<'_>,
1941 warnings: &mut Vec<String>,
1942) -> BackupReport {
1943 let report =
1944 converge::converge_restore(surfaces, record, mode, policy, baseline, steps, warnings).await;
1945 if let Some(reason) = report.reason.filter(|_| !report.converged) {
1946 warnings.push(format!(
1947 "backup_unsynced_after_restore ({})",
1948 reason.as_str()
1949 ));
1950 }
1951 report
1952}
1953
1954pub struct RestoreInput<'a> {
1956 pub run_id: &'a RunId,
1960 pub run_dir: &'a RunDir,
1963 pub record: &'a RunRecord,
1965 pub held: Option<&'a HeldLock>,
1967 pub cause: Option<RollbackCause>,
1970}
1971
1972pub struct RestoreContext<'a> {
1975 pub env: &'a str,
1977 pub master: &'a NodeTarget,
1979 pub host: &'a dyn ApplyHost,
1981 pub deploy: &'a dyn DeployApi,
1983 pub clock: &'a dyn Clock,
1985 pub hold: &'a HoldModel,
1987 pub poll: &'a PollConfig,
1989}
1990
1991#[derive(Debug, Clone, PartialEq, Eq)]
1993pub struct RestoreReport {
1994 pub restored: usize,
1996 pub removed: usize,
1998 pub fresh_lock: bool,
2001 pub warnings: Vec<String>,
2003 pub snapshot_sha256: BTreeMap<String, String>,
2006}
2007
2008pub async fn restore_snapshot(
2032 ctx: &RestoreContext<'_>,
2033 input: &RestoreInput<'_>,
2034 steps: &mut Steps<'_>,
2035) -> Result<RestoreReport, CoreError> {
2036 let record = input.record;
2037 match restore_steps(ctx, input, steps).await {
2038 Ok(report) => Ok(report),
2039 Err(cause) => Err(CoreError::RollbackFailed(Box::new(RollbackFailure {
2040 run_id: record.run_id.clone(),
2041 env: ctx.env.to_string(),
2042 run_dir: Some(input.run_dir.path().to_string_lossy().into_owned()),
2043 journal: journal_location(
2044 LedgerRoot::for_node(ctx.master),
2045 &record.gateway,
2046 &record.mode,
2047 ),
2048 cause,
2049 scope: RollbackScope::Files,
2050 }))),
2051 }
2052}
2053
2054async fn restore_steps(
2056 ctx: &RestoreContext<'_>,
2057 input: &RestoreInput<'_>,
2058 steps: &mut Steps<'_>,
2059) -> Result<RestoreReport, String> {
2060 let gw = |step: &'static str| move |e: CoreError| format!("{step}: {}", e.code());
2063 let host = |step: &'static str| move |e: CoreError| format!("{step}: {}", with_env(e, ctx.env));
2064 let event = |e: CoreError| format!("events: {}", e.code());
2065
2066 let record = input.record;
2067 let deploy = ctx.deploy;
2068 steps
2069 .emit(ApplyEvent::RestoreStarted { cause: input.cause })
2070 .map_err(event)?;
2071
2072 let need_s = ctx.hold.estimate_commit_s(record.touched.len()).ceil() as i64 + LOCK_MARGIN_S;
2074 let reuse = match input.held {
2075 Some(lock) => match lock
2076 .check_gateway(deploy, need_s)
2077 .await
2078 .map_err(gw("checking the scan-lock"))?
2079 {
2080 LockVerdict::Held { .. } => true,
2081 LockVerdict::Expiring { .. } => {
2082 lock.wait_for_lapse(deploy, ctx.poll.interval)
2083 .await
2084 .map_err(gw("waiting for the scan-lock to lapse"))?;
2085 false
2086 }
2087 LockVerdict::NotHeld => false,
2088 },
2089 None => false,
2090 };
2091 if !reuse {
2092 HeldLock::acquire(deploy, ScanKind::Config, ctx.hold.hold_s, ctx.clock)
2093 .await
2094 .map_err(gw("taking the scan-lock"))?;
2095 steps
2096 .emit(ApplyEvent::LockAcquired {
2097 hold_s: ctx.hold.hold_s,
2098 })
2099 .map_err(event)?;
2100 }
2101
2102 let report = ctx.host.restore(record).await.map_err(host("restore"))?;
2104 if let Some(failure) = &report.failure {
2105 return Err(format!("restore: {failure}"));
2106 }
2107 if report.stale {
2108 return Err("restore: the host reported a stale pre-image".to_string());
2109 }
2110 match scan_and_wait(deploy, ScanKind::Config, ctx.poll.clone())
2111 .await
2112 .map_err(gw("the scan after the restore"))?
2113 {
2114 ScanOutcome::Complete(_) => steps.emit(ApplyEvent::ScanDone).map_err(event)?,
2115 ScanOutcome::NotComplete { .. } => {
2116 return Err("the scan after the restore did not complete".to_string());
2117 }
2118 }
2119
2120 let touched: Vec<String> = record.touched.iter().map(|e| e.path.clone()).collect();
2122 let live = ctx
2123 .host
2124 .fingerprint(&touched)
2125 .await
2126 .map_err(host("reading the restored files"))?;
2127 let pre = record.pre_map();
2128 let differing: Vec<String> = pre
2129 .iter()
2130 .filter(|(path, hash)| live.get(*path) != Some(*hash))
2131 .map(|(path, _)| path.clone())
2132 .collect();
2133 if !differing.is_empty() {
2134 return Err(format!(
2135 "{} restored path(s) differ from the snapshot: {}",
2136 differing.len(),
2137 listed(&differing)
2138 ));
2139 }
2140 let mode = deploy
2141 .deployment_mode()
2142 .await
2143 .map_err(gw("reading the deployment mode"))?;
2144 if mode.as_deref() != Some(record.mode.as_str()) {
2145 return Err("the gateway's deployment mode is not the target's".to_string());
2146 }
2147
2148 let mut rolled_back = record.clone();
2150 rolled_back.state = RecordState::RolledBack;
2151 rolled_back.rolled_back_by = Some(input.run_id.as_str().to_string());
2152 rolled_back.post_fingerprint = Some(record.pre_fingerprint.clone());
2153 ctx.host
2154 .finalize(&rolled_back)
2155 .await
2156 .map_err(host("finalize"))?;
2157 steps.emit(ApplyEvent::Restored).map_err(event)?;
2158
2159 let mut warnings = Vec::new();
2160 let mut snapshot_sha256 = BTreeMap::new();
2161 match RunId::parse(&record.run_id) {
2162 Ok(restored_run) => {
2163 match ctx
2164 .host
2165 .fetch_snapshot(&restored_run, input.run_dir.path())
2166 .await
2167 {
2168 Ok(hashes) => {
2169 warnings.extend(snapshot_warnings(record, &hashes));
2170 snapshot_sha256 = hashes;
2171 }
2172 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
2173 }
2174 }
2175 Err(e) => warnings.push(format!("snapshot_download_failed ({})", e.code())),
2176 }
2177 Ok(RestoreReport {
2178 restored: report.moved.len(),
2179 removed: report.deleted.len(),
2180 fresh_lock: !reuse,
2181 warnings,
2182 snapshot_sha256,
2183 })
2184}
2185
2186fn listed(paths: &[String]) -> String {
2188 let shown = paths.len().min(crate::error::APPLY_LIST_SHOWN);
2189 let mut out = paths[..shown].join(", ");
2190 if paths.len() > shown {
2191 out.push_str(&format!(" and {} more", paths.len() - shown));
2192 }
2193 out
2194}
2195
2196async fn plan_stale(
2201 ctx: &ApplyContext<'_>,
2202 plan: &DeployPlanResult,
2203 opts: &ApplyOptions,
2204 run_id: &RunId,
2205) -> CoreError {
2206 let _ = ctx.host.discard_interrupted(run_id).await;
2209 let _ = ctx.deploy.scan_request(ScanKind::Config).await;
2210 let actual = match deploy_plan(
2211 ctx.declared,
2212 ctx.reader,
2213 &PlanOptions {
2214 base: opts.base.clone(),
2215 diff: false,
2216 },
2217 )
2218 .await
2219 {
2220 Ok(now) => now.plan_sha256,
2221 Err(_) => "unavailable".to_string(),
2222 };
2223 CoreError::PlanStale {
2224 expected: plan.plan_sha256.clone(),
2225 actual,
2226 detail: "a live file changed between the plan and the lock; nothing was written"
2227 .to_string(),
2228 }
2229}
2230
2231#[cfg(test)]
2232mod tests {
2233 use super::*;
2234
2235 fn record(touched: &[(&str, Option<&str>)]) -> RunRecord {
2236 let touched: Vec<TouchedEntry> = touched
2237 .iter()
2238 .map(|(path, pre)| TouchedEntry {
2239 path: (*path).to_string(),
2240 pre_sha256: pre.map(str::to_string),
2241 post_sha256: Some("f".repeat(64)),
2242 })
2243 .collect();
2244 RunRecord {
2245 schema_version: 1,
2246 run_id: "20261016T000000Z-aaaaaa".to_string(),
2247 gateway: "gw".to_string(),
2248 mode: "dev".to_string(),
2249 render_sha256: "a".repeat(64),
2250 plan_sha256: "b".repeat(64),
2251 state: RecordState::Applying,
2252 rolled_back_by: None,
2253 touched,
2254 pre_fingerprint: "c".repeat(64),
2255 post_fingerprint: None,
2256 }
2257 }
2258
2259 #[test]
2260 fn the_commit_budget_is_the_hold_less_its_margin_and_never_zero() {
2261 let hold = |hold_s| HoldModel {
2262 hold_s,
2263 ..HoldModel::MEASURED
2264 };
2265 assert_eq!(commit_budget_s(&hold(176)), 174);
2268 assert_eq!(commit_budget_s(&HoldModel::MEASURED), 174);
2269 assert_eq!(commit_budget_s(&hold(2)), 1);
2272 assert_eq!(commit_budget_s(&hold(0)), 1);
2273 assert_eq!(commit_budget_s(&hold(1_000_000)), BUDGET_MAX_S);
2274 }
2275
2276 #[test]
2277 fn snapshot_warnings_name_a_differing_or_missing_copy_and_skip_created_paths() {
2278 let (h1, h2) = ("1".repeat(64), "2".repeat(64));
2279 let rec = record(&[
2280 ("a/config.json", Some(&h1)),
2281 ("b/config.json", Some(&h1)),
2282 ("c/config.json", None),
2283 (LEDGER_KEY, Some(&h2)),
2284 ]);
2285 let mut copied = BTreeMap::new();
2286 copied.insert("files/a/config.json".to_string(), h1.clone());
2287 copied.insert("files/b/config.json".to_string(), h2.clone());
2288 copied.insert("ledger.json".to_string(), h2);
2289 assert_eq!(
2292 snapshot_warnings(&rec, &copied),
2293 ["snapshot_copy_differs (b/config.json)"]
2294 );
2295 copied.remove("files/a/config.json");
2297 assert_eq!(
2298 snapshot_warnings(&rec, &copied),
2299 [
2300 "snapshot_copy_differs (a/config.json)",
2301 "snapshot_copy_differs (b/config.json)"
2302 ]
2303 );
2304 }
2305
2306 fn shell_plan() -> DeployPlanResult {
2308 DeployPlanResult {
2309 env: "dev".to_string(),
2310 gateway: "gw".to_string(),
2311 mode: "dev".to_string(),
2312 node: "master".to_string(),
2313 transport: "local".to_string(),
2314 render_sha256: "a".repeat(64),
2315 plan_sha256: "b".repeat(64),
2316 summary: crate::deploy::plan::PlanSummary::default(),
2317 entries: Vec::new(),
2318 delete_set: crate::deploy::transport::DeleteSet::default(),
2319 base: None,
2320 warnings: Vec::new(),
2321 excluded_tag_providers: Vec::new(),
2322 unreadable: Vec::new(),
2323 provider_wipes: Vec::new(),
2324 pair_mode_tag_dirs: Vec::new(),
2325 }
2326 }
2327
2328 fn spec_of(writes: &[(&str, Option<&str>)], deletes: &[&str]) -> BatchSpec {
2329 let set = WriteSet {
2330 writes: writes
2331 .iter()
2332 .map(|(path, pre)| WriteOp {
2333 path: RelPath::new(path).expect("path"),
2334 sha256: "1".repeat(64),
2335 pre: pre.map(str::to_string),
2336 })
2337 .collect(),
2338 deletes: deletes
2339 .iter()
2340 .map(|path| DeleteOp {
2341 path: RelPath::new(path).expect("path"),
2342 pre: "2".repeat(64),
2343 })
2344 .collect(),
2345 };
2346 let run_id = RunId::parse("20261102T000000Z-aaaaaa").expect("run id");
2347 batch_spec(&shell_plan(), &run_id, &set, b"{}\n".to_vec(), None, 60)
2348 }
2349
2350 const CORE_TAGS: &str = ".core/ignition/tag-definition/default/Deploy/tags.json";
2351 const MODE_TAGS: &str = "ignition/tag-definition/default/Deploy/tags.json";
2352
2353 #[test]
2357 fn core_key_batch_spec_accepts_a_migration() {
2358 let spec = spec_of(&[(CORE_TAGS, None)], &[MODE_TAGS]);
2359 assert_eq!(spec.problem(), None);
2360 assert_eq!(spec.touched_paths(), [CORE_TAGS, MODE_TAGS, LEDGER_KEY]);
2361 assert_eq!(spec.journal.touched, spec.touched_paths());
2362 let clash = spec_of(&[(MODE_TAGS, None)], &[MODE_TAGS]);
2364 assert!(clash.problem().is_some());
2365 }
2366
2367 #[test]
2371 fn core_key_outside_tag_types_refused() {
2372 for outside in [
2373 ".core/ignition/tag-provider/p/config.json",
2374 ".core/ignition/database-translator/X/config.json",
2375 ] {
2376 let write = spec_of(&[(outside, None)], &[]);
2377 assert!(write.problem().is_some(), "write {outside}");
2378 let delete = spec_of(&[], &[outside]);
2379 assert!(delete.problem().is_some(), "delete {outside}");
2380 assert!(
2381 crate::deploy::transport::batch::WorkList::from_spec(&write).is_err(),
2382 "{outside}"
2383 );
2384 assert!(journal::check_touched([outside].into_iter()).is_err());
2385 }
2386 }
2387
2388 #[test]
2389 fn core_key_snapshot_slot_and_warnings() {
2390 assert_eq!(snapshot_slot(MODE_TAGS), format!("files/{MODE_TAGS}"));
2391 assert_eq!(
2392 snapshot_slot(CORE_TAGS),
2393 "core/ignition/tag-definition/default/Deploy/tags.json"
2394 );
2395 let h = "1".repeat(64);
2396 let rec = record(&[(CORE_TAGS, Some(&h)), (MODE_TAGS, Some(&h))]);
2397 let mut copied = BTreeMap::new();
2398 copied.insert(snapshot_slot(CORE_TAGS), h.clone());
2399 copied.insert(snapshot_slot(MODE_TAGS), h);
2400 assert!(snapshot_warnings(&rec, &copied).is_empty());
2401 }
2402}