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), or holds a rendered or deleted unit the backup cannot read (WR-02; the apply refuses it `deploy_unreadable`); `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, split_key};
48use crate::deploy::pair::{
49    BackupNode, FileComparison, SecretCheckMode, check_secret_names, collect_referenced_secrets,
50    declared_secret_providers, exempt_unreadable, secrets_defined_by_render, unreadable_units,
51};
52use crate::deploy::target::Target;
53use crate::deploy::transport::{ObservedFiles, 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 collection paths this run deletes from the master (a subset of
227    /// `touched_paths`): with the rendered paths, what the backup's files
228    /// are compared over after the apply, so `backup_read` refuses a unit of
229    /// either that the backup cannot read (WR-02).
230    pub deleted_paths: &'a [String],
231    /// The hold the lock will be requested with and its estimates.
232    pub hold: &'a HoldModel,
233    /// What to do about open Designer sessions.
234    pub designers: &'a DesignersPolicy,
235    /// The monotonic clock the Designer wait is timed by.
236    pub clock: &'a dyn Clock,
237}
238
239/// A Designer session as D-15 records it: user, project, host.
240pub type DesignerSeen = (String, String, String);
241
242/// Every check's verdict, in the fixed order, and the sessions seen.
243#[derive(Debug, Clone, PartialEq, Eq)]
244pub struct PreflightReport {
245    /// One finding per check, in [`PreflightCheck::ALL`] order.
246    pub findings: Vec<PreflightFinding>,
247    /// The Designer sessions open at the last read, whatever the mode.
248    pub designers_seen: Vec<DesignerSeen>,
249    /// Unit keys the apply writes or deletes that the backup's host cannot
250    /// read (quick-261029-ms3, WR-02), byte-sorted. Non-empty only with a
251    /// failed `backup_read`; the apply refuses them `deploy_unreadable`.
252    pub backup_unreadable: Vec<String>,
253}
254
255impl PreflightReport {
256    /// Whether no check failed.
257    pub fn passed(&self) -> bool {
258        self.findings
259            .iter()
260            .all(|f| f.status != FindingStatus::Fail)
261    }
262
263    /// The failed checks as the refusal carries them, in check order.
264    pub fn failures(&self) -> Vec<PreflightFailure> {
265        self.findings
266            .iter()
267            .filter(|f| f.status == FindingStatus::Fail)
268            .map(|f| PreflightFailure {
269                check: f.check.as_str().to_string(),
270                detail: f.detail.clone(),
271            })
272            .collect()
273    }
274}
275
276/// Run every preflight (see the module doc) and report every verdict.
277///
278/// Reads the gateway, the host and the Designer list; writes nothing but the
279/// harmless project-scan probe and the host's own probe file, and emits
280/// `designers_poll` and `designers_proceed` events through `steps`. An
281/// unreachable gateway or host is that call's own error, not a finding.
282pub async fn run_preflights(
283    input: &PreflightInput<'_>,
284    steps: &mut Steps<'_>,
285) -> Result<PreflightReport, CoreError> {
286    let mut findings = Vec::with_capacity(PreflightCheck::ALL.len());
287    let mode = input.target.mode.as_str();
288
289    let master_seen = normalize_deployment_mode(input.master.deployment_mode().await?);
290    findings.push(mode_finding(
291        PreflightCheck::ModeMaster,
292        "master",
293        Some(master_seen),
294        mode,
295    ));
296    findings.push(if input.target.nodes.backup.is_some() {
297        let backup_seen = match input.backup {
298            Some(backup) => Some(backup.deployment_mode().await?),
299            None => None,
300        };
301        mode_finding(PreflightCheck::ModeBackup, "backup", backup_seen, mode)
302    } else {
303        PreflightFinding {
304            check: PreflightCheck::ModeBackup,
305            status: FindingStatus::Skipped,
306            detail: "the target has no backup node".to_string(),
307        }
308    });
309    findings.push(wrong_target_finding(input).await?);
310    findings.push(write_rest_finding(input.gateway).await?);
311    findings.push(write_host_finding(input.host, input.touched_paths).await?);
312    let (designers, designers_seen) = designers_finding(input, steps).await?;
313    findings.push(designers);
314    findings.push(hold_budget_finding(input.hold, input.touched_files));
315    findings.push(master_active_finding(input).await?);
316    let (backup_read, backup_unreadable) = backup_read_finding(input).await;
317    findings.push(backup_read);
318    findings.push(secrets_finding(input).await?);
319
320    Ok(PreflightReport {
321        findings,
322        designers_seen,
323        backup_unreadable,
324    })
325}
326
327fn finding(check: PreflightCheck, ok: bool, detail: String) -> PreflightFinding {
328    PreflightFinding {
329        check,
330        status: if ok {
331            FindingStatus::Pass
332        } else {
333            FindingStatus::Fail
334        },
335        detail,
336    }
337}
338
339/// `text` without control characters, at most [`SHOWN_NAME_CHARS`] long.
340fn printable(text: &str) -> String {
341    let mut out: String = text
342        .chars()
343        .filter(|c| !c.is_control())
344        .take(SHOWN_NAME_CHARS)
345        .collect();
346    if text.chars().count() > SHOWN_NAME_CHARS {
347        out.push_str("...");
348    }
349    out
350}
351
352// ---- mode_master, mode_backup ----------------------------------------------
353
354/// `seen` is the node's normalized mode as already read, or `None` when the
355/// caller holds no connection to the node.
356fn mode_finding(
357    check: PreflightCheck,
358    node: &str,
359    seen: Option<Option<String>>,
360    target_mode: &str,
361) -> PreflightFinding {
362    let Some(seen) = seen else {
363        // The target names a node the caller holds no connection to: the
364        // mode cannot be confirmed, so the run does not go ahead.
365        return finding(
366            check,
367            false,
368            format!("the {node} node has no gateway connection, so its mode cannot be confirmed"),
369        );
370    };
371    let ok = seen.as_deref() == Some(target_mode);
372    let detail = match (&seen, ok) {
373        (_, true) => format!("the {node} runs mode `{}`", printable(target_mode)),
374        (Some(seen), false) => format!(
375            "the {node} runs mode `{}` but the target's mode is `{}`",
376            printable(seen),
377            printable(target_mode)
378        ),
379        (None, false) => format!(
380            "the {node} runs no deployment mode but the target's mode is `{}`",
381            printable(target_mode)
382        ),
383    };
384    finding(check, ok, detail)
385}
386
387// ---- wrong_target -----------------------------------------------------------
388
389/// The host's `systemName`, or why it cannot be read.
390async fn host_system_name(
391    reader: &dyn TreeReader,
392    mode: &str,
393) -> Result<Result<String, &'static str>, CoreError> {
394    let (module, ty) = SYSTEM_PROPERTIES_UNIT
395        .split_once('/')
396        .unwrap_or((SYSTEM_PROPERTIES_UNIT, ""));
397    let observed = reader
398        .read(&Scope {
399            mode: mode.to_string(),
400            types: vec![(module.to_string(), ty.to_string())],
401            tag_providers: Vec::new(),
402        })
403        .await?;
404    // The gateway lets `local` override the mode collection and the mode
405    // collection override `core`; only the bytes of the last two are read.
406    if observed
407        .local_units
408        .iter()
409        .any(|u| u == SYSTEM_PROPERTIES_UNIT)
410    {
411        return Ok(Err("it is overridden in the local collection"));
412    }
413    // A file of the unit the host cannot serve (quick-261029-ms3) is never
414    // taken as absent (WR-01): an unreadable mode copy is the one the
415    // gateway uses, so it fails closed instead of falling back to `core`,
416    // and an unreadable `core` copy fails closed when no mode copy overrides
417    // it.
418    let unreadable_in = |collection: &str| {
419        observed.unreadable.iter().any(|u| {
420            u.collection == collection
421                && u.path
422                    .as_str()
423                    .strip_prefix(SYSTEM_PROPERTIES_UNIT)
424                    .is_some_and(|rest| rest.starts_with('/'))
425        })
426    };
427    if unreadable_in("mode") {
428        return Ok(Err(
429            "the mode collection's system-properties file has a path too long for the host's sftp server to read",
430        ));
431    }
432    let path = RelPath::new(&format!("{SYSTEM_PROPERTIES_UNIT}/config.json"))
433        .map_err(|why| CoreError::Internal(format!("system-properties path: {why}")))?;
434    let blob = match observed.mode.get(&path) {
435        Some(blob) => blob,
436        None if unreadable_in("core") => {
437            return Ok(Err(
438                "the core collection's system-properties file has a path too long for the host's sftp server to read",
439            ));
440        }
441        None => match observed.core.get(&path) {
442            Some(blob) => blob,
443            None => return Ok(Err("the host has no system-properties file")),
444        },
445    };
446    let Ok(doc) = serde_json::from_slice::<Value>(&blob.bytes) else {
447        return Ok(Err("the system-properties file is not JSON"));
448    };
449    match doc.get("systemName").and_then(Value::as_str) {
450        Some(name) if !name.trim().is_empty() => Ok(Ok(name.to_string())),
451        _ => Ok(Err("the system-properties file has no systemName")),
452    }
453}
454
455/// What is wrong with the gateway the REST name, the host's `systemName` and
456/// the target's `nodes.master.gateway_name` describe (one comparison, shared by
457/// `deploy apply` and `deploy projects`, 25-14). `rest` is the non-blank REST
458/// name; `host` is the `systemName` or why it cannot be read; `declared` is
459/// `nodes.master.gateway_name`. An empty answer means the write may go ahead.
460/// No problem text carries a name: the names come from the gateway and the
461/// host, which is what is being checked.
462pub(crate) fn wrong_target_problems(
463    rest: Option<&str>,
464    host: Result<&str, &str>,
465    declared: Option<&str>,
466) -> Vec<String> {
467    let mut problems: Vec<String> = Vec::new();
468    match (rest, host) {
469        (None, _) => problems.push("the gateway reports no name over REST".to_string()),
470        (Some(_), Err(why)) => problems.push(format!("the host's systemName is unreadable: {why}")),
471        (Some(rest), Ok(host)) if rest != host => {
472            problems.push("the gateway's REST name is not the host's systemName".to_string())
473        }
474        (Some(_), Ok(_)) => {}
475    }
476    if let (Some(rest), Some(declared)) = (rest, declared)
477        && rest != declared
478    {
479        problems.push("the gateway's REST name is not nodes.master.gateway_name".to_string());
480    }
481    problems
482}
483
484/// The refusal for a non-empty [`wrong_target_problems`] answer: one
485/// `wrong_target` failure in the shape the preflight list carries.
486pub(crate) fn wrong_target_failure(problems: &[String]) -> PreflightFailure {
487    PreflightFailure {
488        check: PreflightCheck::WrongTarget.as_str().to_string(),
489        detail: problems.join("; "),
490    }
491}
492
493/// The wrong-target comparison for a caller that has no [`PreflightInput`]
494/// (`deploy projects`, 25-14): reads the host's `systemName` through `reader`
495/// and compares it with `rest` and `declared`. An unreadable gateway or host
496/// is that call's own error, not a refusal.
497pub(crate) async fn wrong_target_gate(
498    rest: Option<&str>,
499    reader: &dyn TreeReader,
500    mode: &str,
501    declared: Option<&str>,
502) -> Result<Vec<String>, CoreError> {
503    let host = host_system_name(reader, mode).await?;
504    let host = host.as_ref().map(String::as_str).map_err(|why| *why);
505    Ok(wrong_target_problems(rest, host, declared))
506}
507
508async fn wrong_target_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
509    let check = PreflightCheck::WrongTarget;
510    let rest = input
511        .gateway
512        .gateway_info()
513        .await?
514        .name
515        .filter(|n| !n.trim().is_empty());
516    let problems = wrong_target_gate(
517        rest.as_deref(),
518        input.reader,
519        &input.target.mode,
520        input.target.nodes.master.gateway_name.as_deref(),
521    )
522    .await?;
523    if problems.is_empty() {
524        Ok(finding(
525            check,
526            true,
527            "the REST name matches the host's systemName".to_string(),
528        ))
529    } else {
530        Ok(finding(check, false, problems.join("; ")))
531    }
532}
533
534// ---- write_rest ---------------------------------------------------------------
535
536async fn write_rest_finding(gateway: &dyn GatewayApi) -> Result<PreflightFinding, CoreError> {
537    let check = PreflightCheck::WriteRest;
538    match gateway.scan_projects().await {
539        Ok(()) => Ok(finding(
540            check,
541            true,
542            "the project scan was accepted".to_string(),
543        )),
544        Err(CoreError::Auth { status: 403, .. }) => Ok(finding(
545            check,
546            false,
547            "the token is read-only (HTTP 403 on the project scan)".to_string(),
548        )),
549        Err(other) => Err(other),
550    }
551}
552
553// ---- write_host -------------------------------------------------------------
554
555async fn write_host_finding(
556    host: &dyn ApplyHost,
557    touched: &[String],
558) -> Result<PreflightFinding, CoreError> {
559    let check = PreflightCheck::WriteHost;
560    let report = host.probe_paths(touched).await?;
561    if report.writable {
562        // A run that writes `core` (quick 261102-cef, P10) was probed there too.
563        let detail = if crate::deploy::transport::core_tag_types(touched).is_empty() {
564            "the mode collection and the ign-deploy root are writable"
565        } else {
566            "the mode collection, the core tag folders and the ign-deploy root are writable"
567        };
568        return Ok(finding(check, true, detail.to_string()));
569    }
570    let detail = if report.problems.is_empty() {
571        "the host is not writable".to_string()
572    } else {
573        report.problems.join("; ")
574    };
575    Ok(finding(check, false, detail))
576}
577
578// ---- designers --------------------------------------------------------------
579
580pub(crate) async fn read_designers(
581    gateway: &dyn GatewayApi,
582) -> Result<Vec<DesignerSeen>, CoreError> {
583    let page = gateway.designers(&ListQuery::default()).await?;
584    Ok(page
585        .items
586        .into_iter()
587        .map(|d| {
588            (
589                printable(&d.user),
590                printable(&d.project),
591                printable(&d.address),
592            )
593        })
594        .collect())
595}
596
597/// `n Designer session(s) open: user on project P from host H, ...`.
598pub(crate) fn designers_detail(open: &[DesignerSeen]) -> String {
599    let each: Vec<String> = open
600        .iter()
601        .map(|(user, project, host)| {
602            let project = if project.is_empty() {
603                "no project"
604            } else {
605                project
606            };
607            format!("{user} on {project} from {host}")
608        })
609        .collect();
610    let take = each.len().min(APPLY_LIST_SHOWN);
611    let mut text = format!(
612        "{} Designer session(s) open: {}",
613        open.len(),
614        each[..take].join(", ")
615    );
616    if each.len() > take {
617        text.push_str(&format!(" and {} more", each.len() - take));
618    }
619    text
620}
621
622/// What `wait` does next, given the sessions still open and the time spent.
623#[derive(Debug, Clone, Copy, PartialEq, Eq)]
624pub(crate) enum WaitStep {
625    /// No session is open.
626    Clear,
627    /// Still open and the timeout has passed.
628    TimedOut,
629    /// Still open: sleep this long, then read again.
630    Sleep(Duration),
631}
632
633/// The decision of the `wait` loop. A timeout equal to the elapsed time has
634/// passed, and the last sleep never runs past the timeout.
635pub(crate) fn wait_step(open: usize, elapsed: Duration, policy: &DesignersPolicy) -> WaitStep {
636    if open == 0 {
637        WaitStep::Clear
638    } else if elapsed >= policy.timeout {
639        WaitStep::TimedOut
640    } else {
641        WaitStep::Sleep(
642            policy
643                .interval
644                .max(Duration::from_millis(1))
645                .min(policy.timeout - elapsed),
646        )
647    }
648}
649
650/// What [`designers_gate`] decided (D-14, D-15).
651#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652pub enum DesignersVerdict {
653    /// No Designer session is open.
654    Clear,
655    /// Sessions are open and the mode does not allow going ahead (`fail`, or
656    /// `wait` after the timeout).
657    Open,
658    /// Sessions are open and `--designers proceed` was given: go ahead, with
659    /// the sessions on record.
660    Proceeded,
661}
662
663/// The read-poll-decide loop of the Designer check, shared by `deploy apply`
664/// and `deploy projects` (25-14).
665///
666/// `read` lists the open sessions; it is called once, and again after each
667/// sleep under [`DesignersMode::Wait`]. `on_poll` hears the number of open
668/// sessions after each read under `wait`. Returns the verdict and the sessions
669/// seen at the last read; a read that fails is that call's own error.
670pub(crate) async fn designers_gate<F, Fut>(
671    mut read: F,
672    policy: &DesignersPolicy,
673    clock: &dyn Clock,
674    on_poll: &mut (dyn FnMut(usize) -> Result<(), CoreError> + Send),
675) -> Result<(DesignersVerdict, Vec<DesignerSeen>), CoreError>
676where
677    F: FnMut() -> Fut,
678    Fut: std::future::Future<Output = Result<Vec<DesignerSeen>, CoreError>>,
679{
680    let started = clock.now();
681    let mut open = read().await?;
682    if policy.mode == DesignersMode::Wait {
683        loop {
684            on_poll(open.len())?;
685            let elapsed = clock.now().saturating_duration_since(started);
686            match wait_step(open.len(), elapsed, policy) {
687                WaitStep::Clear | WaitStep::TimedOut => break,
688                WaitStep::Sleep(pause) => {
689                    tokio::time::sleep(pause).await;
690                    open = read().await?;
691                }
692            }
693        }
694    }
695    let verdict = if open.is_empty() {
696        DesignersVerdict::Clear
697    } else if policy.mode == DesignersMode::Proceed {
698        DesignersVerdict::Proceeded
699    } else {
700        DesignersVerdict::Open
701    };
702    Ok((verdict, open))
703}
704
705async fn designers_finding(
706    input: &PreflightInput<'_>,
707    steps: &mut Steps<'_>,
708) -> Result<(PreflightFinding, Vec<DesignerSeen>), CoreError> {
709    let check = PreflightCheck::Designers;
710    let (verdict, open) = designers_gate(
711        || read_designers(input.gateway),
712        input.designers,
713        input.clock,
714        &mut |count| steps.emit(ApplyEvent::DesignersPoll { open: count }),
715    )
716    .await?;
717    let found = match verdict {
718        DesignersVerdict::Clear => finding(check, true, "no Designer session is open".to_string()),
719        DesignersVerdict::Proceeded => {
720            // D-15: going ahead is allowed only with the choice and the
721            // sessions on record (run.json and the events).
722            steps.emit(ApplyEvent::DesignersProceed {
723                sessions: open.clone(),
724            })?;
725            finding(
726                check,
727                true,
728                format!(
729                    "{}; going ahead because --designers proceed was given",
730                    designers_detail(&open)
731                ),
732            )
733        }
734        DesignersVerdict::Open => finding(check, false, designers_detail(&open)),
735    };
736    Ok((found, open))
737}
738
739// ---- hold_budget ------------------------------------------------------------
740
741pub(super) fn hold_budget_finding(hold: &HoldModel, files: usize) -> PreflightFinding {
742    let estimate = hold.estimate_commit_s(files);
743    let needed = hold_for(estimate, hold.p95_scan_s);
744    let detail = format!(
745        "{files} file(s): estimated commit {estimate:.1} s and scan {:.1} s need a hold of {needed} s; \
746         the budget is {} s",
747        hold.p95_scan_s, hold.hold_s
748    );
749    finding(PreflightCheck::HoldBudget, needed <= hold.hold_s, detail)
750}
751
752// ---- master_active ------------------------------------------------------------
753
754/// The master is the only active node of its pair (D-11, K3).
755///
756/// Which node is active is decided by `activityLevel`, never by `role`: after
757/// a switch-over the former master keeps `role: Master` while its level drops to
758/// `Cold` (C-S3). The target file still names that node as the master, so an
759/// apply or a rollback would write to the standby, whose collection the
760/// active node's sync then overwrites. Both nodes are read here and the check
761/// fails on a moved, doubled or missing activity. Rollback runs the same
762/// check. The activity can still move after this read; the lock-time guards
763/// and the backup phase are what catch a failover during the run.
764async fn master_active_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
765    let check = PreflightCheck::MasterActive;
766    if input.target.nodes.backup.is_none() {
767        return Ok(PreflightFinding {
768            check,
769            status: FindingStatus::Skipped,
770            detail: "the target has no backup node".to_string(),
771        });
772    }
773    let Some(backup) = input.backup else {
774        return Ok(finding(
775            check,
776            false,
777            "the backup node has no gateway connection, so which node is active cannot be confirmed"
778                .to_string(),
779        ));
780    };
781    let master = input.gateway.redundancy_status().await?;
782    let standby = backup.status().await?;
783    let level = |wire: &RedundancyStatusWire| {
784        wire.activity_level
785            .as_deref()
786            .map_or_else(|| "none".to_string(), printable)
787    };
788    let (master_level, backup_level) = (level(&master), level(&standby));
789    let both = format!("master {master_level}, backup {backup_level}");
790    Ok(match active_node(&master, &standby) {
791        ActiveNode::OnlyA => finding(
792            check,
793            true,
794            format!("the master is the active node ({both})"),
795        ),
796        ActiveNode::OnlyB => finding(
797            check,
798            false,
799            format!(
800                "the backup reports activityLevel Active and the master {master_level}: \
801                 activity moved to the backup (the pair switched over); apply and rollback write only to the active master ({both})"
802            ),
803        ),
804        ActiveNode::Both => finding(
805            check,
806            false,
807            format!("both nodes report activityLevel Active (split-brain) ({both})"),
808        ),
809        ActiveNode::Neither => finding(
810            check,
811            false,
812            format!("neither node reports activityLevel Active ({both})"),
813        ),
814    })
815}
816
817// ---- backup_read ----------------------------------------------------------------
818
819/// The backup's mode collection can be read (D-02).
820///
821/// This is the one check that turns a read error into a finding instead of an
822/// abort: the read is the check. The file-hash comparison after the apply
823/// reads the same collection through the same transport, and an account that
824/// cannot list the data directory (the Linux gap Phase 23 recorded for list
825/// rights on `data` and `data/config`) would only be found after the master
826/// was written. Reading it here refuses before the lock. The scope is the
827/// manifest's, the same one the comparison reads. `skipped` when there is
828/// nothing to read: no backup node, a REST-only backup (the detail says the
829/// file-hash check will not run, D-04), or a rollback, which renders nothing.
830///
831/// The same read also refuses before any write what the file-hash check
832/// would otherwise find only after the master was written (WR-02): a unit
833/// the apply renders or deletes that the backup's host cannot read
834/// (quick-261029-ms3). The units are matched exactly as that check matches
835/// them ([`exempt_unreadable`] over the rendered and deleted paths); their
836/// keys come back beside the failed finding, and the apply refuses them
837/// `deploy_unreadable`. Any other unit the backup cannot read is left to the
838/// file-hash check's warning.
839async fn backup_read_finding(input: &PreflightInput<'_>) -> (PreflightFinding, Vec<String>) {
840    let check = PreflightCheck::BackupRead;
841    let skipped = |detail: &str| {
842        (
843            PreflightFinding {
844                check,
845                status: FindingStatus::Skipped,
846                detail: detail.to_string(),
847            },
848            Vec::new(),
849        )
850    };
851    if input.target.nodes.backup.is_none() {
852        return skipped("the target has no backup node");
853    }
854    let Some(declared) = input.declared else {
855        return skipped("a rollback renders no tree, so the backup's files are not compared");
856    };
857    let Some(backup) = input.backup else {
858        return skipped("the backup node has no gateway connection, so its files are not read");
859    };
860    if !backup.has_files() {
861        return skipped(
862            "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
863        );
864    }
865    let scope = Scope::from_manifest(
866        &declared.manifest,
867        &declared.target.mode,
868        &declared.excluded_tag_providers(),
869    );
870    match backup.read_files(&scope).await {
871        Some(Ok(observed)) => {
872            let blocked = backup_blocked_units(input, declared, &observed);
873            if blocked.is_empty() {
874                return (
875                    finding(
876                        check,
877                        true,
878                        format!(
879                            "read {} files of the backup's mode collection",
880                            observed.core.len() + observed.mode.len()
881                        ),
882                    ),
883                    blocked,
884                );
885            }
886            let take = blocked.len().min(APPLY_LIST_SHOWN);
887            let mut detail = format!(
888                "the backup's sftp server cannot read {} unit(s) this apply writes or deletes: {}",
889                blocked.len(),
890                blocked[..take]
891                    .iter()
892                    .map(|k| printable(k))
893                    .collect::<Vec<_>>()
894                    .join(", ")
895            );
896            if blocked.len() > take {
897                detail.push_str(&format!(" and {} more", blocked.len() - take));
898            }
899            (finding(check, false, detail), blocked)
900        }
901        Some(Err(err)) => (
902            finding(
903                check,
904                false,
905                format!(
906                    "the backup's mode collection cannot be read: {}",
907                    printable(err.code())
908                ),
909            ),
910            Vec::new(),
911        ),
912        None => skipped(
913            "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
914        ),
915    }
916}
917
918/// The unit keys of the backup's read that the file-hash check would fail
919/// as `unreadable`: a unit holding a rendered path or a path this run
920/// deletes. Byte-sorted and unique.
921fn backup_blocked_units(
922    input: &PreflightInput<'_>,
923    declared: &Declared,
924    observed: &ObservedFiles,
925) -> Vec<String> {
926    let units = unreadable_units(observed);
927    if units.is_empty() {
928        return Vec::new();
929    }
930    let present: BTreeSet<RelPath> = declared.outcome.tree.files.keys().cloned().collect();
931    // The backup is read as the gateway reads it (mode over `core`), so a
932    // `.core/` delete is its collection path (quick 261102-cef, P8).
933    let absent: BTreeSet<RelPath> = input
934        .deleted_paths
935        .iter()
936        .filter_map(|p| RelPath::new(split_key(p).1).ok())
937        .collect();
938    let mut comparison = FileComparison::default();
939    exempt_unreadable(&mut comparison, &present, &absent, &[("backup", &units)]);
940    let mut keys: Vec<String> = comparison
941        .unreadable
942        .into_iter()
943        .map(|(_, key)| key)
944        .collect();
945    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
946    keys.dedup();
947    keys
948}
949
950// ---- secrets ------------------------------------------------------------------------
951
952/// Every secret name the rendered tree references exists on each node (D-14).
953///
954/// Only the names a provider lists are read (`DeployApi::secret_names`); no
955/// value is fetched and none can appear in a detail, which lists `node:
956/// provider/name` pairs. A listing that cannot be answered at all (a network or
957/// auth error) aborts like the other reads. Names compare exactly (K9). A
958/// provider the render itself declares that answers 404 on a node is the first
959/// deploy that creates it, so it is skipped by name (K8; `deploy verify`
960/// still requires it); a provider that answers 501 or 500 cannot list and is
961/// named unverifiable (K7). Neither fails the check. K8 extension (WR-04): a
962/// name the render itself adds to a provider it declares is defined by the
963/// deploy, so it is not asked of a node that already has the provider; only
964/// names the tree references and does not provide are refused.
965async fn secrets_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
966    let check = PreflightCheck::Secrets;
967    let skipped = |detail: &str| PreflightFinding {
968        check,
969        status: FindingStatus::Skipped,
970        detail: detail.to_string(),
971    };
972    let Some(declared) = input.declared else {
973        return Ok(skipped(
974            "a rollback renders no tree, so no secret reference is checked",
975        ));
976    };
977    let all_referenced = collect_referenced_secrets(&declared.outcome.tree);
978    if all_referenced.is_empty() {
979        return Ok(skipped("the rendered tree references no secret"));
980    }
981    // A name this render adds to a provider it declares is defined by the
982    // deploy itself (K8 extension, WR-04): a node cannot list it before the
983    // apply, so it is not asked for. Only names the tree references and does
984    // not provide are checked.
985    let defined = secrets_defined_by_render(&declared.outcome.tree);
986    let referenced: BTreeSet<(String, String)> =
987        all_referenced.difference(&defined).cloned().collect();
988    if referenced.is_empty() {
989        return Ok(skipped(
990            "every secret name the rendered tree references is defined by the tree itself",
991        ));
992    }
993    let own_names = all_referenced.len() - referenced.len();
994    let declared_providers = declared_secret_providers(&declared.outcome.tree);
995    let providers: BTreeSet<&str> = referenced.iter().map(|(p, _)| p.as_str()).collect();
996
997    let mut reports = Vec::with_capacity(2);
998    let mut on_master = BTreeMap::new();
999    for provider in &providers {
1000        on_master.insert(
1001            (*provider).to_string(),
1002            input.master.secret_names(provider).await?,
1003        );
1004    }
1005    reports.push(check_secret_names(
1006        "master",
1007        &referenced,
1008        &on_master,
1009        &declared_providers,
1010        SecretCheckMode::Preflight,
1011    ));
1012    if input.target.nodes.backup.is_some() {
1013        let Some(backup) = input.backup else {
1014            return Ok(finding(
1015                check,
1016                false,
1017                "the backup node has no gateway connection, so its secret names cannot be checked"
1018                    .to_string(),
1019            ));
1020        };
1021        let mut on_backup = BTreeMap::new();
1022        for provider in &providers {
1023            on_backup.insert(
1024                (*provider).to_string(),
1025                backup.secret_names(provider).await?,
1026            );
1027        }
1028        reports.push(check_secret_names(
1029            "backup",
1030            &referenced,
1031            &on_backup,
1032            &declared_providers,
1033            SecretCheckMode::Preflight,
1034        ));
1035    }
1036
1037    let missing: Vec<String> = reports
1038        .iter()
1039        .flat_map(|r| {
1040            r.missing.iter().map(|m| {
1041                format!(
1042                    "{}: {}/{}",
1043                    r.node,
1044                    printable(&m.provider),
1045                    printable(&m.name)
1046                )
1047            })
1048        })
1049        .collect();
1050    let mut notes: Vec<String> = Vec::new();
1051    for r in &reports {
1052        if !r.skipped_providers.is_empty() {
1053            notes.push(format!(
1054                "{}: skipped {} (declared by this render, not on the node yet)",
1055                r.node,
1056                provider_list(&r.skipped_providers)
1057            ));
1058        }
1059        if !r.unverifiable.is_empty() {
1060            notes.push(format!(
1061                "{}: unverifiable {} (the provider cannot list its names)",
1062                r.node,
1063                provider_list(&r.unverifiable)
1064            ));
1065        }
1066    }
1067    if missing.is_empty() {
1068        let mut detail = format!(
1069            "{} referenced secret name(s) in {} provider(s) checked on {}",
1070            referenced.len(),
1071            providers.len(),
1072            reports
1073                .iter()
1074                .map(|r| r.node.as_str())
1075                .collect::<Vec<_>>()
1076                .join(" and ")
1077        );
1078        if own_names > 0 {
1079            detail.push_str(&format!(
1080                "; {own_names} name(s) defined by this render were not required on the node"
1081            ));
1082        }
1083        for note in notes {
1084            detail.push_str("; ");
1085            detail.push_str(&note);
1086        }
1087        return Ok(finding(check, true, detail));
1088    }
1089    let take = missing.len().min(APPLY_LIST_SHOWN);
1090    let mut detail = format!(
1091        "{} referenced secret name(s) missing: {}",
1092        missing.len(),
1093        missing[..take].join(", ")
1094    );
1095    if missing.len() > take {
1096        detail.push_str(&format!(" and {} more", missing.len() - take));
1097    }
1098    for note in notes {
1099        detail.push_str("; ");
1100        detail.push_str(&note);
1101    }
1102    Ok(finding(check, false, detail))
1103}
1104
1105/// Provider names, capped like every other list in a detail.
1106fn provider_list(providers: &[String]) -> String {
1107    let take = providers.len().min(APPLY_LIST_SHOWN);
1108    let mut text = providers[..take]
1109        .iter()
1110        .map(|p| printable(p))
1111        .collect::<Vec<_>>()
1112        .join(", ");
1113    if providers.len() > take {
1114        text.push_str(&format!(" and {} more", providers.len() - take));
1115    }
1116    text
1117}
1118
1119#[cfg(test)]
1120mod tests {
1121    use super::*;
1122
1123    #[test]
1124    fn ids_are_the_contract_words_in_report_order() {
1125        let ids: Vec<&str> = PreflightCheck::ALL.iter().map(|c| c.as_str()).collect();
1126        assert_eq!(
1127            ids,
1128            [
1129                "mode_master",
1130                "mode_backup",
1131                "wrong_target",
1132                "write_rest",
1133                "write_host",
1134                "designers",
1135                "hold_budget",
1136                "master_active",
1137                "backup_read",
1138                "secrets"
1139            ]
1140        );
1141    }
1142
1143    #[test]
1144    fn production_defaults_are_the_decided_numbers() {
1145        assert_eq!((DESIGNERS_POLL_S, DESIGNERS_TIMEOUT_DEFAULT_S), (10, 600));
1146        let d = DesignersPolicy::default();
1147        assert_eq!(d.mode, DesignersMode::Fail);
1148        assert_eq!(d.timeout, Duration::from_secs(600));
1149        assert_eq!(d.interval, Duration::from_secs(10));
1150    }
1151
1152    #[test]
1153    fn the_session_list_is_capped_and_counts_the_rest() {
1154        let open: Vec<DesignerSeen> = (0..13)
1155            .map(|i| (format!("u{i}"), "P".to_string(), "h".to_string()))
1156            .collect();
1157        let text = designers_detail(&open);
1158        assert!(text.starts_with("13 Designer session(s) open: u0 on P from h"));
1159        assert!(text.ends_with("and 3 more"), "{text}");
1160        assert!(!text.contains("u12"), "{text}");
1161    }
1162
1163    #[test]
1164    fn a_designer_without_a_project_is_named_so() {
1165        let text = designers_detail(&[("bob".into(), String::new(), "h".into())]);
1166        assert!(text.contains("bob on no project from h"), "{text}");
1167    }
1168
1169    #[test]
1170    fn printable_drops_controls_and_caps_length() {
1171        assert_eq!(printable("a\nb\u{1b}c"), "abc");
1172        let long = "x".repeat(SHOWN_NAME_CHARS + 5);
1173        assert_eq!(printable(&long).len(), SHOWN_NAME_CHARS + 3);
1174    }
1175
1176    #[test]
1177    fn hold_budget_boundary_is_inclusive() {
1178        // need(n) = ceil(2 x (1 + 0.5 n + 1) + 1) = 5 + n.
1179        let hold = |hold_s| HoldModel {
1180            hold_s,
1181            commit_fixed_s: 1.0,
1182            commit_per_file_s: 0.5,
1183            p95_scan_s: 1.0,
1184        };
1185        assert_eq!(
1186            hold_budget_finding(&hold(14), 9).status,
1187            FindingStatus::Pass
1188        );
1189        let over = hold_budget_finding(&hold(14), 10);
1190        assert_eq!(over.status, FindingStatus::Fail);
1191        assert!(over.detail.contains("hold of 15 s"), "{}", over.detail);
1192        assert!(over.detail.contains("budget is 14 s"), "{}", over.detail);
1193        assert!(
1194            over.detail.contains("estimated commit 6.0 s"),
1195            "{}",
1196            over.detail
1197        );
1198    }
1199
1200    fn policy(timeout_ms: u64, interval_ms: u64) -> DesignersPolicy {
1201        DesignersPolicy {
1202            mode: DesignersMode::Wait,
1203            timeout: Duration::from_millis(timeout_ms),
1204            interval: Duration::from_millis(interval_ms),
1205        }
1206    }
1207
1208    #[test]
1209    fn wait_with_nothing_open_is_clear_at_any_time() {
1210        let p = policy(1000, 100);
1211        assert_eq!(wait_step(0, Duration::ZERO, &p), WaitStep::Clear);
1212        assert_eq!(wait_step(0, Duration::from_secs(99), &p), WaitStep::Clear);
1213    }
1214
1215    #[test]
1216    fn wait_timeout_equal_to_the_elapsed_time_has_passed() {
1217        // Edge DAPL-04 adjacency.
1218        let p = policy(1000, 100);
1219        assert_eq!(
1220            wait_step(1, Duration::from_millis(999), &p),
1221            WaitStep::Sleep(Duration::from_millis(1))
1222        );
1223        assert_eq!(
1224            wait_step(1, Duration::from_millis(1000), &p),
1225            WaitStep::TimedOut
1226        );
1227        assert_eq!(
1228            wait_step(1, Duration::from_millis(1001), &p),
1229            WaitStep::TimedOut
1230        );
1231    }
1232
1233    #[test]
1234    fn wait_sleeps_one_interval_unless_the_timeout_is_nearer() {
1235        let p = policy(1000, 100);
1236        assert_eq!(
1237            wait_step(2, Duration::ZERO, &p),
1238            WaitStep::Sleep(Duration::from_millis(100))
1239        );
1240        assert_eq!(
1241            wait_step(2, Duration::from_millis(950), &p),
1242            WaitStep::Sleep(Duration::from_millis(50))
1243        );
1244    }
1245
1246    #[test]
1247    fn a_zero_interval_never_spins() {
1248        let p = policy(1000, 0);
1249        assert_eq!(
1250            wait_step(1, Duration::ZERO, &p),
1251            WaitStep::Sleep(Duration::from_millis(1))
1252        );
1253    }
1254
1255    // ---- designers_gate ---------------------------------------------------
1256
1257    use std::sync::Mutex;
1258
1259    use crate::deploy::apply::hold::SystemClock;
1260
1261    fn seen(user: &str) -> DesignerSeen {
1262        (user.to_string(), "P".to_string(), "h".to_string())
1263    }
1264
1265    /// Runs the gate over `script` (one list per read; the last repeats) and
1266    /// returns the verdict, the sessions, the poll counts and the read count.
1267    async fn gate_over(
1268        mode: DesignersMode,
1269        timeout_ms: u64,
1270        script: Vec<Vec<DesignerSeen>>,
1271    ) -> (DesignersVerdict, Vec<DesignerSeen>, Vec<usize>, usize) {
1272        let reads = Mutex::new(0usize);
1273        let policy = DesignersPolicy {
1274            mode,
1275            timeout: Duration::from_millis(timeout_ms),
1276            interval: Duration::from_millis(1),
1277        };
1278        let mut polls = Vec::new();
1279        let (verdict, open) = designers_gate(
1280            || {
1281                let mut n = reads.lock().expect("reads");
1282                let list = script[(*n).min(script.len() - 1)].clone();
1283                *n += 1;
1284                async move { Ok(list) }
1285            },
1286            &policy,
1287            &SystemClock,
1288            &mut |count| {
1289                polls.push(count);
1290                Ok(())
1291            },
1292        )
1293        .await
1294        .expect("the gate");
1295        let n = *reads.lock().expect("reads");
1296        (verdict, open, polls, n)
1297    }
1298
1299    #[tokio::test]
1300    async fn designers_gate_shared() {
1301        // Clear: nothing open, one read, no poll under `fail`.
1302        let (verdict, open, polls, reads) = gate_over(DesignersMode::Fail, 0, vec![vec![]]).await;
1303        assert_eq!(verdict, DesignersVerdict::Clear);
1304        assert!(open.is_empty() && polls.is_empty());
1305        assert_eq!(reads, 1);
1306
1307        // Open under `fail`: the sessions come back, nothing is polled.
1308        let (verdict, open, polls, reads) =
1309            gate_over(DesignersMode::Fail, 0, vec![vec![seen("alice")]]).await;
1310        assert_eq!(verdict, DesignersVerdict::Open);
1311        assert_eq!(open, [seen("alice")]);
1312        assert!(polls.is_empty());
1313        assert_eq!(reads, 1);
1314
1315        // Proceeded: the same sessions, with the choice reported.
1316        let (verdict, open, ..) =
1317            gate_over(DesignersMode::Proceed, 0, vec![vec![seen("alice")]]).await;
1318        assert_eq!(verdict, DesignersVerdict::Proceeded);
1319        assert_eq!(open, [seen("alice")]);
1320
1321        // `wait` clears: one poll per read, the last read is empty.
1322        let (verdict, open, polls, reads) = gate_over(
1323            DesignersMode::Wait,
1324            30_000,
1325            vec![vec![seen("alice")], vec![seen("alice")], vec![]],
1326        )
1327        .await;
1328        assert_eq!(verdict, DesignersVerdict::Clear);
1329        assert!(open.is_empty());
1330        assert_eq!(polls, [1, 1, 0]);
1331        assert_eq!(reads, 3);
1332
1333        // `wait` times out with the sessions still open: the `fail` verdict.
1334        let (verdict, open, polls, _) =
1335            gate_over(DesignersMode::Wait, 20, vec![vec![seen("alice")]]).await;
1336        assert_eq!(verdict, DesignersVerdict::Open);
1337        assert_eq!(open, [seen("alice")]);
1338        assert!(polls.len() >= 2, "{polls:?}");
1339    }
1340
1341    #[tokio::test]
1342    async fn designers_gate_read_error_is_the_reads_own() {
1343        let policy = DesignersPolicy::default();
1344        let err = designers_gate(
1345            || async {
1346                Err::<Vec<DesignerSeen>, _>(CoreError::TransportFailed {
1347                    operation: "read".to_string(),
1348                    node: "nodes.master".to_string(),
1349                    detail: "down".to_string(),
1350                })
1351            },
1352            &policy,
1353            &SystemClock,
1354            &mut |_| Ok(()),
1355        )
1356        .await
1357        .expect_err("the read fails");
1358        assert_eq!(err.code(), "transport_failed");
1359    }
1360}