use kube::ResourceExt;
use kube::api::{Api, ListParams, Patch, PatchParams};
use crate::context::OperatorContext;
use crate::crd::{
ApprovalMode, CandidatePhase, PlanPhase, PolicyMode, PostgresPolicy, PostgresPolicyCandidate,
PostgresPolicyPlan, candidate_reason, promoted_condition, ready_condition, set_condition_in,
};
use crate::plan::{PlanApprovalState, SupersedeCause, check_plan_approval};
use crate::reconciler::ReconcileError;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct CandidateFacts {
pub name: String,
pub content_digest: Option<String>,
pub terminal: bool,
pub plan: Option<PlanFacts>,
pub owns_applied_plan: bool,
pub promoted: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PlanFacts {
pub name: String,
pub approved: bool,
pub base_content_digest: Option<String>,
}
impl CandidateFacts {
fn approved_plan(&self) -> Option<&PlanFacts> {
self.plan.as_ref().filter(|plan| plan.approved)
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Promotion {
None,
Approved {
candidate: String,
plan: String,
superseded: Vec<String>,
},
WithoutApproval { candidate: String },
Mismatch { candidates: Vec<String> },
BaseChanged { candidates: Vec<String> },
}
pub fn decide_promotion(
policy_digest: &str,
previous_digest: Option<&str>,
candidates: &[CandidateFacts],
) -> Promotion {
let open = || candidates.iter().filter(|candidate| !candidate.terminal);
let matches = || {
open().filter(|candidate| {
candidate
.content_digest
.as_deref()
.is_some_and(|digest| digest == policy_digest)
})
};
let base_is_fresh = |plan: &PlanFacts| {
plan.base_content_digest.as_deref().is_some_and(|pin| {
previous_digest == Some(pin) || previous_digest == Some(policy_digest)
})
};
if let Some(matched) =
matches().find(|candidate| candidate.approved_plan().is_some_and(&base_is_fresh))
{
let plan = matched
.approved_plan()
.expect("matched on an approved plan");
return Promotion::Approved {
candidate: matched.name.clone(),
plan: plan.name.clone(),
superseded: open()
.filter(|other| other.name != matched.name)
.filter(|other| other.approved_plan().is_some())
.map(|other| other.name.clone())
.collect(),
};
}
let stale_approved: Vec<String> = matches()
.filter(|candidate| candidate.approved_plan().is_some())
.map(|candidate| candidate.name.clone())
.collect();
if !stale_approved.is_empty() {
return Promotion::BaseChanged {
candidates: stale_approved,
};
}
if let Some(matched) = matches().next() {
return Promotion::WithoutApproval {
candidate: matched.name.clone(),
};
}
let content_changed = previous_digest.is_some_and(|previous| previous != policy_digest);
if content_changed {
let stranded: Vec<String> = open()
.filter(|candidate| candidate.approved_plan().is_some())
.map(|candidate| candidate.name.clone())
.collect();
if !stranded.is_empty() {
return Promotion::Mismatch {
candidates: stranded,
};
}
}
Promotion::None
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PromotionAction {
Ignore,
ExecuteApprovedPlan { candidate: String, plan: String },
WithoutApproval { candidate: String },
Mismatch {
candidates: Vec<String>,
enforcement_suspended: bool,
},
NotExecuted { candidate: String },
BaseChanged {
candidates: Vec<String>,
enforcement_suspended: bool,
},
}
pub fn promotion_action(
promotion: Promotion,
mode: PolicyMode,
approval: ApprovalMode,
) -> PromotionAction {
let never_executes = mode.never_executes();
let enforcement_suspended = !never_executes && approval == ApprovalMode::Manual;
match promotion {
Promotion::None => PromotionAction::Ignore,
Promotion::Approved {
candidate, plan, ..
} => {
if never_executes {
PromotionAction::NotExecuted { candidate }
} else if approval == ApprovalMode::Auto {
PromotionAction::Ignore
} else {
PromotionAction::ExecuteApprovedPlan { candidate, plan }
}
}
Promotion::WithoutApproval { candidate } => {
if never_executes {
PromotionAction::NotExecuted { candidate }
} else if approval == ApprovalMode::Auto {
PromotionAction::Ignore
} else {
PromotionAction::WithoutApproval { candidate }
}
}
Promotion::Mismatch { candidates } => PromotionAction::Mismatch {
candidates,
enforcement_suspended,
},
Promotion::BaseChanged { candidates } => PromotionAction::BaseChanged {
candidates,
enforcement_suspended,
},
}
}
pub fn stranded_by_promotion(
promoted: &str,
applied_digest: &str,
candidates: &[CandidateFacts],
) -> Vec<String> {
candidates
.iter()
.filter(|candidate| !candidate.terminal)
.filter(|candidate| candidate.name != promoted)
.filter(|candidate| {
candidate
.approved_plan()
.is_some_and(|plan| plan.base_content_digest.as_deref() != Some(applied_digest))
})
.map(|candidate| candidate.name.clone())
.collect()
}
pub struct PromotionContext {
pub candidates: Vec<PostgresPolicyCandidate>,
pub facts: Vec<CandidateFacts>,
plans: Vec<(String, PostgresPolicyPlan)>,
}
impl PromotionContext {
pub fn candidate(&self, name: &str) -> Option<&PostgresPolicyCandidate> {
self.candidates
.iter()
.find(|candidate| candidate.name_any() == name)
}
pub fn plan_of(&self, candidate: &str) -> Option<&PostgresPolicyPlan> {
self.plans
.iter()
.find(|(owner, _)| owner == candidate)
.map(|(_, plan)| plan)
}
}
fn plan_is_live(plan: &PostgresPolicyPlan) -> bool {
plan.status
.as_ref()
.is_some_and(|status| matches!(status.phase, PlanPhase::Pending | PlanPhase::Approved))
}
pub async fn load_context(
ctx: &OperatorContext,
policy: &PostgresPolicy,
) -> Result<PromotionContext, ReconcileError> {
let namespace = policy.namespace().ok_or(ReconcileError::NoNamespace)?;
let candidates: Vec<PostgresPolicyCandidate> =
Api::<PostgresPolicyCandidate>::namespaced(ctx.kube_client.clone(), &namespace)
.list(&ListParams::default())
.await?
.into_iter()
.filter(|candidate| crate::candidate::candidate_belongs_to(candidate, policy))
.collect();
if candidates.is_empty() {
return Ok(PromotionContext {
candidates,
facts: Vec::new(),
plans: Vec::new(),
});
}
let all_plans: Vec<PostgresPolicyPlan> =
Api::<PostgresPolicyPlan>::namespaced(ctx.kube_client.clone(), &namespace)
.list(&ListParams::default())
.await?
.into_iter()
.collect();
let mut plans: Vec<(String, PostgresPolicyPlan)> = Vec::new();
let mut facts: Vec<CandidateFacts> = Vec::new();
for candidate in &candidates {
let uid = candidate.metadata.uid.clone().unwrap_or_default();
let owned: Vec<&PostgresPolicyPlan> = all_plans
.iter()
.filter(|plan| !uid.is_empty() && crate::plan::is_owned_by_uid(*plan, &uid))
.collect();
let live = owned.iter().copied().find(|plan| plan_is_live(plan));
let plan_facts = live.map(|plan| PlanFacts {
name: plan.name_any(),
approved: check_plan_approval(plan) == PlanApprovalState::Approved,
base_content_digest: plan
.spec
.origin
.as_ref()
.and_then(|origin| origin.base_content_digest.clone()),
});
if let Some(plan) = live {
plans.push((candidate.name_any(), plan.clone()));
}
let owns_applied_plan = owned.iter().any(|plan| {
plan.status
.as_ref()
.is_some_and(|status| status.phase == PlanPhase::Applied)
});
facts.push(CandidateFacts {
name: candidate.name_any(),
content_digest: candidate
.status
.as_ref()
.and_then(|status| status.content_digest.clone()),
terminal: candidate
.status
.as_ref()
.map(|status| status.phase)
.unwrap_or_default()
.is_terminal(),
plan: plan_facts,
owns_applied_plan,
promoted: candidate
.status
.as_ref()
.is_some_and(|status| status.phase == CandidatePhase::Promoted),
});
}
Ok(PromotionContext {
candidates,
facts,
plans,
})
}
pub async fn recognize(
ctx: &OperatorContext,
policy: &PostgresPolicy,
content_digest: &str,
) -> Result<Option<PostgresPolicyPlan>, ReconcileError> {
let context = load_context(ctx, policy).await?;
let previous = policy
.status
.as_ref()
.and_then(|status| status.content_digest.clone());
let promotion = decide_promotion(content_digest, previous.as_deref(), &context.facts);
let action = promotion_action(
promotion,
policy.spec.mode,
policy.spec.effective_approval(),
);
let plan = match &action {
PromotionAction::Ignore => None,
PromotionAction::ExecuteApprovedPlan { candidate, plan } => {
tracing::info!(
policy = %policy.name_any(),
%candidate,
%plan,
"recognised promotion of an approved candidate; its plan is this reconcile's plan"
);
context.plan_of(candidate).cloned().filter(|found| {
found.name_any() == *plan
})
}
PromotionAction::WithoutApproval { candidate } => {
let message = format!(
"this candidate's content was promoted into policy {} while its plan held no \
approval, so nothing executed on it; the policy falls back to its ordinary \
manual-plan flow, and this candidate becomes Promoted once that fresh plan is \
approved and applied",
policy.name_any()
);
report(
ctx,
&context,
candidate,
candidate_reason::PROMOTED_WITHOUT_APPROVAL,
&message,
)
.await?;
None
}
PromotionAction::NotExecuted { candidate } => {
let message = format!(
"this candidate's content was promoted into policy {}, but that policy is in \
mode: observe and never executes; the candidate can only become Promoted once \
the \
policy is in mode: apply and the content is applied",
policy.name_any()
);
report(
ctx,
&context,
candidate,
candidate_reason::PROMOTION_NOT_EXECUTED,
&message,
)
.await?;
None
}
PromotionAction::Mismatch {
candidates,
enforcement_suspended,
} => {
for candidate in candidates {
let mut message = format!(
"policy {} now carries content that is not this approved candidate — it was \
edited or rebased after approval — so the approval does not authorise it and \
nothing has executed.",
policy.name_any()
);
if *enforcement_suspended {
message.push_str(
" The merged spec is now the desired state and is NOT being enforced: \
drift against either state goes unreconciled until a fresh plan is \
approved. Approve the policy's new plan, or file a successor candidate \
for the content that was actually merged.",
);
}
report(
ctx,
&context,
candidate,
candidate_reason::PROMOTION_DIGEST_MISMATCH,
&message,
)
.await?;
}
None
}
PromotionAction::BaseChanged {
candidates,
enforcement_suspended,
} => {
for candidate in candidates {
let mut message = format!(
"policy {} carries exactly this candidate's content, but the plan's approval \
was reviewed against a base the policy no longer has — other content was \
promoted or applied in between — so the approval does not authorise this \
merge and nothing has executed on it. The candidate's plan is being replaced \
by one computed against the current base; approve that fresh plan.",
policy.name_any()
);
if *enforcement_suspended {
message.push_str(
" Until then the merged spec is the desired state and is NOT being \
enforced.",
);
}
report(
ctx,
&context,
candidate,
candidate_reason::PROMOTION_BASE_CHANGED,
&message,
)
.await?;
}
None
}
};
if previous.as_deref() != Some(content_digest) {
stamp_content_digest(ctx, policy, content_digest).await?;
}
Ok(plan)
}
fn select_promoted<'a>(
facts: &'a [CandidateFacts],
content_digest: &str,
) -> Option<&'a CandidateFacts> {
let matches = || {
facts.iter().filter(|candidate| {
!candidate.terminal
&& candidate
.content_digest
.as_deref()
.is_some_and(|digest| digest == content_digest)
})
};
matches()
.find(|candidate| candidate.owns_applied_plan)
.or_else(|| matches().find(|candidate| candidate.approved_plan().is_some()))
.or_else(|| matches().next())
}
pub async fn record_promotion(
ctx: &OperatorContext,
policy: &PostgresPolicy,
content_digest: &str,
) -> Result<(), ReconcileError> {
let context = load_context(ctx, policy).await?;
let digest_matches = |candidate: &&CandidateFacts| {
candidate
.content_digest
.as_deref()
.is_some_and(|digest| digest == content_digest)
};
let already = context
.facts
.iter()
.find(|candidate| candidate.promoted && digest_matches(candidate))
.map(|candidate| candidate.name.clone());
let newly_promoted = already.is_none();
let promoted_name = match already {
Some(name) => name,
None => match select_promoted(&context.facts, content_digest) {
Some(selected) => selected.name.clone(),
None => return Ok(()),
},
};
let duplicates: Vec<String> = context
.facts
.iter()
.filter(|candidate| !candidate.terminal && candidate.name != promoted_name)
.filter(digest_matches)
.map(|candidate| candidate.name.clone())
.collect();
for duplicate in &duplicates {
if let Some(plan) = context.plan_of(duplicate) {
crate::plan::mark_plan_superseded(
&ctx.kube_client,
plan,
SupersedeCause::SupersededByPromotion,
)
.await?;
}
if let Some(candidate) = context.candidate(duplicate) {
let message = format!(
"identical content was promoted and applied through candidate {promoted_name}; this duplicate proposal is spent"
);
let mut candidate = candidate.clone();
crate::candidate::mark_superseded(
ctx,
&mut candidate,
candidate_reason::SUPERSEDED_BY_PROMOTION,
&message,
)
.await?;
}
}
for stranded in stranded_by_promotion(&promoted_name, content_digest, &context.facts)
.into_iter()
.filter(|name| !duplicates.contains(name))
{
let Some(plan) = context.plan_of(&stranded) else {
continue;
};
crate::plan::mark_plan_superseded(
&ctx.kube_client,
plan,
SupersedeCause::SupersededByPromotion,
)
.await?;
if let Some(candidate) = context.candidate(&stranded) {
let message = format!(
"candidate {promoted_name} was promoted and applied, so plan {} — approved \
against the previous base — can never execute; file a successor candidate to \
propose this change against the new base",
plan.name_any()
);
set_candidate_condition(
ctx,
candidate,
promoted_condition(false, candidate_reason::SUPERSEDED_BY_PROMOTION, &message),
candidate_reason::SUPERSEDED_BY_PROMOTION,
&message,
true,
)
.await?;
}
}
if !newly_promoted {
return Ok(());
}
let Some(candidate) = context.candidate(&promoted_name) else {
return Ok(());
};
let plan_note = context
.plan_of(&promoted_name)
.map(|plan| format!(" (plan {})", plan.name_any()))
.unwrap_or_default();
let message = format!(
"content promoted into policy {} and applied{plan_note}",
policy.name_any()
);
let namespace = candidate.namespace().ok_or(ReconcileError::NoNamespace)?;
let api: Api<PostgresPolicyCandidate> = Api::namespaced(ctx.kube_client.clone(), &namespace);
let mut status = candidate.status.clone().unwrap_or_default();
status.phase = CandidatePhase::Promoted;
set_condition_in(
&mut status.conditions,
promoted_condition(true, candidate_reason::PROMOTED, &message),
);
set_condition_in(
&mut status.conditions,
ready_condition(true, candidate_reason::PROMOTED, &message),
);
api.patch_status(
&promoted_name,
&PatchParams::apply("pgroles-operator"),
&Patch::Merge(&serde_json::json!({ "status": status })),
)
.await?;
crate::events::publish_candidate_event(
&ctx.event_recorder,
candidate,
false,
candidate_reason::PROMOTED,
message,
)
.await
.ok();
tracing::info!(
policy = %policy.name_any(),
candidate = %promoted_name,
"candidate promoted"
);
Ok(())
}
async fn report(
ctx: &OperatorContext,
context: &PromotionContext,
candidate: &str,
reason: &str,
message: &str,
) -> Result<(), ReconcileError> {
let Some(object) = context.candidate(candidate) else {
return Ok(());
};
set_candidate_condition(
ctx,
object,
promoted_condition(false, reason, message),
reason,
message,
true,
)
.await
}
async fn set_candidate_condition(
ctx: &OperatorContext,
candidate: &PostgresPolicyCandidate,
condition: crate::crd::PolicyCondition,
reason: &str,
message: &str,
warning: bool,
) -> Result<(), ReconcileError> {
let already = candidate.status.as_ref().is_some_and(|status| {
status.conditions.iter().any(|c| {
c.condition_type == condition.condition_type && c.reason.as_deref() == Some(reason)
})
});
let namespace = candidate.namespace().ok_or(ReconcileError::NoNamespace)?;
let api: Api<PostgresPolicyCandidate> = Api::namespaced(ctx.kube_client.clone(), &namespace);
let mut status = candidate.status.clone().unwrap_or_default();
set_condition_in(&mut status.conditions, condition);
api.patch_status(
&candidate.name_any(),
&PatchParams::apply("pgroles-operator"),
&Patch::Merge(&serde_json::json!({ "status": status })),
)
.await?;
if !already {
crate::events::publish_candidate_event(
&ctx.event_recorder,
candidate,
warning,
reason,
message.to_string(),
)
.await
.ok();
}
Ok(())
}
async fn stamp_content_digest(
ctx: &OperatorContext,
policy: &PostgresPolicy,
content_digest: &str,
) -> Result<(), ReconcileError> {
let namespace = policy.namespace().ok_or(ReconcileError::NoNamespace)?;
let api: Api<PostgresPolicy> = Api::namespaced(ctx.kube_client.clone(), &namespace);
api.patch_status(
&policy.name_any(),
&PatchParams::apply("pgroles-operator"),
&Patch::Merge(&serde_json::json!({
"status": { "content_digest": content_digest }
})),
)
.await?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
fn candidate(name: &str, digest: &str) -> CandidateFacts {
CandidateFacts {
name: name.to_string(),
content_digest: Some(digest.to_string()),
terminal: false,
plan: None,
owns_applied_plan: false,
promoted: false,
}
}
fn with_plan(mut facts: CandidateFacts, plan: &str, approved: bool) -> CandidateFacts {
facts.plan = Some(PlanFacts {
name: plan.to_string(),
approved,
base_content_digest: Some("sha256:old".to_string()),
});
facts
}
fn with_stale_base(mut facts: CandidateFacts) -> CandidateFacts {
if let Some(plan) = facts.plan.as_mut() {
plan.base_content_digest = Some("sha256:some-other-base".to_string());
}
facts
}
#[test]
fn an_approval_reviewed_against_a_replaced_base_does_not_authorise_promotion() {
let facts = vec![with_stale_base(with_plan(
candidate("x", "sha256:aa"),
"x-plan",
true,
))];
assert_eq!(
decide_promotion("sha256:aa", Some("sha256:old"), &facts),
Promotion::BaseChanged {
candidates: vec!["x".to_string()],
}
);
}
#[test]
fn a_promotion_retry_after_the_stamp_is_still_recognised() {
let facts = vec![with_stale_base(with_plan(
candidate("x", "sha256:aa"),
"x-plan",
true,
))];
assert!(matches!(
decide_promotion("sha256:aa", Some("sha256:aa"), &facts),
Promotion::Approved { .. }
));
}
#[test]
fn an_unpinned_approved_plan_is_never_adopted() {
let mut facts = with_plan(candidate("x", "sha256:aa"), "x-plan", true);
facts.plan.as_mut().expect("plan set").base_content_digest = None;
assert_eq!(
decide_promotion("sha256:aa", Some("sha256:old"), &[facts]),
Promotion::BaseChanged {
candidates: vec!["x".to_string()],
}
);
}
#[test]
fn a_fresh_base_approval_outranks_a_stale_one() {
let facts = vec![
with_stale_base(with_plan(candidate("stale", "sha256:aa"), "p1", true)),
with_plan(candidate("fresh", "sha256:aa"), "p2", true),
];
assert!(matches!(
decide_promotion("sha256:aa", Some("sha256:old"), &facts),
Promotion::Approved { ref candidate, .. } if candidate == "fresh"
));
}
fn with_applied_plan(mut facts: CandidateFacts) -> CandidateFacts {
facts.owns_applied_plan = true;
facts
}
#[test]
fn identical_content_promotes_the_candidate_whose_plan_executed() {
let facts = vec![
candidate("first-by-creation", "sha256:aa"),
with_applied_plan(candidate("actually-executed", "sha256:aa")),
];
let promoted = select_promoted(&facts, "sha256:aa").expect("a candidate is promoted");
assert_eq!(promoted.name, "actually-executed");
}
#[test]
fn identical_content_without_an_applied_plan_prefers_the_approved_candidate() {
let facts = vec![
with_plan(candidate("pending", "sha256:aa"), "p1", false),
with_plan(candidate("approved", "sha256:aa"), "p2", true),
];
let promoted = select_promoted(&facts, "sha256:aa").expect("a candidate is promoted");
assert_eq!(promoted.name, "approved");
}
#[test]
fn a_terminal_candidate_is_never_selected_for_promotion() {
let mut done = with_applied_plan(candidate("already-promoted", "sha256:aa"));
done.terminal = true;
let facts = vec![done, candidate("open-duplicate", "sha256:aa")];
let promoted = select_promoted(&facts, "sha256:aa").expect("a candidate is promoted");
assert_eq!(promoted.name, "open-duplicate");
}
#[test]
fn approved_content_promotes_through_the_candidates_own_plan() {
let facts = vec![with_plan(candidate("c1", "sha256:aa"), "c1-plan", true)];
assert_eq!(
decide_promotion("sha256:aa", Some("sha256:old"), &facts),
Promotion::Approved {
candidate: "c1".to_string(),
plan: "c1-plan".to_string(),
superseded: Vec::new(),
}
);
}
#[test]
fn content_matching_an_unapproved_candidate_falls_back_and_says_so() {
let facts = vec![with_plan(candidate("c1", "sha256:aa"), "c1-plan", false)];
assert_eq!(
decide_promotion("sha256:aa", Some("sha256:old"), &facts),
Promotion::WithoutApproval {
candidate: "c1".to_string()
}
);
assert_eq!(
promotion_action(
decide_promotion("sha256:aa", Some("sha256:old"), &facts),
PolicyMode::Apply,
ApprovalMode::Manual
),
PromotionAction::WithoutApproval {
candidate: "c1".to_string()
}
);
}
#[test]
fn content_matching_no_candidate_is_the_ordinary_policy_flow() {
let facts = vec![candidate("c1", "sha256:aa")];
assert_eq!(
decide_promotion("sha256:zz", Some("sha256:zz"), &facts),
Promotion::None
);
assert_eq!(decide_promotion("sha256:zz", None, &[]), Promotion::None);
}
#[test]
fn content_edited_after_approval_strands_the_candidate_with_an_explicit_reason() {
let facts = vec![with_plan(candidate("c1", "sha256:aa"), "c1-plan", true)];
assert_eq!(
decide_promotion("sha256:edited", Some("sha256:old"), &facts),
Promotion::Mismatch {
candidates: vec!["c1".to_string()]
}
);
assert_eq!(
promotion_action(
decide_promotion("sha256:edited", Some("sha256:old"), &facts),
PolicyMode::Apply,
ApprovalMode::Manual
),
PromotionAction::Mismatch {
candidates: vec!["c1".to_string()],
enforcement_suspended: true,
}
);
}
#[test]
fn an_unchanged_policy_with_an_open_approved_candidate_reports_nothing() {
let facts = vec![with_plan(candidate("c1", "sha256:aa"), "c1-plan", true)];
assert_eq!(
decide_promotion("sha256:base", Some("sha256:base"), &facts),
Promotion::None
);
assert_eq!(
decide_promotion("sha256:base", None, &facts),
Promotion::None
);
}
#[test]
fn approving_one_candidate_and_merging_another_promotes_the_merged_one() {
let facts = vec![
with_plan(candidate("x", "sha256:xx"), "x-plan", true),
with_plan(candidate("y", "sha256:yy"), "y-plan", true),
];
assert_eq!(
decide_promotion("sha256:yy", Some("sha256:old"), &facts),
Promotion::Approved {
candidate: "y".to_string(),
plan: "y-plan".to_string(),
superseded: vec!["x".to_string()],
}
);
assert_eq!(
stranded_by_promotion("y", "sha256:yy", &facts),
vec!["x".to_string()]
);
let pending = vec![
with_plan(candidate("x", "sha256:xx"), "x-plan", false),
with_plan(candidate("y", "sha256:yy"), "y-plan", true),
];
assert!(stranded_by_promotion("y", "sha256:yy", &pending).is_empty());
}
#[test]
fn a_replayed_promotion_does_not_strand_an_approval_planned_against_it() {
let mut successor = with_plan(candidate("cand-2", "sha256:bb"), "cand-2-plan", true);
successor
.plan
.as_mut()
.expect("plan set")
.base_content_digest = Some("sha256:aa".to_string());
let mut promoted = candidate("cand-1", "sha256:aa");
promoted.terminal = true;
promoted.promoted = true;
let facts = vec![promoted, successor.clone()];
assert!(stranded_by_promotion("cand-1", "sha256:aa", &facts).is_empty());
assert_eq!(
decide_promotion("sha256:cc", Some("sha256:aa"), &facts),
Promotion::Mismatch {
candidates: vec!["cand-2".to_string()],
}
);
}
#[test]
fn identical_content_prefers_the_candidate_holding_the_approval() {
let unapproved_first = vec![
with_plan(candidate("first", "sha256:aa"), "first-plan", false),
with_plan(candidate("second", "sha256:aa"), "second-plan", true),
];
assert_eq!(
decide_promotion("sha256:aa", Some("sha256:old"), &unapproved_first),
Promotion::Approved {
candidate: "second".to_string(),
plan: "second-plan".to_string(),
superseded: Vec::new(),
}
);
let none_approved = vec![
with_plan(candidate("first", "sha256:aa"), "first-plan", false),
with_plan(candidate("second", "sha256:aa"), "second-plan", false),
];
assert_eq!(
decide_promotion("sha256:aa", Some("sha256:old"), &none_approved),
Promotion::WithoutApproval {
candidate: "first".to_string()
}
);
}
#[test]
fn a_terminal_candidate_is_never_promoted_again() {
let mut facts = with_plan(candidate("c1", "sha256:aa"), "c1-plan", true);
facts.terminal = true;
assert_eq!(
decide_promotion("sha256:aa", Some("sha256:old"), &[facts]),
Promotion::None
);
}
#[test]
fn a_candidate_without_a_stamped_digest_cannot_match() {
let facts = vec![CandidateFacts {
name: "c1".to_string(),
content_digest: None,
terminal: false,
plan: None,
owns_applied_plan: false,
promoted: false,
}];
assert_eq!(
decide_promotion("sha256:aa", Some("sha256:old"), &facts),
Promotion::None
);
}
#[test]
fn auto_approval_executes_immediately_and_needs_no_candidate_plan() {
for approved in [true, false] {
let facts = vec![with_plan(candidate("c1", "sha256:aa"), "c1-plan", approved)];
assert_eq!(
promotion_action(
decide_promotion("sha256:aa", Some("sha256:old"), &facts),
PolicyMode::Apply,
ApprovalMode::Auto
),
PromotionAction::Ignore
);
}
}
#[test]
fn observe_mode_promotion_never_executes_and_the_candidate_stays_open() {
for approved in [true, false] {
let facts = vec![with_plan(candidate("c1", "sha256:aa"), "c1-plan", approved)];
assert_eq!(
promotion_action(
decide_promotion("sha256:aa", Some("sha256:old"), &facts),
PolicyMode::Observe,
ApprovalMode::Manual
),
PromotionAction::NotExecuted {
candidate: "c1".to_string()
}
);
}
}
#[test]
fn the_enforcement_gap_is_claimed_only_where_it_exists() {
let facts = vec![with_plan(candidate("c1", "sha256:aa"), "c1-plan", true)];
let mismatch = || decide_promotion("sha256:edited", Some("sha256:old"), &facts);
for (mode, approval) in [
(PolicyMode::Apply, ApprovalMode::Auto),
(PolicyMode::Observe, ApprovalMode::Manual),
] {
assert_eq!(
promotion_action(mismatch(), mode, approval),
PromotionAction::Mismatch {
candidates: vec!["c1".to_string()],
enforcement_suspended: false,
}
);
}
}
}