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;
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        return Ok(finding(
563            check,
564            true,
565            "the mode collection and the ign-deploy root are writable".to_string(),
566        ));
567    }
568    let detail = if report.problems.is_empty() {
569        "the host is not writable".to_string()
570    } else {
571        report.problems.join("; ")
572    };
573    Ok(finding(check, false, detail))
574}
575
576// ---- designers --------------------------------------------------------------
577
578pub(crate) async fn read_designers(
579    gateway: &dyn GatewayApi,
580) -> Result<Vec<DesignerSeen>, CoreError> {
581    let page = gateway.designers(&ListQuery::default()).await?;
582    Ok(page
583        .items
584        .into_iter()
585        .map(|d| {
586            (
587                printable(&d.user),
588                printable(&d.project),
589                printable(&d.address),
590            )
591        })
592        .collect())
593}
594
595/// `n Designer session(s) open: user on project P from host H, ...`.
596pub(crate) fn designers_detail(open: &[DesignerSeen]) -> String {
597    let each: Vec<String> = open
598        .iter()
599        .map(|(user, project, host)| {
600            let project = if project.is_empty() {
601                "no project"
602            } else {
603                project
604            };
605            format!("{user} on {project} from {host}")
606        })
607        .collect();
608    let take = each.len().min(APPLY_LIST_SHOWN);
609    let mut text = format!(
610        "{} Designer session(s) open: {}",
611        open.len(),
612        each[..take].join(", ")
613    );
614    if each.len() > take {
615        text.push_str(&format!(" and {} more", each.len() - take));
616    }
617    text
618}
619
620/// What `wait` does next, given the sessions still open and the time spent.
621#[derive(Debug, Clone, Copy, PartialEq, Eq)]
622pub(crate) enum WaitStep {
623    /// No session is open.
624    Clear,
625    /// Still open and the timeout has passed.
626    TimedOut,
627    /// Still open: sleep this long, then read again.
628    Sleep(Duration),
629}
630
631/// The decision of the `wait` loop. A timeout equal to the elapsed time has
632/// passed, and the last sleep never runs past the timeout.
633pub(crate) fn wait_step(open: usize, elapsed: Duration, policy: &DesignersPolicy) -> WaitStep {
634    if open == 0 {
635        WaitStep::Clear
636    } else if elapsed >= policy.timeout {
637        WaitStep::TimedOut
638    } else {
639        WaitStep::Sleep(
640            policy
641                .interval
642                .max(Duration::from_millis(1))
643                .min(policy.timeout - elapsed),
644        )
645    }
646}
647
648/// What [`designers_gate`] decided (D-14, D-15).
649#[derive(Debug, Clone, Copy, PartialEq, Eq)]
650pub enum DesignersVerdict {
651    /// No Designer session is open.
652    Clear,
653    /// Sessions are open and the mode does not allow going ahead (`fail`, or
654    /// `wait` after the timeout).
655    Open,
656    /// Sessions are open and `--designers proceed` was given: go ahead, with
657    /// the sessions on record.
658    Proceeded,
659}
660
661/// The read-poll-decide loop of the Designer check, shared by `deploy apply`
662/// and `deploy projects` (25-14).
663///
664/// `read` lists the open sessions; it is called once, and again after each
665/// sleep under [`DesignersMode::Wait`]. `on_poll` hears the number of open
666/// sessions after each read under `wait`. Returns the verdict and the sessions
667/// seen at the last read; a read that fails is that call's own error.
668pub(crate) async fn designers_gate<F, Fut>(
669    mut read: F,
670    policy: &DesignersPolicy,
671    clock: &dyn Clock,
672    on_poll: &mut (dyn FnMut(usize) -> Result<(), CoreError> + Send),
673) -> Result<(DesignersVerdict, Vec<DesignerSeen>), CoreError>
674where
675    F: FnMut() -> Fut,
676    Fut: std::future::Future<Output = Result<Vec<DesignerSeen>, CoreError>>,
677{
678    let started = clock.now();
679    let mut open = read().await?;
680    if policy.mode == DesignersMode::Wait {
681        loop {
682            on_poll(open.len())?;
683            let elapsed = clock.now().saturating_duration_since(started);
684            match wait_step(open.len(), elapsed, policy) {
685                WaitStep::Clear | WaitStep::TimedOut => break,
686                WaitStep::Sleep(pause) => {
687                    tokio::time::sleep(pause).await;
688                    open = read().await?;
689                }
690            }
691        }
692    }
693    let verdict = if open.is_empty() {
694        DesignersVerdict::Clear
695    } else if policy.mode == DesignersMode::Proceed {
696        DesignersVerdict::Proceeded
697    } else {
698        DesignersVerdict::Open
699    };
700    Ok((verdict, open))
701}
702
703async fn designers_finding(
704    input: &PreflightInput<'_>,
705    steps: &mut Steps<'_>,
706) -> Result<(PreflightFinding, Vec<DesignerSeen>), CoreError> {
707    let check = PreflightCheck::Designers;
708    let (verdict, open) = designers_gate(
709        || read_designers(input.gateway),
710        input.designers,
711        input.clock,
712        &mut |count| steps.emit(ApplyEvent::DesignersPoll { open: count }),
713    )
714    .await?;
715    let found = match verdict {
716        DesignersVerdict::Clear => finding(check, true, "no Designer session is open".to_string()),
717        DesignersVerdict::Proceeded => {
718            // D-15: going ahead is allowed only with the choice and the
719            // sessions on record (run.json and the events).
720            steps.emit(ApplyEvent::DesignersProceed {
721                sessions: open.clone(),
722            })?;
723            finding(
724                check,
725                true,
726                format!(
727                    "{}; going ahead because --designers proceed was given",
728                    designers_detail(&open)
729                ),
730            )
731        }
732        DesignersVerdict::Open => finding(check, false, designers_detail(&open)),
733    };
734    Ok((found, open))
735}
736
737// ---- hold_budget ------------------------------------------------------------
738
739pub(super) fn hold_budget_finding(hold: &HoldModel, files: usize) -> PreflightFinding {
740    let estimate = hold.estimate_commit_s(files);
741    let needed = hold_for(estimate, hold.p95_scan_s);
742    let detail = format!(
743        "{files} file(s): estimated commit {estimate:.1} s and scan {:.1} s need a hold of {needed} s; \
744         the budget is {} s",
745        hold.p95_scan_s, hold.hold_s
746    );
747    finding(PreflightCheck::HoldBudget, needed <= hold.hold_s, detail)
748}
749
750// ---- master_active ------------------------------------------------------------
751
752/// The master is the only active node of its pair (D-11, K3).
753///
754/// Which node is active is decided by `activityLevel`, never by `role`: after
755/// a switch-over the former master keeps `role: Master` while its level drops to
756/// `Cold` (C-S3). The target file still names that node as the master, so an
757/// apply or a rollback would write to the standby, whose collection the
758/// active node's sync then overwrites. Both nodes are read here and the check
759/// fails on a moved, doubled or missing activity. Rollback runs the same
760/// check. The activity can still move after this read; the lock-time guards
761/// and the backup phase are what catch a failover during the run.
762async fn master_active_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
763    let check = PreflightCheck::MasterActive;
764    if input.target.nodes.backup.is_none() {
765        return Ok(PreflightFinding {
766            check,
767            status: FindingStatus::Skipped,
768            detail: "the target has no backup node".to_string(),
769        });
770    }
771    let Some(backup) = input.backup else {
772        return Ok(finding(
773            check,
774            false,
775            "the backup node has no gateway connection, so which node is active cannot be confirmed"
776                .to_string(),
777        ));
778    };
779    let master = input.gateway.redundancy_status().await?;
780    let standby = backup.status().await?;
781    let level = |wire: &RedundancyStatusWire| {
782        wire.activity_level
783            .as_deref()
784            .map_or_else(|| "none".to_string(), printable)
785    };
786    let (master_level, backup_level) = (level(&master), level(&standby));
787    let both = format!("master {master_level}, backup {backup_level}");
788    Ok(match active_node(&master, &standby) {
789        ActiveNode::OnlyA => finding(
790            check,
791            true,
792            format!("the master is the active node ({both})"),
793        ),
794        ActiveNode::OnlyB => finding(
795            check,
796            false,
797            format!(
798                "the backup reports activityLevel Active and the master {master_level}: \
799                 activity moved to the backup (the pair switched over); apply and rollback write only to the active master ({both})"
800            ),
801        ),
802        ActiveNode::Both => finding(
803            check,
804            false,
805            format!("both nodes report activityLevel Active (split-brain) ({both})"),
806        ),
807        ActiveNode::Neither => finding(
808            check,
809            false,
810            format!("neither node reports activityLevel Active ({both})"),
811        ),
812    })
813}
814
815// ---- backup_read ----------------------------------------------------------------
816
817/// The backup's mode collection can be read (D-02).
818///
819/// This is the one check that turns a read error into a finding instead of an
820/// abort: the read is the check. The file-hash comparison after the apply
821/// reads the same collection through the same transport, and an account that
822/// cannot list the data directory (the Linux gap Phase 23 recorded for list
823/// rights on `data` and `data/config`) would only be found after the master
824/// was written. Reading it here refuses before the lock. The scope is the
825/// manifest's, the same one the comparison reads. `skipped` when there is
826/// nothing to read: no backup node, a REST-only backup (the detail says the
827/// file-hash check will not run, D-04), or a rollback, which renders nothing.
828///
829/// The same read also refuses before any write what the file-hash check
830/// would otherwise find only after the master was written (WR-02): a unit
831/// the apply renders or deletes that the backup's host cannot read
832/// (quick-261029-ms3). The units are matched exactly as that check matches
833/// them ([`exempt_unreadable`] over the rendered and deleted paths); their
834/// keys come back beside the failed finding, and the apply refuses them
835/// `deploy_unreadable`. Any other unit the backup cannot read is left to the
836/// file-hash check's warning.
837async fn backup_read_finding(input: &PreflightInput<'_>) -> (PreflightFinding, Vec<String>) {
838    let check = PreflightCheck::BackupRead;
839    let skipped = |detail: &str| {
840        (
841            PreflightFinding {
842                check,
843                status: FindingStatus::Skipped,
844                detail: detail.to_string(),
845            },
846            Vec::new(),
847        )
848    };
849    if input.target.nodes.backup.is_none() {
850        return skipped("the target has no backup node");
851    }
852    let Some(declared) = input.declared else {
853        return skipped("a rollback renders no tree, so the backup's files are not compared");
854    };
855    let Some(backup) = input.backup else {
856        return skipped("the backup node has no gateway connection, so its files are not read");
857    };
858    if !backup.has_files() {
859        return skipped(
860            "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
861        );
862    }
863    let scope = Scope::from_manifest(
864        &declared.manifest,
865        &declared.target.mode,
866        &declared.excluded_tag_providers(),
867    );
868    match backup.read_files(&scope).await {
869        Some(Ok(observed)) => {
870            let blocked = backup_blocked_units(input, declared, &observed);
871            if blocked.is_empty() {
872                return (
873                    finding(
874                        check,
875                        true,
876                        format!(
877                            "read {} files of the backup's mode collection",
878                            observed.core.len() + observed.mode.len()
879                        ),
880                    ),
881                    blocked,
882                );
883            }
884            let take = blocked.len().min(APPLY_LIST_SHOWN);
885            let mut detail = format!(
886                "the backup's sftp server cannot read {} unit(s) this apply writes or deletes: {}",
887                blocked.len(),
888                blocked[..take]
889                    .iter()
890                    .map(|k| printable(k))
891                    .collect::<Vec<_>>()
892                    .join(", ")
893            );
894            if blocked.len() > take {
895                detail.push_str(&format!(" and {} more", blocked.len() - take));
896            }
897            (finding(check, false, detail), blocked)
898        }
899        Some(Err(err)) => (
900            finding(
901                check,
902                false,
903                format!(
904                    "the backup's mode collection cannot be read: {}",
905                    printable(err.code())
906                ),
907            ),
908            Vec::new(),
909        ),
910        None => skipped(
911            "nodes.backup declares no ssh or local transport, so the file-hash check did not run",
912        ),
913    }
914}
915
916/// The unit keys of the backup's read that the file-hash check would fail
917/// as `unreadable`: a unit holding a rendered path or a path this run
918/// deletes. Byte-sorted and unique.
919fn backup_blocked_units(
920    input: &PreflightInput<'_>,
921    declared: &Declared,
922    observed: &ObservedFiles,
923) -> Vec<String> {
924    let units = unreadable_units(observed);
925    if units.is_empty() {
926        return Vec::new();
927    }
928    let present: BTreeSet<RelPath> = declared.outcome.tree.files.keys().cloned().collect();
929    let absent: BTreeSet<RelPath> = input
930        .deleted_paths
931        .iter()
932        .filter_map(|p| RelPath::new(p).ok())
933        .collect();
934    let mut comparison = FileComparison::default();
935    exempt_unreadable(&mut comparison, &present, &absent, &[("backup", &units)]);
936    let mut keys: Vec<String> = comparison
937        .unreadable
938        .into_iter()
939        .map(|(_, key)| key)
940        .collect();
941    keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
942    keys.dedup();
943    keys
944}
945
946// ---- secrets ------------------------------------------------------------------------
947
948/// Every secret name the rendered tree references exists on each node (D-14).
949///
950/// Only the names a provider lists are read (`DeployApi::secret_names`); no
951/// value is fetched and none can appear in a detail, which lists `node:
952/// provider/name` pairs. A listing that cannot be answered at all (a network or
953/// auth error) aborts like the other reads. Names compare exactly (K9). A
954/// provider the render itself declares that answers 404 on a node is the first
955/// deploy that creates it, so it is skipped by name (K8; `deploy verify`
956/// still requires it); a provider that answers 501 or 500 cannot list and is
957/// named unverifiable (K7). Neither fails the check. K8 extension (WR-04): a
958/// name the render itself adds to a provider it declares is defined by the
959/// deploy, so it is not asked of a node that already has the provider; only
960/// names the tree references and does not provide are refused.
961async fn secrets_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
962    let check = PreflightCheck::Secrets;
963    let skipped = |detail: &str| PreflightFinding {
964        check,
965        status: FindingStatus::Skipped,
966        detail: detail.to_string(),
967    };
968    let Some(declared) = input.declared else {
969        return Ok(skipped(
970            "a rollback renders no tree, so no secret reference is checked",
971        ));
972    };
973    let all_referenced = collect_referenced_secrets(&declared.outcome.tree);
974    if all_referenced.is_empty() {
975        return Ok(skipped("the rendered tree references no secret"));
976    }
977    // A name this render adds to a provider it declares is defined by the
978    // deploy itself (K8 extension, WR-04): a node cannot list it before the
979    // apply, so it is not asked for. Only names the tree references and does
980    // not provide are checked.
981    let defined = secrets_defined_by_render(&declared.outcome.tree);
982    let referenced: BTreeSet<(String, String)> =
983        all_referenced.difference(&defined).cloned().collect();
984    if referenced.is_empty() {
985        return Ok(skipped(
986            "every secret name the rendered tree references is defined by the tree itself",
987        ));
988    }
989    let own_names = all_referenced.len() - referenced.len();
990    let declared_providers = declared_secret_providers(&declared.outcome.tree);
991    let providers: BTreeSet<&str> = referenced.iter().map(|(p, _)| p.as_str()).collect();
992
993    let mut reports = Vec::with_capacity(2);
994    let mut on_master = BTreeMap::new();
995    for provider in &providers {
996        on_master.insert(
997            (*provider).to_string(),
998            input.master.secret_names(provider).await?,
999        );
1000    }
1001    reports.push(check_secret_names(
1002        "master",
1003        &referenced,
1004        &on_master,
1005        &declared_providers,
1006        SecretCheckMode::Preflight,
1007    ));
1008    if input.target.nodes.backup.is_some() {
1009        let Some(backup) = input.backup else {
1010            return Ok(finding(
1011                check,
1012                false,
1013                "the backup node has no gateway connection, so its secret names cannot be checked"
1014                    .to_string(),
1015            ));
1016        };
1017        let mut on_backup = BTreeMap::new();
1018        for provider in &providers {
1019            on_backup.insert(
1020                (*provider).to_string(),
1021                backup.secret_names(provider).await?,
1022            );
1023        }
1024        reports.push(check_secret_names(
1025            "backup",
1026            &referenced,
1027            &on_backup,
1028            &declared_providers,
1029            SecretCheckMode::Preflight,
1030        ));
1031    }
1032
1033    let missing: Vec<String> = reports
1034        .iter()
1035        .flat_map(|r| {
1036            r.missing.iter().map(|m| {
1037                format!(
1038                    "{}: {}/{}",
1039                    r.node,
1040                    printable(&m.provider),
1041                    printable(&m.name)
1042                )
1043            })
1044        })
1045        .collect();
1046    let mut notes: Vec<String> = Vec::new();
1047    for r in &reports {
1048        if !r.skipped_providers.is_empty() {
1049            notes.push(format!(
1050                "{}: skipped {} (declared by this render, not on the node yet)",
1051                r.node,
1052                provider_list(&r.skipped_providers)
1053            ));
1054        }
1055        if !r.unverifiable.is_empty() {
1056            notes.push(format!(
1057                "{}: unverifiable {} (the provider cannot list its names)",
1058                r.node,
1059                provider_list(&r.unverifiable)
1060            ));
1061        }
1062    }
1063    if missing.is_empty() {
1064        let mut detail = format!(
1065            "{} referenced secret name(s) in {} provider(s) checked on {}",
1066            referenced.len(),
1067            providers.len(),
1068            reports
1069                .iter()
1070                .map(|r| r.node.as_str())
1071                .collect::<Vec<_>>()
1072                .join(" and ")
1073        );
1074        if own_names > 0 {
1075            detail.push_str(&format!(
1076                "; {own_names} name(s) defined by this render were not required on the node"
1077            ));
1078        }
1079        for note in notes {
1080            detail.push_str("; ");
1081            detail.push_str(&note);
1082        }
1083        return Ok(finding(check, true, detail));
1084    }
1085    let take = missing.len().min(APPLY_LIST_SHOWN);
1086    let mut detail = format!(
1087        "{} referenced secret name(s) missing: {}",
1088        missing.len(),
1089        missing[..take].join(", ")
1090    );
1091    if missing.len() > take {
1092        detail.push_str(&format!(" and {} more", missing.len() - take));
1093    }
1094    for note in notes {
1095        detail.push_str("; ");
1096        detail.push_str(&note);
1097    }
1098    Ok(finding(check, false, detail))
1099}
1100
1101/// Provider names, capped like every other list in a detail.
1102fn provider_list(providers: &[String]) -> String {
1103    let take = providers.len().min(APPLY_LIST_SHOWN);
1104    let mut text = providers[..take]
1105        .iter()
1106        .map(|p| printable(p))
1107        .collect::<Vec<_>>()
1108        .join(", ");
1109    if providers.len() > take {
1110        text.push_str(&format!(" and {} more", providers.len() - take));
1111    }
1112    text
1113}
1114
1115#[cfg(test)]
1116mod tests {
1117    use super::*;
1118
1119    #[test]
1120    fn ids_are_the_contract_words_in_report_order() {
1121        let ids: Vec<&str> = PreflightCheck::ALL.iter().map(|c| c.as_str()).collect();
1122        assert_eq!(
1123            ids,
1124            [
1125                "mode_master",
1126                "mode_backup",
1127                "wrong_target",
1128                "write_rest",
1129                "write_host",
1130                "designers",
1131                "hold_budget",
1132                "master_active",
1133                "backup_read",
1134                "secrets"
1135            ]
1136        );
1137    }
1138
1139    #[test]
1140    fn production_defaults_are_the_decided_numbers() {
1141        assert_eq!((DESIGNERS_POLL_S, DESIGNERS_TIMEOUT_DEFAULT_S), (10, 600));
1142        let d = DesignersPolicy::default();
1143        assert_eq!(d.mode, DesignersMode::Fail);
1144        assert_eq!(d.timeout, Duration::from_secs(600));
1145        assert_eq!(d.interval, Duration::from_secs(10));
1146    }
1147
1148    #[test]
1149    fn the_session_list_is_capped_and_counts_the_rest() {
1150        let open: Vec<DesignerSeen> = (0..13)
1151            .map(|i| (format!("u{i}"), "P".to_string(), "h".to_string()))
1152            .collect();
1153        let text = designers_detail(&open);
1154        assert!(text.starts_with("13 Designer session(s) open: u0 on P from h"));
1155        assert!(text.ends_with("and 3 more"), "{text}");
1156        assert!(!text.contains("u12"), "{text}");
1157    }
1158
1159    #[test]
1160    fn a_designer_without_a_project_is_named_so() {
1161        let text = designers_detail(&[("bob".into(), String::new(), "h".into())]);
1162        assert!(text.contains("bob on no project from h"), "{text}");
1163    }
1164
1165    #[test]
1166    fn printable_drops_controls_and_caps_length() {
1167        assert_eq!(printable("a\nb\u{1b}c"), "abc");
1168        let long = "x".repeat(SHOWN_NAME_CHARS + 5);
1169        assert_eq!(printable(&long).len(), SHOWN_NAME_CHARS + 3);
1170    }
1171
1172    #[test]
1173    fn hold_budget_boundary_is_inclusive() {
1174        // need(n) = ceil(2 x (1 + 0.5 n + 1) + 1) = 5 + n.
1175        let hold = |hold_s| HoldModel {
1176            hold_s,
1177            commit_fixed_s: 1.0,
1178            commit_per_file_s: 0.5,
1179            p95_scan_s: 1.0,
1180        };
1181        assert_eq!(
1182            hold_budget_finding(&hold(14), 9).status,
1183            FindingStatus::Pass
1184        );
1185        let over = hold_budget_finding(&hold(14), 10);
1186        assert_eq!(over.status, FindingStatus::Fail);
1187        assert!(over.detail.contains("hold of 15 s"), "{}", over.detail);
1188        assert!(over.detail.contains("budget is 14 s"), "{}", over.detail);
1189        assert!(
1190            over.detail.contains("estimated commit 6.0 s"),
1191            "{}",
1192            over.detail
1193        );
1194    }
1195
1196    fn policy(timeout_ms: u64, interval_ms: u64) -> DesignersPolicy {
1197        DesignersPolicy {
1198            mode: DesignersMode::Wait,
1199            timeout: Duration::from_millis(timeout_ms),
1200            interval: Duration::from_millis(interval_ms),
1201        }
1202    }
1203
1204    #[test]
1205    fn wait_with_nothing_open_is_clear_at_any_time() {
1206        let p = policy(1000, 100);
1207        assert_eq!(wait_step(0, Duration::ZERO, &p), WaitStep::Clear);
1208        assert_eq!(wait_step(0, Duration::from_secs(99), &p), WaitStep::Clear);
1209    }
1210
1211    #[test]
1212    fn wait_timeout_equal_to_the_elapsed_time_has_passed() {
1213        // Edge DAPL-04 adjacency.
1214        let p = policy(1000, 100);
1215        assert_eq!(
1216            wait_step(1, Duration::from_millis(999), &p),
1217            WaitStep::Sleep(Duration::from_millis(1))
1218        );
1219        assert_eq!(
1220            wait_step(1, Duration::from_millis(1000), &p),
1221            WaitStep::TimedOut
1222        );
1223        assert_eq!(
1224            wait_step(1, Duration::from_millis(1001), &p),
1225            WaitStep::TimedOut
1226        );
1227    }
1228
1229    #[test]
1230    fn wait_sleeps_one_interval_unless_the_timeout_is_nearer() {
1231        let p = policy(1000, 100);
1232        assert_eq!(
1233            wait_step(2, Duration::ZERO, &p),
1234            WaitStep::Sleep(Duration::from_millis(100))
1235        );
1236        assert_eq!(
1237            wait_step(2, Duration::from_millis(950), &p),
1238            WaitStep::Sleep(Duration::from_millis(50))
1239        );
1240    }
1241
1242    #[test]
1243    fn a_zero_interval_never_spins() {
1244        let p = policy(1000, 0);
1245        assert_eq!(
1246            wait_step(1, Duration::ZERO, &p),
1247            WaitStep::Sleep(Duration::from_millis(1))
1248        );
1249    }
1250
1251    // ---- designers_gate ---------------------------------------------------
1252
1253    use std::sync::Mutex;
1254
1255    use crate::deploy::apply::hold::SystemClock;
1256
1257    fn seen(user: &str) -> DesignerSeen {
1258        (user.to_string(), "P".to_string(), "h".to_string())
1259    }
1260
1261    /// Runs the gate over `script` (one list per read; the last repeats) and
1262    /// returns the verdict, the sessions, the poll counts and the read count.
1263    async fn gate_over(
1264        mode: DesignersMode,
1265        timeout_ms: u64,
1266        script: Vec<Vec<DesignerSeen>>,
1267    ) -> (DesignersVerdict, Vec<DesignerSeen>, Vec<usize>, usize) {
1268        let reads = Mutex::new(0usize);
1269        let policy = DesignersPolicy {
1270            mode,
1271            timeout: Duration::from_millis(timeout_ms),
1272            interval: Duration::from_millis(1),
1273        };
1274        let mut polls = Vec::new();
1275        let (verdict, open) = designers_gate(
1276            || {
1277                let mut n = reads.lock().expect("reads");
1278                let list = script[(*n).min(script.len() - 1)].clone();
1279                *n += 1;
1280                async move { Ok(list) }
1281            },
1282            &policy,
1283            &SystemClock,
1284            &mut |count| {
1285                polls.push(count);
1286                Ok(())
1287            },
1288        )
1289        .await
1290        .expect("the gate");
1291        let n = *reads.lock().expect("reads");
1292        (verdict, open, polls, n)
1293    }
1294
1295    #[tokio::test]
1296    async fn designers_gate_shared() {
1297        // Clear: nothing open, one read, no poll under `fail`.
1298        let (verdict, open, polls, reads) = gate_over(DesignersMode::Fail, 0, vec![vec![]]).await;
1299        assert_eq!(verdict, DesignersVerdict::Clear);
1300        assert!(open.is_empty() && polls.is_empty());
1301        assert_eq!(reads, 1);
1302
1303        // Open under `fail`: the sessions come back, nothing is polled.
1304        let (verdict, open, polls, reads) =
1305            gate_over(DesignersMode::Fail, 0, vec![vec![seen("alice")]]).await;
1306        assert_eq!(verdict, DesignersVerdict::Open);
1307        assert_eq!(open, [seen("alice")]);
1308        assert!(polls.is_empty());
1309        assert_eq!(reads, 1);
1310
1311        // Proceeded: the same sessions, with the choice reported.
1312        let (verdict, open, ..) =
1313            gate_over(DesignersMode::Proceed, 0, vec![vec![seen("alice")]]).await;
1314        assert_eq!(verdict, DesignersVerdict::Proceeded);
1315        assert_eq!(open, [seen("alice")]);
1316
1317        // `wait` clears: one poll per read, the last read is empty.
1318        let (verdict, open, polls, reads) = gate_over(
1319            DesignersMode::Wait,
1320            30_000,
1321            vec![vec![seen("alice")], vec![seen("alice")], vec![]],
1322        )
1323        .await;
1324        assert_eq!(verdict, DesignersVerdict::Clear);
1325        assert!(open.is_empty());
1326        assert_eq!(polls, [1, 1, 0]);
1327        assert_eq!(reads, 3);
1328
1329        // `wait` times out with the sessions still open: the `fail` verdict.
1330        let (verdict, open, polls, _) =
1331            gate_over(DesignersMode::Wait, 20, vec![vec![seen("alice")]]).await;
1332        assert_eq!(verdict, DesignersVerdict::Open);
1333        assert_eq!(open, [seen("alice")]);
1334        assert!(polls.len() >= 2, "{polls:?}");
1335    }
1336
1337    #[tokio::test]
1338    async fn designers_gate_read_error_is_the_reads_own() {
1339        let policy = DesignersPolicy::default();
1340        let err = designers_gate(
1341            || async {
1342                Err::<Vec<DesignerSeen>, _>(CoreError::TransportFailed {
1343                    operation: "read".to_string(),
1344                    node: "nodes.master".to_string(),
1345                    detail: "down".to_string(),
1346                })
1347            },
1348            &policy,
1349            &SystemClock,
1350            &mut |_| Ok(()),
1351        )
1352        .await
1353        .expect_err("the read fails");
1354        assert_eq!(err.code(), "transport_failed");
1355    }
1356}