Skip to main content

ignition_core/deploy/apply/
preflight.rs

1//! The apply preflights (Phase 23, DAPL-04, D-03, D-14, D-15).
2//!
3//! [`run_preflights`] runs after the run directory exists and before any
4//! `.gwbk` download, staging, lock or write. It runs every check, so one
5//! refusal lists every problem, and it reports them in one fixed order
6//! ([`PreflightCheck::ALL`]) whichever check finished first.
7//!
8//! | Check | Fails when |
9//! |---|---|
10//! | `mode_master` | the master's active deployment mode is not the target's `mode` |
11//! | `mode_backup` | the same through the backup's own profile; `skipped` when the target has no `nodes.backup` |
12//! | `wrong_target` | the REST `gateway-info` name is not the host's `systemName` (the `ignition/system-properties` singleton), or not `nodes.master.gateway_name` when that is set |
13//! | `write_rest` | the project-scan write probe answers 403 (a read-only token) |
14//! | `write_host` | [`ApplyHost::probe`] says the mode collection or the `ign-deploy` root is not writable |
15//! | `designers` | a Designer session is open (see [`DesignersMode`]) |
16//! | `hold_budget` | `ceil(2 x (estimated commit + p95 scan) + 1)` for this run's writes and deletes is more than the hold (D-03); never clamped |
17//! | `master_active` | the master is not the only node whose `activityLevel` is `Active` (a failover moved activity, split-brain, or neither is active); `skipped` when the target has no `nodes.backup`. Runs for `rollback` too (D-11, K3) |
18//! | `backup_read` | one read of the backup's mode collection through the backup's own file transport fails (D-02); `skipped` for a REST-only backup (the file-hash check will not run), without a backup node and in `rollback` |
19//! | `secrets` | a secret name the rendered tree references is missing on the master or on the backup (D-14), by exact name (K9); a provider the render declares that does not exist yet is skipped by name (K8) and a provider that cannot list is named unverifiable (K7), neither fails; `skipped` when the tree references no secret and in `rollback` |
20//!
21//! A check that cannot get its answer (an unreachable gateway or host, a
22//! refused credential) is not a failed check: it aborts with its own error
23//! (`network_error`, `transport_failed`, `auth_rejected`) and nothing else is
24//! claimed about the checks that did not run.
25//!
26//! Details name checks, counts, seconds and the Designer sessions D-15 asks
27//! for (user, project, host). They never carry file content or a secret.
28//!
29//! A Designer session or a foreign edit that starts after the preflights is
30//! not seen by them. The under-lock pre-image check is the guard for file
31//! changes; C2 in `23-LIVE-CAPTURES.md` records whether the lock blocks
32//! Designer saves.
33
34use std::collections::{BTreeMap, BTreeSet};
35use std::time::Duration;
36
37use super::{ApplyEvent, Steps};
38use serde_json::Value;
39
40use super::hold::{Clock, HoldModel, hold_for};
41use super::host::ApplyHost;
42use crate::actions::deploy::Declared;
43use crate::client::mode::normalize_deployment_mode;
44use crate::client::query::ListQuery;
45use crate::client::redundancy::{ActiveNode, RedundancyStatusWire, active_node};
46use crate::client::{DeployApi, GatewayApi};
47use crate::deploy::layout::RelPath;
48use crate::deploy::pair::{
49    BackupNode, SecretCheckMode, check_secret_names, collect_referenced_secrets,
50    declared_secret_providers, secrets_defined_by_render,
51};
52use crate::deploy::target::Target;
53use crate::deploy::transport::{Scope, TreeReader};
54use crate::error::{APPLY_LIST_SHOWN, CoreError, PreflightFailure};
55
56/// Seconds between two reads of the Designer list under `--designers wait`
57/// (D-14).
58pub const DESIGNERS_POLL_S: u64 = 10;
59
60/// Default `--designers-timeout`, in seconds (D-14).
61pub const DESIGNERS_TIMEOUT_DEFAULT_S: u64 = 600;
62
63/// The longest a name from the gateway is shown in a detail.
64const SHOWN_NAME_CHARS: usize = 64;
65
66/// The singleton whose `systemName` names the gateway on its host.
67const SYSTEM_PROPERTIES_UNIT: &str = "ignition/system-properties";
68
69/// One preflight check, by the id it is reported under.
70#[derive(Debug, Clone, Copy, PartialEq, Eq)]
71pub enum PreflightCheck {
72    /// The master runs the target's mode.
73    ModeMaster,
74    /// The backup runs the target's mode.
75    ModeBackup,
76    /// The REST identity matches the host's.
77    WrongTarget,
78    /// The API token may write.
79    WriteRest,
80    /// The host may be written.
81    WriteHost,
82    /// No Designer session is open.
83    Designers,
84    /// The run fits in the hold.
85    HoldBudget,
86    /// The master is the active node of its pair.
87    MasterActive,
88    /// The backup's files can be read by the account the target names.
89    BackupRead,
90    /// Every referenced secret name exists on each node.
91    Secrets,
92}
93
94impl PreflightCheck {
95    /// Every check, in the fixed report order.
96    pub const ALL: [PreflightCheck; 10] = [
97        PreflightCheck::ModeMaster,
98        PreflightCheck::ModeBackup,
99        PreflightCheck::WrongTarget,
100        PreflightCheck::WriteRest,
101        PreflightCheck::WriteHost,
102        PreflightCheck::Designers,
103        PreflightCheck::HoldBudget,
104        PreflightCheck::MasterActive,
105        PreflightCheck::BackupRead,
106        PreflightCheck::Secrets,
107    ];
108
109    /// The id of the check in `deploy_preflight_failed` and `run.json`.
110    pub fn as_str(self) -> &'static str {
111        match self {
112            PreflightCheck::ModeMaster => "mode_master",
113            PreflightCheck::ModeBackup => "mode_backup",
114            PreflightCheck::WrongTarget => "wrong_target",
115            PreflightCheck::WriteRest => "write_rest",
116            PreflightCheck::WriteHost => "write_host",
117            PreflightCheck::Designers => "designers",
118            PreflightCheck::HoldBudget => "hold_budget",
119            PreflightCheck::MasterActive => "master_active",
120            PreflightCheck::BackupRead => "backup_read",
121            PreflightCheck::Secrets => "secrets",
122        }
123    }
124}
125
126/// What one check found.
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum FindingStatus {
129    /// The check ran and passed.
130    Pass,
131    /// The check ran and failed.
132    Fail,
133    /// The check does not apply to this target (a missing backup node is
134    /// skipped, never passed).
135    Skipped,
136}
137
138impl FindingStatus {
139    /// The word in `run.json` and the events.
140    pub fn as_str(self) -> &'static str {
141        match self {
142            FindingStatus::Pass => "pass",
143            FindingStatus::Fail => "fail",
144            FindingStatus::Skipped => "skipped",
145        }
146    }
147}
148
149/// One check's verdict.
150#[derive(Debug, Clone, PartialEq, Eq)]
151pub struct PreflightFinding {
152    /// Which check.
153    pub check: PreflightCheck,
154    /// What it found.
155    pub status: FindingStatus,
156    /// Why, without any content or secret.
157    pub detail: String,
158}
159
160/// What to do about open Designer sessions (D-14, D-15).
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum DesignersMode {
163    /// Refuse while any is open (the default).
164    Fail,
165    /// Poll until none is open or the timeout passes, then refuse.
166    Wait,
167    /// Go ahead, and record that and the open sessions.
168    Proceed,
169}
170
171impl DesignersMode {
172    /// The `--designers` word.
173    pub fn as_str(self) -> &'static str {
174        match self {
175            DesignersMode::Fail => "fail",
176            DesignersMode::Wait => "wait",
177            DesignersMode::Proceed => "proceed",
178        }
179    }
180}
181
182/// `--designers`, `--designers-timeout` and the poll interval.
183#[derive(Debug, Clone, Copy, PartialEq, Eq)]
184pub struct DesignersPolicy {
185    /// What to do about open sessions.
186    pub mode: DesignersMode,
187    /// How long `wait` polls before it gives up.
188    pub timeout: Duration,
189    /// How long `wait` sleeps between two reads.
190    pub interval: Duration,
191}
192
193impl Default for DesignersPolicy {
194    /// `fail`, with the production timeout and interval.
195    fn default() -> Self {
196        DesignersPolicy {
197            mode: DesignersMode::Fail,
198            timeout: Duration::from_secs(DESIGNERS_TIMEOUT_DEFAULT_S),
199            interval: Duration::from_secs(DESIGNERS_POLL_S),
200        }
201    }
202}
203
204/// What the preflights read.
205pub struct PreflightInput<'a> {
206    /// The target of this run.
207    pub target: &'a Target,
208    /// The declared render of this run: `None` for a rollback, which renders
209    /// nothing, so the checks that need the rendered tree are `skipped`.
210    pub declared: Option<&'a Declared>,
211    /// The master's deploy surface (mode).
212    pub master: &'a dyn DeployApi,
213    /// The backup node (a read-only handle), when the target has one.
214    pub backup: Option<&'a BackupNode<'a>>,
215    /// The master's gateway surface (identity, write probe, Designers).
216    pub gateway: &'a dyn GatewayApi,
217    /// The read half of the master's host.
218    pub reader: &'a dyn TreeReader,
219    /// The write seam of the master's host.
220    pub host: &'a dyn ApplyHost,
221    /// Files this run writes plus files it deletes.
222    pub touched_files: usize,
223    /// The paths this run touches (collection paths, and the ledger key when
224    /// the ledger is swapped), for the limits a host puts on a path.
225    pub touched_paths: &'a [String],
226    /// The hold the lock will be requested with and its estimates.
227    pub hold: &'a HoldModel,
228    /// What to do about open Designer sessions.
229    pub designers: &'a DesignersPolicy,
230    /// The monotonic clock the Designer wait is timed by.
231    pub clock: &'a dyn Clock,
232}
233
234/// A Designer session as D-15 records it: user, project, host.
235pub type DesignerSeen = (String, String, String);
236
237/// Every check's verdict, in the fixed order, and the sessions seen.
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub struct PreflightReport {
240    /// One finding per check, in [`PreflightCheck::ALL`] order.
241    pub findings: Vec<PreflightFinding>,
242    /// The Designer sessions open at the last read, whatever the mode.
243    pub designers_seen: Vec<DesignerSeen>,
244}
245
246impl PreflightReport {
247    /// Whether no check failed.
248    pub fn passed(&self) -> bool {
249        self.findings
250            .iter()
251            .all(|f| f.status != FindingStatus::Fail)
252    }
253
254    /// The failed checks as the refusal carries them, in check order.
255    pub fn failures(&self) -> Vec<PreflightFailure> {
256        self.findings
257            .iter()
258            .filter(|f| f.status == FindingStatus::Fail)
259            .map(|f| PreflightFailure {
260                check: f.check.as_str().to_string(),
261                detail: f.detail.clone(),
262            })
263            .collect()
264    }
265}
266
267/// Run every preflight (see the module doc) and report every verdict.
268///
269/// Reads the gateway, the host and the Designer list; writes nothing but the
270/// harmless project-scan probe and the host's own probe file, and emits
271/// `designers_poll` and `designers_proceed` events through `steps`. An
272/// unreachable gateway or host is that call's own error, not a finding.
273pub async fn run_preflights(
274    input: &PreflightInput<'_>,
275    steps: &mut Steps<'_>,
276) -> Result<PreflightReport, CoreError> {
277    let mut findings = Vec::with_capacity(PreflightCheck::ALL.len());
278    let mode = input.target.mode.as_str();
279
280    let master_seen = normalize_deployment_mode(input.master.deployment_mode().await?);
281    findings.push(mode_finding(
282        PreflightCheck::ModeMaster,
283        "master",
284        Some(master_seen),
285        mode,
286    ));
287    findings.push(if input.target.nodes.backup.is_some() {
288        let backup_seen = match input.backup {
289            Some(backup) => Some(backup.deployment_mode().await?),
290            None => None,
291        };
292        mode_finding(PreflightCheck::ModeBackup, "backup", backup_seen, mode)
293    } else {
294        PreflightFinding {
295            check: PreflightCheck::ModeBackup,
296            status: FindingStatus::Skipped,
297            detail: "the target has no backup node".to_string(),
298        }
299    });
300    findings.push(wrong_target_finding(input).await?);
301    findings.push(write_rest_finding(input.gateway).await?);
302    findings.push(write_host_finding(input.host, input.touched_paths).await?);
303    let (designers, designers_seen) = designers_finding(input, steps).await?;
304    findings.push(designers);
305    findings.push(hold_budget_finding(input.hold, input.touched_files));
306    findings.push(master_active_finding(input).await?);
307    findings.push(backup_read_finding(input).await);
308    findings.push(secrets_finding(input).await?);
309
310    Ok(PreflightReport {
311        findings,
312        designers_seen,
313    })
314}
315
316fn finding(check: PreflightCheck, ok: bool, detail: String) -> PreflightFinding {
317    PreflightFinding {
318        check,
319        status: if ok {
320            FindingStatus::Pass
321        } else {
322            FindingStatus::Fail
323        },
324        detail,
325    }
326}
327
328/// `text` without control characters, at most [`SHOWN_NAME_CHARS`] long.
329fn printable(text: &str) -> String {
330    let mut out: String = text
331        .chars()
332        .filter(|c| !c.is_control())
333        .take(SHOWN_NAME_CHARS)
334        .collect();
335    if text.chars().count() > SHOWN_NAME_CHARS {
336        out.push_str("...");
337    }
338    out
339}
340
341// ---- mode_master, mode_backup ----------------------------------------------
342
343/// `seen` is the node's normalized mode as already read, or `None` when the
344/// caller holds no connection to the node.
345fn mode_finding(
346    check: PreflightCheck,
347    node: &str,
348    seen: Option<Option<String>>,
349    target_mode: &str,
350) -> PreflightFinding {
351    let Some(seen) = seen else {
352        // The target names a node the caller holds no connection to: the
353        // mode cannot be confirmed, so the run does not go ahead.
354        return finding(
355            check,
356            false,
357            format!("the {node} node has no gateway connection, so its mode cannot be confirmed"),
358        );
359    };
360    let ok = seen.as_deref() == Some(target_mode);
361    let detail = match (&seen, ok) {
362        (_, true) => format!("the {node} runs mode `{}`", printable(target_mode)),
363        (Some(seen), false) => format!(
364            "the {node} runs mode `{}` but the target's mode is `{}`",
365            printable(seen),
366            printable(target_mode)
367        ),
368        (None, false) => format!(
369            "the {node} runs no deployment mode but the target's mode is `{}`",
370            printable(target_mode)
371        ),
372    };
373    finding(check, ok, detail)
374}
375
376// ---- wrong_target -----------------------------------------------------------
377
378/// The host's `systemName`, or why it cannot be read.
379async fn host_system_name(
380    reader: &dyn TreeReader,
381    mode: &str,
382) -> Result<Result<String, &'static str>, CoreError> {
383    let (module, ty) = SYSTEM_PROPERTIES_UNIT
384        .split_once('/')
385        .unwrap_or((SYSTEM_PROPERTIES_UNIT, ""));
386    let observed = reader
387        .read(&Scope {
388            mode: mode.to_string(),
389            types: vec![(module.to_string(), ty.to_string())],
390            tag_providers: Vec::new(),
391        })
392        .await?;
393    // The gateway lets `local` override the mode collection and the mode
394    // collection override `core`; only the bytes of the last two are read.
395    if observed
396        .local_units
397        .iter()
398        .any(|u| u == SYSTEM_PROPERTIES_UNIT)
399    {
400        return Ok(Err("it is overridden in the local collection"));
401    }
402    let path = RelPath::new(&format!("{SYSTEM_PROPERTIES_UNIT}/config.json"))
403        .map_err(|why| CoreError::Internal(format!("system-properties path: {why}")))?;
404    let Some(blob) = observed
405        .mode
406        .get(&path)
407        .or_else(|| observed.core.get(&path))
408    else {
409        return Ok(Err("the host has no system-properties file"));
410    };
411    let Ok(doc) = serde_json::from_slice::<Value>(&blob.bytes) else {
412        return Ok(Err("the system-properties file is not JSON"));
413    };
414    match doc.get("systemName").and_then(Value::as_str) {
415        Some(name) if !name.trim().is_empty() => Ok(Ok(name.to_string())),
416        _ => Ok(Err("the system-properties file has no systemName")),
417    }
418}
419
420/// What is wrong with the gateway the REST name, the host's `systemName` and
421/// the target's `nodes.master.gateway_name` describe (one comparison, shared by
422/// `deploy apply` and `deploy projects`, 25-14). `rest` is the non-blank REST
423/// name; `host` is the `systemName` or why it cannot be read; `declared` is
424/// `nodes.master.gateway_name`. An empty answer means the write may go ahead.
425/// No problem text carries a name: the names come from the gateway and the
426/// host, which is what is being checked.
427pub(crate) fn wrong_target_problems(
428    rest: Option<&str>,
429    host: Result<&str, &str>,
430    declared: Option<&str>,
431) -> Vec<String> {
432    let mut problems: Vec<String> = Vec::new();
433    match (rest, host) {
434        (None, _) => problems.push("the gateway reports no name over REST".to_string()),
435        (Some(_), Err(why)) => problems.push(format!("the host's systemName is unreadable: {why}")),
436        (Some(rest), Ok(host)) if rest != host => {
437            problems.push("the gateway's REST name is not the host's systemName".to_string())
438        }
439        (Some(_), Ok(_)) => {}
440    }
441    if let (Some(rest), Some(declared)) = (rest, declared)
442        && rest != declared
443    {
444        problems.push("the gateway's REST name is not nodes.master.gateway_name".to_string());
445    }
446    problems
447}
448
449/// The refusal for a non-empty [`wrong_target_problems`] answer: one
450/// `wrong_target` failure in the shape the preflight list carries.
451pub(crate) fn wrong_target_failure(problems: &[String]) -> PreflightFailure {
452    PreflightFailure {
453        check: PreflightCheck::WrongTarget.as_str().to_string(),
454        detail: problems.join("; "),
455    }
456}
457
458/// The wrong-target comparison for a caller that has no [`PreflightInput`]
459/// (`deploy projects`, 25-14): reads the host's `systemName` through `reader`
460/// and compares it with `rest` and `declared`. An unreadable gateway or host
461/// is that call's own error, not a refusal.
462pub(crate) async fn wrong_target_gate(
463    rest: Option<&str>,
464    reader: &dyn TreeReader,
465    mode: &str,
466    declared: Option<&str>,
467) -> Result<Vec<String>, CoreError> {
468    let host = host_system_name(reader, mode).await?;
469    let host = host.as_ref().map(String::as_str).map_err(|why| *why);
470    Ok(wrong_target_problems(rest, host, declared))
471}
472
473async fn wrong_target_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
474    let check = PreflightCheck::WrongTarget;
475    let rest = input
476        .gateway
477        .gateway_info()
478        .await?
479        .name
480        .filter(|n| !n.trim().is_empty());
481    let problems = wrong_target_gate(
482        rest.as_deref(),
483        input.reader,
484        &input.target.mode,
485        input.target.nodes.master.gateway_name.as_deref(),
486    )
487    .await?;
488    if problems.is_empty() {
489        Ok(finding(
490            check,
491            true,
492            "the REST name matches the host's systemName".to_string(),
493        ))
494    } else {
495        Ok(finding(check, false, problems.join("; ")))
496    }
497}
498
499// ---- write_rest ---------------------------------------------------------------
500
501async fn write_rest_finding(gateway: &dyn GatewayApi) -> Result<PreflightFinding, CoreError> {
502    let check = PreflightCheck::WriteRest;
503    match gateway.scan_projects().await {
504        Ok(()) => Ok(finding(
505            check,
506            true,
507            "the project scan was accepted".to_string(),
508        )),
509        Err(CoreError::Auth { status: 403, .. }) => Ok(finding(
510            check,
511            false,
512            "the token is read-only (HTTP 403 on the project scan)".to_string(),
513        )),
514        Err(other) => Err(other),
515    }
516}
517
518// ---- write_host -------------------------------------------------------------
519
520async fn write_host_finding(
521    host: &dyn ApplyHost,
522    touched: &[String],
523) -> Result<PreflightFinding, CoreError> {
524    let check = PreflightCheck::WriteHost;
525    let report = host.probe_paths(touched).await?;
526    if report.writable {
527        return Ok(finding(
528            check,
529            true,
530            "the mode collection and the ign-deploy root are writable".to_string(),
531        ));
532    }
533    let detail = if report.problems.is_empty() {
534        "the host is not writable".to_string()
535    } else {
536        report.problems.join("; ")
537    };
538    Ok(finding(check, false, detail))
539}
540
541// ---- designers --------------------------------------------------------------
542
543pub(crate) async fn read_designers(
544    gateway: &dyn GatewayApi,
545) -> Result<Vec<DesignerSeen>, CoreError> {
546    let page = gateway.designers(&ListQuery::default()).await?;
547    Ok(page
548        .items
549        .into_iter()
550        .map(|d| {
551            (
552                printable(&d.user),
553                printable(&d.project),
554                printable(&d.address),
555            )
556        })
557        .collect())
558}
559
560/// `n Designer session(s) open: user on project P from host H, ...`.
561pub(crate) fn designers_detail(open: &[DesignerSeen]) -> String {
562    let each: Vec<String> = open
563        .iter()
564        .map(|(user, project, host)| {
565            let project = if project.is_empty() {
566                "no project"
567            } else {
568                project
569            };
570            format!("{user} on {project} from {host}")
571        })
572        .collect();
573    let take = each.len().min(APPLY_LIST_SHOWN);
574    let mut text = format!(
575        "{} Designer session(s) open: {}",
576        open.len(),
577        each[..take].join(", ")
578    );
579    if each.len() > take {
580        text.push_str(&format!(" and {} more", each.len() - take));
581    }
582    text
583}
584
585/// What `wait` does next, given the sessions still open and the time spent.
586#[derive(Debug, Clone, Copy, PartialEq, Eq)]
587pub(crate) enum WaitStep {
588    /// No session is open.
589    Clear,
590    /// Still open and the timeout has passed.
591    TimedOut,
592    /// Still open: sleep this long, then read again.
593    Sleep(Duration),
594}
595
596/// The decision of the `wait` loop. A timeout equal to the elapsed time has
597/// passed, and the last sleep never runs past the timeout.
598pub(crate) fn wait_step(open: usize, elapsed: Duration, policy: &DesignersPolicy) -> WaitStep {
599    if open == 0 {
600        WaitStep::Clear
601    } else if elapsed >= policy.timeout {
602        WaitStep::TimedOut
603    } else {
604        WaitStep::Sleep(
605            policy
606                .interval
607                .max(Duration::from_millis(1))
608                .min(policy.timeout - elapsed),
609        )
610    }
611}
612
613/// What [`designers_gate`] decided (D-14, D-15).
614#[derive(Debug, Clone, Copy, PartialEq, Eq)]
615pub enum DesignersVerdict {
616    /// No Designer session is open.
617    Clear,
618    /// Sessions are open and the mode does not allow going ahead (`fail`, or
619    /// `wait` after the timeout).
620    Open,
621    /// Sessions are open and `--designers proceed` was given: go ahead, with
622    /// the sessions on record.
623    Proceeded,
624}
625
626/// The read-poll-decide loop of the Designer check, shared by `deploy apply`
627/// and `deploy projects` (25-14).
628///
629/// `read` lists the open sessions; it is called once, and again after each
630/// sleep under [`DesignersMode::Wait`]. `on_poll` hears the number of open
631/// sessions after each read under `wait`. Returns the verdict and the sessions
632/// seen at the last read; a read that fails is that call's own error.
633pub(crate) async fn designers_gate<F, Fut>(
634    mut read: F,
635    policy: &DesignersPolicy,
636    clock: &dyn Clock,
637    on_poll: &mut (dyn FnMut(usize) -> Result<(), CoreError> + Send),
638) -> Result<(DesignersVerdict, Vec<DesignerSeen>), CoreError>
639where
640    F: FnMut() -> Fut,
641    Fut: std::future::Future<Output = Result<Vec<DesignerSeen>, CoreError>>,
642{
643    let started = clock.now();
644    let mut open = read().await?;
645    if policy.mode == DesignersMode::Wait {
646        loop {
647            on_poll(open.len())?;
648            let elapsed = clock.now().saturating_duration_since(started);
649            match wait_step(open.len(), elapsed, policy) {
650                WaitStep::Clear | WaitStep::TimedOut => break,
651                WaitStep::Sleep(pause) => {
652                    tokio::time::sleep(pause).await;
653                    open = read().await?;
654                }
655            }
656        }
657    }
658    let verdict = if open.is_empty() {
659        DesignersVerdict::Clear
660    } else if policy.mode == DesignersMode::Proceed {
661        DesignersVerdict::Proceeded
662    } else {
663        DesignersVerdict::Open
664    };
665    Ok((verdict, open))
666}
667
668async fn designers_finding(
669    input: &PreflightInput<'_>,
670    steps: &mut Steps<'_>,
671) -> Result<(PreflightFinding, Vec<DesignerSeen>), CoreError> {
672    let check = PreflightCheck::Designers;
673    let (verdict, open) = designers_gate(
674        || read_designers(input.gateway),
675        input.designers,
676        input.clock,
677        &mut |count| steps.emit(ApplyEvent::DesignersPoll { open: count }),
678    )
679    .await?;
680    let found = match verdict {
681        DesignersVerdict::Clear => finding(check, true, "no Designer session is open".to_string()),
682        DesignersVerdict::Proceeded => {
683            // D-15: going ahead is allowed only with the choice and the
684            // sessions on record (run.json and the events).
685            steps.emit(ApplyEvent::DesignersProceed {
686                sessions: open.clone(),
687            })?;
688            finding(
689                check,
690                true,
691                format!(
692                    "{}; going ahead because --designers proceed was given",
693                    designers_detail(&open)
694                ),
695            )
696        }
697        DesignersVerdict::Open => finding(check, false, designers_detail(&open)),
698    };
699    Ok((found, open))
700}
701
702// ---- hold_budget ------------------------------------------------------------
703
704pub(super) fn hold_budget_finding(hold: &HoldModel, files: usize) -> PreflightFinding {
705    let estimate = hold.estimate_commit_s(files);
706    let needed = hold_for(estimate, hold.p95_scan_s);
707    let detail = format!(
708        "{files} file(s): estimated commit {estimate:.1} s and scan {:.1} s need a hold of {needed} s; \
709         the budget is {} s",
710        hold.p95_scan_s, hold.hold_s
711    );
712    finding(PreflightCheck::HoldBudget, needed <= hold.hold_s, detail)
713}
714
715// ---- master_active ------------------------------------------------------------
716
717/// The master is the only active node of its pair (D-11, K3).
718///
719/// Which node is active is decided by `activityLevel`, never by `role`: after
720/// a switch-over the former master keeps `role: Master` while its level drops to
721/// `Cold` (C-S3). The target file still names that node as the master, so an
722/// apply or a rollback would write to the standby, whose collection the
723/// active node's sync then overwrites. Both nodes are read here and the check
724/// fails on a moved, doubled or missing activity. Rollback runs the same
725/// check. The activity can still move after this read; the lock-time guards
726/// and the backup phase are what catch a failover during the run.
727async fn master_active_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
728    let check = PreflightCheck::MasterActive;
729    if input.target.nodes.backup.is_none() {
730        return Ok(PreflightFinding {
731            check,
732            status: FindingStatus::Skipped,
733            detail: "the target has no backup node".to_string(),
734        });
735    }
736    let Some(backup) = input.backup else {
737        return Ok(finding(
738            check,
739            false,
740            "the backup node has no gateway connection, so which node is active cannot be confirmed"
741                .to_string(),
742        ));
743    };
744    let master = input.gateway.redundancy_status().await?;
745    let standby = backup.status().await?;
746    let level = |wire: &RedundancyStatusWire| {
747        wire.activity_level
748            .as_deref()
749            .map_or_else(|| "none".to_string(), printable)
750    };
751    let (master_level, backup_level) = (level(&master), level(&standby));
752    let both = format!("master {master_level}, backup {backup_level}");
753    Ok(match active_node(&master, &standby) {
754        ActiveNode::OnlyA => finding(
755            check,
756            true,
757            format!("the master is the active node ({both})"),
758        ),
759        ActiveNode::OnlyB => finding(
760            check,
761            false,
762            format!(
763                "the backup reports activityLevel Active and the master {master_level}: \
764                 activity moved to the backup (the pair switched over); apply and rollback write only to the active master ({both})"
765            ),
766        ),
767        ActiveNode::Both => finding(
768            check,
769            false,
770            format!("both nodes report activityLevel Active (split-brain) ({both})"),
771        ),
772        ActiveNode::Neither => finding(
773            check,
774            false,
775            format!("neither node reports activityLevel Active ({both})"),
776        ),
777    })
778}
779
780// ---- backup_read ----------------------------------------------------------------
781
782/// The backup's mode collection can be read (D-02).
783///
784/// This is the one check that turns a read error into a finding instead of an
785/// abort: the read is the check. The file-hash comparison after the apply
786/// reads the same collection through the same transport, and an account that
787/// cannot list the data directory (the Linux gap Phase 23 recorded for list
788/// rights on `data` and `data/config`) would only be found after the master
789/// was written. Reading it here refuses before the lock. The scope is the
790/// manifest's, the same one the comparison reads. `skipped` when there is
791/// nothing to read: no backup node, a REST-only backup (the detail says the
792/// file-hash check will not run, D-04), or a rollback, which renders nothing.
793async fn backup_read_finding(input: &PreflightInput<'_>) -> PreflightFinding {
794    let check = PreflightCheck::BackupRead;
795    let skipped = |detail: &str| PreflightFinding {
796        check,
797        status: FindingStatus::Skipped,
798        detail: detail.to_string(),
799    };
800    if input.target.nodes.backup.is_none() {
801        return skipped("the target has no backup node");
802    }
803    let Some(declared) = input.declared else {
804        return skipped("a rollback renders no tree, so the backup's files are not compared");
805    };
806    let Some(backup) = input.backup else {
807        return skipped("the backup node has no gateway connection, so its files are not read");
808    };
809    if !backup.has_files() {
810        return skipped(
811            "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
812        );
813    }
814    let scope = Scope::from_manifest(&declared.manifest, &declared.target.mode);
815    match backup.read_files(&scope).await {
816        Some(Ok(observed)) => finding(
817            check,
818            true,
819            format!(
820                "read {} files of the backup's mode collection",
821                observed.core.len() + observed.mode.len()
822            ),
823        ),
824        Some(Err(err)) => finding(
825            check,
826            false,
827            format!(
828                "the backup's mode collection cannot be read: {}",
829                printable(err.code())
830            ),
831        ),
832        None => skipped(
833            "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
834        ),
835    }
836}
837
838// ---- secrets ------------------------------------------------------------------------
839
840/// Every secret name the rendered tree references exists on each node (D-14).
841///
842/// Only the names a provider lists are read (`DeployApi::secret_names`); no
843/// value is fetched and none can appear in a detail, which lists `node:
844/// provider/name` pairs. A listing that cannot be answered at all (a network or
845/// auth error) aborts like the other reads. Names compare exactly (K9). A
846/// provider the render itself declares that answers 404 on a node is the first
847/// deploy that creates it, so it is skipped by name (K8; `deploy verify`
848/// still requires it); a provider that answers 501 or 500 cannot list and is
849/// named unverifiable (K7). Neither fails the check. K8 extension (WR-04): a
850/// name the render itself adds to a provider it declares is defined by the
851/// deploy, so it is not asked of a node that already has the provider; only
852/// names the tree references and does not provide are refused.
853async fn secrets_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
854    let check = PreflightCheck::Secrets;
855    let skipped = |detail: &str| PreflightFinding {
856        check,
857        status: FindingStatus::Skipped,
858        detail: detail.to_string(),
859    };
860    let Some(declared) = input.declared else {
861        return Ok(skipped(
862            "a rollback renders no tree, so no secret reference is checked",
863        ));
864    };
865    let all_referenced = collect_referenced_secrets(&declared.outcome.tree);
866    if all_referenced.is_empty() {
867        return Ok(skipped("the rendered tree references no secret"));
868    }
869    // A name this render adds to a provider it declares is defined by the
870    // deploy itself (K8 extension, WR-04): a node cannot list it before the
871    // apply, so it is not asked for. Only names the tree references and does
872    // not provide are checked.
873    let defined = secrets_defined_by_render(&declared.outcome.tree);
874    let referenced: BTreeSet<(String, String)> =
875        all_referenced.difference(&defined).cloned().collect();
876    if referenced.is_empty() {
877        return Ok(skipped(
878            "every secret name the rendered tree references is defined by the tree itself",
879        ));
880    }
881    let own_names = all_referenced.len() - referenced.len();
882    let declared_providers = declared_secret_providers(&declared.outcome.tree);
883    let providers: BTreeSet<&str> = referenced.iter().map(|(p, _)| p.as_str()).collect();
884
885    let mut reports = Vec::with_capacity(2);
886    let mut on_master = BTreeMap::new();
887    for provider in &providers {
888        on_master.insert(
889            (*provider).to_string(),
890            input.master.secret_names(provider).await?,
891        );
892    }
893    reports.push(check_secret_names(
894        "master",
895        &referenced,
896        &on_master,
897        &declared_providers,
898        SecretCheckMode::Preflight,
899    ));
900    if input.target.nodes.backup.is_some() {
901        let Some(backup) = input.backup else {
902            return Ok(finding(
903                check,
904                false,
905                "the backup node has no gateway connection, so its secret names cannot be checked"
906                    .to_string(),
907            ));
908        };
909        let mut on_backup = BTreeMap::new();
910        for provider in &providers {
911            on_backup.insert(
912                (*provider).to_string(),
913                backup.secret_names(provider).await?,
914            );
915        }
916        reports.push(check_secret_names(
917            "backup",
918            &referenced,
919            &on_backup,
920            &declared_providers,
921            SecretCheckMode::Preflight,
922        ));
923    }
924
925    let missing: Vec<String> = reports
926        .iter()
927        .flat_map(|r| {
928            r.missing.iter().map(|m| {
929                format!(
930                    "{}: {}/{}",
931                    r.node,
932                    printable(&m.provider),
933                    printable(&m.name)
934                )
935            })
936        })
937        .collect();
938    let mut notes: Vec<String> = Vec::new();
939    for r in &reports {
940        if !r.skipped_providers.is_empty() {
941            notes.push(format!(
942                "{}: skipped {} (declared by this render, not on the node yet)",
943                r.node,
944                provider_list(&r.skipped_providers)
945            ));
946        }
947        if !r.unverifiable.is_empty() {
948            notes.push(format!(
949                "{}: unverifiable {} (the provider cannot list its names)",
950                r.node,
951                provider_list(&r.unverifiable)
952            ));
953        }
954    }
955    if missing.is_empty() {
956        let mut detail = format!(
957            "{} referenced secret name(s) in {} provider(s) checked on {}",
958            referenced.len(),
959            providers.len(),
960            reports
961                .iter()
962                .map(|r| r.node.as_str())
963                .collect::<Vec<_>>()
964                .join(" and ")
965        );
966        if own_names > 0 {
967            detail.push_str(&format!(
968                "; {own_names} name(s) defined by this render were not required on the node"
969            ));
970        }
971        for note in notes {
972            detail.push_str("; ");
973            detail.push_str(&note);
974        }
975        return Ok(finding(check, true, detail));
976    }
977    let take = missing.len().min(APPLY_LIST_SHOWN);
978    let mut detail = format!(
979        "{} referenced secret name(s) missing: {}",
980        missing.len(),
981        missing[..take].join(", ")
982    );
983    if missing.len() > take {
984        detail.push_str(&format!(" and {} more", missing.len() - take));
985    }
986    for note in notes {
987        detail.push_str("; ");
988        detail.push_str(&note);
989    }
990    Ok(finding(check, false, detail))
991}
992
993/// Provider names, capped like every other list in a detail.
994fn provider_list(providers: &[String]) -> String {
995    let take = providers.len().min(APPLY_LIST_SHOWN);
996    let mut text = providers[..take]
997        .iter()
998        .map(|p| printable(p))
999        .collect::<Vec<_>>()
1000        .join(", ");
1001    if providers.len() > take {
1002        text.push_str(&format!(" and {} more", providers.len() - take));
1003    }
1004    text
1005}
1006
1007#[cfg(test)]
1008mod tests {
1009    use super::*;
1010
1011    #[test]
1012    fn ids_are_the_contract_words_in_report_order() {
1013        let ids: Vec<&str> = PreflightCheck::ALL.iter().map(|c| c.as_str()).collect();
1014        assert_eq!(
1015            ids,
1016            [
1017                "mode_master",
1018                "mode_backup",
1019                "wrong_target",
1020                "write_rest",
1021                "write_host",
1022                "designers",
1023                "hold_budget",
1024                "master_active",
1025                "backup_read",
1026                "secrets"
1027            ]
1028        );
1029    }
1030
1031    #[test]
1032    fn production_defaults_are_the_decided_numbers() {
1033        assert_eq!((DESIGNERS_POLL_S, DESIGNERS_TIMEOUT_DEFAULT_S), (10, 600));
1034        let d = DesignersPolicy::default();
1035        assert_eq!(d.mode, DesignersMode::Fail);
1036        assert_eq!(d.timeout, Duration::from_secs(600));
1037        assert_eq!(d.interval, Duration::from_secs(10));
1038    }
1039
1040    #[test]
1041    fn the_session_list_is_capped_and_counts_the_rest() {
1042        let open: Vec<DesignerSeen> = (0..13)
1043            .map(|i| (format!("u{i}"), "P".to_string(), "h".to_string()))
1044            .collect();
1045        let text = designers_detail(&open);
1046        assert!(text.starts_with("13 Designer session(s) open: u0 on P from h"));
1047        assert!(text.ends_with("and 3 more"), "{text}");
1048        assert!(!text.contains("u12"), "{text}");
1049    }
1050
1051    #[test]
1052    fn a_designer_without_a_project_is_named_so() {
1053        let text = designers_detail(&[("bob".into(), String::new(), "h".into())]);
1054        assert!(text.contains("bob on no project from h"), "{text}");
1055    }
1056
1057    #[test]
1058    fn printable_drops_controls_and_caps_length() {
1059        assert_eq!(printable("a\nb\u{1b}c"), "abc");
1060        let long = "x".repeat(SHOWN_NAME_CHARS + 5);
1061        assert_eq!(printable(&long).len(), SHOWN_NAME_CHARS + 3);
1062    }
1063
1064    #[test]
1065    fn hold_budget_boundary_is_inclusive() {
1066        // need(n) = ceil(2 x (1 + 0.5 n + 1) + 1) = 5 + n.
1067        let hold = |hold_s| HoldModel {
1068            hold_s,
1069            commit_fixed_s: 1.0,
1070            commit_per_file_s: 0.5,
1071            p95_scan_s: 1.0,
1072        };
1073        assert_eq!(
1074            hold_budget_finding(&hold(14), 9).status,
1075            FindingStatus::Pass
1076        );
1077        let over = hold_budget_finding(&hold(14), 10);
1078        assert_eq!(over.status, FindingStatus::Fail);
1079        assert!(over.detail.contains("hold of 15 s"), "{}", over.detail);
1080        assert!(over.detail.contains("budget is 14 s"), "{}", over.detail);
1081        assert!(
1082            over.detail.contains("estimated commit 6.0 s"),
1083            "{}",
1084            over.detail
1085        );
1086    }
1087
1088    fn policy(timeout_ms: u64, interval_ms: u64) -> DesignersPolicy {
1089        DesignersPolicy {
1090            mode: DesignersMode::Wait,
1091            timeout: Duration::from_millis(timeout_ms),
1092            interval: Duration::from_millis(interval_ms),
1093        }
1094    }
1095
1096    #[test]
1097    fn wait_with_nothing_open_is_clear_at_any_time() {
1098        let p = policy(1000, 100);
1099        assert_eq!(wait_step(0, Duration::ZERO, &p), WaitStep::Clear);
1100        assert_eq!(wait_step(0, Duration::from_secs(99), &p), WaitStep::Clear);
1101    }
1102
1103    #[test]
1104    fn wait_timeout_equal_to_the_elapsed_time_has_passed() {
1105        // Edge DAPL-04 adjacency.
1106        let p = policy(1000, 100);
1107        assert_eq!(
1108            wait_step(1, Duration::from_millis(999), &p),
1109            WaitStep::Sleep(Duration::from_millis(1))
1110        );
1111        assert_eq!(
1112            wait_step(1, Duration::from_millis(1000), &p),
1113            WaitStep::TimedOut
1114        );
1115        assert_eq!(
1116            wait_step(1, Duration::from_millis(1001), &p),
1117            WaitStep::TimedOut
1118        );
1119    }
1120
1121    #[test]
1122    fn wait_sleeps_one_interval_unless_the_timeout_is_nearer() {
1123        let p = policy(1000, 100);
1124        assert_eq!(
1125            wait_step(2, Duration::ZERO, &p),
1126            WaitStep::Sleep(Duration::from_millis(100))
1127        );
1128        assert_eq!(
1129            wait_step(2, Duration::from_millis(950), &p),
1130            WaitStep::Sleep(Duration::from_millis(50))
1131        );
1132    }
1133
1134    #[test]
1135    fn a_zero_interval_never_spins() {
1136        let p = policy(1000, 0);
1137        assert_eq!(
1138            wait_step(1, Duration::ZERO, &p),
1139            WaitStep::Sleep(Duration::from_millis(1))
1140        );
1141    }
1142
1143    // ---- designers_gate ---------------------------------------------------
1144
1145    use std::sync::Mutex;
1146
1147    use crate::deploy::apply::hold::SystemClock;
1148
1149    fn seen(user: &str) -> DesignerSeen {
1150        (user.to_string(), "P".to_string(), "h".to_string())
1151    }
1152
1153    /// Runs the gate over `script` (one list per read; the last repeats) and
1154    /// returns the verdict, the sessions, the poll counts and the read count.
1155    async fn gate_over(
1156        mode: DesignersMode,
1157        timeout_ms: u64,
1158        script: Vec<Vec<DesignerSeen>>,
1159    ) -> (DesignersVerdict, Vec<DesignerSeen>, Vec<usize>, usize) {
1160        let reads = Mutex::new(0usize);
1161        let policy = DesignersPolicy {
1162            mode,
1163            timeout: Duration::from_millis(timeout_ms),
1164            interval: Duration::from_millis(1),
1165        };
1166        let mut polls = Vec::new();
1167        let (verdict, open) = designers_gate(
1168            || {
1169                let mut n = reads.lock().expect("reads");
1170                let list = script[(*n).min(script.len() - 1)].clone();
1171                *n += 1;
1172                async move { Ok(list) }
1173            },
1174            &policy,
1175            &SystemClock,
1176            &mut |count| {
1177                polls.push(count);
1178                Ok(())
1179            },
1180        )
1181        .await
1182        .expect("the gate");
1183        let n = *reads.lock().expect("reads");
1184        (verdict, open, polls, n)
1185    }
1186
1187    #[tokio::test]
1188    async fn designers_gate_shared() {
1189        // Clear: nothing open, one read, no poll under `fail`.
1190        let (verdict, open, polls, reads) = gate_over(DesignersMode::Fail, 0, vec![vec![]]).await;
1191        assert_eq!(verdict, DesignersVerdict::Clear);
1192        assert!(open.is_empty() && polls.is_empty());
1193        assert_eq!(reads, 1);
1194
1195        // Open under `fail`: the sessions come back, nothing is polled.
1196        let (verdict, open, polls, reads) =
1197            gate_over(DesignersMode::Fail, 0, vec![vec![seen("alice")]]).await;
1198        assert_eq!(verdict, DesignersVerdict::Open);
1199        assert_eq!(open, [seen("alice")]);
1200        assert!(polls.is_empty());
1201        assert_eq!(reads, 1);
1202
1203        // Proceeded: the same sessions, with the choice reported.
1204        let (verdict, open, ..) =
1205            gate_over(DesignersMode::Proceed, 0, vec![vec![seen("alice")]]).await;
1206        assert_eq!(verdict, DesignersVerdict::Proceeded);
1207        assert_eq!(open, [seen("alice")]);
1208
1209        // `wait` clears: one poll per read, the last read is empty.
1210        let (verdict, open, polls, reads) = gate_over(
1211            DesignersMode::Wait,
1212            30_000,
1213            vec![vec![seen("alice")], vec![seen("alice")], vec![]],
1214        )
1215        .await;
1216        assert_eq!(verdict, DesignersVerdict::Clear);
1217        assert!(open.is_empty());
1218        assert_eq!(polls, [1, 1, 0]);
1219        assert_eq!(reads, 3);
1220
1221        // `wait` times out with the sessions still open: the `fail` verdict.
1222        let (verdict, open, polls, _) =
1223            gate_over(DesignersMode::Wait, 20, vec![vec![seen("alice")]]).await;
1224        assert_eq!(verdict, DesignersVerdict::Open);
1225        assert_eq!(open, [seen("alice")]);
1226        assert!(polls.len() >= 2, "{polls:?}");
1227    }
1228
1229    #[tokio::test]
1230    async fn designers_gate_read_error_is_the_reads_own() {
1231        let policy = DesignersPolicy::default();
1232        let err = designers_gate(
1233            || async {
1234                Err::<Vec<DesignerSeen>, _>(CoreError::TransportFailed {
1235                    operation: "read".to_string(),
1236                    node: "nodes.master".to_string(),
1237                    detail: "down".to_string(),
1238                })
1239            },
1240            &policy,
1241            &SystemClock,
1242            &mut |_| Ok(()),
1243        )
1244        .await
1245        .expect_err("the read fails");
1246        assert_eq!(err.code(), "transport_failed");
1247    }
1248}