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 `.gwbk` file name in the run directory; null for a no-op or
104    /// `--skip-gwbk`.
105    pub gwbk: Option<String>,
106    /// Whether the run was started with `--skip-gwbk` (always false for a
107    /// no-op, which takes no options).
108    pub skip_gwbk: bool,
109    /// The pending restarts after the run; `[]` when none (never null).
110    pub restart_pending: Vec<String>,
111    /// The pending restarts the run added; `[]` when none (never null).
112    pub restart_pending_new: Vec<String>,
113    /// What the backup phase found (V2); null without a backup node, for a
114    /// no-op and for a rolled-back run (V10). Boxed so that the no-op variant
115    /// of [`ApplyPrepared`] stays small.
116    pub backup: Option<Box<BackupReport>>,
117}
118
119impl DeployApplyResult {
120    /// `Some(6)` exactly when the run rolled back or the master applied with
121    /// an unproven backup (`applied_backup_unsynced`).
122    pub fn failure_exit_code(&self) -> Option<u8> {
123        matches!(
124            self.outcome,
125            OutcomeKind::RolledBack | OutcomeKind::AppliedBackupUnsynced
126        )
127        .then_some(6)
128    }
129}
130
131/// The result of a run that did nothing: counts and hashes of `plan`.
132fn base_result(plan: &DeployPlanResult, outcome: OutcomeKind) -> DeployApplyResult {
133    DeployApplyResult {
134        env: plan.env.clone(),
135        gateway: plan.gateway.clone(),
136        mode: plan.mode.clone(),
137        node: plan.node.clone(),
138        transport: plan.transport.clone(),
139        run_id: None,
140        run_dir: None,
141        outcome,
142        ok: !matches!(
143            outcome,
144            OutcomeKind::RolledBack | OutcomeKind::AppliedBackupUnsynced
145        ),
146        slug: match outcome {
147            OutcomeKind::RolledBack => Some(ROLLED_BACK_SLUG),
148            OutcomeKind::AppliedBackupUnsynced => Some(BACKUP_UNSYNCED_SLUG),
149            OutcomeKind::Applied | OutcomeKind::Noop => None,
150        },
151        cause: None,
152        plan_sha256: plan.plan_sha256.clone(),
153        render_sha256: plan.render_sha256.clone(),
154        summary: plan.summary.clone(),
155        written: Vec::new(),
156        deleted: Vec::new(),
157        warnings: plan.warnings.clone(),
158        gwbk: None,
159        skip_gwbk: false,
160        restart_pending: Vec::new(),
161        restart_pending_new: Vec::new(),
162        backup: None,
163    }
164}
165
166/// Validate the flags, re-plan against the host and evaluate the gates.
167///
168/// Reads the host (through `reader`) and nothing else: no gateway session,
169/// no lock, no scan, no run directory. An empty plan is
170/// [`ApplyPrepared::Noop`] with outcome `noop` (DAPL-03); a refused gate is
171/// its error.
172pub async fn deploy_apply_prepare(
173    declared: &Declared,
174    reader: &dyn TreeReader,
175    opts: &PrepareOptions,
176) -> Result<ApplyPrepared, CoreError> {
177    // A malformed flag is invalid_input before any host contact.
178    if let Some(hash) = &opts.gates.plan_sha256 {
179        validate_plan_sha256(hash)?;
180    }
181    if let Some(reason) = &opts.gates.allow_drift {
182        validate_drift_reason(reason)?;
183    }
184    let plan = deploy_plan(
185        declared,
186        reader,
187        &PlanOptions {
188            base: opts.base.clone(),
189            diff: false,
190        },
191    )
192    .await?;
193    match evaluate_gates(&plan, &opts.gates)? {
194        GateOutcome::Noop => Ok(ApplyPrepared::Noop(base_result(&plan, OutcomeKind::Noop))),
195        GateOutcome::Ready { prose } => Ok(ApplyPrepared::Ready { plan, prose }),
196    }
197}
198
199/// Run a prepared apply: a no-op is returned as it is, a ready plan goes
200/// through [`run_apply`].
201pub async fn deploy_apply_execute(
202    prepared: ApplyPrepared,
203    ctx: &ApplyContext<'_>,
204    opts: &ApplyOptions,
205) -> Result<DeployApplyResult, CoreError> {
206    let plan = match prepared {
207        ApplyPrepared::Noop(result) => return Ok(result),
208        ApplyPrepared::Ready { plan, .. } => plan,
209    };
210    let outcome = run_apply(ctx, &plan, opts).await?;
211    let mut result = base_result(&plan, outcome.kind);
212    result.run_id = outcome.run_id;
213    result.run_dir = outcome.run_dir;
214    result.cause = outcome.cause;
215    result.written = outcome.written;
216    result.deleted = outcome.deleted;
217    result.warnings.extend(outcome.warnings);
218    result.gwbk = outcome.gwbk;
219    result.skip_gwbk = outcome.skip_gwbk;
220    result.restart_pending = outcome.restart_pending;
221    result.restart_pending_new = outcome.restart_pending_new;
222    result.backup = outcome.backup.map(Box::new);
223    Ok(result)
224}
225
226/// The gateway surfaces a confirmed apply talks to.
227pub struct ApplySurfaces<'a> {
228    /// The master's REST surface (lock, scan, mode, read-back).
229    pub deploy: &'a dyn DeployApi,
230    /// The backup node (a read-only handle), when the target has a backup
231    /// node.
232    pub backup: Option<&'a BackupNode<'a>>,
233    /// The master's log, WebDev and Designer surface.
234    pub gateway: &'a dyn GatewayApi,
235}
236
237/// What a confirmed apply needs besides the prepared plan.
238#[derive(Debug, Clone)]
239pub struct ApplyRunOptions {
240    /// The `--base` the plan was drawn against.
241    pub base: Option<PathBuf>,
242    /// The gate flags the plan passed with.
243    pub gates: GateFlags,
244    /// The WebDev project the verify tag probes read through.
245    pub project: String,
246    /// `--designers` and `--designers-timeout`.
247    pub designers: DesignersPolicy,
248    /// `--skip-gwbk`.
249    pub skip_gwbk: bool,
250    /// `--backup-timeout`: each of the two waits for the backup node to
251    /// converge. Unused when the target has no backup node.
252    pub backup_timeout: Duration,
253}
254
255/// Seconds between two reads of the scan status while a config scan runs.
256const SCAN_POLL_INTERVAL: Duration = Duration::from_millis(500);
257
258/// How long a config scan may take before the run rolls back with
259/// `scan_incomplete`. The longest scan measured (23-10) took under 1.6 s.
260const SCAN_WAIT: Duration = Duration::from_secs(120);
261
262/// How the production verbs wait for a config scan to finish.
263fn scan_poll(subject: &str) -> PollConfig {
264    PollConfig {
265        subject: subject.to_string(),
266        interval: SCAN_POLL_INTERVAL,
267        max: Duration::from_secs(2),
268        deadline: SCAN_WAIT,
269    }
270}
271
272/// Run a prepared apply against the master's host: the production entry of
273/// `ign deploy apply` after the confirmation (DAPL-01).
274///
275/// A no-op returns at once, touching neither the host nor the gateway. A
276/// ready plan builds the master's apply host here (so a caller never names
277/// the write half), takes the one measured hold ([`HoldModel::MEASURED`]: no
278/// override exists, D-02) and the real clock, and runs the engine. The
279/// engine's steps reach `observer` as they happen.
280pub async fn deploy_apply_run(
281    prepared: ApplyPrepared,
282    declared: &Declared,
283    repo: &Path,
284    reader: &dyn TreeReader,
285    surfaces: &ApplySurfaces<'_>,
286    opts: &ApplyRunOptions,
287    observer: &dyn ApplyObserver,
288) -> Result<DeployApplyResult, CoreError> {
289    deploy_apply_run_in(
290        prepared, declared, repo, repo, reader, surfaces, opts, observer,
291    )
292    .await
293}
294
295/// [`deploy_apply_run`] with the master's host built against `targets`
296/// (`--targets DIR`, D-14): a relative `local.data_dir` and `known_hosts`
297/// resolve there. The run directory (`.ign/`) stays under `repo`.
298#[allow(clippy::too_many_arguments)]
299pub async fn deploy_apply_run_in(
300    prepared: ApplyPrepared,
301    declared: &Declared,
302    repo: &Path,
303    targets: &Path,
304    reader: &dyn TreeReader,
305    surfaces: &ApplySurfaces<'_>,
306    opts: &ApplyRunOptions,
307    observer: &dyn ApplyObserver,
308) -> Result<DeployApplyResult, CoreError> {
309    if let ApplyPrepared::Noop(result) = prepared {
310        return Ok(result);
311    }
312    let host = apply_host_for_node(
313        targets,
314        &declared.target_file,
315        "nodes.master",
316        &declared.target.nodes.master,
317        &declared.target.gateway,
318        &declared.target.mode,
319    )?;
320    let ctx = ApplyContext {
321        declared,
322        repo,
323        reader,
324        host: host.as_ref(),
325        deploy: surfaces.deploy,
326        gateway: surfaces.gateway,
327        backup: surfaces.backup,
328        clock: &SystemClock,
329        observer,
330    };
331    let engine = EngineOptions {
332        base: opts.base.clone(),
333        gates: opts.gates.clone(),
334        project: opts.project.clone(),
335        hold: HoldModel::MEASURED,
336        poll: scan_poll("config scan after apply"),
337        verify: VerifyOptions::default(),
338        designers: opts.designers,
339        skip_gwbk: opts.skip_gwbk,
340        backup: BackupPolicy {
341            budget: opts.backup_timeout,
342            interval: Duration::from_secs(BACKUP_POLL_S),
343        },
344    };
345    deploy_apply_execute(prepared, &ctx, &engine).await
346}
347
348// ============================================================================
349// Rollback (23-11)
350// ============================================================================
351
352/// What `deploy_rollback_prepare` takes besides the run id.
353#[derive(Debug, Clone, Copy, Default)]
354pub struct RollbackPrepareOptions {
355    /// `--discard-live-changes`: carry changed files instead of refusing.
356    pub discard_live_changes: bool,
357}
358
359/// What `deploy_rollback_prepare` found: the rollback may go ahead.
360#[derive(Debug, Clone)]
361pub struct RollbackPrepared {
362    /// The run to roll back or recover (`--run`).
363    pub run_id: String,
364    /// The on-box record of the run: at state `applied` for a rollback; for
365    /// the recovery of an interrupted run at any state, or `None` when the
366    /// record is lost.
367    pub record: Option<RunRecord>,
368    /// Set when the run's own journal is on the box: this is its recovery
369    /// (D-09), which restores or clears (D-10).
370    pub interrupted: Option<InterruptedRun>,
371    /// The confirmation text: run, gateway, mode, counts and `plan_sha256`.
372    pub prose: String,
373    /// Touched paths changed since the run (only with
374    /// `discard_live_changes`).
375    pub live_changed: Vec<String>,
376    /// The target the file names (the caller builds the rollback context
377    /// from it).
378    pub target: Target,
379    /// Repo-relative path of the target file.
380    pub target_file: String,
381}
382
383/// The `ign deploy rollback` result (the envelope's `data`).
384#[derive(Debug, Clone, PartialEq, Serialize)]
385pub struct DeployRollbackResult {
386    /// The `--env` value.
387    pub env: String,
388    /// Gateway directory name from the target.
389    pub gateway: String,
390    /// Deployment mode from the target.
391    pub mode: String,
392    /// This rollback's own run id.
393    pub run_id: String,
394    /// The run that was rolled back (`--run`).
395    pub target_run_id: String,
396    /// This rollback's run directory.
397    pub run_dir: String,
398    /// `restored`.
399    pub outcome: RollbackKind,
400    /// Always true: a rollback that cannot restore is an error, not a result.
401    pub ok: bool,
402    /// Collection paths put back from the snapshot.
403    pub restored: Vec<String>,
404    /// Collection paths the rolled-back run had created, now removed.
405    pub deleted: Vec<String>,
406    /// Touched paths that had changed since the run and were overwritten;
407    /// `[]` when none (never null).
408    pub discarded_live_changes: Vec<String>,
409    /// Engine warnings.
410    pub warnings: Vec<String>,
411    /// The pending restarts after the rollback; `[]` when none (never null).
412    pub restart_pending: Vec<String>,
413    /// The pending restarts the rollback added; `[]` when none (never null).
414    pub restart_pending_new: Vec<String>,
415}
416
417/// The confirmation prose of a rollback: byte-stable, no file content.
418fn rollback_prose(run_id: &str, target: &Target, checked: &RollbackChecked) -> String {
419    let record = checked.record.as_ref();
420    let (restored, deleted) = record.map(restore_set).unwrap_or_default();
421    let ledger = record
422        .and_then(|r| {
423            r.touched
424                .iter()
425                .find(|e| e.path == crate::deploy::apply::fingerprint::LEDGER_KEY)
426        })
427        .map(|e| {
428            if e.pre_sha256.is_some() {
429                ", and restore the ledger"
430            } else {
431                ", and remove the ledger"
432            }
433        })
434        .unwrap_or("");
435    let mut prose = match (&checked.interrupted, record) {
436        (Some(found), _) if found.action == RecoveryAction::ClearWithoutRestore => format!(
437            "Clear run {run_id} on gateway {} (mode {}), which was interrupted at phase {} \
438             before any file moved and whose touched files are as they were before the run: \
439             remove its journal, partial snapshot and staging. Nothing is restored.",
440            target.gateway,
441            target.mode,
442            found.journal.phase.as_str(),
443        ),
444        (Some(found), Some(record)) => format!(
445            "Recover run {run_id} on gateway {} (mode {}), which was interrupted at phase {}: \
446             restore {} file(s) from the on-box snapshot and delete {} file(s) the run \
447             created{ledger}, then remove the journal. The run was applying plan_sha256 {}.",
448            target.gateway,
449            target.mode,
450            found.journal.phase.as_str(),
451            restored.len(),
452            deleted.len(),
453            record.plan_sha256,
454        ),
455        (Some(found), None) => format!(
456            "Recover run {run_id} on gateway {} (mode {}), which was interrupted at phase {} \
457             and whose record is missing: restore the touched files from the on-box snapshot, \
458             then remove the journal.",
459            target.gateway,
460            target.mode,
461            found.journal.phase.as_str(),
462        ),
463        (None, Some(record)) => format!(
464            "Roll back run {run_id} on gateway {} (mode {}): restore {} file(s) from the on-box \
465             snapshot and delete {} file(s) the run created{ledger}. The run applied \
466             plan_sha256 {}.",
467            target.gateway,
468            target.mode,
469            restored.len(),
470            deleted.len(),
471            record.plan_sha256,
472        ),
473        (None, None) => format!("Roll back run {run_id} on gateway {}.", target.gateway),
474    };
475    if !checked.live_changed.is_empty() {
476        prose.push_str(&format!(
477            " {} file(s) changed after the run and will be overwritten: {}.",
478            checked.live_changed.len(),
479            checked
480                .live_changed
481                .iter()
482                .take(crate::error::APPLY_LIST_SHOWN)
483                .cloned()
484                .collect::<Vec<_>>()
485                .join(", ")
486        ));
487    }
488    prose
489}
490
491/// Validate the run id, then read the host and decide whether the run can be
492/// rolled back.
493///
494/// The id is checked before any host contact (`invalid_input`). After that
495/// this reads the target file, the host's journal, the on-box record, the
496/// run's local run directory when it exists, and the live hashes of the
497/// touched set; it writes nothing and takes no lock. See
498/// [`check_rollback`] for the refusals.
499pub async fn deploy_rollback_prepare(
500    repo: &Path,
501    env: &str,
502    run_id_text: &str,
503    reader: &dyn TreeReader,
504    host: &dyn ApplyHost,
505    opts: &RollbackPrepareOptions,
506) -> Result<RollbackPrepared, CoreError> {
507    deploy_rollback_prepare_in(repo, repo, env, run_id_text, reader, host, opts).await
508}
509
510/// [`deploy_rollback_prepare`] with the target read from `targets`
511/// (`--targets DIR`, D-14). The run directory is still read under `repo`.
512pub async fn deploy_rollback_prepare_in(
513    repo: &Path,
514    targets: &Path,
515    env: &str,
516    run_id_text: &str,
517    reader: &dyn TreeReader,
518    host: &dyn ApplyHost,
519    opts: &RollbackPrepareOptions,
520) -> Result<RollbackPrepared, CoreError> {
521    let run_id = RunId::parse(run_id_text)?;
522    let (target_file, target) = load_target(targets, env)?;
523    let checked = check_rollback(
524        env,
525        repo,
526        &target,
527        reader,
528        host,
529        &run_id,
530        opts.discard_live_changes,
531    )
532    .await?;
533    Ok(RollbackPrepared {
534        prose: rollback_prose(run_id.as_str(), &target, &checked),
535        run_id: run_id.as_str().to_string(),
536        record: checked.record,
537        interrupted: checked.interrupted,
538        live_changed: checked.live_changed,
539        target,
540        target_file,
541    })
542}
543
544/// Run a prepared rollback through [`run_rollback`].
545pub async fn deploy_rollback_execute(
546    prepared: RollbackPrepared,
547    ctx: &RollbackContext<'_>,
548    opts: &RollbackOptions,
549) -> Result<DeployRollbackResult, CoreError> {
550    if prepared.run_id != opts.run_id.as_str() {
551        return Err(CoreError::Internal(
552            "the prepared rollback is for another run than the options name".to_string(),
553        ));
554    }
555    let outcome = run_rollback(ctx, opts).await?;
556    Ok(DeployRollbackResult {
557        env: ctx.env.to_string(),
558        gateway: ctx.target.gateway.clone(),
559        mode: ctx.target.mode.clone(),
560        run_id: outcome.run_id,
561        target_run_id: outcome.target_run_id,
562        run_dir: outcome.run_dir,
563        outcome: outcome.kind,
564        ok: true,
565        restored: outcome.restored,
566        deleted: outcome.deleted,
567        discarded_live_changes: outcome.discarded_live_changes,
568        warnings: outcome.warnings,
569        restart_pending: outcome.restart_pending,
570        restart_pending_new: outcome.restart_pending_new,
571    })
572}
573
574/// [`deploy_rollback_prepare`] for the production verb: builds the master's
575/// apply host from the target file itself, so a caller never names the write
576/// half. The run id is checked first, before the target is read or any host
577/// is touched.
578pub async fn deploy_rollback_prepare_for_env(
579    repo: &Path,
580    env: &str,
581    run_id_text: &str,
582    reader: &dyn TreeReader,
583    opts: &RollbackPrepareOptions,
584) -> Result<RollbackPrepared, CoreError> {
585    deploy_rollback_prepare_for_env_in(repo, repo, env, run_id_text, reader, opts).await
586}
587
588/// [`deploy_rollback_prepare_for_env`] with the target and the host built
589/// against `targets` (`--targets DIR`, D-14).
590pub async fn deploy_rollback_prepare_for_env_in(
591    repo: &Path,
592    targets: &Path,
593    env: &str,
594    run_id_text: &str,
595    reader: &dyn TreeReader,
596    opts: &RollbackPrepareOptions,
597) -> Result<RollbackPrepared, CoreError> {
598    RunId::parse(run_id_text)?;
599    let (target_file, target) = load_target(targets, env)?;
600    let host = apply_host_for_node(
601        targets,
602        &target_file,
603        "nodes.master",
604        &target.nodes.master,
605        &target.gateway,
606        &target.mode,
607    )?;
608    deploy_rollback_prepare_in(repo, targets, env, run_id_text, reader, host.as_ref(), opts).await
609}
610
611/// What a confirmed rollback needs besides the prepared value.
612#[derive(Debug, Clone, Copy, Default)]
613pub struct RollbackRunOptions {
614    /// `--discard-live-changes`, as given to the prepare.
615    pub discard_live_changes: bool,
616    /// `--designers` and `--designers-timeout`.
617    pub designers: DesignersPolicy,
618}
619
620/// Run a prepared rollback against the master's host: the production entry
621/// of `ign deploy rollback` after the confirmation (DAPL-08).
622///
623/// Builds the host here, takes the one measured hold ([`HoldModel::MEASURED`],
624/// no override: D-02) and the real clock, and drives [`run_rollback`] with the
625/// engine's own scan wait. The engine's steps reach `observer`.
626pub async fn deploy_rollback_run(
627    prepared: RollbackPrepared,
628    env: &str,
629    repo: &Path,
630    reader: &dyn TreeReader,
631    surfaces: &ApplySurfaces<'_>,
632    opts: &RollbackRunOptions,
633    observer: &dyn ApplyObserver,
634) -> Result<DeployRollbackResult, CoreError> {
635    deploy_rollback_run_in(prepared, env, repo, repo, reader, surfaces, opts, observer).await
636}
637
638/// [`deploy_rollback_run`] with the master's host built against `targets`
639/// (`--targets DIR`, D-14). The run directory stays under `repo`.
640#[allow(clippy::too_many_arguments)]
641pub async fn deploy_rollback_run_in(
642    prepared: RollbackPrepared,
643    env: &str,
644    repo: &Path,
645    targets: &Path,
646    reader: &dyn TreeReader,
647    surfaces: &ApplySurfaces<'_>,
648    opts: &RollbackRunOptions,
649    observer: &dyn ApplyObserver,
650) -> Result<DeployRollbackResult, CoreError> {
651    let host = apply_host_for_node(
652        targets,
653        &prepared.target_file,
654        "nodes.master",
655        &prepared.target.nodes.master,
656        &prepared.target.gateway,
657        &prepared.target.mode,
658    )?;
659    let run_id = RunId::parse(&prepared.run_id)?;
660    let target = prepared.target.clone();
661    let target_file = prepared.target_file.clone();
662    let ctx = RollbackContext {
663        env,
664        repo,
665        target_file: &target_file,
666        target: &target,
667        reader,
668        host: host.as_ref(),
669        deploy: surfaces.deploy,
670        gateway: surfaces.gateway,
671        backup: surfaces.backup,
672        clock: &SystemClock,
673        observer,
674    };
675    let engine = RollbackOptions {
676        run_id,
677        discard_live_changes: opts.discard_live_changes,
678        designers: opts.designers,
679        hold: HoldModel::MEASURED,
680        poll: scan_poll("config scan after rollback"),
681    };
682    deploy_rollback_execute(prepared, &ctx, &engine).await
683}