use std::collections::{BTreeMap, BTreeSet};
use std::time::Duration;
use super::{ApplyEvent, Steps};
use serde_json::Value;
use super::hold::{Clock, HoldModel, hold_for};
use super::host::ApplyHost;
use crate::actions::deploy::Declared;
use crate::client::mode::normalize_deployment_mode;
use crate::client::query::ListQuery;
use crate::client::redundancy::{ActiveNode, RedundancyStatusWire, active_node};
use crate::client::{DeployApi, GatewayApi};
use crate::deploy::layout::{RelPath, split_key};
use crate::deploy::pair::{
BackupNode, FileComparison, SecretCheckMode, check_secret_names, collect_referenced_secrets,
declared_secret_providers, exempt_unreadable, secrets_defined_by_render, unreadable_units,
};
use crate::deploy::target::Target;
use crate::deploy::transport::{ObservedFiles, Scope, TreeReader};
use crate::error::{APPLY_LIST_SHOWN, CoreError, PreflightFailure};
pub const DESIGNERS_POLL_S: u64 = 10;
pub const DESIGNERS_TIMEOUT_DEFAULT_S: u64 = 600;
const SHOWN_NAME_CHARS: usize = 64;
const SYSTEM_PROPERTIES_UNIT: &str = "ignition/system-properties";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PreflightCheck {
ModeMaster,
ModeBackup,
WrongTarget,
WriteRest,
WriteHost,
Designers,
HoldBudget,
MasterActive,
BackupRead,
Secrets,
}
impl PreflightCheck {
pub const ALL: [PreflightCheck; 10] = [
PreflightCheck::ModeMaster,
PreflightCheck::ModeBackup,
PreflightCheck::WrongTarget,
PreflightCheck::WriteRest,
PreflightCheck::WriteHost,
PreflightCheck::Designers,
PreflightCheck::HoldBudget,
PreflightCheck::MasterActive,
PreflightCheck::BackupRead,
PreflightCheck::Secrets,
];
pub fn as_str(self) -> &'static str {
match self {
PreflightCheck::ModeMaster => "mode_master",
PreflightCheck::ModeBackup => "mode_backup",
PreflightCheck::WrongTarget => "wrong_target",
PreflightCheck::WriteRest => "write_rest",
PreflightCheck::WriteHost => "write_host",
PreflightCheck::Designers => "designers",
PreflightCheck::HoldBudget => "hold_budget",
PreflightCheck::MasterActive => "master_active",
PreflightCheck::BackupRead => "backup_read",
PreflightCheck::Secrets => "secrets",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FindingStatus {
Pass,
Fail,
Skipped,
}
impl FindingStatus {
pub fn as_str(self) -> &'static str {
match self {
FindingStatus::Pass => "pass",
FindingStatus::Fail => "fail",
FindingStatus::Skipped => "skipped",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreflightFinding {
pub check: PreflightCheck,
pub status: FindingStatus,
pub detail: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DesignersMode {
Fail,
Wait,
Proceed,
}
impl DesignersMode {
pub fn as_str(self) -> &'static str {
match self {
DesignersMode::Fail => "fail",
DesignersMode::Wait => "wait",
DesignersMode::Proceed => "proceed",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct DesignersPolicy {
pub mode: DesignersMode,
pub timeout: Duration,
pub interval: Duration,
}
impl Default for DesignersPolicy {
fn default() -> Self {
DesignersPolicy {
mode: DesignersMode::Fail,
timeout: Duration::from_secs(DESIGNERS_TIMEOUT_DEFAULT_S),
interval: Duration::from_secs(DESIGNERS_POLL_S),
}
}
}
pub struct PreflightInput<'a> {
pub target: &'a Target,
pub declared: Option<&'a Declared>,
pub master: &'a dyn DeployApi,
pub backup: Option<&'a BackupNode<'a>>,
pub gateway: &'a dyn GatewayApi,
pub reader: &'a dyn TreeReader,
pub host: &'a dyn ApplyHost,
pub touched_files: usize,
pub touched_paths: &'a [String],
pub deleted_paths: &'a [String],
pub hold: &'a HoldModel,
pub designers: &'a DesignersPolicy,
pub clock: &'a dyn Clock,
}
pub type DesignerSeen = (String, String, String);
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PreflightReport {
pub findings: Vec<PreflightFinding>,
pub designers_seen: Vec<DesignerSeen>,
pub backup_unreadable: Vec<String>,
}
impl PreflightReport {
pub fn passed(&self) -> bool {
self.findings
.iter()
.all(|f| f.status != FindingStatus::Fail)
}
pub fn failures(&self) -> Vec<PreflightFailure> {
self.findings
.iter()
.filter(|f| f.status == FindingStatus::Fail)
.map(|f| PreflightFailure {
check: f.check.as_str().to_string(),
detail: f.detail.clone(),
})
.collect()
}
}
pub async fn run_preflights(
input: &PreflightInput<'_>,
steps: &mut Steps<'_>,
) -> Result<PreflightReport, CoreError> {
let mut findings = Vec::with_capacity(PreflightCheck::ALL.len());
let mode = input.target.mode.as_str();
let master_seen = normalize_deployment_mode(input.master.deployment_mode().await?);
findings.push(mode_finding(
PreflightCheck::ModeMaster,
"master",
Some(master_seen),
mode,
));
findings.push(if input.target.nodes.backup.is_some() {
let backup_seen = match input.backup {
Some(backup) => Some(backup.deployment_mode().await?),
None => None,
};
mode_finding(PreflightCheck::ModeBackup, "backup", backup_seen, mode)
} else {
PreflightFinding {
check: PreflightCheck::ModeBackup,
status: FindingStatus::Skipped,
detail: "the target has no backup node".to_string(),
}
});
findings.push(wrong_target_finding(input).await?);
findings.push(write_rest_finding(input.gateway).await?);
findings.push(write_host_finding(input.host, input.touched_paths).await?);
let (designers, designers_seen) = designers_finding(input, steps).await?;
findings.push(designers);
findings.push(hold_budget_finding(input.hold, input.touched_files));
findings.push(master_active_finding(input).await?);
let (backup_read, backup_unreadable) = backup_read_finding(input).await;
findings.push(backup_read);
findings.push(secrets_finding(input).await?);
Ok(PreflightReport {
findings,
designers_seen,
backup_unreadable,
})
}
fn finding(check: PreflightCheck, ok: bool, detail: String) -> PreflightFinding {
PreflightFinding {
check,
status: if ok {
FindingStatus::Pass
} else {
FindingStatus::Fail
},
detail,
}
}
fn printable(text: &str) -> String {
let mut out: String = text
.chars()
.filter(|c| !c.is_control())
.take(SHOWN_NAME_CHARS)
.collect();
if text.chars().count() > SHOWN_NAME_CHARS {
out.push_str("...");
}
out
}
fn mode_finding(
check: PreflightCheck,
node: &str,
seen: Option<Option<String>>,
target_mode: &str,
) -> PreflightFinding {
let Some(seen) = seen else {
return finding(
check,
false,
format!("the {node} node has no gateway connection, so its mode cannot be confirmed"),
);
};
let ok = seen.as_deref() == Some(target_mode);
let detail = match (&seen, ok) {
(_, true) => format!("the {node} runs mode `{}`", printable(target_mode)),
(Some(seen), false) => format!(
"the {node} runs mode `{}` but the target's mode is `{}`",
printable(seen),
printable(target_mode)
),
(None, false) => format!(
"the {node} runs no deployment mode but the target's mode is `{}`",
printable(target_mode)
),
};
finding(check, ok, detail)
}
async fn host_system_name(
reader: &dyn TreeReader,
mode: &str,
) -> Result<Result<String, &'static str>, CoreError> {
let (module, ty) = SYSTEM_PROPERTIES_UNIT
.split_once('/')
.unwrap_or((SYSTEM_PROPERTIES_UNIT, ""));
let observed = reader
.read(&Scope {
mode: mode.to_string(),
types: vec![(module.to_string(), ty.to_string())],
tag_providers: Vec::new(),
})
.await?;
if observed
.local_units
.iter()
.any(|u| u == SYSTEM_PROPERTIES_UNIT)
{
return Ok(Err("it is overridden in the local collection"));
}
let unreadable_in = |collection: &str| {
observed.unreadable.iter().any(|u| {
u.collection == collection
&& u.path
.as_str()
.strip_prefix(SYSTEM_PROPERTIES_UNIT)
.is_some_and(|rest| rest.starts_with('/'))
})
};
if unreadable_in("mode") {
return Ok(Err(
"the mode collection's system-properties file has a path too long for the host's sftp server to read",
));
}
let path = RelPath::new(&format!("{SYSTEM_PROPERTIES_UNIT}/config.json"))
.map_err(|why| CoreError::Internal(format!("system-properties path: {why}")))?;
let blob = match observed.mode.get(&path) {
Some(blob) => blob,
None if unreadable_in("core") => {
return Ok(Err(
"the core collection's system-properties file has a path too long for the host's sftp server to read",
));
}
None => match observed.core.get(&path) {
Some(blob) => blob,
None => return Ok(Err("the host has no system-properties file")),
},
};
let Ok(doc) = serde_json::from_slice::<Value>(&blob.bytes) else {
return Ok(Err("the system-properties file is not JSON"));
};
match doc.get("systemName").and_then(Value::as_str) {
Some(name) if !name.trim().is_empty() => Ok(Ok(name.to_string())),
_ => Ok(Err("the system-properties file has no systemName")),
}
}
pub(crate) fn wrong_target_problems(
rest: Option<&str>,
host: Result<&str, &str>,
declared: Option<&str>,
) -> Vec<String> {
let mut problems: Vec<String> = Vec::new();
match (rest, host) {
(None, _) => problems.push("the gateway reports no name over REST".to_string()),
(Some(_), Err(why)) => problems.push(format!("the host's systemName is unreadable: {why}")),
(Some(rest), Ok(host)) if rest != host => {
problems.push("the gateway's REST name is not the host's systemName".to_string())
}
(Some(_), Ok(_)) => {}
}
if let (Some(rest), Some(declared)) = (rest, declared)
&& rest != declared
{
problems.push("the gateway's REST name is not nodes.master.gateway_name".to_string());
}
problems
}
pub(crate) fn wrong_target_failure(problems: &[String]) -> PreflightFailure {
PreflightFailure {
check: PreflightCheck::WrongTarget.as_str().to_string(),
detail: problems.join("; "),
}
}
pub(crate) async fn wrong_target_gate(
rest: Option<&str>,
reader: &dyn TreeReader,
mode: &str,
declared: Option<&str>,
) -> Result<Vec<String>, CoreError> {
let host = host_system_name(reader, mode).await?;
let host = host.as_ref().map(String::as_str).map_err(|why| *why);
Ok(wrong_target_problems(rest, host, declared))
}
async fn wrong_target_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
let check = PreflightCheck::WrongTarget;
let rest = input
.gateway
.gateway_info()
.await?
.name
.filter(|n| !n.trim().is_empty());
let problems = wrong_target_gate(
rest.as_deref(),
input.reader,
&input.target.mode,
input.target.nodes.master.gateway_name.as_deref(),
)
.await?;
if problems.is_empty() {
Ok(finding(
check,
true,
"the REST name matches the host's systemName".to_string(),
))
} else {
Ok(finding(check, false, problems.join("; ")))
}
}
async fn write_rest_finding(gateway: &dyn GatewayApi) -> Result<PreflightFinding, CoreError> {
let check = PreflightCheck::WriteRest;
match gateway.scan_projects().await {
Ok(()) => Ok(finding(
check,
true,
"the project scan was accepted".to_string(),
)),
Err(CoreError::Auth { status: 403, .. }) => Ok(finding(
check,
false,
"the token is read-only (HTTP 403 on the project scan)".to_string(),
)),
Err(other) => Err(other),
}
}
async fn write_host_finding(
host: &dyn ApplyHost,
touched: &[String],
) -> Result<PreflightFinding, CoreError> {
let check = PreflightCheck::WriteHost;
let report = host.probe_paths(touched).await?;
if report.writable {
let detail = if crate::deploy::transport::core_tag_types(touched).is_empty() {
"the mode collection and the ign-deploy root are writable"
} else {
"the mode collection, the core tag folders and the ign-deploy root are writable"
};
return Ok(finding(check, true, detail.to_string()));
}
let detail = if report.problems.is_empty() {
"the host is not writable".to_string()
} else {
report.problems.join("; ")
};
Ok(finding(check, false, detail))
}
pub(crate) async fn read_designers(
gateway: &dyn GatewayApi,
) -> Result<Vec<DesignerSeen>, CoreError> {
let page = gateway.designers(&ListQuery::default()).await?;
Ok(page
.items
.into_iter()
.map(|d| {
(
printable(&d.user),
printable(&d.project),
printable(&d.address),
)
})
.collect())
}
pub(crate) fn designers_detail(open: &[DesignerSeen]) -> String {
let each: Vec<String> = open
.iter()
.map(|(user, project, host)| {
let project = if project.is_empty() {
"no project"
} else {
project
};
format!("{user} on {project} from {host}")
})
.collect();
let take = each.len().min(APPLY_LIST_SHOWN);
let mut text = format!(
"{} Designer session(s) open: {}",
open.len(),
each[..take].join(", ")
);
if each.len() > take {
text.push_str(&format!(" and {} more", each.len() - take));
}
text
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum WaitStep {
Clear,
TimedOut,
Sleep(Duration),
}
pub(crate) fn wait_step(open: usize, elapsed: Duration, policy: &DesignersPolicy) -> WaitStep {
if open == 0 {
WaitStep::Clear
} else if elapsed >= policy.timeout {
WaitStep::TimedOut
} else {
WaitStep::Sleep(
policy
.interval
.max(Duration::from_millis(1))
.min(policy.timeout - elapsed),
)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DesignersVerdict {
Clear,
Open,
Proceeded,
}
pub(crate) async fn designers_gate<F, Fut>(
mut read: F,
policy: &DesignersPolicy,
clock: &dyn Clock,
on_poll: &mut (dyn FnMut(usize) -> Result<(), CoreError> + Send),
) -> Result<(DesignersVerdict, Vec<DesignerSeen>), CoreError>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<Vec<DesignerSeen>, CoreError>>,
{
let started = clock.now();
let mut open = read().await?;
if policy.mode == DesignersMode::Wait {
loop {
on_poll(open.len())?;
let elapsed = clock.now().saturating_duration_since(started);
match wait_step(open.len(), elapsed, policy) {
WaitStep::Clear | WaitStep::TimedOut => break,
WaitStep::Sleep(pause) => {
tokio::time::sleep(pause).await;
open = read().await?;
}
}
}
}
let verdict = if open.is_empty() {
DesignersVerdict::Clear
} else if policy.mode == DesignersMode::Proceed {
DesignersVerdict::Proceeded
} else {
DesignersVerdict::Open
};
Ok((verdict, open))
}
async fn designers_finding(
input: &PreflightInput<'_>,
steps: &mut Steps<'_>,
) -> Result<(PreflightFinding, Vec<DesignerSeen>), CoreError> {
let check = PreflightCheck::Designers;
let (verdict, open) = designers_gate(
|| read_designers(input.gateway),
input.designers,
input.clock,
&mut |count| steps.emit(ApplyEvent::DesignersPoll { open: count }),
)
.await?;
let found = match verdict {
DesignersVerdict::Clear => finding(check, true, "no Designer session is open".to_string()),
DesignersVerdict::Proceeded => {
steps.emit(ApplyEvent::DesignersProceed {
sessions: open.clone(),
})?;
finding(
check,
true,
format!(
"{}; going ahead because --designers proceed was given",
designers_detail(&open)
),
)
}
DesignersVerdict::Open => finding(check, false, designers_detail(&open)),
};
Ok((found, open))
}
pub(super) fn hold_budget_finding(hold: &HoldModel, files: usize) -> PreflightFinding {
let estimate = hold.estimate_commit_s(files);
let needed = hold_for(estimate, hold.p95_scan_s);
let detail = format!(
"{files} file(s): estimated commit {estimate:.1} s and scan {:.1} s need a hold of {needed} s; \
the budget is {} s",
hold.p95_scan_s, hold.hold_s
);
finding(PreflightCheck::HoldBudget, needed <= hold.hold_s, detail)
}
async fn master_active_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
let check = PreflightCheck::MasterActive;
if input.target.nodes.backup.is_none() {
return Ok(PreflightFinding {
check,
status: FindingStatus::Skipped,
detail: "the target has no backup node".to_string(),
});
}
let Some(backup) = input.backup else {
return Ok(finding(
check,
false,
"the backup node has no gateway connection, so which node is active cannot be confirmed"
.to_string(),
));
};
let master = input.gateway.redundancy_status().await?;
let standby = backup.status().await?;
let level = |wire: &RedundancyStatusWire| {
wire.activity_level
.as_deref()
.map_or_else(|| "none".to_string(), printable)
};
let (master_level, backup_level) = (level(&master), level(&standby));
let both = format!("master {master_level}, backup {backup_level}");
Ok(match active_node(&master, &standby) {
ActiveNode::OnlyA => finding(
check,
true,
format!("the master is the active node ({both})"),
),
ActiveNode::OnlyB => finding(
check,
false,
format!(
"the backup reports activityLevel Active and the master {master_level}: \
activity moved to the backup (the pair switched over); apply and rollback write only to the active master ({both})"
),
),
ActiveNode::Both => finding(
check,
false,
format!("both nodes report activityLevel Active (split-brain) ({both})"),
),
ActiveNode::Neither => finding(
check,
false,
format!("neither node reports activityLevel Active ({both})"),
),
})
}
async fn backup_read_finding(input: &PreflightInput<'_>) -> (PreflightFinding, Vec<String>) {
let check = PreflightCheck::BackupRead;
let skipped = |detail: &str| {
(
PreflightFinding {
check,
status: FindingStatus::Skipped,
detail: detail.to_string(),
},
Vec::new(),
)
};
if input.target.nodes.backup.is_none() {
return skipped("the target has no backup node");
}
let Some(declared) = input.declared else {
return skipped("a rollback renders no tree, so the backup's files are not compared");
};
let Some(backup) = input.backup else {
return skipped("the backup node has no gateway connection, so its files are not read");
};
if !backup.has_files() {
return skipped(
"nodes.backup declares no ssh or local transport, so the file-hash check did not run",
);
}
let scope = Scope::from_manifest(
&declared.manifest,
&declared.target.mode,
&declared.excluded_tag_providers(),
);
match backup.read_files(&scope).await {
Some(Ok(observed)) => {
let blocked = backup_blocked_units(input, declared, &observed);
if blocked.is_empty() {
return (
finding(
check,
true,
format!(
"read {} files of the backup's mode collection",
observed.core.len() + observed.mode.len()
),
),
blocked,
);
}
let take = blocked.len().min(APPLY_LIST_SHOWN);
let mut detail = format!(
"the backup's sftp server cannot read {} unit(s) this apply writes or deletes: {}",
blocked.len(),
blocked[..take]
.iter()
.map(|k| printable(k))
.collect::<Vec<_>>()
.join(", ")
);
if blocked.len() > take {
detail.push_str(&format!(" and {} more", blocked.len() - take));
}
(finding(check, false, detail), blocked)
}
Some(Err(err)) => (
finding(
check,
false,
format!(
"the backup's mode collection cannot be read: {}",
printable(err.code())
),
),
Vec::new(),
),
None => skipped(
"nodes.backup declares no ssh or local transport, so the file-hash check did not run",
),
}
}
fn backup_blocked_units(
input: &PreflightInput<'_>,
declared: &Declared,
observed: &ObservedFiles,
) -> Vec<String> {
let units = unreadable_units(observed);
if units.is_empty() {
return Vec::new();
}
let present: BTreeSet<RelPath> = declared.outcome.tree.files.keys().cloned().collect();
let absent: BTreeSet<RelPath> = input
.deleted_paths
.iter()
.filter_map(|p| RelPath::new(split_key(p).1).ok())
.collect();
let mut comparison = FileComparison::default();
exempt_unreadable(&mut comparison, &present, &absent, &[("backup", &units)]);
let mut keys: Vec<String> = comparison
.unreadable
.into_iter()
.map(|(_, key)| key)
.collect();
keys.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
keys.dedup();
keys
}
async fn secrets_finding(input: &PreflightInput<'_>) -> Result<PreflightFinding, CoreError> {
let check = PreflightCheck::Secrets;
let skipped = |detail: &str| PreflightFinding {
check,
status: FindingStatus::Skipped,
detail: detail.to_string(),
};
let Some(declared) = input.declared else {
return Ok(skipped(
"a rollback renders no tree, so no secret reference is checked",
));
};
let all_referenced = collect_referenced_secrets(&declared.outcome.tree);
if all_referenced.is_empty() {
return Ok(skipped("the rendered tree references no secret"));
}
let defined = secrets_defined_by_render(&declared.outcome.tree);
let referenced: BTreeSet<(String, String)> =
all_referenced.difference(&defined).cloned().collect();
if referenced.is_empty() {
return Ok(skipped(
"every secret name the rendered tree references is defined by the tree itself",
));
}
let own_names = all_referenced.len() - referenced.len();
let declared_providers = declared_secret_providers(&declared.outcome.tree);
let providers: BTreeSet<&str> = referenced.iter().map(|(p, _)| p.as_str()).collect();
let mut reports = Vec::with_capacity(2);
let mut on_master = BTreeMap::new();
for provider in &providers {
on_master.insert(
(*provider).to_string(),
input.master.secret_names(provider).await?,
);
}
reports.push(check_secret_names(
"master",
&referenced,
&on_master,
&declared_providers,
SecretCheckMode::Preflight,
));
if input.target.nodes.backup.is_some() {
let Some(backup) = input.backup else {
return Ok(finding(
check,
false,
"the backup node has no gateway connection, so its secret names cannot be checked"
.to_string(),
));
};
let mut on_backup = BTreeMap::new();
for provider in &providers {
on_backup.insert(
(*provider).to_string(),
backup.secret_names(provider).await?,
);
}
reports.push(check_secret_names(
"backup",
&referenced,
&on_backup,
&declared_providers,
SecretCheckMode::Preflight,
));
}
let missing: Vec<String> = reports
.iter()
.flat_map(|r| {
r.missing.iter().map(|m| {
format!(
"{}: {}/{}",
r.node,
printable(&m.provider),
printable(&m.name)
)
})
})
.collect();
let mut notes: Vec<String> = Vec::new();
for r in &reports {
if !r.skipped_providers.is_empty() {
notes.push(format!(
"{}: skipped {} (declared by this render, not on the node yet)",
r.node,
provider_list(&r.skipped_providers)
));
}
if !r.unverifiable.is_empty() {
notes.push(format!(
"{}: unverifiable {} (the provider cannot list its names)",
r.node,
provider_list(&r.unverifiable)
));
}
}
if missing.is_empty() {
let mut detail = format!(
"{} referenced secret name(s) in {} provider(s) checked on {}",
referenced.len(),
providers.len(),
reports
.iter()
.map(|r| r.node.as_str())
.collect::<Vec<_>>()
.join(" and ")
);
if own_names > 0 {
detail.push_str(&format!(
"; {own_names} name(s) defined by this render were not required on the node"
));
}
for note in notes {
detail.push_str("; ");
detail.push_str(¬e);
}
return Ok(finding(check, true, detail));
}
let take = missing.len().min(APPLY_LIST_SHOWN);
let mut detail = format!(
"{} referenced secret name(s) missing: {}",
missing.len(),
missing[..take].join(", ")
);
if missing.len() > take {
detail.push_str(&format!(" and {} more", missing.len() - take));
}
for note in notes {
detail.push_str("; ");
detail.push_str(¬e);
}
Ok(finding(check, false, detail))
}
fn provider_list(providers: &[String]) -> String {
let take = providers.len().min(APPLY_LIST_SHOWN);
let mut text = providers[..take]
.iter()
.map(|p| printable(p))
.collect::<Vec<_>>()
.join(", ");
if providers.len() > take {
text.push_str(&format!(" and {} more", providers.len() - take));
}
text
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ids_are_the_contract_words_in_report_order() {
let ids: Vec<&str> = PreflightCheck::ALL.iter().map(|c| c.as_str()).collect();
assert_eq!(
ids,
[
"mode_master",
"mode_backup",
"wrong_target",
"write_rest",
"write_host",
"designers",
"hold_budget",
"master_active",
"backup_read",
"secrets"
]
);
}
#[test]
fn production_defaults_are_the_decided_numbers() {
assert_eq!((DESIGNERS_POLL_S, DESIGNERS_TIMEOUT_DEFAULT_S), (10, 600));
let d = DesignersPolicy::default();
assert_eq!(d.mode, DesignersMode::Fail);
assert_eq!(d.timeout, Duration::from_secs(600));
assert_eq!(d.interval, Duration::from_secs(10));
}
#[test]
fn the_session_list_is_capped_and_counts_the_rest() {
let open: Vec<DesignerSeen> = (0..13)
.map(|i| (format!("u{i}"), "P".to_string(), "h".to_string()))
.collect();
let text = designers_detail(&open);
assert!(text.starts_with("13 Designer session(s) open: u0 on P from h"));
assert!(text.ends_with("and 3 more"), "{text}");
assert!(!text.contains("u12"), "{text}");
}
#[test]
fn a_designer_without_a_project_is_named_so() {
let text = designers_detail(&[("bob".into(), String::new(), "h".into())]);
assert!(text.contains("bob on no project from h"), "{text}");
}
#[test]
fn printable_drops_controls_and_caps_length() {
assert_eq!(printable("a\nb\u{1b}c"), "abc");
let long = "x".repeat(SHOWN_NAME_CHARS + 5);
assert_eq!(printable(&long).len(), SHOWN_NAME_CHARS + 3);
}
#[test]
fn hold_budget_boundary_is_inclusive() {
let hold = |hold_s| HoldModel {
hold_s,
commit_fixed_s: 1.0,
commit_per_file_s: 0.5,
p95_scan_s: 1.0,
};
assert_eq!(
hold_budget_finding(&hold(14), 9).status,
FindingStatus::Pass
);
let over = hold_budget_finding(&hold(14), 10);
assert_eq!(over.status, FindingStatus::Fail);
assert!(over.detail.contains("hold of 15 s"), "{}", over.detail);
assert!(over.detail.contains("budget is 14 s"), "{}", over.detail);
assert!(
over.detail.contains("estimated commit 6.0 s"),
"{}",
over.detail
);
}
fn policy(timeout_ms: u64, interval_ms: u64) -> DesignersPolicy {
DesignersPolicy {
mode: DesignersMode::Wait,
timeout: Duration::from_millis(timeout_ms),
interval: Duration::from_millis(interval_ms),
}
}
#[test]
fn wait_with_nothing_open_is_clear_at_any_time() {
let p = policy(1000, 100);
assert_eq!(wait_step(0, Duration::ZERO, &p), WaitStep::Clear);
assert_eq!(wait_step(0, Duration::from_secs(99), &p), WaitStep::Clear);
}
#[test]
fn wait_timeout_equal_to_the_elapsed_time_has_passed() {
let p = policy(1000, 100);
assert_eq!(
wait_step(1, Duration::from_millis(999), &p),
WaitStep::Sleep(Duration::from_millis(1))
);
assert_eq!(
wait_step(1, Duration::from_millis(1000), &p),
WaitStep::TimedOut
);
assert_eq!(
wait_step(1, Duration::from_millis(1001), &p),
WaitStep::TimedOut
);
}
#[test]
fn wait_sleeps_one_interval_unless_the_timeout_is_nearer() {
let p = policy(1000, 100);
assert_eq!(
wait_step(2, Duration::ZERO, &p),
WaitStep::Sleep(Duration::from_millis(100))
);
assert_eq!(
wait_step(2, Duration::from_millis(950), &p),
WaitStep::Sleep(Duration::from_millis(50))
);
}
#[test]
fn a_zero_interval_never_spins() {
let p = policy(1000, 0);
assert_eq!(
wait_step(1, Duration::ZERO, &p),
WaitStep::Sleep(Duration::from_millis(1))
);
}
use std::sync::Mutex;
use crate::deploy::apply::hold::SystemClock;
fn seen(user: &str) -> DesignerSeen {
(user.to_string(), "P".to_string(), "h".to_string())
}
async fn gate_over(
mode: DesignersMode,
timeout_ms: u64,
script: Vec<Vec<DesignerSeen>>,
) -> (DesignersVerdict, Vec<DesignerSeen>, Vec<usize>, usize) {
let reads = Mutex::new(0usize);
let policy = DesignersPolicy {
mode,
timeout: Duration::from_millis(timeout_ms),
interval: Duration::from_millis(1),
};
let mut polls = Vec::new();
let (verdict, open) = designers_gate(
|| {
let mut n = reads.lock().expect("reads");
let list = script[(*n).min(script.len() - 1)].clone();
*n += 1;
async move { Ok(list) }
},
&policy,
&SystemClock,
&mut |count| {
polls.push(count);
Ok(())
},
)
.await
.expect("the gate");
let n = *reads.lock().expect("reads");
(verdict, open, polls, n)
}
#[tokio::test]
async fn designers_gate_shared() {
let (verdict, open, polls, reads) = gate_over(DesignersMode::Fail, 0, vec![vec![]]).await;
assert_eq!(verdict, DesignersVerdict::Clear);
assert!(open.is_empty() && polls.is_empty());
assert_eq!(reads, 1);
let (verdict, open, polls, reads) =
gate_over(DesignersMode::Fail, 0, vec![vec![seen("alice")]]).await;
assert_eq!(verdict, DesignersVerdict::Open);
assert_eq!(open, [seen("alice")]);
assert!(polls.is_empty());
assert_eq!(reads, 1);
let (verdict, open, ..) =
gate_over(DesignersMode::Proceed, 0, vec![vec![seen("alice")]]).await;
assert_eq!(verdict, DesignersVerdict::Proceeded);
assert_eq!(open, [seen("alice")]);
let (verdict, open, polls, reads) = gate_over(
DesignersMode::Wait,
30_000,
vec![vec![seen("alice")], vec![seen("alice")], vec![]],
)
.await;
assert_eq!(verdict, DesignersVerdict::Clear);
assert!(open.is_empty());
assert_eq!(polls, [1, 1, 0]);
assert_eq!(reads, 3);
let (verdict, open, polls, _) =
gate_over(DesignersMode::Wait, 20, vec![vec![seen("alice")]]).await;
assert_eq!(verdict, DesignersVerdict::Open);
assert_eq!(open, [seen("alice")]);
assert!(polls.len() >= 2, "{polls:?}");
}
#[tokio::test]
async fn designers_gate_read_error_is_the_reads_own() {
let policy = DesignersPolicy::default();
let err = designers_gate(
|| async {
Err::<Vec<DesignerSeen>, _>(CoreError::TransportFailed {
operation: "read".to_string(),
node: "nodes.master".to_string(),
detail: "down".to_string(),
})
},
&policy,
&SystemClock,
&mut |_| Ok(()),
)
.await
.expect_err("the read fails");
assert_eq!(err.code(), "transport_failed");
}
}