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, 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)? {
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}
420
421fn rollback_prose(run_id: &str, target: &Target, checked: &RollbackChecked) -> String {
423 let record = checked.record.as_ref();
424 let (restored, deleted) = record.map(restore_set).unwrap_or_default();
425 let ledger = record
426 .and_then(|r| {
427 r.touched
428 .iter()
429 .find(|e| e.path == crate::deploy::apply::fingerprint::LEDGER_KEY)
430 })
431 .map(|e| {
432 if e.pre_sha256.is_some() {
433 ", and restore the ledger"
434 } else {
435 ", and remove the ledger"
436 }
437 })
438 .unwrap_or("");
439 let mut prose = match (&checked.interrupted, record) {
440 (Some(found), _) if found.action == RecoveryAction::ClearWithoutRestore => format!(
441 "Clear run {run_id} on gateway {} (mode {}), which was interrupted at phase {} \
442 before any file moved and whose touched files are as they were before the run: \
443 remove its journal, partial snapshot and staging. Nothing is restored.",
444 target.gateway,
445 target.mode,
446 found.journal.phase.as_str(),
447 ),
448 (Some(found), Some(record)) => format!(
449 "Recover run {run_id} on gateway {} (mode {}), which was interrupted at phase {}: \
450 restore {} file(s) from the on-box snapshot and delete {} file(s) the run \
451 created{ledger}, then remove the journal. The run was applying plan_sha256 {}.",
452 target.gateway,
453 target.mode,
454 found.journal.phase.as_str(),
455 restored.len(),
456 deleted.len(),
457 record.plan_sha256,
458 ),
459 (Some(found), None) => format!(
460 "Recover run {run_id} on gateway {} (mode {}), which was interrupted at phase {} \
461 and whose record is missing: restore the touched files from the on-box snapshot, \
462 then remove the journal.",
463 target.gateway,
464 target.mode,
465 found.journal.phase.as_str(),
466 ),
467 (None, Some(record)) => format!(
468 "Roll back run {run_id} on gateway {} (mode {}): restore {} file(s) from the on-box \
469 snapshot and delete {} file(s) the run created{ledger}. The run applied \
470 plan_sha256 {}.",
471 target.gateway,
472 target.mode,
473 restored.len(),
474 deleted.len(),
475 record.plan_sha256,
476 ),
477 (None, None) => format!("Roll back run {run_id} on gateway {}.", target.gateway),
478 };
479 if !checked.live_changed.is_empty() {
480 prose.push_str(&format!(
481 " {} file(s) changed after the run and will be overwritten: {}.",
482 checked.live_changed.len(),
483 checked
484 .live_changed
485 .iter()
486 .take(crate::error::APPLY_LIST_SHOWN)
487 .cloned()
488 .collect::<Vec<_>>()
489 .join(", ")
490 ));
491 }
492 prose
493}
494
495pub async fn deploy_rollback_prepare(
504 repo: &Path,
505 env: &str,
506 run_id_text: &str,
507 reader: &dyn TreeReader,
508 host: &dyn ApplyHost,
509 opts: &RollbackPrepareOptions,
510) -> Result<RollbackPrepared, CoreError> {
511 deploy_rollback_prepare_in(repo, repo, env, run_id_text, reader, host, opts).await
512}
513
514pub async fn deploy_rollback_prepare_in(
517 repo: &Path,
518 targets: &Path,
519 env: &str,
520 run_id_text: &str,
521 reader: &dyn TreeReader,
522 host: &dyn ApplyHost,
523 opts: &RollbackPrepareOptions,
524) -> Result<RollbackPrepared, CoreError> {
525 let run_id = RunId::parse(run_id_text)?;
526 let (target_file, target) = load_target(targets, env)?;
527 let checked = check_rollback(
528 env,
529 repo,
530 &target,
531 reader,
532 host,
533 &run_id,
534 opts.discard_live_changes,
535 )
536 .await?;
537 Ok(RollbackPrepared {
538 prose: rollback_prose(run_id.as_str(), &target, &checked),
539 run_id: run_id.as_str().to_string(),
540 record: checked.record,
541 interrupted: checked.interrupted,
542 live_changed: checked.live_changed,
543 target,
544 target_file,
545 })
546}
547
548pub async fn deploy_rollback_execute(
550 prepared: RollbackPrepared,
551 ctx: &RollbackContext<'_>,
552 opts: &RollbackOptions,
553) -> Result<DeployRollbackResult, CoreError> {
554 if prepared.run_id != opts.run_id.as_str() {
555 return Err(CoreError::Internal(
556 "the prepared rollback is for another run than the options name".to_string(),
557 ));
558 }
559 let outcome = run_rollback(ctx, opts).await?;
560 Ok(DeployRollbackResult {
561 env: ctx.env.to_string(),
562 gateway: ctx.target.gateway.clone(),
563 mode: ctx.target.mode.clone(),
564 run_id: outcome.run_id,
565 target_run_id: outcome.target_run_id,
566 run_dir: outcome.run_dir,
567 outcome: outcome.kind,
568 ok: true,
569 restored: outcome.restored,
570 deleted: outcome.deleted,
571 discarded_live_changes: outcome.discarded_live_changes,
572 warnings: outcome.warnings,
573 restart_pending: outcome.restart_pending,
574 restart_pending_new: outcome.restart_pending_new,
575 })
576}
577
578pub async fn deploy_rollback_prepare_for_env(
583 repo: &Path,
584 env: &str,
585 run_id_text: &str,
586 reader: &dyn TreeReader,
587 opts: &RollbackPrepareOptions,
588) -> Result<RollbackPrepared, CoreError> {
589 deploy_rollback_prepare_for_env_in(repo, repo, env, run_id_text, reader, opts).await
590}
591
592pub async fn deploy_rollback_prepare_for_env_in(
595 repo: &Path,
596 targets: &Path,
597 env: &str,
598 run_id_text: &str,
599 reader: &dyn TreeReader,
600 opts: &RollbackPrepareOptions,
601) -> Result<RollbackPrepared, CoreError> {
602 RunId::parse(run_id_text)?;
603 let (target_file, target) = load_target(targets, env)?;
604 let host = apply_host_for_node(
605 targets,
606 &target_file,
607 "nodes.master",
608 &target.nodes.master,
609 &target.gateway,
610 &target.mode,
611 )?;
612 deploy_rollback_prepare_in(repo, targets, env, run_id_text, reader, host.as_ref(), opts).await
613}
614
615#[derive(Debug, Clone, Copy, Default)]
617pub struct RollbackRunOptions {
618 pub discard_live_changes: bool,
620 pub designers: DesignersPolicy,
622}
623
624pub async fn deploy_rollback_run(
631 prepared: RollbackPrepared,
632 env: &str,
633 repo: &Path,
634 reader: &dyn TreeReader,
635 surfaces: &ApplySurfaces<'_>,
636 opts: &RollbackRunOptions,
637 observer: &dyn ApplyObserver,
638) -> Result<DeployRollbackResult, CoreError> {
639 deploy_rollback_run_in(prepared, env, repo, repo, reader, surfaces, opts, observer).await
640}
641
642#[allow(clippy::too_many_arguments)]
645pub async fn deploy_rollback_run_in(
646 prepared: RollbackPrepared,
647 env: &str,
648 repo: &Path,
649 targets: &Path,
650 reader: &dyn TreeReader,
651 surfaces: &ApplySurfaces<'_>,
652 opts: &RollbackRunOptions,
653 observer: &dyn ApplyObserver,
654) -> Result<DeployRollbackResult, CoreError> {
655 let host = apply_host_for_node(
656 targets,
657 &prepared.target_file,
658 "nodes.master",
659 &prepared.target.nodes.master,
660 &prepared.target.gateway,
661 &prepared.target.mode,
662 )?;
663 let run_id = RunId::parse(&prepared.run_id)?;
664 let target = prepared.target.clone();
665 let target_file = prepared.target_file.clone();
666 let ctx = RollbackContext {
667 env,
668 repo,
669 target_file: &target_file,
670 target: &target,
671 reader,
672 host: host.as_ref(),
673 deploy: surfaces.deploy,
674 gateway: surfaces.gateway,
675 backup: surfaces.backup,
676 clock: &SystemClock,
677 observer,
678 };
679 let engine = RollbackOptions {
680 run_id,
681 discard_live_changes: opts.discard_live_changes,
682 designers: opts.designers,
683 hold: HoldModel::MEASURED,
684 poll: scan_poll("config scan after rollback"),
685 };
686 deploy_rollback_execute(prepared, &ctx, &engine).await
687}