use std::collections::BTreeSet;
use serde_json::Value;
use crate::cotejo::diff_object;
use crate::normalize::Normalizer;
use crate::verdict::{Divergence, Severity, Side};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)]
pub enum RuntimeOp {
Create,
Start,
State,
Kill,
Delete,
}
impl RuntimeOp {
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Self::Create => "create",
Self::Start => "start",
Self::State => "state",
Self::Kill => "kill",
Self::Delete => "delete",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RuntimeObservation {
pub op: RuntimeOp,
pub exit_code: Option<i32>,
pub stdout: Vec<u8>,
pub stderr: Vec<u8>,
}
impl RuntimeObservation {
#[must_use]
pub fn state_json(&self) -> Option<Value> {
if self.op != RuntimeOp::State {
return None;
}
serde_json::from_slice(&self.stdout).ok()
}
#[must_use]
pub fn is_success(&self) -> bool {
self.exit_code == Some(0)
}
}
#[must_use]
pub fn state_normalizer() -> Normalizer {
use crate::normalize::Mask;
use crate::path::JsonPath;
let mut n = Normalizer::default();
for key in [
"pid",
"bundle",
"systemd-scope",
"owner",
"created",
"annotations",
] {
n = n.with(Mask::Drop(JsonPath::parse(key)));
}
n
}
#[must_use]
pub fn diff_runtime_op(
reference: &RuntimeObservation,
under_test: &RuntimeObservation,
norm: &Normalizer,
) -> Vec<Divergence> {
use crate::path::JsonPath;
assert_eq!(reference.op, under_test.op, "compare like with like");
let op = reference.op;
if reference.exit_code != under_test.exit_code {
return vec![Divergence::FieldDiff {
path: JsonPath::parse(&format!("{}.exitCode", op.as_str())),
engenho: serde_json::json!(under_test.exit_code),
k3s: serde_json::json!(reference.exit_code),
severity: Severity::Hard,
}];
}
if !reference.is_success() {
return Vec::new();
}
if op == RuntimeOp::State {
return match (reference.state_json(), under_test.state_json()) {
(Some(r), Some(u)) => diff_object(&norm.apply(&u), &norm.apply(&r), Severity::Hard),
(r, u) => vec![Divergence::FieldDiff {
path: JsonPath::parse("state.document"),
engenho: serde_json::json!(if u.is_some() { "json" } else { "not json" }),
k3s: serde_json::json!(if r.is_some() { "json" } else { "not json" }),
severity: Severity::Hard,
}],
};
}
let mut out = Vec::new();
if reference.stdout != under_test.stdout {
out.push(Divergence::FieldDiff {
path: JsonPath::parse(&format!("{}.stdout", op.as_str())),
engenho: serde_json::json!(String::from_utf8_lossy(&under_test.stdout)),
k3s: serde_json::json!(String::from_utf8_lossy(&reference.stdout)),
severity: Severity::Hard,
});
}
if reference.stderr.is_empty() != under_test.stderr.is_empty() {
out.push(Divergence::FieldDiff {
path: JsonPath::parse(&format!("{}.stderr", op.as_str())),
engenho: serde_json::json!(if under_test.stderr.is_empty() {
"empty"
} else {
"non-empty"
}),
k3s: serde_json::json!(if reference.stderr.is_empty() {
"empty"
} else {
"non-empty"
}),
severity: Severity::Hard,
});
}
out
}
#[must_use]
pub fn diff_runtime_run(
reference: &[RuntimeObservation],
under_test: &[RuntimeObservation],
norm: &Normalizer,
) -> Vec<Divergence> {
use crate::path::JsonPath;
let r_ops: BTreeSet<RuntimeOp> = reference.iter().map(|o| o.op).collect();
let u_ops: BTreeSet<RuntimeOp> = under_test.iter().map(|o| o.op).collect();
if r_ops != u_ops {
return vec![Divergence::FieldDiff {
path: JsonPath::parse("lifecycle.subcommands"),
engenho: serde_json::json!(format!("{u_ops:?}")),
k3s: serde_json::json!(format!("{r_ops:?}")),
severity: Severity::Hard,
}];
}
let mut out = Vec::new();
for op in r_ops {
let r = reference.iter().find(|o| o.op == op).expect("present");
let u = under_test.iter().find(|o| o.op == op).expect("present");
out.extend(diff_runtime_op(r, u, norm));
}
out
}
#[must_use]
pub fn reference_side() -> Side {
Side::K3s
}
#[cfg(test)]
mod tests {
use super::*;
fn obs(op: RuntimeOp, code: i32, stdout: &str) -> RuntimeObservation {
RuntimeObservation {
op,
exit_code: Some(code),
stdout: stdout.as_bytes().to_vec(),
stderr: Vec::new(),
}
}
fn state(json: &str) -> RuntimeObservation {
obs(RuntimeOp::State, 0, json)
}
#[test]
fn the_same_run_against_itself_is_parity() {
let run = vec![
obs(RuntimeOp::Create, 0, ""),
obs(RuntimeOp::Start, 0, ""),
state(r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":1,"bundle":"/a"}"#),
obs(RuntimeOp::Delete, 0, ""),
];
assert!(diff_runtime_run(&run, &run, &state_normalizer()).is_empty());
}
#[test]
fn a_runtime_that_does_nothing_diverges_naming_the_field() {
let good = vec![obs(RuntimeOp::Create, 0, "")];
let dead = vec![obs(RuntimeOp::Create, 1, "")];
let d = diff_runtime_run(&good, &dead, &state_normalizer());
assert_eq!(d.len(), 1, "{d:?}");
assert!(
format!("{:?}", d[0]).contains("exitCode"),
"the divergence must NAME the field: {:?}",
d[0]
);
}
#[test]
fn per_run_and_runtime_private_state_keys_are_masked() {
let crun = state(
r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":11,
"bundle":"/x","systemd-scope":"s","owner":"root","created":"t1"}"#,
);
let runc = state(
r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":22,
"bundle":"/y","annotations":{"a":"b"}}"#,
);
assert!(
diff_runtime_run(&[crun], &[runc], &state_normalizer()).is_empty(),
"only the spec-defined fields may be compared"
);
}
#[test]
fn a_real_state_difference_is_still_caught_through_the_mask() {
let a = state(r#"{"ociVersion":"1.0.2","id":"c","status":"running","pid":1}"#);
let b = state(r#"{"ociVersion":"1.0.2","id":"c","status":"stopped","pid":2}"#);
let d = diff_runtime_run(&[a], &[b], &state_normalizer());
assert!(!d.is_empty(), "a status difference must survive the mask");
assert!(format!("{d:?}").contains("status"), "{d:?}");
}
#[test]
fn a_short_run_is_a_divergence_not_a_silent_pass() {
let full = vec![obs(RuntimeOp::Create, 0, ""), state(r#"{"id":"c"}"#)];
let short = vec![obs(RuntimeOp::Create, 0, "")];
let d = diff_runtime_run(&full, &short, &state_normalizer());
assert_eq!(d.len(), 1);
assert!(format!("{d:?}").contains("subcommands"), "{d:?}");
}
#[test]
fn stderr_is_compared_for_emptiness_not_for_prose() {
let mut a = obs(RuntimeOp::Kill, 0, "");
let mut b = obs(RuntimeOp::Kill, 0, "");
a.stderr = b"container not running\n".to_vec();
b.stderr = b"the container is not running\n".to_vec();
assert!(diff_runtime_run(&[a.clone()], &[b], &state_normalizer()).is_empty());
let quiet = obs(RuntimeOp::Kill, 0, "");
assert!(!diff_runtime_run(&[a], &[quiet], &state_normalizer()).is_empty());
}
}