use super::hashing::{hash_desired_state, NON_IDENTITY_FIELDS};
use crate::core::types::{PlanAction, Resource, ResourceLock, ResourceStatus, ResourceType};
use serde_yaml_ng::{Mapping, Value};
fn generic_probes() -> Vec<Value> {
[
"forjar-e01-sentinel",
"7",
"true",
"[forjar-e01-sentinel]",
"[7]",
"{forjar-e01-sentinel: forjar-e01-sentinel}",
]
.iter()
.map(|s| serde_yaml_ng::from_str::<Value>(s).expect("probe literal parses"))
.collect()
}
const STRUCTURED_PROBES: &[(&str, &str)] = &[
("type", "file"),
("task_mode", "pipeline"),
("quality_gate", "{command: forjar-e01-gate}"),
("health_check", "{command: forjar-e01-health}"),
("restart_policy", "{max_restarts: 9}"),
("stages", "[{name: forjar-e01-stage}]"),
("output_equivalence", "{forjar-e01-artifact: external}"),
(
"budget_reclaim",
"[{name: forjar-e01-rule, roots: ['/tmp/forjar-e01']}]",
),
("lifecycle", "{prevent_destroy: true}"),
];
fn probes_for(field: &str) -> Vec<Value> {
let mut out: Vec<Value> = STRUCTURED_PROBES
.iter()
.filter(|(f, _)| *f == field)
.map(|(_, yaml)| serde_yaml_ng::from_str::<Value>(yaml).expect("structured probe parses"))
.collect();
out.extend(generic_probes());
out
}
fn as_mapping(resource: &Resource) -> Mapping {
match serde_yaml_ng::to_value(resource).expect("Resource serialises") {
Value::Mapping(m) => m,
_ => panic!("Resource is a struct"),
}
}
fn mutate_one(base_map: &Mapping, field: &str) -> Option<Resource> {
let key = Value::String(field.to_string());
let before = base_map.get(&key).cloned().unwrap_or(Value::Null);
for probe in probes_for(field) {
let mut m = base_map.clone();
m.insert(key.clone(), probe);
let Ok(candidate) = serde_yaml_ng::from_value::<Resource>(Value::Mapping(m)) else {
continue;
};
let after = as_mapping(&candidate)
.get(&key)
.cloned()
.unwrap_or(Value::Null);
if after != before {
return Some(candidate);
}
}
None
}
#[test]
fn every_identity_field_moves_the_desired_state_hash() {
let base = Resource::default();
let base_hash = hash_desired_state(&base);
let base_map = as_mapping(&base);
let mut unprobeable = Vec::new();
let mut unhashed = Vec::new();
let mut leaked = Vec::new();
for key in base_map.keys() {
let field = key.as_str().expect("field names are strings");
let Some(mutated) = mutate_one(&base_map, field) else {
unprobeable.push(field.to_string());
continue;
};
let moved = hash_desired_state(&mutated) != base_hash;
match (moved, NON_IDENTITY_FIELDS.contains(&field)) {
(false, false) => unhashed.push(field.to_string()),
(true, true) => leaked.push(field.to_string()),
_ => {}
}
}
assert!(
unprobeable.is_empty(),
"no probe value could set these fields, so the guard below cannot see them \
({} of {}):\n {}\n\nAdd a YAML probe to STRUCTURED_PROBES for each.",
unprobeable.len(),
base_map.len(),
unprobeable.join("\n ")
);
assert!(
unhashed.is_empty(),
"these {} of {} fields on `Resource` do NOT change hash_desired_state, so \
editing one leaves the lock hash identical: `plan` reports no change and \
`apply` reports `unchanged` over a machine that still holds the old \
value.\n {}\n\nEither fold the field into the desired-state hash or add \
it to NON_IDENTITY_FIELDS with a reason.",
unhashed.len(),
base_map.len(),
unhashed.join("\n ")
);
assert!(
leaked.is_empty(),
"these fields are on NON_IDENTITY_FIELDS but still change the hash, so a \
cosmetic edit forces a fleet-wide re-apply:\n {}",
leaked.join("\n ")
);
}
#[test]
fn non_identity_fields_are_all_real_resource_fields() {
let real = as_mapping(&Resource::default());
let stale: Vec<&&str> = NON_IDENTITY_FIELDS
.iter()
.filter(|f| !real.contains_key(Value::String((**f).to_string())))
.collect();
assert!(
stale.is_empty(),
"NON_IDENTITY_FIELDS names keys that are not fields on `Resource`: {stale:?}"
);
}
#[test]
fn the_reflection_sees_the_fields_e01_measured() {
let base = Resource::default();
let base_hash = hash_desired_state(&base);
let base_map = as_mapping(&base);
for field in [
"tag",
"binary",
"uid",
"checksum",
"driver_version",
"working_dir",
] {
let mutated = mutate_one(&base_map, field)
.unwrap_or_else(|| panic!("`{field}` could not be probed at all"));
assert_ne!(
hash_desired_state(&mutated),
base_hash,
"`{field}` is one of the fields E01 measured; if it does not move the \
hash the guard above is reporting on a probe that stopped working"
);
}
}
type EditCase = (&'static str, ResourceType, fn(&mut Resource));
fn converged_lock(
resource_type: ResourceType,
hash: String,
) -> std::collections::HashMap<String, crate::core::types::StateLock> {
let mut resources = indexmap::IndexMap::new();
resources.insert(
"r".to_string(),
ResourceLock {
resource_type,
status: ResourceStatus::Converged,
applied_at: None,
duration_seconds: None,
hash,
observed: None,
details: std::collections::HashMap::new(),
},
);
let mut locks = std::collections::HashMap::new();
locks.insert(
"m1".to_string(),
crate::core::types::StateLock {
schema: "1.0".to_string(),
machine: "m1".to_string(),
hostname: "m1".to_string(),
generated_at: "2026-01-01T00:00:00Z".to_string(),
generator: "forjar".to_string(),
blake3_version: "1.8".to_string(),
resources,
},
);
locks
}
#[test]
fn e01_editing_one_identity_field_replans_as_update() {
let cases: &[EditCase] = &[
("tag", ResourceType::GithubRelease, |r| {
r.tag = Some("v2.0.0".into());
}),
("binary", ResourceType::GithubRelease, |r| {
r.binary = Some("other-binary".into());
}),
("asset_pattern", ResourceType::GithubRelease, |r| {
r.asset_pattern = Some("*aarch64*".into());
}),
("uid", ResourceType::User, |r| r.uid = Some(4242)),
("ssh_authorized_keys", ResourceType::User, |r| {
r.ssh_authorized_keys = vec!["ssh-ed25519 AAAA-attacker".into()];
}),
("driver_version", ResourceType::Gpu, |r| {
r.driver_version = Some("570".into());
}),
("cuda_version", ResourceType::Gpu, |r| {
r.cuda_version = Some("12.9".into());
}),
("checksum", ResourceType::Model, |r| {
r.checksum = Some("blake3:deadbeef".into());
}),
("quantization", ResourceType::Model, |r| {
r.quantization = Some("q8_0".into());
}),
("working_dir", ResourceType::Task, |r| {
r.working_dir = Some("/srv/other".into());
}),
("timeout", ResourceType::Task, |r| r.timeout = Some(9_999)),
("sudo", ResourceType::Task, |r| r.sudo = true),
];
let mut silent = Vec::new();
for (field, rtype, edit) in cases {
let old = Resource {
resource_type: rtype.clone(),
machine: crate::core::types::MachineTarget::Single("m1".into()),
name: Some("r".into()),
..Resource::default()
};
let mut new = old.clone();
edit(&mut new);
let locks = converged_lock(rtype.clone(), hash_desired_state(&old));
let action =
super::determine_action("r", &new, "m1", &locks, &std::collections::HashMap::new());
if action != PlanAction::Update {
silent.push(format!("{field} ({rtype}) -> {action:?}"));
}
}
assert!(
silent.is_empty(),
"editing these fields after convergence did not replan as Update, so \
`forjar apply` reports `unchanged` and never writes the new value:\n {}",
silent.join("\n ")
);
}