use std::collections::{BTreeMap, BTreeSet};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
use k8s_openapi::jiff::{SignedDuration, Timestamp};
use kube::api::{Api, DeleteParams, ListParams, Patch, PatchParams};
use kube::{Resource, ResourceExt};
use tracing::info;
use pgroles_core::model::MembershipEdge;
use pgroles_core::overlap::{
EffectPair, describe_pair, effect_pairs, intersecting_pairs, membership_overlay_pairs,
};
use crate::context::OperatorContext;
use crate::crd::{
CandidatePhase, CandidateTarget, ConnectionSpec, DatabaseIdentity, PlanPhase, PlanReference,
PolicyCondition, PostgresPolicy, PostgresPolicyCandidate, PostgresPolicyCandidateStatus,
PostgresPolicyPlan, SecretReference, candidate_reason, is_retention_exempt, ready_condition,
set_condition_in, superseded_condition,
};
use crate::plan::{CandidatePlanBinding, SupersedeCause};
use crate::reconciler::{ReconcileError, ResolvedPassword};
const DEFAULT_MAX_TERMINAL_CANDIDATES: usize = 10;
const DEFAULT_MAX_OPEN_CANDIDATES: usize = 32;
const DEFAULT_OPEN_CANDIDATE_TTL: SignedDuration = SignedDuration::from_hours(14 * 24);
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BlockCause {
AwaitingDecision,
Unstable,
}
impl BlockCause {
fn message(self) -> &'static str {
match self {
BlockCause::AwaitingDecision => {
"the active policy has changes of its own awaiting a decision, so this candidate \
would be planned against a state the database is not in yet"
}
BlockCause::Unstable => {
"the active policy is not converging, so there is no post-enforcement state to \
plan this candidate against"
}
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ParentGate {
Stable,
Blocked(BlockCause),
}
pub fn parent_gate(converged: bool, has_actionable_plan: bool) -> ParentGate {
if !converged {
ParentGate::Blocked(BlockCause::Unstable)
} else if has_actionable_plan {
ParentGate::Blocked(BlockCause::AwaitingDecision)
} else {
ParentGate::Stable
}
}
pub struct CandidatePlanning<'a> {
pub pool: &'a sqlx::PgPool,
pub identity: &'a DatabaseIdentity,
pub target_identity: &'a pgroles_core::approval::TargetIdentity,
pub overlay_edges: &'a [MembershipEdge],
pub gate: ParentGate,
}
#[derive(Debug, Clone, PartialEq, Eq)]
enum CandidateOutcome {
Planned { plan_name: String, changes: i32 },
NoEffects,
OverlayOverlap {
plan_name: String,
changes: i32,
overlapping: Vec<String>,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NotPlanned {
Expired,
OverBudget,
}
fn classify_open_candidates(
candidates: &[PostgresPolicyCandidate],
now: Timestamp,
) -> BTreeMap<String, NotPlanned> {
let mut verdicts = BTreeMap::new();
let mut budget_remaining = DEFAULT_MAX_OPEN_CANDIDATES;
for candidate in candidates {
if candidate_phase(candidate).is_terminal() {
continue;
}
let exempt = is_retention_exempt(candidate);
let expired = !exempt
&& candidate
.metadata
.creation_timestamp
.as_ref()
.is_some_and(|created| {
now.duration_since(created.0) > DEFAULT_OPEN_CANDIDATE_TTL
});
if expired {
verdicts.insert(candidate.name_any(), NotPlanned::Expired);
continue;
}
if budget_remaining > 0 {
budget_remaining -= 1;
} else {
verdicts.insert(candidate.name_any(), NotPlanned::OverBudget);
}
}
verdicts
}
pub async fn reconcile_candidates(
ctx: &OperatorContext,
policy: &PostgresPolicy,
planning: &CandidatePlanning<'_>,
) -> Result<(), ReconcileError> {
let namespace = policy.namespace().ok_or(ReconcileError::NoNamespace)?;
let policy_name = policy.name_any();
let candidates_api: Api<PostgresPolicyCandidate> =
Api::namespaced(ctx.kube_client.clone(), &namespace);
let mut candidates: Vec<PostgresPolicyCandidate> = candidates_api
.list(&ListParams::default())
.await?
.into_iter()
.filter(|candidate| candidate_belongs_to(candidate, policy))
.collect();
if candidates.is_empty() {
return Ok(());
}
candidates.sort_by(|a, b| {
a.metadata
.creation_timestamp
.cmp(&b.metadata.creation_timestamp)
.then_with(|| a.name_any().cmp(&b.name_any()))
});
let overlay_pairs = membership_overlay_pairs(planning.overlay_edges);
let not_planned = classify_open_candidates(&candidates, Timestamp::now());
let shared = shared_inspection(planning, &candidates, ¬_planned).await;
for candidate in &mut candidates {
if let Err(err) = adopt_candidate(ctx, policy, candidate).await {
tracing::warn!(
candidate = %candidate.name_any(),
%err,
"failed to adopt candidate; skipping this cycle"
);
continue;
}
if candidate_phase(candidate).is_terminal() {
continue;
}
match not_planned.get(&candidate.name_any()) {
Some(NotPlanned::Expired) => {
mark_superseded(
ctx,
candidate,
candidate_reason::EXPIRED,
&format!(
"no decision within {} days of being filed, so this proposal is \
treated as abandoned; file a successor to revive it, or label \
it pgroles.io/keep=true to exempt it",
DEFAULT_OPEN_CANDIDATE_TTL.as_hours() / 24
),
)
.await?;
continue;
}
Some(NotPlanned::OverBudget) => {
mark_over_budget(ctx, candidate).await?;
continue;
}
None => {}
}
let denied = match plan_was_denied(ctx, candidate, &namespace).await {
Ok(denied) => denied,
Err(err) => {
tracing::warn!(
candidate = %candidate.name_any(),
%err,
"failed to read this candidate's plans; skipping this cycle"
);
continue;
}
};
if denied {
mark_superseded(
ctx,
candidate,
candidate_reason::PLAN_DENIED,
"the plan for this candidate was denied; file a successor to propose a revision",
)
.await?;
continue;
}
if let ParentGate::Blocked(cause) = planning.gate {
block_candidate(ctx, candidate, cause).await?;
continue;
}
let planning_started_at = std::time::Instant::now();
let outcome =
plan_candidate(ctx, policy, candidate, planning, &overlay_pairs, &shared).await;
ctx.observability
.record_candidate_planning(planning_started_at.elapsed());
match outcome {
Ok(outcome) => {
record_outcome(ctx, candidate, outcome).await?;
}
Err(err) => {
let message = err.to_string();
tracing::warn!(
candidate = %candidate.name_any(),
policy = %policy_name,
%message,
"failed to plan candidate"
);
write_status(ctx, candidate, |status| {
set_condition_in(
&mut status.conditions,
ready_condition(false, candidate_reason::PLANNING_FAILED, &message),
);
})
.await?;
crate::events::publish_candidate_event(
&ctx.event_recorder,
candidate,
true,
candidate_reason::PLANNING_FAILED,
message,
)
.await
.ok();
}
}
}
ctx.observability.record_candidate_inspections(
shared
.inspections
.load(std::sync::atomic::Ordering::Relaxed),
shared.plannable,
);
apply_replacements(ctx, &candidates).await?;
cleanup_terminal_candidates(ctx, &namespace, &candidates).await;
Ok(())
}
struct CandidateInputs {
manifest: pgroles_core::manifest::PolicyManifest,
expanded: pgroles_core::manifest::ExpandedManifest,
desired: pgroles_core::model::RoleGraph,
inspect_config: pgroles_inspect::InspectConfig,
}
fn candidate_inputs(
candidate: &PostgresPolicyCandidate,
overlay_edges: &[MembershipEdge],
) -> Result<CandidateInputs, ReconcileError> {
let manifest = candidate.spec.content.to_policy_manifest();
let expanded = pgroles_core::manifest::expand_manifest(&manifest)?;
let mut desired = pgroles_core::model::RoleGraph::from_expanded(
&expanded,
manifest.default_owner.as_deref(),
)?;
let mut overlay_roles: BTreeSet<String> = BTreeSet::new();
for edge in overlay_edges {
if !desired.roles.contains_key(&edge.role) || !desired.roles.contains_key(&edge.member) {
continue;
}
if desired
.memberships
.iter()
.any(|existing| existing.role == edge.role && existing.member == edge.member)
{
continue;
}
overlay_roles.insert(edge.role.clone());
overlay_roles.insert(edge.member.clone());
desired.memberships.insert(edge.clone());
}
let has_database_grants = expanded
.grants
.iter()
.any(|g| g.object.object_type == pgroles_core::manifest::ObjectType::Database);
let inspect_config =
pgroles_inspect::InspectConfig::from_expanded(&expanded, has_database_grants)
.with_additional_roles(
manifest
.retirements
.iter()
.map(|retirement| retirement.role.clone()),
)
.with_additional_roles(overlay_roles);
Ok(CandidateInputs {
manifest,
expanded,
desired,
inspect_config,
})
}
struct SharedInspection {
raw: Option<pgroles_inspect::RawInspection>,
inspections: std::sync::atomic::AtomicUsize,
plannable: usize,
}
impl SharedInspection {
fn none() -> Self {
Self {
raw: None,
inspections: std::sync::atomic::AtomicUsize::new(0),
plannable: 0,
}
}
async fn inspect(
&self,
target: &CandidateTargetContext<'_>,
config: &pgroles_inspect::InspectConfig,
) -> Result<pgroles_inspect::InspectionResult, ReconcileError> {
if let CandidateTargetContext::Parent(_) = target
&& let Some(raw) = self.raw.as_ref()
&& raw.covers(config)
{
return Ok(raw.derive(target.pool(), config).await?);
}
self.inspections
.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
Ok(pgroles_inspect::inspect_with_diagnostics(target.pool(), config).await?)
}
}
async fn shared_inspection(
planning: &CandidatePlanning<'_>,
candidates: &[PostgresPolicyCandidate],
not_planned: &BTreeMap<String, NotPlanned>,
) -> SharedInspection {
if !matches!(planning.gate, ParentGate::Stable) {
return SharedInspection::none();
}
let configs: Vec<pgroles_inspect::InspectConfig> = candidates
.iter()
.filter(|candidate| {
!candidate_phase(candidate).is_terminal()
&& candidate.spec.target.is_none()
&& !not_planned.contains_key(&candidate.name_any())
})
.filter_map(|candidate| {
candidate_inputs(candidate, planning.overlay_edges)
.ok()
.map(|inputs| inputs.inspect_config)
})
.collect();
if configs.is_empty() {
return SharedInspection::none();
}
let plannable = configs.len();
let union = pgroles_inspect::InspectConfig::union_of(configs.iter());
match pgroles_inspect::RawInspection::read(planning.pool, &union).await {
Ok(raw) => SharedInspection {
raw: Some(raw),
inspections: std::sync::atomic::AtomicUsize::new(1),
plannable,
},
Err(err) => {
tracing::warn!(
%err,
"shared candidate inspection failed; falling back to one inspection per candidate"
);
SharedInspection {
raw: None,
inspections: std::sync::atomic::AtomicUsize::new(0),
plannable,
}
}
}
}
async fn plan_candidate(
ctx: &OperatorContext,
policy: &PostgresPolicy,
candidate: &PostgresPolicyCandidate,
planning: &CandidatePlanning<'_>,
overlay_pairs: &BTreeSet<EffectPair>,
shared: &SharedInspection,
) -> Result<CandidateOutcome, ReconcileError> {
let namespace = candidate.namespace().ok_or(ReconcileError::NoNamespace)?;
let inputs = candidate_inputs(candidate, planning.overlay_edges)?;
let target = resolve_target(ctx, policy, candidate, planning, &namespace).await?;
let result = plan_against_target(
ctx,
policy,
candidate,
overlay_pairs,
&target,
&inputs,
&namespace,
shared,
)
.await;
target.release().await;
result
}
#[allow(clippy::too_many_arguments)]
async fn plan_against_target(
ctx: &OperatorContext,
policy: &PostgresPolicy,
candidate: &PostgresPolicyCandidate,
overlay_pairs: &BTreeSet<EffectPair>,
target: &CandidateTargetContext<'_>,
inputs: &CandidateInputs,
namespace: &str,
shared: &SharedInspection,
) -> Result<CandidateOutcome, ReconcileError> {
let content = &candidate.spec.content;
let CandidateInputs {
manifest,
expanded,
desired,
inspect_config,
} = inputs;
let inspection = shared.inspect(target, inspect_config).await?;
if let Some(message) = inspection.diagnostics.blocking_message() {
return Err(ReconcileError::UnsatisfiableWildcardGrant(message));
}
let current = inspection.graph;
crate::reconciler::validate_referenced_schemas_exist(target.pool(), expanded).await?;
let reconciliation_mode: pgroles_core::diff::ReconciliationMode =
content.reconciliation_mode.into();
if pgroles_core::diff::additive_ignores_absence_assertions(desired, reconciliation_mode) {
tracing::warn!(
candidate = %candidate.name_any(),
"additive reconciliation ignores every `ensure: absent` assertion; \
use adopt or authoritative mode to enforce absence"
);
}
let mut changes = pgroles_core::diff::filter_changes(
pgroles_core::diff::apply_role_retirements(
pgroles_core::diff::diff(¤t, desired),
&manifest.retirements,
),
reconciliation_mode,
);
changes = pgroles_core::diff::filter_external_role_changes(changes, &expanded.roles);
let resolved_passwords = crate::reconciler::resolve_passwords_for_roles(
ctx,
policy,
namespace,
&content.roles,
false,
)
.await?;
let (password_changes, password_source_versions) =
candidate_password_changes(&changes, &resolved_passwords, policy);
if !password_changes.is_empty() {
changes = pgroles_core::diff::inject_password_changes(changes, &password_changes);
}
let summary = crate::reconciler::summarize_changes(&changes);
if changes.is_empty() {
supersede_candidate_plan(ctx, candidate, namespace, SupersedeCause::EffectsCleared).await?;
return Ok(CandidateOutcome::NoEffects);
}
let candidate_pairs = effect_pairs(&changes);
let overlapping = intersecting_pairs(&candidate_pairs, overlay_pairs);
let sql_ctx = crate::reconciler::detect_sql_context(target.pool(), inspect_config).await?;
let content_digest = content_digest(candidate);
let base_content_digest = policy.spec.content_digest();
let creation = crate::plan::create_or_update_plan(
&ctx.kube_client,
policy,
&changes,
&sql_ctx,
inspect_config,
content.reconciliation_mode,
target.identity(),
target.target_identity(),
&summary,
&password_source_versions,
ctx.plan_retention,
Some(CandidatePlanBinding {
candidate,
content_digest: &content_digest,
content_digest_encoding: pgroles_core::candidate::CANDIDATE_CONTENT_ENCODING_V1,
base_content_digest: &base_content_digest,
}),
)
.await?;
let plan_name = creation.plan_name().to_string();
if creation.is_created() {
crate::events::publish_candidate_event(
&ctx.event_recorder,
candidate,
false,
candidate_reason::PLANNED,
format!("Plan {plan_name} created with {} change(s)", summary.total),
)
.await
.ok();
}
if !overlapping.is_empty() {
return Ok(CandidateOutcome::OverlayOverlap {
plan_name,
changes: summary.total,
overlapping: overlapping.iter().map(describe_pair).collect(),
});
}
Ok(CandidateOutcome::Planned {
plan_name,
changes: summary.total,
})
}
enum CandidateTargetContext<'a> {
Parent(&'a CandidatePlanning<'a>),
Override {
pool: sqlx::PgPool,
identity: DatabaseIdentity,
target_identity: pgroles_core::approval::TargetIdentity,
_db_lock: crate::context::DatabaseLockGuard,
advisory_lock: Option<crate::advisory::AdvisoryLock>,
},
}
impl CandidateTargetContext<'_> {
fn pool(&self) -> &sqlx::PgPool {
match self {
CandidateTargetContext::Parent(planning) => planning.pool,
CandidateTargetContext::Override { pool, .. } => pool,
}
}
fn identity(&self) -> &str {
match self {
CandidateTargetContext::Parent(planning) => planning.identity.as_str(),
CandidateTargetContext::Override { identity, .. } => identity.as_str(),
}
}
fn target_identity(&self) -> &pgroles_core::approval::TargetIdentity {
match self {
CandidateTargetContext::Parent(planning) => planning.target_identity,
CandidateTargetContext::Override {
target_identity, ..
} => target_identity,
}
}
async fn release(self) {
if let CandidateTargetContext::Override {
advisory_lock: Some(lock),
..
} = self
{
lock.release().await;
}
}
}
pub(crate) fn override_connection_spec(target: &CandidateTarget) -> ConnectionSpec {
ConnectionSpec {
secret_ref: Some(SecretReference {
name: target.connection_ref.secret_name.clone(),
}),
secret_key: Some(target.connection_ref.key.clone()),
params: None,
require_physical_identity: None,
}
}
async fn resolve_target<'a>(
ctx: &OperatorContext,
policy: &PostgresPolicy,
candidate: &PostgresPolicyCandidate,
planning: &'a CandidatePlanning<'a>,
namespace: &str,
) -> Result<CandidateTargetContext<'a>, ReconcileError> {
let Some(target) = candidate.spec.target.as_ref() else {
return Ok(CandidateTargetContext::Parent(planning));
};
let connection = override_connection_spec(target);
let identity = DatabaseIdentity::from_connection(namespace, &connection);
if identity.as_str() == planning.identity.as_str() {
return Ok(CandidateTargetContext::Parent(planning));
}
let pool = ctx
.get_or_create_pool(namespace, &connection)
.await
.map_err(Box::new)?;
let physical = pgroles_inspect::detect_system_identifier(&pool).await?;
let logical = ctx
.resolve_database_target_fingerprint(namespace, &connection)
.await
.map_err(Box::new)?;
let same_logical_fingerprint =
planning.target_identity.logical.as_deref() == Some(logical.as_str());
if same_logical_fingerprint {
tracing::info!(
candidate = %candidate.name_any(),
policy = %policy.name_any(),
target = %identity.as_str(),
"candidate target override aliases the parent's own database; \
reusing the parent's context and locks"
);
return Ok(CandidateTargetContext::Parent(planning));
}
let db_lock = ctx
.try_lock_database(identity.as_str())
.await
.ok_or_else(|| {
ReconcileError::LockContention(
identity.as_str().to_string(),
"candidate target override lock held by another reconcile".to_string(),
)
})?;
let advisory_lock = match crate::advisory::try_acquire(&pool, identity.as_str()).await {
Ok(Some(lock)) => Some(lock),
Ok(None) => {
return Err(ReconcileError::LockContention(
identity.as_str().to_string(),
"candidate target override advisory lock held by another session — this is also \
what an unrecognized alias of a locked database (including the parent's own) \
looks like"
.to_string(),
));
}
Err(err) => return Err(ReconcileError::SqlExec(err)),
};
tracing::info!(
candidate = %candidate.name_any(),
policy = %policy.name_any(),
target = %identity.as_str(),
"planning candidate against an explicit target override"
);
Ok(CandidateTargetContext::Override {
pool,
identity,
target_identity: pgroles_core::approval::TargetIdentity {
physical,
logical: Some(logical),
},
_db_lock: db_lock,
advisory_lock,
})
}
pub(crate) fn candidate_password_changes(
changes: &[pgroles_core::diff::Change],
resolved: &BTreeMap<String, ResolvedPassword>,
policy: &PostgresPolicy,
) -> (BTreeMap<String, String>, BTreeMap<String, String>) {
crate::reconciler::select_password_changes(changes, resolved, policy.status.as_ref())
}
fn candidate_phase(candidate: &PostgresPolicyCandidate) -> CandidatePhase {
candidate
.status
.as_ref()
.map(|status| status.phase)
.unwrap_or_default()
}
fn content_digest(candidate: &PostgresPolicyCandidate) -> String {
pgroles_core::candidate::compute_content_digest(&candidate.spec.content)
}
pub(crate) fn policy_owner_reference(policy: &PostgresPolicy) -> OwnerReference {
OwnerReference {
api_version: PostgresPolicy::api_version(&()).to_string(),
kind: PostgresPolicy::kind(&()).to_string(),
name: policy.name_any(),
uid: policy.metadata.uid.clone().unwrap_or_default(),
controller: Some(true),
block_owner_deletion: Some(true),
}
}
pub(crate) fn has_policy_owner_reference(
candidate: &PostgresPolicyCandidate,
policy_uid: &str,
) -> bool {
crate::plan::is_owned_by_uid(candidate, policy_uid)
}
pub(crate) fn candidate_belongs_to(
candidate: &PostgresPolicyCandidate,
policy: &PostgresPolicy,
) -> bool {
if candidate.spec.policy_ref.name != policy.name_any() {
return false;
}
let controller_uid = candidate
.metadata
.owner_references
.as_deref()
.unwrap_or_default()
.iter()
.find(|owner| owner.controller.unwrap_or(false))
.map(|owner| owner.uid.as_str());
match (controller_uid, policy.metadata.uid.as_deref()) {
(Some(owned_by), live_uid) => live_uid == Some(owned_by),
(None, _) => true,
}
}
async fn adopt_candidate(
ctx: &OperatorContext,
policy: &PostgresPolicy,
candidate: &mut PostgresPolicyCandidate,
) -> Result<(), ReconcileError> {
let namespace = candidate.namespace().ok_or(ReconcileError::NoNamespace)?;
let api: Api<PostgresPolicyCandidate> = Api::namespaced(ctx.kube_client.clone(), &namespace);
let name = candidate.name_any();
let policy_uid = policy.metadata.uid.as_deref().unwrap_or_default();
if !policy_uid.is_empty() && !has_policy_owner_reference(candidate, policy_uid) {
let mut owner_references = candidate
.metadata
.owner_references
.clone()
.unwrap_or_default();
owner_references.retain(|owner| !owner.controller.unwrap_or(false));
owner_references.push(policy_owner_reference(policy));
let patch = serde_json::json!({ "metadata": { "ownerReferences": owner_references } });
*candidate = api
.patch(
&name,
&PatchParams::apply("pgroles-operator"),
&Patch::Merge(&patch),
)
.await?;
info!(candidate = %name, policy = %policy.name_any(), "adopted candidate");
}
let digest = content_digest(candidate);
let generation = candidate.metadata.generation;
let status = candidate.status.clone().unwrap_or_default();
if status.content_digest.as_deref() != Some(digest.as_str())
|| status.observed_generation != generation
{
write_status(ctx, candidate, |status| {
status.content_digest = Some(digest.clone());
status.observed_generation = generation;
})
.await?;
}
Ok(())
}
async fn write_status<F>(
ctx: &OperatorContext,
candidate: &mut PostgresPolicyCandidate,
mutate: F,
) -> Result<(), ReconcileError>
where
F: FnOnce(&mut PostgresPolicyCandidateStatus),
{
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();
mutate(&mut status);
let patch = serde_json::json!({ "status": status });
let updated = api
.patch_status(
&candidate.name_any(),
&PatchParams::apply("pgroles-operator"),
&Patch::Merge(&patch),
)
.await?;
*candidate = updated;
Ok(())
}
async fn record_outcome(
ctx: &OperatorContext,
candidate: &mut PostgresPolicyCandidate,
outcome: CandidateOutcome,
) -> Result<(), ReconcileError> {
match outcome {
CandidateOutcome::Planned { plan_name, changes } => {
let message = format!("Plan {plan_name} holds {changes} change(s) for review");
write_status(ctx, candidate, |status| {
status.phase = CandidatePhase::Planned;
status.plan_ref = Some(PlanReference {
name: plan_name.clone(),
});
set_condition_in(
&mut status.conditions,
ready_condition(true, candidate_reason::PLANNED, &message),
);
})
.await
}
CandidateOutcome::NoEffects => {
let message =
"this candidate's content is already the database's state; nothing to review"
.to_string();
write_status(ctx, candidate, |status| {
status.phase = CandidatePhase::Planned;
status.plan_ref = None;
set_condition_in(
&mut status.conditions,
ready_condition(true, candidate_reason::NO_EFFECTS, &message),
);
})
.await
}
CandidateOutcome::OverlayOverlap {
plan_name,
changes,
overlapping,
} => {
let message = format!(
"an active ephemeral grant touches {} of this candidate's effects ({}); plan \
{plan_name} holds {changes} change(s) and requires fresh review",
overlapping.len(),
overlapping.join(", "),
);
write_status(ctx, candidate, |status| {
status.phase = CandidatePhase::Planned;
status.plan_ref = Some(PlanReference {
name: plan_name.clone(),
});
set_condition_in(
&mut status.conditions,
ready_condition(false, candidate_reason::OVERLAY_OVERLAP, &message),
);
})
.await?;
crate::events::publish_candidate_event(
&ctx.event_recorder,
candidate,
true,
candidate_reason::OVERLAY_OVERLAP,
message,
)
.await
.ok();
Ok(())
}
}
}
async fn mark_over_budget(
ctx: &OperatorContext,
candidate: &mut PostgresPolicyCandidate,
) -> Result<(), ReconcileError> {
let message = format!(
"this policy already has {DEFAULT_MAX_OPEN_CANDIDATES} open candidates being \
planned; this one is planned once older proposals are decided or expire"
);
let already_over_budget = candidate.status.as_ref().is_some_and(|status| {
status.conditions.iter().any(|c| {
c.condition_type == "Ready"
&& c.status == "False"
&& c.reason.as_deref() == Some(candidate_reason::OVER_BUDGET)
})
});
write_status(ctx, candidate, |status| {
set_condition_in(
&mut status.conditions,
ready_condition(false, candidate_reason::OVER_BUDGET, &message),
);
})
.await?;
if !already_over_budget {
crate::events::publish_candidate_event(
&ctx.event_recorder,
candidate,
true,
candidate_reason::OVER_BUDGET,
message,
)
.await
.ok();
}
Ok(())
}
async fn block_candidate(
ctx: &OperatorContext,
candidate: &mut PostgresPolicyCandidate,
cause: BlockCause,
) -> Result<(), ReconcileError> {
let already_blocked = candidate.status.as_ref().is_some_and(|status| {
status.conditions.iter().any(|c| {
c.condition_type == "Ready"
&& c.status == "False"
&& c.reason.as_deref() == Some(candidate_reason::BLOCKED_BY_ACTIVE_POLICY)
})
});
write_status(ctx, candidate, |status| {
set_condition_in(
&mut status.conditions,
ready_condition(
false,
candidate_reason::BLOCKED_BY_ACTIVE_POLICY,
cause.message(),
),
);
})
.await?;
if !already_blocked {
crate::events::publish_candidate_event(
&ctx.event_recorder,
candidate,
true,
candidate_reason::BLOCKED_BY_ACTIVE_POLICY,
cause.message().to_string(),
)
.await
.ok();
}
Ok(())
}
pub(crate) async fn mark_superseded(
ctx: &OperatorContext,
candidate: &mut PostgresPolicyCandidate,
reason: &str,
message: &str,
) -> Result<(), ReconcileError> {
if candidate_phase(candidate) == CandidatePhase::Superseded {
return Ok(());
}
write_status(ctx, candidate, |status| {
status.phase = CandidatePhase::Superseded;
set_condition_in(
&mut status.conditions,
superseded_condition(reason, message),
);
set_condition_in(
&mut status.conditions,
ready_condition(false, reason, message),
);
})
.await?;
crate::events::publish_candidate_event(
&ctx.event_recorder,
candidate,
true,
reason,
message.to_string(),
)
.await
.ok();
Ok(())
}
async fn apply_replacements(
ctx: &OperatorContext,
candidates: &[PostgresPolicyCandidate],
) -> Result<(), ReconcileError> {
let planned: BTreeMap<String, String> = candidates
.iter()
.filter(|candidate| {
candidate
.status
.as_ref()
.is_some_and(|status| status.plan_ref.is_some())
})
.filter_map(|candidate| {
candidate
.spec
.replaces
.clone()
.map(|replaced| (replaced, candidate.name_any()))
})
.collect();
if planned.is_empty() {
return Ok(());
}
for candidate in candidates {
let Some(successor) = planned.get(&candidate.name_any()) else {
continue;
};
if candidate_phase(candidate).is_terminal() {
continue;
}
let mut candidate = candidate.clone();
mark_superseded(
ctx,
&mut candidate,
candidate_reason::REPLACED,
&format!("replaced by candidate {successor}"),
)
.await?;
}
Ok(())
}
async fn candidate_plans(
ctx: &OperatorContext,
candidate: &PostgresPolicyCandidate,
namespace: &str,
) -> Result<Vec<PostgresPolicyPlan>, ReconcileError> {
let Some(uid) = candidate.metadata.uid.as_deref() else {
return Ok(Vec::new());
};
let plans: Api<PostgresPolicyPlan> = Api::namespaced(ctx.kube_client.clone(), namespace);
Ok(plans
.list(&ListParams::default())
.await?
.into_iter()
.filter(|plan| crate::plan::is_owned_by_uid(plan, uid))
.collect())
}
async fn plan_was_denied(
ctx: &OperatorContext,
candidate: &PostgresPolicyCandidate,
namespace: &str,
) -> Result<bool, ReconcileError> {
Ok(candidate_plans(ctx, candidate, namespace)
.await?
.iter()
.any(|plan| {
matches!(
crate::plan::check_plan_approval(plan),
crate::plan::PlanApprovalState::Rejected
) || plan
.status
.as_ref()
.is_some_and(|status| status.phase == PlanPhase::Rejected)
}))
}
async fn supersede_candidate_plan(
ctx: &OperatorContext,
candidate: &PostgresPolicyCandidate,
namespace: &str,
cause: SupersedeCause,
) -> Result<(), ReconcileError> {
for plan in candidate_plans(ctx, candidate, namespace).await? {
let is_live = plan
.status
.as_ref()
.is_some_and(|status| matches!(status.phase, PlanPhase::Pending | PlanPhase::Approved));
if !is_live {
continue;
}
crate::plan::mark_plan_superseded(&ctx.kube_client, &plan, cause).await?;
crate::events::publish_candidate_event(
&ctx.event_recorder,
candidate,
false,
"PlanSuperseded",
format!("Plan {} superseded: {}", plan.name_any(), cause.message()),
)
.await
.ok();
}
Ok(())
}
async fn cleanup_terminal_candidates(
ctx: &OperatorContext,
namespace: &str,
candidates: &[PostgresPolicyCandidate],
) {
let terminal: Vec<&PostgresPolicyCandidate> = candidates
.iter()
.filter(|candidate| candidate_phase(candidate).is_terminal())
.collect();
let retention = ctx.plan_retention;
if terminal.len() <= DEFAULT_MAX_TERMINAL_CANDIDATES && terminal.len() <= retention.applied {
return;
}
let plans: Vec<PostgresPolicyPlan> =
match Api::<PostgresPolicyPlan>::namespaced(ctx.kube_client.clone(), namespace)
.list(&ListParams::default())
.await
{
Ok(list) => list.items,
Err(err) => {
tracing::warn!(%err, "could not read plans; skipping terminal-candidate pruning");
return;
}
};
let records: Vec<(&PostgresPolicyCandidate, Vec<&PostgresPolicyPlan>)> = terminal
.into_iter()
.map(|candidate| {
let uid = candidate.metadata.uid.clone().unwrap_or_default();
let owned: Vec<&PostgresPolicyPlan> = plans
.iter()
.filter(|plan| !uid.is_empty() && crate::plan::is_owned_by_uid(*plan, &uid))
.collect();
(candidate, owned)
})
.collect();
let api: Api<PostgresPolicyCandidate> = Api::namespaced(ctx.kube_client.clone(), namespace);
let now_ts = Timestamp::now().as_second();
for candidate in terminal_candidates_to_prune(&records, retention, now_ts) {
let name = candidate.name_any();
info!(candidate = %name, "pruning terminal candidate");
if let Err(err) = api.delete(&name, &DeleteParams::default()).await {
tracing::warn!(candidate = %name, %err, "failed to prune terminal candidate");
}
}
}
fn terminal_candidates_to_prune<'a>(
records: &[(&'a PostgresPolicyCandidate, Vec<&'a PostgresPolicyPlan>)],
retention: crate::plan::PlanRetention,
now_ts: i64,
) -> Vec<&'a PostgresPolicyCandidate> {
let mut churn: Vec<&PostgresPolicyCandidate> = Vec::new();
let mut applied: Vec<(&PostgresPolicyCandidate, &PostgresPolicyPlan)> = Vec::new();
for (candidate, plans) in records {
if is_retention_exempt(*candidate) || plans.iter().any(|plan| is_retention_exempt(*plan)) {
continue;
}
let applied_plan = plans.iter().find(|plan| {
plan.status
.as_ref()
.is_some_and(|status| status.phase == PlanPhase::Applied)
});
match applied_plan {
Some(plan) => applied.push((candidate, plan)),
None => churn.push(candidate),
}
}
let mut prune: Vec<&PostgresPolicyCandidate> = Vec::new();
if churn.len() > DEFAULT_MAX_TERMINAL_CANDIDATES {
churn.sort_by(|a, b| {
a.metadata
.creation_timestamp
.cmp(&b.metadata.creation_timestamp)
});
let excess = churn.len() - DEFAULT_MAX_TERMINAL_CANDIDATES;
prune.extend(churn.into_iter().take(excess));
}
let evicted_plans: BTreeSet<String> = crate::plan::applied_plans_to_evict(
applied.iter().map(|(_, plan)| *plan).collect(),
retention,
now_ts,
)
.into_iter()
.map(|plan| plan.name_any())
.collect();
prune.extend(
applied
.into_iter()
.filter(|(_, plan)| evicted_plans.contains(&plan.name_any()))
.map(|(candidate, _)| candidate),
);
prune
}
pub fn ready_reason(status: &PostgresPolicyCandidateStatus) -> Option<&str> {
status
.conditions
.iter()
.find(|c: &&PolicyCondition| c.condition_type == "Ready")
.and_then(|c| c.reason.as_deref())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crd::{
GeneratePasswordSpec, LocalObjectReference, PolicyContent, PostgresPolicyCandidateSpec,
PostgresPolicySpec, RoleSpec,
};
fn policy(name: &str) -> PostgresPolicy {
let spec: PostgresPolicySpec = serde_json::from_value(serde_json::json!({
"connection": { "secretRef": { "name": "db" } },
}))
.expect("minimal policy spec");
let mut policy = PostgresPolicy::new(name, spec);
policy.metadata.uid = Some(format!("{name}-uid"));
policy.metadata.namespace = Some("default".to_string());
policy
}
fn role(name: &str) -> RoleSpec {
serde_json::from_value(serde_json::json!({ "name": name, "login": true }))
.expect("minimal role spec")
}
fn candidate(name: &str, content: PolicyContent) -> PostgresPolicyCandidate {
PostgresPolicyCandidate::new(
name,
PostgresPolicyCandidateSpec {
policy_ref: LocalObjectReference {
name: "orders".to_string(),
},
replaces: None,
target: None,
content,
},
)
}
fn open_candidate(name: &str, now: Timestamp, age_hours: i64) -> PostgresPolicyCandidate {
let mut candidate = candidate(name, PolicyContent::default());
candidate.metadata.creation_timestamp =
Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
now - SignedDuration::from_hours(age_hours),
));
candidate
}
const TTL_HOURS: i64 = DEFAULT_OPEN_CANDIDATE_TTL.as_hours();
fn terminal_candidate(
name: &str,
phase: CandidatePhase,
age_secs: i64,
now_ts: i64,
) -> PostgresPolicyCandidate {
let mut candidate = candidate(name, PolicyContent::default());
candidate.metadata.uid = Some(format!("{name}-uid"));
candidate.metadata.creation_timestamp =
Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
jiff::Timestamp::from_second(now_ts - age_secs).expect("epoch second in range"),
));
candidate.status = Some(PostgresPolicyCandidateStatus {
phase,
..Default::default()
});
candidate
}
fn applied_plan(name: &str, applied_age_secs: i64, now_ts: i64) -> PostgresPolicyPlan {
let spec = crate::crd::PostgresPolicyPlanSpec {
policy_ref: crate::crd::PolicyPlanRef {
name: "orders".to_string(),
},
policy_generation: 1,
reconciliation_mode: crate::crd::CrdReconciliationMode::Authoritative,
owned_roles: Vec::new(),
owned_schemas: Vec::new(),
managed_database_identity: "default/db/DATABASE_URL".to_string(),
origin: None,
scope: None,
};
let mut plan = PostgresPolicyPlan::new(name, spec);
plan.status = Some(crate::crd::PostgresPolicyPlanStatus {
phase: PlanPhase::Applied,
applied_at: Some(
jiff::Timestamp::from_second(now_ts - applied_age_secs)
.expect("epoch second in range")
.to_string(),
),
..Default::default()
});
plan
}
fn keep_plan(mut plan: PostgresPolicyPlan) -> PostgresPolicyPlan {
plan.metadata
.labels
.get_or_insert_with(Default::default)
.insert("pgroles.io/keep".to_string(), "true".to_string());
plan
}
fn pruned_names(
records: &[(&PostgresPolicyCandidate, Vec<&PostgresPolicyPlan>)],
retention: crate::plan::PlanRetention,
now_ts: i64,
) -> Vec<String> {
let mut names: Vec<String> = terminal_candidates_to_prune(records, retention, now_ts)
.into_iter()
.map(|candidate| candidate.name_any())
.collect();
names.sort();
names
}
#[test]
fn proposal_churn_cannot_prune_a_promoted_candidate_with_a_fresh_applied_plan() {
let now = 1_700_000_000;
let retention = crate::plan::PlanRetention::default();
let promoted = terminal_candidate("promoted", CandidatePhase::Promoted, 100_000, now);
let promoted_plan = applied_plan("promoted-plan", 60, now);
let churn: Vec<PostgresPolicyCandidate> = (0..DEFAULT_MAX_TERMINAL_CANDIDATES + 2)
.map(|i| {
terminal_candidate(
&format!("churn-{i:03}"),
CandidatePhase::Superseded,
1_000 - i as i64,
now,
)
})
.collect();
let mut records: Vec<(&PostgresPolicyCandidate, Vec<&PostgresPolicyPlan>)> =
vec![(&promoted, vec![&promoted_plan])];
records.extend(
churn
.iter()
.map(|candidate| (candidate, Vec::<&PostgresPolicyPlan>::new())),
);
assert!(
churn
.iter()
.all(|c| c.metadata.creation_timestamp > promoted.metadata.creation_timestamp),
"the fixture must make the promoted candidate the oldest object"
);
let pruned = pruned_names(&records, retention, now);
assert_eq!(
pruned,
vec!["churn-000".to_string(), "churn-001".to_string()],
"exactly the excess churn goes, oldest first"
);
assert!(
!pruned.contains(&"promoted".to_string()),
"a promoted candidate with a fresh Applied plan is execution history, not churn"
);
}
#[test]
fn a_keep_label_on_the_child_plan_protects_the_candidate() {
let now = 1_700_000_000;
let retention = crate::plan::PlanRetention {
applied: 1,
applied_ceiling: 1,
applied_min_age_secs: 0,
..Default::default()
};
let oldest = terminal_candidate("p-old", CandidatePhase::Promoted, 900, now);
let oldest_plan = keep_plan(applied_plan("p-old-plan", 300, now));
let middle = terminal_candidate("p-mid", CandidatePhase::Promoted, 800, now);
let middle_plan = applied_plan("p-mid-plan", 200, now);
let newest = terminal_candidate("p-new", CandidatePhase::Promoted, 700, now);
let newest_plan = applied_plan("p-new-plan", 100, now);
let records: Vec<(&PostgresPolicyCandidate, Vec<&PostgresPolicyPlan>)> = vec![
(&oldest, vec![&oldest_plan]),
(&middle, vec![&middle_plan]),
(&newest, vec![&newest_plan]),
];
assert_eq!(
pruned_names(&records, retention, now),
vec!["p-mid".to_string()]
);
}
fn keep(mut candidate: PostgresPolicyCandidate) -> PostgresPolicyCandidate {
candidate
.metadata
.labels
.get_or_insert_with(Default::default)
.insert("pgroles.io/keep".to_string(), "true".to_string());
candidate
}
#[test]
fn an_undecided_candidate_expires_once_it_is_past_the_ttl() {
let now = Timestamp::now();
let candidates = vec![
open_candidate("abandoned", now, TTL_HOURS + 1),
open_candidate("borderline", now, TTL_HOURS),
open_candidate("fresh", now, 1),
];
let verdicts = classify_open_candidates(&candidates, now);
assert_eq!(verdicts.get("abandoned"), Some(&NotPlanned::Expired));
assert_eq!(verdicts.get("borderline"), None);
assert_eq!(verdicts.get("fresh"), None);
}
#[test]
fn the_budget_keeps_the_oldest_and_queues_the_rest() {
let now = Timestamp::now();
let candidates: Vec<PostgresPolicyCandidate> = (0..DEFAULT_MAX_OPEN_CANDIDATES + 3)
.map(|i| {
open_candidate(
&format!("candidate-{i:03}"),
now,
(DEFAULT_MAX_OPEN_CANDIDATES + 3 - i) as i64,
)
})
.collect();
let verdicts = classify_open_candidates(&candidates, now);
assert_eq!(verdicts.len(), 3, "exactly the excess is queued");
for i in 0..DEFAULT_MAX_OPEN_CANDIDATES {
assert_eq!(
verdicts.get(&format!("candidate-{i:03}")),
None,
"the oldest proposals keep planning — a flood of new ones must not \
evict what is already under review"
);
}
for i in DEFAULT_MAX_OPEN_CANDIDATES..DEFAULT_MAX_OPEN_CANDIDATES + 3 {
assert_eq!(
verdicts.get(&format!("candidate-{i:03}")),
Some(&NotPlanned::OverBudget)
);
}
}
#[test]
fn an_expired_candidate_does_not_consume_a_budget_slot() {
let now = Timestamp::now();
let mut candidates: Vec<PostgresPolicyCandidate> = (0..DEFAULT_MAX_OPEN_CANDIDATES)
.map(|i| open_candidate(&format!("stale-{i:03}"), now, TTL_HOURS + 10))
.collect();
candidates.push(open_candidate("live", now, 1));
let verdicts = classify_open_candidates(&candidates, now);
assert_eq!(
verdicts.get("live"),
None,
"a live candidate must not be queued behind abandoned ones"
);
assert_eq!(verdicts.get("stale-000"), Some(&NotPlanned::Expired));
}
#[test]
fn a_kept_candidate_is_exempt_from_both_but_still_occupies_a_slot() {
let now = Timestamp::now();
let ancient = keep(open_candidate("ancient", now, TTL_HOURS + 100));
assert_eq!(
classify_open_candidates(std::slice::from_ref(&ancient), now).get("ancient"),
None,
"pgroles.io/keep=true exempts a candidate from the TTL"
);
let mut candidates: Vec<PostgresPolicyCandidate> = (0..DEFAULT_MAX_OPEN_CANDIDATES - 1)
.map(|i| open_candidate(&format!("live-{i:03}"), now, 5))
.collect();
candidates.push(keep(open_candidate("kept", now, 4)));
candidates.push(open_candidate("queued", now, 3));
let verdicts = classify_open_candidates(&candidates, now);
assert_eq!(verdicts.get("kept"), None);
assert_eq!(verdicts.get("queued"), Some(&NotPlanned::OverBudget));
}
#[test]
fn the_keep_label_cannot_be_used_to_exceed_the_budget() {
let now = Timestamp::now();
let mut candidates: Vec<PostgresPolicyCandidate> = (0..DEFAULT_MAX_OPEN_CANDIDATES)
.map(|i| open_candidate(&format!("live-{i:03}"), now, 5))
.collect();
candidates.push(keep(open_candidate("kept-over", now, 4)));
let verdicts = classify_open_candidates(&candidates, now);
assert_eq!(
verdicts.get("kept-over"),
Some(&NotPlanned::OverBudget),
"keep exempts from the TTL, not from the budget"
);
let all_kept: Vec<PostgresPolicyCandidate> = (0..DEFAULT_MAX_OPEN_CANDIDATES + 8)
.map(|i| keep(open_candidate(&format!("k-{i:03}"), now, 5)))
.collect();
let verdicts = classify_open_candidates(&all_kept, now);
assert_eq!(
verdicts.len(),
8,
"everything past the budget must be queued however it is labelled"
);
assert_eq!(verdicts.get("k-000"), None);
assert_eq!(
verdicts.get(&format!("k-{:03}", DEFAULT_MAX_OPEN_CANDIDATES)),
Some(&NotPlanned::OverBudget)
);
}
#[test]
fn terminal_candidates_are_neither_expired_nor_counted_against_the_budget() {
let now = Timestamp::now();
let mut candidates: Vec<PostgresPolicyCandidate> = (0..DEFAULT_MAX_OPEN_CANDIDATES)
.map(|i| {
let mut c = open_candidate(&format!("done-{i:03}"), now, TTL_HOURS + 5);
c.status = Some(PostgresPolicyCandidateStatus {
phase: CandidatePhase::Promoted,
..Default::default()
});
c
})
.collect();
candidates.push(open_candidate("live", now, 1));
let verdicts = classify_open_candidates(&candidates, now);
assert!(
verdicts.is_empty(),
"terminal candidates are invisible to both the TTL and the budget"
);
}
#[test]
fn the_gate_blocks_only_a_parent_that_has_not_finished_its_own_work() {
assert_eq!(parent_gate(true, false), ParentGate::Stable);
assert_eq!(
parent_gate(true, true),
ParentGate::Blocked(BlockCause::AwaitingDecision)
);
assert_eq!(
parent_gate(false, false),
ParentGate::Blocked(BlockCause::Unstable)
);
assert_eq!(
parent_gate(false, true),
ParentGate::Blocked(BlockCause::Unstable)
);
}
#[test]
fn candidate_planning_never_materialises_secrets() {
let resolved = BTreeMap::from([(
"reporting_reader".to_string(),
ResolvedPassword {
cleartext: "in-memory".to_string(),
source_version: "orders-reporting-reader:password:missing".to_string(),
pending_materialization: Some(crate::reconciler::PendingGeneratedSecret {
role: "reporting_reader".to_string(),
spec: GeneratePasswordSpec {
length: None,
secret_name: None,
secret_key: None,
},
}),
},
)]);
let changes = vec![pgroles_core::diff::Change::CreateRole {
name: "reporting_reader".to_string(),
state: Default::default(),
}];
let (passwords, versions) =
candidate_password_changes(&changes, &resolved, &policy("orders"));
assert_eq!(
passwords.get("reporting_reader").map(String::as_str),
Some("in-memory")
);
assert_eq!(
versions.get("reporting_reader").map(String::as_str),
Some("orders-reporting-reader:password:missing")
);
}
#[test]
fn the_candidate_module_calls_no_writer() {
let source = include_str!("candidate.rs");
let body: String = source
.split_once("mod tests {")
.map(|(before, _)| before)
.expect("this module has a test module")
.lines()
.filter(|line| {
let trimmed = line.trim_start();
!trimmed.starts_with("//")
})
.collect::<Vec<_>>()
.join("\n");
for writer in [
"ensure_generated_secret",
"materialize_pending_generated_secrets",
"execute_changes_in_transaction",
"execute_plan",
] {
assert!(
!body.contains(&format!("{writer}(")),
"candidate planning must not call {writer}: planning adds no writes beyond the \
active policy's own reconcile"
);
}
}
#[test]
fn a_candidates_inspection_scope_is_its_own_content_plus_retirements_and_overlay() {
let content: PolicyContent = serde_json::from_value(serde_json::json!({
"roles": [{ "name": "reporting_reader" }, { "name": "app_owner" }],
"grants": [{
"role": "reporting_reader",
"privileges": ["SELECT"],
"object": { "type": "table", "schema": "reporting", "name": "*" },
}],
"retirements": [{ "role": "legacy_reader" }],
"memberships": [{ "role": "app_owner", "members": [{ "name": "reporting_reader" }] }],
}))
.expect("candidate content");
let candidate = candidate("orders-change-x7k2p", content);
let overlay = vec![MembershipEdge {
role: "app_owner".to_string(),
member: "grafana".to_string(),
inherit: true,
admin: false,
}];
let inputs = candidate_inputs(&candidate, &overlay).expect("inputs");
assert!(
inputs
.inspect_config
.managed_roles
.contains(&"reporting_reader".to_string())
);
assert!(
inputs
.inspect_config
.managed_roles
.contains(&"legacy_reader".to_string()),
"a retired role must stay in scope or its drop cannot be planned"
);
assert!(
!inputs
.inspect_config
.managed_roles
.contains(&"grafana".to_string()),
"an overlay edge whose member the candidate does not declare is left out"
);
assert_eq!(
inputs.inspect_config.privilege_schemas,
vec!["reporting".to_string()]
);
}
#[test]
fn the_union_of_candidate_scopes_contains_every_candidates_scope() {
let make = |role: &str, schema: &str| {
let content: PolicyContent = serde_json::from_value(serde_json::json!({
"roles": [{ "name": role }],
"grants": [{
"role": role,
"privileges": ["SELECT"],
"object": { "type": "table", "schema": schema, "name": "*" },
}],
}))
.expect("candidate content");
candidate_inputs(&candidate("orders-change-x7k2p", content), &[])
.expect("inputs")
.inspect_config
};
let first = make("reporting_reader", "reporting");
let second = make("billing_reader", "billing");
let union = pgroles_inspect::InspectConfig::union_of([&first, &second]);
for config in [&first, &second] {
for role in &config.managed_roles {
assert!(union.managed_roles.contains(role));
}
for schema in &config.privilege_schemas {
assert!(union.privilege_schemas.contains(schema));
}
}
}
#[test]
fn an_override_target_resolves_through_the_ordinary_connection_spec() {
let spec = override_connection_spec(&CandidateTarget {
connection_ref: crate::crd::CandidateConnectionRef {
secret_name: "orders-new-postgres".to_string(),
key: "url".to_string(),
},
});
assert_eq!(
spec.secret_ref.as_ref().map(|r| r.name.as_str()),
Some("orders-new-postgres")
);
assert_eq!(spec.effective_secret_key(), "url");
assert!(spec.params.is_none());
assert!(!spec.requires_physical_identity());
}
#[test]
fn the_owner_reference_is_a_controller_reference_to_the_policy() {
let policy = policy("orders");
let owner = policy_owner_reference(&policy);
assert_eq!(owner.kind, "PostgresPolicy");
assert_eq!(owner.uid, "orders-uid");
assert_eq!(owner.controller, Some(true));
let mut candidate = candidate("orders-change-x7k2p", PolicyContent::default());
assert!(!has_policy_owner_reference(&candidate, "orders-uid"));
candidate.metadata.owner_references = Some(vec![owner]);
assert!(has_policy_owner_reference(&candidate, "orders-uid"));
assert!(!has_policy_owner_reference(&candidate, "orders-uid-2"));
}
#[test]
fn a_candidate_belongs_only_to_the_policy_that_adopted_it() {
let orders = policy("orders");
let mut unadopted = candidate("orders-change-x7k2p", PolicyContent::default());
assert!(!candidate_belongs_to(&unadopted, &policy("billing")));
assert!(candidate_belongs_to(&unadopted, &orders));
unadopted.metadata.owner_references = Some(vec![policy_owner_reference(&orders)]);
let adopted = unadopted;
assert!(candidate_belongs_to(&adopted, &orders));
let mut recreated = policy("orders");
recreated.metadata.uid = Some("orders-uid-2".to_string());
assert!(!candidate_belongs_to(&adopted, &recreated));
let mut anonymous = policy("orders");
anonymous.metadata.uid = None;
assert!(!candidate_belongs_to(&adopted, &anonymous));
let fresh = candidate("orders-change-a1b2c", PolicyContent::default());
assert!(candidate_belongs_to(&fresh, &anonymous));
}
#[test]
fn phases_are_terminal_exactly_where_the_docs_say() {
assert!(CandidatePhase::Promoted.is_terminal());
assert!(CandidatePhase::Superseded.is_terminal());
assert!(!CandidatePhase::Pending.is_terminal());
assert!(!CandidatePhase::Planned.is_terminal());
assert!(!CandidatePhase::Stale.is_terminal());
}
#[test]
fn a_blocked_cause_says_which_half_of_the_rule_fired() {
assert!(
BlockCause::AwaitingDecision
.message()
.contains("awaiting a decision")
);
assert!(BlockCause::Unstable.message().contains("not converging"));
}
#[test]
fn the_content_digest_is_the_phase_1a_digest() {
let candidate = candidate(
"orders-change-x7k2p",
PolicyContent {
roles: vec![role("reporting_reader")],
..Default::default()
},
);
assert_eq!(
content_digest(&candidate),
pgroles_core::candidate::compute_content_digest(&candidate.spec.content)
);
assert!(content_digest(&candidate).starts_with("sha256:"));
}
}