Skip to main content

ignition_core/actions/
deploy_apply.rs

1//! `ign deploy apply` and `ign deploy rollback` as library verbs (Phase 23,
2//! DAPL-01, DAPL-03, DAPL-06, DAPL-08): [`deploy_apply_prepare`] re-plans and
3//! evaluates the gates, [`deploy_apply_execute`] drives the engine;
4//! [`deploy_rollback_prepare`] reads the on-box record of a past run and
5//! refuses what cannot be rolled back, [`deploy_rollback_execute`] restores
6//! it. None of them prints.
7
8use 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/// What `prepare` needs.
42#[derive(Debug, Clone, Default)]
43pub struct PrepareOptions {
44    /// The `--base` render for the drift check.
45    pub base: Option<PathBuf>,
46    /// The gate flags.
47    pub gates: GateFlags,
48}
49
50/// What `prepare` found.
51#[derive(Debug, Clone, PartialEq)]
52pub enum ApplyPrepared {
53    /// The plan is empty; the result is final.
54    Noop(DeployApplyResult),
55    /// Every gate passed.
56    Ready {
57        /// The plan to apply.
58        plan: DeployPlanResult,
59        /// The confirmation text.
60        prose: String,
61    },
62}
63
64/// The `ign deploy apply` result (the envelope's `data`).
65#[derive(Debug, Clone, PartialEq, Serialize)]
66pub struct DeployApplyResult {
67    /// The `--env` value.
68    pub env: String,
69    /// Gateway directory name from the target.
70    pub gateway: String,
71    /// Deployment mode from the target.
72    pub mode: String,
73    /// The node: always `master`.
74    pub node: String,
75    /// How the host was reached: `local` or `ssh`.
76    pub transport: String,
77    /// The run; null for a no-op.
78    pub run_id: Option<String>,
79    /// The run directory; null for a no-op.
80    pub run_dir: Option<String>,
81    /// `applied`, `noop`, `rolled_back` or `applied_backup_unsynced`.
82    pub outcome: OutcomeKind,
83    /// True unless the run rolled back or the backup is unproven.
84    pub ok: bool,
85    /// `deploy_rolled_back` when the run rolled back, `deploy_backup_unsynced`
86    /// when the master applied and verified but the backup did not converge,
87    /// else null.
88    pub slug: Option<&'static str>,
89    /// Why the run rolled back.
90    pub cause: Option<RollbackCause>,
91    /// `plan_sha256` of the plan.
92    pub plan_sha256: String,
93    /// `render_sha256` of the render.
94    pub render_sha256: String,
95    /// The plan's counts by status.
96    pub summary: PlanSummary,
97    /// Collection paths written.
98    pub written: Vec<String>,
99    /// Collection paths deleted.
100    pub deleted: Vec<String>,
101    /// Plan and engine warnings.
102    pub warnings: Vec<String>,
103    /// The plan's effective tag provider exclusion set: byte-sorted, always
104    /// holds `System`. Those providers were not read, written or deleted.
105    pub excluded_tag_providers: Vec<String>,
106    /// The `.gwbk` file name in the run directory; null for a no-op or
107    /// `--skip-gwbk`.
108    pub gwbk: Option<String>,
109    /// Whether the run was started with `--skip-gwbk` (always false for a
110    /// no-op, which takes no options).
111    pub skip_gwbk: bool,
112    /// The pending restarts after the run; `[]` when none (never null).
113    pub restart_pending: Vec<String>,
114    /// The pending restarts the run added; `[]` when none (never null).
115    pub restart_pending_new: Vec<String>,
116    /// What the backup phase found (V2); null without a backup node, for a
117    /// no-op and for a rolled-back run (V10). Boxed so that the no-op variant
118    /// of [`ApplyPrepared`] stays small.
119    pub backup: Option<Box<BackupReport>>,
120}
121
122impl DeployApplyResult {
123    /// `Some(6)` exactly when the run rolled back or the master applied with
124    /// an unproven backup (`applied_backup_unsynced`).
125    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
134/// The result of a run that did nothing: counts and hashes of `plan`.
135fn 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
170/// Validate the flags, re-plan against the host and evaluate the gates.
171///
172/// Reads the host (through `reader`) and nothing else: no gateway session,
173/// no lock, no scan, no run directory. An empty plan is
174/// [`ApplyPrepared::Noop`] with outcome `noop` (DAPL-03); a refused gate is
175/// its error.
176pub async fn deploy_apply_prepare(
177    declared: &Declared,
178    reader: &dyn TreeReader,
179    opts: &PrepareOptions,
180) -> Result<ApplyPrepared, CoreError> {
181    // A malformed flag is invalid_input before any host contact.
182    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
203/// Run a prepared apply: a no-op is returned as it is, a ready plan goes
204/// through [`run_apply`].
205pub 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
230/// The gateway surfaces a confirmed apply talks to.
231pub struct ApplySurfaces<'a> {
232    /// The master's REST surface (lock, scan, mode, read-back).
233    pub deploy: &'a dyn DeployApi,
234    /// The backup node (a read-only handle), when the target has a backup
235    /// node.
236    pub backup: Option<&'a BackupNode<'a>>,
237    /// The master's log, WebDev and Designer surface.
238    pub gateway: &'a dyn GatewayApi,
239}
240
241/// What a confirmed apply needs besides the prepared plan.
242#[derive(Debug, Clone)]
243pub struct ApplyRunOptions {
244    /// The `--base` the plan was drawn against.
245    pub base: Option<PathBuf>,
246    /// The gate flags the plan passed with.
247    pub gates: GateFlags,
248    /// The WebDev project the verify tag probes read through.
249    pub project: String,
250    /// `--designers` and `--designers-timeout`.
251    pub designers: DesignersPolicy,
252    /// `--skip-gwbk`.
253    pub skip_gwbk: bool,
254    /// `--backup-timeout`: each of the two waits for the backup node to
255    /// converge. Unused when the target has no backup node.
256    pub backup_timeout: Duration,
257}
258
259/// Seconds between two reads of the scan status while a config scan runs.
260const SCAN_POLL_INTERVAL: Duration = Duration::from_millis(500);
261
262/// How long a config scan may take before the run rolls back with
263/// `scan_incomplete`. The longest scan measured (23-10) took under 1.6 s.
264const SCAN_WAIT: Duration = Duration::from_secs(120);
265
266/// How the production verbs wait for a config scan to finish.
267fn 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
276/// Run a prepared apply against the master's host: the production entry of
277/// `ign deploy apply` after the confirmation (DAPL-01).
278///
279/// A no-op returns at once, touching neither the host nor the gateway. A
280/// ready plan builds the master's apply host here (so a caller never names
281/// the write half), takes the one measured hold ([`HoldModel::MEASURED`]: no
282/// override exists, D-02) and the real clock, and runs the engine. The
283/// engine's steps reach `observer` as they happen.
284pub 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/// [`deploy_apply_run`] with the master's host built against `targets`
300/// (`--targets DIR`, D-14): a relative `local.data_dir` and `known_hosts`
301/// resolve there. The run directory (`.ign/`) stays under `repo`.
302#[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// ============================================================================
353// Rollback (23-11)
354// ============================================================================
355
356/// What `deploy_rollback_prepare` takes besides the run id.
357#[derive(Debug, Clone, Copy, Default)]
358pub struct RollbackPrepareOptions {
359    /// `--discard-live-changes`: carry changed files instead of refusing.
360    pub discard_live_changes: bool,
361}
362
363/// What `deploy_rollback_prepare` found: the rollback may go ahead.
364#[derive(Debug, Clone)]
365pub struct RollbackPrepared {
366    /// The run to roll back or recover (`--run`).
367    pub run_id: String,
368    /// The on-box record of the run: at state `applied` for a rollback; for
369    /// the recovery of an interrupted run at any state, or `None` when the
370    /// record is lost.
371    pub record: Option<RunRecord>,
372    /// Set when the run's own journal is on the box: this is its recovery
373    /// (D-09), which restores or clears (D-10).
374    pub interrupted: Option<InterruptedRun>,
375    /// The confirmation text: run, gateway, mode, counts and `plan_sha256`.
376    pub prose: String,
377    /// Touched paths changed since the run (only with
378    /// `discard_live_changes`).
379    pub live_changed: Vec<String>,
380    /// The target the file names (the caller builds the rollback context
381    /// from it).
382    pub target: Target,
383    /// Repo-relative path of the target file.
384    pub target_file: String,
385}
386
387/// The `ign deploy rollback` result (the envelope's `data`).
388#[derive(Debug, Clone, PartialEq, Serialize)]
389pub struct DeployRollbackResult {
390    /// The `--env` value.
391    pub env: String,
392    /// Gateway directory name from the target.
393    pub gateway: String,
394    /// Deployment mode from the target.
395    pub mode: String,
396    /// This rollback's own run id.
397    pub run_id: String,
398    /// The run that was rolled back (`--run`).
399    pub target_run_id: String,
400    /// This rollback's run directory.
401    pub run_dir: String,
402    /// `restored`.
403    pub outcome: RollbackKind,
404    /// Always true: a rollback that cannot restore is an error, not a result.
405    pub ok: bool,
406    /// Collection paths put back from the snapshot.
407    pub restored: Vec<String>,
408    /// Collection paths the rolled-back run had created, now removed.
409    pub deleted: Vec<String>,
410    /// Touched paths that had changed since the run and were overwritten;
411    /// `[]` when none (never null).
412    pub discarded_live_changes: Vec<String>,
413    /// Engine warnings.
414    pub warnings: Vec<String>,
415    /// The pending restarts after the rollback; `[]` when none (never null).
416    pub restart_pending: Vec<String>,
417    /// The pending restarts the rollback added; `[]` when none (never null).
418    pub restart_pending_new: Vec<String>,
419}
420
421/// The confirmation prose of a rollback: byte-stable, no file content.
422fn 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
495/// Validate the run id, then read the host and decide whether the run can be
496/// rolled back.
497///
498/// The id is checked before any host contact (`invalid_input`). After that
499/// this reads the target file, the host's journal, the on-box record, the
500/// run's local run directory when it exists, and the live hashes of the
501/// touched set; it writes nothing and takes no lock. See
502/// [`check_rollback`] for the refusals.
503pub 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
514/// [`deploy_rollback_prepare`] with the target read from `targets`
515/// (`--targets DIR`, D-14). The run directory is still read under `repo`.
516pub 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
548/// Run a prepared rollback through [`run_rollback`].
549pub 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
578/// [`deploy_rollback_prepare`] for the production verb: builds the master's
579/// apply host from the target file itself, so a caller never names the write
580/// half. The run id is checked first, before the target is read or any host
581/// is touched.
582pub 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
592/// [`deploy_rollback_prepare_for_env`] with the target and the host built
593/// against `targets` (`--targets DIR`, D-14).
594pub 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/// What a confirmed rollback needs besides the prepared value.
616#[derive(Debug, Clone, Copy, Default)]
617pub struct RollbackRunOptions {
618    /// `--discard-live-changes`, as given to the prepare.
619    pub discard_live_changes: bool,
620    /// `--designers` and `--designers-timeout`.
621    pub designers: DesignersPolicy,
622}
623
624/// Run a prepared rollback against the master's host: the production entry
625/// of `ign deploy rollback` after the confirmation (DAPL-08).
626///
627/// Builds the host here, takes the one measured hold ([`HoldModel::MEASURED`],
628/// no override: D-02) and the real clock, and drives [`run_rollback`] with the
629/// engine's own scan wait. The engine's steps reach `observer`.
630pub 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/// [`deploy_rollback_run`] with the master's host built against `targets`
643/// (`--targets DIR`, D-14). The run directory stays under `repo`.
644#[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}