1use std::path::{Path, PathBuf};
9
10use serde::Serialize;
11
12use std::time::Duration;
13
14use crate::actions::deploy::{
15 Declared, DeployPlanResult, PlanOptions, VerifyOptions, deploy_plan, load_target,
16};
17use crate::client::{DeployApi, GatewayApi};
18use crate::deploy::apply::gates::{
19 GateFlags, GateOutcome, Redundancy, evaluate_gates, validate_drift_reason, validate_plan_sha256,
20};
21use crate::deploy::apply::hold::{HoldModel, SystemClock};
22use crate::deploy::apply::host::ApplyHost;
23use crate::deploy::apply::journal::RecoveryAction;
24use crate::deploy::apply::preflight::DesignersPolicy;
25use crate::deploy::apply::record::RunRecord;
26use crate::deploy::apply::run_store::RunId;
27use crate::deploy::apply::{
28 ApplyContext, ApplyOptions, BACKUP_UNSYNCED_SLUG, InterruptedRun, OutcomeKind,
29 ROLLED_BACK_SLUG, RollbackCause, RollbackChecked, RollbackContext, RollbackKind,
30 RollbackOptions, check_rollback, restore_set, run_apply, run_rollback,
31};
32use crate::deploy::apply::{ApplyObserver, ApplyOptions as EngineOptions};
33use crate::deploy::pair::{BACKUP_POLL_S, BackupNode, BackupPolicy, BackupReport};
34use crate::deploy::plan::PlanSummary;
35use crate::deploy::target::Target;
36use crate::deploy::transport::TreeReader;
37use crate::deploy::transport::apply_host_for_node;
38use crate::error::CoreError;
39use crate::poll::PollConfig;
40
41#[derive(Debug, Clone, Default)]
43pub struct PrepareOptions {
44 pub base: Option<PathBuf>,
46 pub gates: GateFlags,
48}
49
50#[derive(Debug, Clone, PartialEq)]
52pub enum ApplyPrepared {
53 Noop(DeployApplyResult),
55 Ready {
57 plan: DeployPlanResult,
59 prose: String,
61 },
62}
63
64#[derive(Debug, Clone, PartialEq, Serialize)]
66pub struct DeployApplyResult {
67 pub env: String,
69 pub gateway: String,
71 pub mode: String,
73 pub node: String,
75 pub transport: String,
77 pub run_id: Option<String>,
79 pub run_dir: Option<String>,
81 pub outcome: OutcomeKind,
83 pub ok: bool,
85 pub slug: Option<&'static str>,
89 pub cause: Option<RollbackCause>,
91 pub plan_sha256: String,
93 pub render_sha256: String,
95 pub summary: PlanSummary,
97 pub written: Vec<String>,
99 pub deleted: Vec<String>,
101 pub warnings: Vec<String>,
103 pub excluded_tag_providers: Vec<String>,
106 pub gwbk: Option<String>,
109 pub skip_gwbk: bool,
112 pub restart_pending: Vec<String>,
114 pub restart_pending_new: Vec<String>,
116 pub backup: Option<Box<BackupReport>>,
120}
121
122impl DeployApplyResult {
123 pub fn failure_exit_code(&self) -> Option<u8> {
126 matches!(
127 self.outcome,
128 OutcomeKind::RolledBack | OutcomeKind::AppliedBackupUnsynced
129 )
130 .then_some(6)
131 }
132}
133
134fn base_result(plan: &DeployPlanResult, outcome: OutcomeKind) -> DeployApplyResult {
136 DeployApplyResult {
137 env: plan.env.clone(),
138 gateway: plan.gateway.clone(),
139 mode: plan.mode.clone(),
140 node: plan.node.clone(),
141 transport: plan.transport.clone(),
142 run_id: None,
143 run_dir: None,
144 outcome,
145 ok: !matches!(
146 outcome,
147 OutcomeKind::RolledBack | OutcomeKind::AppliedBackupUnsynced
148 ),
149 slug: match outcome {
150 OutcomeKind::RolledBack => Some(ROLLED_BACK_SLUG),
151 OutcomeKind::AppliedBackupUnsynced => Some(BACKUP_UNSYNCED_SLUG),
152 OutcomeKind::Applied | OutcomeKind::Noop => None,
153 },
154 cause: None,
155 plan_sha256: plan.plan_sha256.clone(),
156 render_sha256: plan.render_sha256.clone(),
157 summary: plan.summary.clone(),
158 written: Vec::new(),
159 deleted: Vec::new(),
160 warnings: plan.warnings.clone(),
161 excluded_tag_providers: plan.excluded_tag_providers.clone(),
162 gwbk: None,
163 skip_gwbk: false,
164 restart_pending: Vec::new(),
165 restart_pending_new: Vec::new(),
166 backup: None,
167 }
168}
169
170pub async fn deploy_apply_prepare(
177 declared: &Declared,
178 reader: &dyn TreeReader,
179 opts: &PrepareOptions,
180) -> Result<ApplyPrepared, CoreError> {
181 if let Some(hash) = &opts.gates.plan_sha256 {
183 validate_plan_sha256(hash)?;
184 }
185 if let Some(reason) = &opts.gates.allow_drift {
186 validate_drift_reason(reason)?;
187 }
188 let plan = deploy_plan(
189 declared,
190 reader,
191 &PlanOptions {
192 base: opts.base.clone(),
193 diff: false,
194 },
195 )
196 .await?;
197 match evaluate_gates(&plan, &opts.gates, Redundancy::of(&declared.target))? {
198 GateOutcome::Noop => Ok(ApplyPrepared::Noop(base_result(&plan, OutcomeKind::Noop))),
199 GateOutcome::Ready { prose } => Ok(ApplyPrepared::Ready { plan, prose }),
200 }
201}
202
203pub async fn deploy_apply_execute(
206 prepared: ApplyPrepared,
207 ctx: &ApplyContext<'_>,
208 opts: &ApplyOptions,
209) -> Result<DeployApplyResult, CoreError> {
210 let plan = match prepared {
211 ApplyPrepared::Noop(result) => return Ok(result),
212 ApplyPrepared::Ready { plan, .. } => plan,
213 };
214 let outcome = run_apply(ctx, &plan, opts).await?;
215 let mut result = base_result(&plan, outcome.kind);
216 result.run_id = outcome.run_id;
217 result.run_dir = outcome.run_dir;
218 result.cause = outcome.cause;
219 result.written = outcome.written;
220 result.deleted = outcome.deleted;
221 result.warnings.extend(outcome.warnings);
222 result.gwbk = outcome.gwbk;
223 result.skip_gwbk = outcome.skip_gwbk;
224 result.restart_pending = outcome.restart_pending;
225 result.restart_pending_new = outcome.restart_pending_new;
226 result.backup = outcome.backup.map(Box::new);
227 Ok(result)
228}
229
230pub struct ApplySurfaces<'a> {
232 pub deploy: &'a dyn DeployApi,
234 pub backup: Option<&'a BackupNode<'a>>,
237 pub gateway: &'a dyn GatewayApi,
239}
240
241#[derive(Debug, Clone)]
243pub struct ApplyRunOptions {
244 pub base: Option<PathBuf>,
246 pub gates: GateFlags,
248 pub project: String,
250 pub designers: DesignersPolicy,
252 pub skip_gwbk: bool,
254 pub backup_timeout: Duration,
257}
258
259const SCAN_POLL_INTERVAL: Duration = Duration::from_millis(500);
261
262const SCAN_WAIT: Duration = Duration::from_secs(120);
265
266fn scan_poll(subject: &str) -> PollConfig {
268 PollConfig {
269 subject: subject.to_string(),
270 interval: SCAN_POLL_INTERVAL,
271 max: Duration::from_secs(2),
272 deadline: SCAN_WAIT,
273 }
274}
275
276pub async fn deploy_apply_run(
285 prepared: ApplyPrepared,
286 declared: &Declared,
287 repo: &Path,
288 reader: &dyn TreeReader,
289 surfaces: &ApplySurfaces<'_>,
290 opts: &ApplyRunOptions,
291 observer: &dyn ApplyObserver,
292) -> Result<DeployApplyResult, CoreError> {
293 deploy_apply_run_in(
294 prepared, declared, repo, repo, reader, surfaces, opts, observer,
295 )
296 .await
297}
298
299#[allow(clippy::too_many_arguments)]
303pub async fn deploy_apply_run_in(
304 prepared: ApplyPrepared,
305 declared: &Declared,
306 repo: &Path,
307 targets: &Path,
308 reader: &dyn TreeReader,
309 surfaces: &ApplySurfaces<'_>,
310 opts: &ApplyRunOptions,
311 observer: &dyn ApplyObserver,
312) -> Result<DeployApplyResult, CoreError> {
313 if let ApplyPrepared::Noop(result) = prepared {
314 return Ok(result);
315 }
316 let host = apply_host_for_node(
317 targets,
318 &declared.target_file,
319 "nodes.master",
320 &declared.target.nodes.master,
321 &declared.target.gateway,
322 &declared.target.mode,
323 )?;
324 let ctx = ApplyContext {
325 declared,
326 repo,
327 reader,
328 host: host.as_ref(),
329 deploy: surfaces.deploy,
330 gateway: surfaces.gateway,
331 backup: surfaces.backup,
332 clock: &SystemClock,
333 observer,
334 };
335 let engine = EngineOptions {
336 base: opts.base.clone(),
337 gates: opts.gates.clone(),
338 project: opts.project.clone(),
339 hold: HoldModel::MEASURED,
340 poll: scan_poll("config scan after apply"),
341 verify: VerifyOptions::default(),
342 designers: opts.designers,
343 skip_gwbk: opts.skip_gwbk,
344 backup: BackupPolicy {
345 budget: opts.backup_timeout,
346 interval: Duration::from_secs(BACKUP_POLL_S),
347 },
348 };
349 deploy_apply_execute(prepared, &ctx, &engine).await
350}
351
352#[derive(Debug, Clone, Copy, Default)]
358pub struct RollbackPrepareOptions {
359 pub discard_live_changes: bool,
361}
362
363#[derive(Debug, Clone)]
365pub struct RollbackPrepared {
366 pub run_id: String,
368 pub record: Option<RunRecord>,
372 pub interrupted: Option<InterruptedRun>,
375 pub prose: String,
377 pub live_changed: Vec<String>,
380 pub target: Target,
383 pub target_file: String,
385}
386
387#[derive(Debug, Clone, PartialEq, Serialize)]
389pub struct DeployRollbackResult {
390 pub env: String,
392 pub gateway: String,
394 pub mode: String,
396 pub run_id: String,
398 pub target_run_id: String,
400 pub run_dir: String,
402 pub outcome: RollbackKind,
404 pub ok: bool,
406 pub restored: Vec<String>,
408 pub deleted: Vec<String>,
410 pub discarded_live_changes: Vec<String>,
413 pub warnings: Vec<String>,
415 pub restart_pending: Vec<String>,
417 pub restart_pending_new: Vec<String>,
419 pub backup: Option<crate::deploy::pair::BackupReport>,
423}
424
425fn rollback_prose(run_id: &str, target: &Target, checked: &RollbackChecked) -> String {
427 let record = checked.record.as_ref();
428 let (restored, deleted) = record.map(restore_set).unwrap_or_default();
429 let ledger = record
430 .and_then(|r| {
431 r.touched
432 .iter()
433 .find(|e| e.path == crate::deploy::apply::fingerprint::LEDGER_KEY)
434 })
435 .map(|e| {
436 if e.pre_sha256.is_some() {
437 ", and restore the ledger"
438 } else {
439 ", and remove the ledger"
440 }
441 })
442 .unwrap_or("");
443 let mut prose = match (&checked.interrupted, record) {
444 (Some(found), _) if found.action == RecoveryAction::ClearWithoutRestore => format!(
445 "Clear run {run_id} on gateway {} (mode {}), which was interrupted at phase {} \
446 before any file moved and whose touched files are as they were before the run: \
447 remove its journal, partial snapshot and staging. Nothing is restored.",
448 target.gateway,
449 target.mode,
450 found.journal.phase.as_str(),
451 ),
452 (Some(found), Some(record)) => format!(
453 "Recover run {run_id} on gateway {} (mode {}), which was interrupted at phase {}: \
454 restore {} file(s) from the on-box snapshot and delete {} file(s) the run \
455 created{ledger}, then remove the journal. The run was applying plan_sha256 {}.",
456 target.gateway,
457 target.mode,
458 found.journal.phase.as_str(),
459 restored.len(),
460 deleted.len(),
461 record.plan_sha256,
462 ),
463 (Some(found), None) => format!(
464 "Recover run {run_id} on gateway {} (mode {}), which was interrupted at phase {} \
465 and whose record is missing: restore the touched files from the on-box snapshot, \
466 then remove the journal.",
467 target.gateway,
468 target.mode,
469 found.journal.phase.as_str(),
470 ),
471 (None, Some(record)) => format!(
472 "Roll back run {run_id} on gateway {} (mode {}): restore {} file(s) from the on-box \
473 snapshot and delete {} file(s) the run created{ledger}. The run applied \
474 plan_sha256 {}.",
475 target.gateway,
476 target.mode,
477 restored.len(),
478 deleted.len(),
479 record.plan_sha256,
480 ),
481 (None, None) => format!("Roll back run {run_id} on gateway {}.", target.gateway),
482 };
483 if !checked.live_changed.is_empty() {
484 prose.push_str(&format!(
485 " {} file(s) changed after the run and will be overwritten: {}.",
486 checked.live_changed.len(),
487 checked
488 .live_changed
489 .iter()
490 .take(crate::error::APPLY_LIST_SHOWN)
491 .cloned()
492 .collect::<Vec<_>>()
493 .join(", ")
494 ));
495 }
496 prose
497}
498
499pub async fn deploy_rollback_prepare(
508 repo: &Path,
509 env: &str,
510 run_id_text: &str,
511 reader: &dyn TreeReader,
512 host: &dyn ApplyHost,
513 opts: &RollbackPrepareOptions,
514) -> Result<RollbackPrepared, CoreError> {
515 deploy_rollback_prepare_in(repo, repo, env, run_id_text, reader, host, opts).await
516}
517
518pub async fn deploy_rollback_prepare_in(
521 repo: &Path,
522 targets: &Path,
523 env: &str,
524 run_id_text: &str,
525 reader: &dyn TreeReader,
526 host: &dyn ApplyHost,
527 opts: &RollbackPrepareOptions,
528) -> Result<RollbackPrepared, CoreError> {
529 let run_id = RunId::parse(run_id_text)?;
530 let (target_file, target) = load_target(targets, env)?;
531 let checked = check_rollback(
532 env,
533 repo,
534 &target,
535 reader,
536 host,
537 &run_id,
538 opts.discard_live_changes,
539 )
540 .await?;
541 Ok(RollbackPrepared {
542 prose: rollback_prose(run_id.as_str(), &target, &checked),
543 run_id: run_id.as_str().to_string(),
544 record: checked.record,
545 interrupted: checked.interrupted,
546 live_changed: checked.live_changed,
547 target,
548 target_file,
549 })
550}
551
552pub async fn deploy_rollback_execute(
554 prepared: RollbackPrepared,
555 ctx: &RollbackContext<'_>,
556 opts: &RollbackOptions,
557) -> Result<DeployRollbackResult, CoreError> {
558 if prepared.run_id != opts.run_id.as_str() {
559 return Err(CoreError::Internal(
560 "the prepared rollback is for another run than the options name".to_string(),
561 ));
562 }
563 let outcome = run_rollback(ctx, opts).await?;
564 Ok(DeployRollbackResult {
565 env: ctx.env.to_string(),
566 gateway: ctx.target.gateway.clone(),
567 mode: ctx.target.mode.clone(),
568 run_id: outcome.run_id,
569 target_run_id: outcome.target_run_id,
570 run_dir: outcome.run_dir,
571 outcome: outcome.kind,
572 ok: true,
573 restored: outcome.restored,
574 deleted: outcome.deleted,
575 discarded_live_changes: outcome.discarded_live_changes,
576 warnings: outcome.warnings,
577 restart_pending: outcome.restart_pending,
578 restart_pending_new: outcome.restart_pending_new,
579 backup: outcome.backup,
580 })
581}
582
583pub async fn deploy_rollback_prepare_for_env(
588 repo: &Path,
589 env: &str,
590 run_id_text: &str,
591 reader: &dyn TreeReader,
592 opts: &RollbackPrepareOptions,
593) -> Result<RollbackPrepared, CoreError> {
594 deploy_rollback_prepare_for_env_in(repo, repo, env, run_id_text, reader, opts).await
595}
596
597pub async fn deploy_rollback_prepare_for_env_in(
600 repo: &Path,
601 targets: &Path,
602 env: &str,
603 run_id_text: &str,
604 reader: &dyn TreeReader,
605 opts: &RollbackPrepareOptions,
606) -> Result<RollbackPrepared, CoreError> {
607 RunId::parse(run_id_text)?;
608 let (target_file, target) = load_target(targets, env)?;
609 let host = apply_host_for_node(
610 targets,
611 &target_file,
612 "nodes.master",
613 &target.nodes.master,
614 &target.gateway,
615 &target.mode,
616 )?;
617 deploy_rollback_prepare_in(repo, targets, env, run_id_text, reader, host.as_ref(), opts).await
618}
619
620#[derive(Debug, Clone, Copy, Default)]
622pub struct RollbackRunOptions {
623 pub discard_live_changes: bool,
625 pub designers: DesignersPolicy,
627 pub backup: crate::deploy::pair::BackupPolicy,
630}
631
632pub async fn deploy_rollback_run(
639 prepared: RollbackPrepared,
640 env: &str,
641 repo: &Path,
642 reader: &dyn TreeReader,
643 surfaces: &ApplySurfaces<'_>,
644 opts: &RollbackRunOptions,
645 observer: &dyn ApplyObserver,
646) -> Result<DeployRollbackResult, CoreError> {
647 deploy_rollback_run_in(prepared, env, repo, repo, reader, surfaces, opts, observer).await
648}
649
650#[allow(clippy::too_many_arguments)]
653pub async fn deploy_rollback_run_in(
654 prepared: RollbackPrepared,
655 env: &str,
656 repo: &Path,
657 targets: &Path,
658 reader: &dyn TreeReader,
659 surfaces: &ApplySurfaces<'_>,
660 opts: &RollbackRunOptions,
661 observer: &dyn ApplyObserver,
662) -> Result<DeployRollbackResult, CoreError> {
663 let host = apply_host_for_node(
664 targets,
665 &prepared.target_file,
666 "nodes.master",
667 &prepared.target.nodes.master,
668 &prepared.target.gateway,
669 &prepared.target.mode,
670 )?;
671 let run_id = RunId::parse(&prepared.run_id)?;
672 let target = prepared.target.clone();
673 let target_file = prepared.target_file.clone();
674 let ctx = RollbackContext {
675 env,
676 repo,
677 target_file: &target_file,
678 target: &target,
679 reader,
680 host: host.as_ref(),
681 deploy: surfaces.deploy,
682 gateway: surfaces.gateway,
683 backup: surfaces.backup,
684 clock: &SystemClock,
685 observer,
686 };
687 let engine = RollbackOptions {
688 run_id,
689 discard_live_changes: opts.discard_live_changes,
690 designers: opts.designers,
691 hold: HoldModel::MEASURED,
692 poll: scan_poll("config scan after rollback"),
693 backup: opts.backup,
694 };
695 deploy_rollback_execute(prepared, &ctx, &engine).await
696}