use std::collections::{BTreeMap, BTreeSet};
use std::fmt;
use std::time::Duration;
use serde::Serialize;
use serde_json::{Map, Value};
use super::layout::TAG_TYPES;
use super::manifest::Manifest;
use super::plan::{PlanStatus, unit_key};
use super::render::RenderOutcome;
use crate::client::scan::ScanStatus;
pub const VERIFY_FAILED_SLUG: &str = "deploy_verify_failed";
pub const PROBE_DEADLINE: Duration = Duration::from_secs(30);
pub const PROBE_INTERVAL: Duration = Duration::from_secs(1);
pub const LOG_WINDOW_SLACK_MS: i64 = 1_000;
pub const LOG_WINDOW_FALLBACK_MS: i64 = 30_000;
pub const LOG_SETTLE_MS: i64 = 5_000;
pub const WILL_NOT_BE_LOADED: &str = "will not be loaded";
pub const NO_READ_ROUTE: &[(&str, &str)] = &[
("com.inductiveautomation.opcua", "one-time"),
("ignition", "images"),
];
pub const SYSTEM_COLLECTION: &str = "system";
const MAX_PATHS: usize = 20;
const ROOT_PATH: &str = "(root)";
const MAX_CODE_CHARS: usize = 96;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum CheckId {
Scan,
Mode,
ReadBack,
Files,
Loaded,
Probes,
}
impl CheckId {
pub fn word(self) -> &'static str {
match self {
Self::Scan => "scan",
Self::Mode => "mode",
Self::ReadBack => "read_back",
Self::Files => "files",
Self::Loaded => "loaded",
Self::Probes => "probes",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum CheckStatus {
Pass,
Fail,
Warn,
}
impl CheckStatus {
pub fn word(self) -> &'static str {
match self {
Self::Pass => "pass",
Self::Fail => "fail",
Self::Warn => "warn",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Finding {
pub code: &'static str,
pub subject: String,
pub detail: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<CheckStatus>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct Check {
pub id: CheckId,
pub status: CheckStatus,
pub detail: String,
pub findings: Vec<Finding>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct VerifyReport {
pub ok: bool,
pub slug: Option<&'static str>,
pub checks: Vec<Check>,
pub resources_checked: usize,
pub warnings: Vec<String>,
}
#[derive(Clone, PartialEq)]
pub enum Compare {
Exact(Value),
NameKeyedSubset(Value),
Attributes(Map<String, Value>),
}
impl fmt::Debug for Compare {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let kind = match self {
Compare::Exact(_) => "Exact",
Compare::NameKeyedSubset(_) => "NameKeyedSubset",
Compare::Attributes(_) => "Attributes",
};
write!(f, "{kind}(<withheld>)")
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ExpectedResource {
pub key: String,
pub module: String,
pub ty: String,
pub name: Option<String>,
pub compare: Compare,
}
impl ExpectedResource {
pub fn exact(module: &str, ty: &str, name: Option<&str>, config: Value) -> Self {
Self::new(module, ty, name, Compare::Exact(config))
}
pub fn new(module: &str, ty: &str, name: Option<&str>, compare: Compare) -> Self {
Self {
key: resource_id(module, ty, name),
module: module.to_string(),
ty: ty.to_string(),
name: name.map(str::to_string),
compare,
}
}
}
pub fn resource_id(module: &str, ty: &str, name: Option<&str>) -> String {
match name {
Some(name) => format!("{module}/{ty}/{name}"),
None => format!("{module}/{ty}"),
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Expected {
pub mode: String,
pub resources: Vec<ExpectedResource>,
pub not_checked: Vec<(String, String)>,
pub declared_ids: Vec<String>,
pub tag_units: Vec<String>,
pub probes: Vec<String>,
}
impl Expected {
pub fn from_render(target_mode: &str, outcome: &RenderOutcome, manifest: &Manifest) -> Self {
let files = &outcome.tree.files;
let is_tag = |module: &str, ty: &str| module == "ignition" && TAG_TYPES.contains(&ty);
let mut tag_units = BTreeSet::new();
for path in files.keys() {
let mut parts = path.components();
if let (Some(module), Some(ty)) = (parts.next(), parts.next())
&& is_tag(module, ty)
{
tag_units.insert(unit_key(path, files));
}
}
let mut resources = BTreeMap::new();
let mut not_checked = BTreeMap::new();
let mut declared_ids = BTreeSet::new();
for res in &outcome.resources {
if is_tag(&res.module, &res.ty) {
continue;
}
let key = resource_id(&res.module, &res.ty, res.name.as_deref());
declared_ids.insert(key.clone());
if NO_READ_ROUTE
.iter()
.any(|(m, t)| *m == res.module && *t == res.ty)
{
not_checked.insert(key, "no REST read route".to_string());
continue;
}
let file = |name: &str| {
super::layout::RelPath::new(&format!("{key}/{name}"))
.ok()
.and_then(|p| files.get(&p))
.map(Vec::as_slice)
};
let compare = match compare_for(&res.module, &res.ty, file) {
Ok(compare) => compare,
Err(reason) => {
not_checked.insert(key, reason.to_string());
continue;
}
};
resources.insert(
key.clone(),
ExpectedResource {
key,
module: res.module.clone(),
ty: res.ty.clone(),
name: res.name.clone(),
compare,
},
);
}
Expected {
mode: target_mode.to_string(),
resources: resources.into_values().collect(),
not_checked: not_checked.into_iter().collect(),
declared_ids: declared_ids.into_iter().collect(),
tag_units: tag_units.into_iter().collect(),
probes: manifest
.verify
.as_ref()
.map(|v| v.tag_probes.clone())
.unwrap_or_default(),
}
}
}
pub fn compare_for<'f>(
module: &str,
ty: &str,
file: impl Fn(&str) -> Option<&'f [u8]>,
) -> Result<Compare, &'static str> {
if let Some(bytes) = file("config.json") {
match serde_json::from_slice::<Value>(bytes) {
Ok(value) if module == "ignition" && ty == "security-levels" => {
Ok(Compare::NameKeyedSubset(value))
}
Ok(value) => Ok(Compare::Exact(value)),
Err(_) => Err("config.json is not JSON"),
}
} else if let Some(attributes) = file("resource.json")
.and_then(|bytes| serde_json::from_slice::<Value>(bytes).ok())
.and_then(|v| v.get("attributes").and_then(Value::as_object).cloned())
{
Ok(Compare::Attributes(attributes))
} else {
Err("no config.json to compare")
}
}
#[derive(Clone, PartialEq)]
pub enum Remote {
Missing,
Config(Value),
System,
}
impl fmt::Debug for Remote {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Remote::Missing => f.write_str("Missing"),
Remote::Config(_) => f.write_str("Config(<withheld>)"),
Remote::System => f.write_str("System"),
}
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct LogLine {
pub timestamp: i64,
pub logger: String,
pub message: String,
}
impl fmt::Debug for LogLine {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("LogLine")
.field("timestamp", &self.timestamp)
.field("logger", &self.logger)
.field("message", &"<withheld>")
.finish()
}
}
#[derive(Clone, PartialEq, Eq)]
pub struct ProbeReading {
pub path: String,
pub quality: String,
}
impl fmt::Debug for ProbeReading {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("ProbeReading")
.field("path", &self.path)
.field("quality", &quality_code(&self.quality))
.finish()
}
}
#[derive(Debug, Clone)]
pub struct ObservedState {
pub scan: ScanStatus,
pub mode: Option<String>,
pub read_back: BTreeMap<String, Remote>,
pub files: Vec<(String, PlanStatus)>,
pub logs: Result<Vec<LogLine>, String>,
pub probes: Option<Result<Vec<ProbeReading>, String>>,
pub unreadable: Vec<(String, bool)>,
}
pub fn log_window_start(scan: &ScanStatus) -> i64 {
let duration = scan.last_scan_duration.unwrap_or(LOG_WINDOW_FALLBACK_MS);
(scan.last_scan_timestamp - duration - LOG_WINDOW_SLACK_MS).max(0)
}
#[derive(Debug, Clone, Copy)]
pub enum ReadBackJudge<'a> {
Strict,
Standalone {
file_status: &'a BTreeMap<String, PlanStatus>,
},
Baseline(&'a ReadBackBaseline),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum BaselineBasis {
DeclaredFile,
LiveFile,
Strict,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Written {
Added,
Changed(Option<Compare>),
}
#[derive(Debug, Clone, PartialEq)]
pub struct BaselineEntry {
basis: BaselineBasis,
before: Remote,
differs_at: BTreeSet<String>,
writes_at: BTreeSet<String>,
shown_at: BTreeSet<String>,
}
impl BaselineEntry {
pub fn basis(&self) -> BaselineBasis {
self.basis
}
pub fn differs_at(&self) -> &BTreeSet<String> {
&self.differs_at
}
pub fn writes_at(&self) -> &BTreeSet<String> {
&self.writes_at
}
fn unobservable(&self, r0: &Value, r1: &Value) -> BTreeSet<String> {
self.writes_at
.iter()
.filter(|w| !shows(r0, "", w) && !shows(r1, "", w))
.cloned()
.collect()
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct ReadBackBaseline {
node: String,
resources: BTreeMap<String, BaselineEntry>,
}
impl ReadBackBaseline {
pub fn build(
node: &str,
expected: &Expected,
before: &BTreeMap<String, Remote>,
written: &BTreeMap<String, Written>,
) -> ReadBackBaseline {
let against = |remote: &Remote, compare: &Compare| match remote {
Remote::Config(rest) => read_back_paths(compare, rest),
Remote::Missing | Remote::System => BTreeSet::new(),
};
let resources = expected
.resources
.iter()
.map(|res| {
let r0 = before.get(&res.key).cloned().unwrap_or(Remote::Missing);
let (basis, differs_at, writes_at, shown_at) = match (written.get(&res.key), &r0) {
(None, _) => (
BaselineBasis::DeclaredFile,
against(&r0, &res.compare),
BTreeSet::new(),
BTreeSet::new(),
),
(Some(Written::Changed(Some(live))), Remote::Config(rest)) => {
let writes_at = write_paths(&res.compare, live);
let shown_at = already_shown(&writes_at, &res.compare, rest);
(
BaselineBasis::LiveFile,
read_back_paths(live, rest),
writes_at,
shown_at,
)
}
(Some(_), _) => (
BaselineBasis::Strict,
against(&r0, &res.compare),
BTreeSet::new(),
BTreeSet::new(),
),
};
(
res.key.clone(),
BaselineEntry {
basis,
before: r0,
differs_at,
writes_at,
shown_at,
},
)
})
.collect();
ReadBackBaseline {
node: node.to_string(),
resources,
}
}
pub fn node(&self) -> &str {
&self.node
}
pub fn entry(&self, key: &str) -> Option<&BaselineEntry> {
self.resources.get(key)
}
pub fn record(&self) -> BaselineRecord {
let resources = self
.resources
.iter()
.map(|(key, entry)| {
let state = match &entry.before {
Remote::Config(_) => "config",
Remote::Missing => "missing",
Remote::System => "system",
};
(
key.clone(),
BaselineRecordEntry {
state,
basis: entry.basis,
differs_at: entry.differs_at.iter().cloned().collect(),
writes_at: entry.writes_at.iter().cloned().collect(),
},
)
})
.collect();
BaselineRecord {
node: self.node.clone(),
resources,
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BaselineRecord {
pub node: String,
pub resources: BTreeMap<String, BaselineRecordEntry>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
pub struct BaselineRecordEntry {
pub state: &'static str,
pub basis: BaselineBasis,
pub differs_at: Vec<String>,
pub writes_at: Vec<String>,
}
pub fn path_covered(path: &str, by: &BTreeSet<String>) -> bool {
by.iter().any(|ancestor| path_under(path, ancestor))
}
fn path_under(path: &str, ancestor: &str) -> bool {
ancestor == ROOT_PATH
|| path == ancestor
|| (path.len() > ancestor.len()
&& path.starts_with(ancestor)
&& matches!(path.as_bytes()[ancestor.len()], b'.' | b'['))
}
fn paths_overlap(a: &str, b: &str) -> bool {
path_under(a, b) || path_under(b, a)
}
fn shows(rest: &Value, at: &str, target: &str) -> bool {
if rest.is_null() {
return false;
}
if target == ROOT_PATH || at == target {
return true;
}
let below =
|child: &str, value: &Value| path_under(target, child) && shows(value, child, target);
match rest {
Value::Object(map) => map
.iter()
.any(|(key, value)| below(&child_path(at, key), value)),
Value::Array(items) => items
.iter()
.enumerate()
.any(|(i, value)| below(&child_path(at, &i.to_string()), value)),
_ => true,
}
}
pub fn evaluate(expected: &Expected, observed: &ObservedState) -> VerifyReport {
evaluate_with(expected, observed, &ReadBackJudge::Strict)
}
pub fn evaluate_with(
expected: &Expected,
observed: &ObservedState,
judge: &ReadBackJudge<'_>,
) -> VerifyReport {
let mut warnings = Vec::new();
let (read_back, resources_checked) =
read_back_check_with(expected, &observed.read_back, judge, &mut warnings);
let checks = vec![
check_scan(observed),
check_mode(expected, observed),
read_back,
check_files(expected, observed, &mut warnings),
check_loaded(expected, observed),
check_probes(expected, observed),
];
for check in &checks {
if check.status == CheckStatus::Warn {
warnings.push(format!("{}: {}", check_word(check.id), check.detail));
}
}
let ok = checks.iter().all(|c| c.status != CheckStatus::Fail);
VerifyReport {
ok,
slug: (!ok).then_some(VERIFY_FAILED_SLUG),
checks,
resources_checked,
warnings,
}
}
fn check_word(id: CheckId) -> &'static str {
match id {
CheckId::Scan => "scan",
CheckId::Mode => "mode",
CheckId::ReadBack => "read_back",
CheckId::Files => "files",
CheckId::Loaded => "loaded",
CheckId::Probes => "probes",
}
}
fn finding(code: &'static str, subject: impl Into<String>, detail: impl Into<String>) -> Finding {
Finding {
code,
subject: subject.into(),
detail: detail.into(),
status: None,
}
}
fn sort_findings(findings: &mut [Finding]) {
findings.sort_by(|a, b| {
a.subject
.as_bytes()
.cmp(b.subject.as_bytes())
.then_with(|| a.code.cmp(b.code))
});
}
fn finished(id: CheckId, detail: String, findings: Vec<Finding>) -> Check {
Check {
id,
status: if findings.is_empty() {
CheckStatus::Pass
} else {
CheckStatus::Fail
},
detail,
findings,
}
}
fn check_scan(observed: &ObservedState) -> Check {
if observed.scan.scan_active {
return finished(
CheckId::Scan,
"a config scan is running".to_string(),
vec![finding(
"scan_active",
"config",
"the gateway reports an active config scan; verify reads settled state only",
)],
);
}
finished(
CheckId::Scan,
"no config scan is running".to_string(),
Vec::new(),
)
}
fn check_mode(expected: &Expected, observed: &ObservedState) -> Check {
mode_check(expected, observed.mode.as_deref())
}
pub(crate) fn mode_check(expected: &Expected, mode: Option<&str>) -> Check {
if mode == Some(expected.mode.as_str()) {
return finished(
CheckId::Mode,
format!("the gateway runs mode `{}`", expected.mode),
Vec::new(),
);
}
let seen = match mode {
Some(mode) => format!("`{}`", printable(mode)),
None => "no mode".to_string(),
};
finished(
CheckId::Mode,
"the gateway does not run the target's mode".to_string(),
vec![finding(
"mode_mismatch",
"mode",
format!("expected `{}`, the gateway reports {seen}", expected.mode),
)],
)
}
pub(crate) fn read_back_check_with(
expected: &Expected,
read_back: &BTreeMap<String, Remote>,
judge: &ReadBackJudge<'_>,
warnings: &mut Vec<String>,
) -> (Check, usize) {
let mut findings = Vec::new();
let mut checked = 0usize;
let mut warned = 0usize;
for res in &expected.resources {
let after = read_back.get(&res.key).unwrap_or(&Remote::Missing);
let paths = match after {
Remote::System => {
match system_red(&res.key, judge) {
Some(f) => {
checked += 1;
findings.push(f);
}
None => warnings.push(format!(
"read_back: {} not compared: the effective copy is in the `{SYSTEM_COLLECTION}` collection (gateway-provided, not file-backed)",
res.key
)),
}
continue;
}
Remote::Missing => None,
Remote::Config(rest) => Some(read_back_paths(&res.compare, rest)),
};
checked += 1;
for verdict in judge_resource(&res.key, after, paths.as_ref(), judge) {
match verdict {
Verdict::Pass => {}
Verdict::Red(f) => findings.push(f),
Verdict::Warn(mut f, line) => {
f.status = Some(CheckStatus::Warn);
findings.push(f);
warnings.push(line);
warned += 1;
}
}
}
}
for (key, reason) in &expected.not_checked {
warnings.push(format!("read_back: {key} not compared: {reason}"));
}
if checked == 0 && findings.is_empty() {
warnings.push(
"read_back: no declared non-tag resource was compared (nothing to read back)"
.to_string(),
);
}
sort_findings(&mut findings);
let mut detail =
format!("{checked} declared resource(s) compared with the REST effective view");
let status = if findings.iter().any(|f| f.status.is_none()) {
CheckStatus::Fail
} else if findings.is_empty() {
CheckStatus::Pass
} else {
detail.push_str(&format!("; {warned} difference(s) reported as warnings"));
CheckStatus::Warn
};
(
Check {
id: CheckId::ReadBack,
status,
detail,
findings,
},
checked,
)
}
pub(crate) fn read_back_check(
expected: &Expected,
read_back: &BTreeMap<String, Remote>,
warnings: &mut Vec<String>,
) -> (Check, usize) {
read_back_check_with(expected, read_back, &ReadBackJudge::Strict, warnings)
}
fn system_red(key: &str, judge: &ReadBackJudge<'_>) -> Option<Finding> {
let ReadBackJudge::Baseline(baseline) = judge else {
return None;
};
let entry = baseline.entry(key)?;
let why = match (entry.basis, &entry.before) {
(BaselineBasis::DeclaredFile, Remote::Config(_)) => {
"it was file-backed before this apply and dropped out of the effective view during this apply"
}
(BaselineBasis::DeclaredFile, _) => return None,
(BaselineBasis::LiveFile | BaselineBasis::Strict, _) => {
"this apply wrote it, so the write did not take effect"
}
};
Some(finding(
"resource_missing",
key,
format!(
"{MISSING_DETAIL} as a file-backed copy: the effective copy is in the `{SYSTEM_COLLECTION}` collection ({why})"
),
))
}
enum Verdict {
Pass,
Red(Finding),
Warn(Finding, String),
}
const MISSING_DETAIL: &str = "not in the gateway's effective view";
const UNOBSERVABLE_DETAIL: &str =
"not visible in the gateway's REST view, so the write could not be verified";
fn warn_differs(key: &str, p: &BTreeSet<String>, why: &str) -> Verdict {
if p.is_empty() {
return Verdict::Pass;
}
let at = join_set(p);
Verdict::Warn(
finding("resource_differs", key, format!("differs at: {at} ({why})")),
format!("read_back: {key} differs from its file at: {at} ({why})"),
)
}
fn warn_unobservable(key: &str, unseen: &BTreeSet<String>) -> Verdict {
if unseen.is_empty() {
return Verdict::Pass;
}
let verb = if unseen.len() == 1 { "is" } else { "are" };
let detail = format!("{} {verb} {UNOBSERVABLE_DETAIL}", join_set(unseen));
Verdict::Warn(
finding("resource_differs", key, detail.clone()),
format!("read_back: {key}: {detail}"),
)
}
fn judge_resource(
key: &str,
after: &Remote,
paths: Option<&BTreeSet<String>>,
judge: &ReadBackJudge<'_>,
) -> Vec<Verdict> {
let strict = || match paths {
None => Verdict::Red(finding("resource_missing", key, MISSING_DETAIL)),
Some(p) if p.is_empty() => Verdict::Pass,
Some(p) => Verdict::Red(finding(
"resource_differs",
key,
format!("differs at: {}", join_set(p)),
)),
};
let warn = |why: &str| match paths {
None => Verdict::Warn(
finding("resource_missing", key, format!("{MISSING_DETAIL} ({why})")),
format!("read_back: {key} is {MISSING_DETAIL} ({why})"),
),
Some(p) => warn_differs(key, p, why),
};
match judge {
ReadBackJudge::Strict => vec![strict()],
ReadBackJudge::Standalone { file_status } => {
vec![if file_status.get(key) == Some(&PlanStatus::Same) {
match paths {
None => warn("the live files equal the repo"),
Some(_) => {
warn("the live files equal the repo; the gateway's REST view differs")
}
}
} else {
strict()
}]
}
ReadBackJudge::Baseline(baseline) => {
let Some(entry) = baseline.entry(key) else {
return vec![strict()];
};
let Some(p) = paths else {
return vec![match (entry.basis, &entry.before) {
(BaselineBasis::DeclaredFile, Remote::Missing) => {
warn("it was not there before this apply either")
}
_ => strict(),
}];
};
judge_baseline(key, entry, after, p)
}
}
}
fn judge_baseline(
key: &str,
entry: &BaselineEntry,
after: &Remote,
p: &BTreeSet<String>,
) -> Vec<Verdict> {
let red = |detail: String| Verdict::Red(finding("resource_differs", key, detail));
let unseen = match (entry.basis, &entry.before, after) {
(BaselineBasis::LiveFile, Remote::Config(r0), Remote::Config(r1)) => {
entry.unobservable(r0, r1)
}
_ => BTreeSet::new(),
};
let mut out = vec![warn_unobservable(key, &unseen)];
if p.is_empty() {
return out;
}
out.push(match (entry.basis, &entry.before, after) {
(BaselineBasis::DeclaredFile, Remote::Config(r0), Remote::Config(r1)) => {
let moved = rest_moved(r0, r1);
if moved.is_empty() {
warn_differs(key, p, "already different before this apply")
} else {
red(format!(
"differs at: {}; its REST view changed during this apply at: {}",
join_set(p),
join_set(&moved)
))
}
}
(BaselineBasis::DeclaredFile, _, _) => red(format!(
"differs at: {}; its REST view changed during this apply at: {ROOT_PATH}",
join_set(p)
)),
(BaselineBasis::LiveFile, Remote::Config(r0), Remote::Config(r1)) => {
judge_written(key, entry, r0, r1, p, &unseen)
}
_ => red(format!("differs at: {}", join_set(p))),
});
out
}
fn judge_written(
key: &str,
entry: &BaselineEntry,
r0: &Value,
r1: &Value,
p: &BTreeSet<String>,
unseen: &BTreeSet<String>,
) -> Verdict {
let red = |detail: String| Verdict::Red(finding("resource_differs", key, detail));
let new: BTreeSet<String> = p
.iter()
.filter(|path| !path_covered(path, &entry.differs_at))
.filter(|path| !unseen.iter().any(|u| paths_overlap(path, u)))
.cloned()
.collect();
if !new.is_empty() {
return red(format!(
"differs at: {} (not different before this apply)",
join_set(&new)
));
}
let moved = rest_moved(r0, r1);
let judged: BTreeSet<&String> = entry
.writes_at
.iter()
.filter(|w| !unseen.contains(*w) && !entry.shown_at.contains(*w))
.collect();
if !judged.is_empty() && moved.is_empty() {
return red(format!(
"differs at: {} (its REST view did not change during this apply, so the write did not take effect)",
join_set(p)
));
}
let stuck: BTreeSet<String> = judged
.into_iter()
.filter(|w| p.iter().any(|q| paths_overlap(q, w)))
.filter(|w| !moved.iter().any(|m| paths_overlap(m, w)))
.cloned()
.collect();
if !stuck.is_empty() {
return red(format!(
"differs at: {} (its REST view did not change there during this apply, so that part of the write did not take effect)",
join_set(&stuck)
));
}
let left: BTreeSet<String> = p
.iter()
.filter(|q| !unseen.iter().any(|u| paths_overlap(q, u)))
.cloned()
.collect();
warn_differs(key, &left, "already different before this apply")
}
fn read_back_paths(compare: &Compare, rest: &Value) -> BTreeSet<String> {
let mut paths = Vec::new();
match compare {
Compare::Exact(file) => {
diff_exact(&strip_nulls(file), &strip_nulls(rest), "", &mut paths);
}
Compare::NameKeyedSubset(file) => {
diff_subset(&strip_nulls(file), &strip_nulls(rest), "", &mut paths);
}
Compare::Attributes(attrs) => diff_attributes(attrs, rest, &mut paths),
}
paths.into_iter().collect()
}
fn rest_moved(before: &Value, after: &Value) -> BTreeSet<String> {
let mut paths = Vec::new();
diff_exact(&strip_nulls(before), &strip_nulls(after), "", &mut paths);
paths.into_iter().collect()
}
fn write_paths(desired: &Compare, live: &Compare) -> BTreeSet<String> {
let ((kd, vd), (kl, vl)) = (compare_doc(desired), compare_doc(live));
if kd != kl {
return BTreeSet::from([ROOT_PATH.to_string()]);
}
let mut paths = Vec::new();
diff_exact(&vd, &vl, "", &mut paths);
paths.into_iter().collect()
}
fn compare_doc(c: &Compare) -> (u8, Value) {
match c {
Compare::Exact(v) => (0, strip_nulls(v)),
Compare::NameKeyedSubset(v) => (1, strip_nulls(v)),
Compare::Attributes(m) => (2, strip_nulls(&Value::Object(m.clone()))),
}
}
fn already_shown(writes_at: &BTreeSet<String>, desired: &Compare, r0: &Value) -> BTreeSet<String> {
let (_, doc) = compare_doc(desired);
let r0 = strip_nulls(r0);
writes_at
.iter()
.filter(|w| match (resolve(&r0, "", w), resolve(&doc, "", w)) {
(Some(seen), Some(wanted)) => {
let mut out = Vec::new();
diff_exact(wanted, seen, "", &mut out);
out.is_empty()
}
_ => false,
})
.cloned()
.collect()
}
fn resolve<'v>(value: &'v Value, at: &str, target: &str) -> Option<&'v Value> {
if target == ROOT_PATH || at == target {
return Some(value);
}
let below = |child: String, v: &'v Value| {
path_under(target, &child)
.then(|| resolve(v, &child, target))
.flatten()
};
match value {
Value::Object(map) => map
.iter()
.find_map(|(key, v)| below(child_path(at, key), v)),
Value::Array(items) => items
.iter()
.enumerate()
.find_map(|(i, v)| below(child_path(at, &i.to_string()), v)),
_ => None,
}
}
fn join_set(paths: &BTreeSet<String>) -> String {
join_paths(paths.iter().cloned().collect())
}
fn strip_nulls(value: &Value) -> Value {
match value {
Value::Object(map) => Value::Object(
map.iter()
.filter(|(_, v)| !v.is_null())
.map(|(k, v)| (k.clone(), strip_nulls(v)))
.collect(),
),
Value::Array(items) => Value::Array(items.iter().map(strip_nulls).collect()),
other => other.clone(),
}
}
fn child_path(path: &str, key: &str) -> String {
let key = printable(key);
if path.is_empty() {
key
} else {
format!("{path}.{key}")
}
}
fn root_or(path: &str) -> String {
if path.is_empty() {
ROOT_PATH.to_string()
} else {
path.to_string()
}
}
fn same_scalar(a: &Value, b: &Value) -> bool {
match (a, b) {
(Value::Number(x), Value::Number(y)) => {
if x.is_f64() || y.is_f64() {
matches!((x.as_f64(), y.as_f64()), (Some(x), Some(y)) if x == y)
} else {
x == y
}
}
_ => a == b,
}
}
fn diff_exact(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
match (file, rest) {
(Value::Object(f), Value::Object(r)) => {
for (key, fv) in f {
match r.get(key) {
Some(rv) => diff_exact(fv, rv, &child_path(path, key), out),
None => out.push(child_path(path, key)),
}
}
for key in r.keys().filter(|k| !f.contains_key(*k)) {
out.push(child_path(path, key));
}
}
(Value::Array(f), Value::Array(r)) if f.len() == r.len() => {
for (i, (fv, rv)) in f.iter().zip(r).enumerate() {
diff_exact(fv, rv, &child_path(path, &i.to_string()), out);
}
}
(Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
out.push(root_or(path));
}
(f, r) => {
if !same_scalar(f, r) {
out.push(root_or(path));
}
}
}
}
fn diff_subset(file: &Value, rest: &Value, path: &str, out: &mut Vec<String>) {
match (file, rest) {
(Value::Object(f), Value::Object(r)) => {
for (key, fv) in f {
match r.get(key) {
Some(rv) => diff_subset(fv, rv, &child_path(path, key), out),
None => out.push(child_path(path, key)),
}
}
}
(Value::Array(f), Value::Array(r)) => {
for (i, fv) in f.iter().enumerate() {
let name = fv.get("name").and_then(Value::as_str);
if let (Some(name), true) = (name, fv.is_object()) {
let at = format!("{path}[{}]", printable(name));
match r
.iter()
.find(|rv| rv.get("name").and_then(Value::as_str) == Some(name))
{
Some(rv) => diff_subset(fv, rv, &at, out),
None => out.push(at),
}
} else {
let found = r.iter().any(|rv| {
let mut inner = Vec::new();
diff_exact(fv, rv, "", &mut inner);
inner.is_empty()
});
if !found {
out.push(child_path(path, &i.to_string()));
}
}
}
}
(Value::Object(_) | Value::Array(_), _) | (_, Value::Object(_) | Value::Array(_)) => {
out.push(root_or(path));
}
(f, r) => {
if !same_scalar(f, r) {
out.push(root_or(path));
}
}
}
}
fn diff_attributes(attrs: &Map<String, Value>, rest: &Value, out: &mut Vec<String>) {
let Some(rest) = rest.as_object() else {
out.push("(root)".to_string());
return;
};
for (key, expected) in attrs {
if let Some(seen) = rest.get(key) {
let mut inner = Vec::new();
diff_exact(&strip_nulls(expected), &strip_nulls(seen), "", &mut inner);
if !inner.is_empty() {
out.push(key.clone());
}
}
}
}
fn join_paths(mut paths: Vec<String>) -> String {
paths.sort_by(|a, b| a.as_bytes().cmp(b.as_bytes()));
paths.dedup();
let more = paths.len().saturating_sub(MAX_PATHS);
paths.truncate(MAX_PATHS);
let mut text = paths.join(", ");
if more > 0 {
text.push_str(&format!(" (+{more} more)"));
}
text
}
const UNREADABLE_DETAIL: &str =
"the unit has a live path the host's sftp server cannot read; its live copy is unknown";
fn check_files(expected: &Expected, observed: &ObservedState, warnings: &mut Vec<String>) -> Check {
let mut findings = Vec::new();
for unit in &expected.tag_units {
if observed.unreadable.iter().any(|(key, _)| key == unit) {
findings.push(finding("unreadable", unit.clone(), UNREADABLE_DETAIL));
continue;
}
let status = observed
.files
.iter()
.find(|(key, _)| key == unit)
.map(|(_, status)| *status);
match status {
Some(PlanStatus::Same) => {}
Some(other) => findings.push(finding(
"files_differ",
unit.clone(),
format!(
"the effective live copy is `{}` against the render",
other.word()
),
)),
None => findings.push(finding(
"files_differ",
unit.clone(),
"the unit was not found among the live files",
)),
}
}
for (key, declared) in &observed.unreadable {
if expected.tag_units.contains(key) {
continue;
}
if *declared {
findings.push(finding("unreadable", key.clone(), UNREADABLE_DETAIL));
} else {
warnings.push(format!(
"files: {key} is unreadable on the host and is not declared; it was not compared"
));
}
}
sort_findings(&mut findings);
finished(
CheckId::Files,
format!(
"{} declared tag unit(s) compared byte for byte with the live files",
expected.tag_units.len()
),
findings,
)
}
fn check_loaded(expected: &Expected, observed: &ObservedState) -> Check {
if observed.scan.scan_active {
return Check {
id: CheckId::Loaded,
status: CheckStatus::Warn,
detail: "not observed: a scan is running, so its log lines are not readable yet"
.to_string(),
findings: Vec::new(),
};
}
let lines = match &observed.logs {
Ok(lines) => lines,
Err(slug) => {
return finished(
CheckId::Loaded,
"the gateway log could not be read".to_string(),
vec![finding("logs_unavailable", "logs", printable(slug))],
);
}
};
let mut hits: BTreeMap<&str, (i64, &str)> = BTreeMap::new();
for line in lines {
if !line.message.contains(WILL_NOT_BE_LOADED) {
continue;
}
let owner = expected
.declared_ids
.iter()
.filter(|id| line.message.contains(&format!("Resource {id} ")))
.max_by_key(|id| id.len());
if let Some(id) = owner {
let entry = hits
.entry(id.as_str())
.or_insert((line.timestamp, line.logger.as_str()));
if line.timestamp < entry.0 {
*entry = (line.timestamp, line.logger.as_str());
}
}
}
let mut findings: Vec<Finding> = hits
.into_iter()
.map(|(id, (timestamp, logger))| {
finding(
"unloaded_resource",
id,
format!("logged at {timestamp} by `{}`", printable(logger)),
)
})
.collect();
sort_findings(&mut findings);
let detail = if findings.is_empty() {
format!(
"no `{WILL_NOT_BE_LOADED}` line for a declared resource since the last scan (the gateway logs it once per scan that finds the resource, and not for every type, so this covers the last scan only)"
)
} else {
format!("{} declared resource(s) were not loaded", findings.len())
};
finished(CheckId::Loaded, detail, findings)
}
fn check_probes(expected: &Expected, observed: &ObservedState) -> Check {
if expected.probes.is_empty() {
return Check {
id: CheckId::Probes,
status: CheckStatus::Warn,
detail: "the manifest declares no `verify.tag_probes`; nothing was probed".to_string(),
findings: vec![finding(
"tag_probes_absent",
"verify.tag_probes",
"add tag paths to the manifest to probe them after a deploy",
)],
};
}
let readings = match &observed.probes {
Some(Ok(readings)) => readings,
Some(Err(slug)) => {
return finished(
CheckId::Probes,
"the probes could not be read".to_string(),
vec![finding("tag_probes_unavailable", "tags", printable(slug))],
);
}
None => {
return finished(
CheckId::Probes,
"the probes could not be read".to_string(),
vec![finding("tag_probes_unavailable", "tags", "not read")],
);
}
};
let mut findings = Vec::new();
for path in &expected.probes {
match readings.iter().find(|r| &r.path == path) {
Some(r) if r.quality.starts_with("Good") => {}
Some(r) => findings.push(finding(
"tag_probe_not_good",
path.clone(),
format!("quality {}", quality_code(&r.quality)),
)),
None => findings.push(finding("tag_probe_not_good", path.clone(), "no reading")),
}
}
finished(
CheckId::Probes,
format!("{} tag probe(s) read", expected.probes.len()),
findings,
)
}
fn quality_code(quality: &str) -> String {
if quality.is_empty() {
return "empty".to_string();
}
let (class, rest) = match quality.split_once('_') {
Some((class, rest)) => (class, Some(rest)),
None => (quality, None),
};
if !matches!(class, "Good" | "Bad" | "Uncertain" | "Error") {
return "unrecognised".to_string();
}
match rest {
Some(rest)
if !rest.is_empty()
&& rest.len() <= 48
&& rest.chars().all(|c| c.is_ascii_alphabetic() || c == '_') =>
{
format!("{class}_{rest}")
}
_ => class.to_string(),
}
}
fn printable(text: &str) -> String {
let mut out: String = text
.chars()
.filter(|c| !c.is_control())
.take(MAX_CODE_CHARS)
.collect();
if text.chars().count() > MAX_CODE_CHARS {
out.push_str("...");
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn words_equal_the_serialized_forms() {
for id in [
CheckId::Scan,
CheckId::Mode,
CheckId::ReadBack,
CheckId::Files,
CheckId::Loaded,
CheckId::Probes,
] {
assert_eq!(json!(id), json!(id.word()));
}
for status in [CheckStatus::Pass, CheckStatus::Fail, CheckStatus::Warn] {
assert_eq!(json!(status), json!(status.word()));
}
}
#[test]
fn quality_codes_carry_no_free_text() {
assert_eq!(quality_code("Good"), "Good");
assert_eq!(quality_code("Good_Provisional"), "Good_Provisional");
assert_eq!(quality_code("Bad_NotFound"), "Bad_NotFound");
assert_eq!(quality_code("Bad_secret-123"), "Bad");
assert_eq!(quality_code("Weird"), "unrecognised");
assert_eq!(quality_code(""), "empty");
}
#[test]
fn printable_drops_controls_and_caps_length() {
assert_eq!(printable("a\nb\u{7}c"), "abc");
let long = "x".repeat(MAX_CODE_CHARS + 10);
assert_eq!(printable(&long).len(), MAX_CODE_CHARS + 3);
}
#[test]
fn gateway_supplied_keys_are_sanitised_in_diff_paths() {
let hostile = format!("evil\u{1b}[31m{}", "k".repeat(MAX_CODE_CHARS * 2));
let mut rest = serde_json::Map::new();
rest.insert(hostile, json!(1));
let mut out = Vec::new();
diff_exact(&json!({}), &Value::Object(rest), "", &mut out);
assert_eq!(out.len(), 1);
assert!(!out[0].contains('\u{1b}'), "{:?}", out[0]);
assert!(
out[0].chars().count() <= MAX_CODE_CHARS + 3,
"{}",
out[0].len()
);
}
#[test]
fn large_integers_compare_exactly() {
let mut out = Vec::new();
diff_exact(
&json!({"id": 9_007_199_254_740_993_i64}),
&json!({"id": 9_007_199_254_740_992_i64}),
"",
&mut out,
);
assert_eq!(out, vec!["id".to_string()]);
let mut out = Vec::new();
diff_exact(
&json!({"id": u64::MAX}),
&json!({"id": u64::MAX - 1}),
"",
&mut out,
);
assert_eq!(out, vec!["id".to_string()]);
let mut out = Vec::new();
diff_exact(
&json!({"id": 9_007_199_254_740_993_i64}),
&json!({"id": 9_007_199_254_740_993_i64}),
"",
&mut out,
);
assert!(out.is_empty());
}
#[test]
fn numbers_compare_by_value() {
let mut out = Vec::new();
diff_exact(
&json!({"a": 1, "b": 2.5}),
&json!({"a": 1.0, "b": 2.5}),
"",
&mut out,
);
assert!(out.is_empty(), "{out:?}");
diff_exact(&json!({"a": 1}), &json!({"a": 2}), "", &mut out);
assert_eq!(out, vec!["a".to_string()]);
}
#[test]
fn shows_resolves_paths_ancestors_and_opaque_values() {
let rest = json!({
"settings": {"enabled": true, "blob": "opaque", "empty": {}, "gone": null},
"list": [{"host": "a"}]
});
assert!(shows(&rest, "", "settings.enabled"));
assert!(shows(&rest, "", "settings"));
assert!(shows(&rest, "", "list.0.host"));
assert!(shows(&rest, "", ROOT_PATH));
assert!(shows(&rest, "", "settings.blob.hostname"));
assert!(!shows(&rest, "", "settings.deviceSettings"));
assert!(!shows(&rest, "", "settings.gone"));
assert!(!shows(&rest, "", "list.1"));
assert!(!shows(&rest, "", "settings.empty.x"));
assert!(!shows(&rest, "", "settingsX"));
assert!(!shows(&Value::Null, "", ROOT_PATH));
assert!(!shows(&Value::Null, "", "namespace"));
}
#[test]
fn paths_overlap_is_symmetric_on_segment_boundaries() {
for (a, b) in [
("a", "a"),
("a", "a.b"),
("a", "a.b.c"),
("a", "a[n]"),
("s", "s.0.a"),
] {
assert!(paths_overlap(a, b), "{a} {b}");
assert!(paths_overlap(b, a), "{b} {a}");
}
for p in ["a", "a.b", "x[y]", ROOT_PATH] {
assert!(paths_overlap(ROOT_PATH, p), "{p}");
assert!(paths_overlap(p, ROOT_PATH), "{p}");
}
for (a, b) in [
("a", "ab"),
("a.b", "a.bc"),
("a.b", "a.c"),
("s.0", "s.1"),
("b.a", "a"),
] {
assert!(!paths_overlap(a, b), "{a} {b}");
assert!(!paths_overlap(b, a), "{b} {a}");
}
}
#[test]
fn resolve_finds_present_paths_only() {
let doc = json!({"s": {"a": 1, "blob": "opaque"}, "l": [{"h": "x"}]});
assert_eq!(resolve(&doc, "", "s.a"), Some(&json!(1)));
assert_eq!(resolve(&doc, "", "l.0.h"), Some(&json!("x")));
assert_eq!(resolve(&doc, "", ROOT_PATH), Some(&doc));
assert_eq!(resolve(&doc, "", "s"), doc.get("s"));
assert_eq!(resolve(&doc, "", "s.b"), None);
assert_eq!(resolve(&doc, "", "l.1"), None);
assert_eq!(resolve(&doc, "", "s.blob.hostname"), None);
assert_eq!(resolve(&doc, "", "sa"), None);
}
#[test]
fn path_list_is_sorted_deduplicated_and_capped() {
let paths: Vec<String> = (0..MAX_PATHS + 3)
.rev()
.map(|i| format!("p{i:02}"))
.collect();
let text = join_paths(paths);
assert!(text.starts_with("p00, p01"), "{text}");
assert!(text.ends_with("(+3 more)"), "{text}");
}
}