Skip to main content

pgroles_operator/
plan.rs

1//! Plan lifecycle management for `PostgresPolicyPlan` resources.
2//!
3//! Handles creating, deduplicating, approving, executing, and cleaning up
4//! reconciliation plans. Plans represent computed SQL change sets that may
5//! require explicit approval before execution against a database.
6
7use std::collections::{BTreeMap, BTreeSet};
8use std::io::Write;
9use std::time::Duration;
10
11use flate2::Compression;
12use flate2::write::GzEncoder;
13use k8s_openapi::ByteString;
14use k8s_openapi::api::core::v1::ConfigMap;
15use k8s_openapi::apimachinery::pkg::apis::meta::v1::OwnerReference;
16use kube::api::{Api, DeleteParams, ListParams, Patch, PatchParams, PostParams};
17use kube::core::labels::{Expression, Selector};
18use kube::{Client, Resource, ResourceExt};
19use sha2::{Digest, Sha256};
20use tracing::info;
21
22use crate::crd::{
23    ChangeSummary, CrdReconciliationMode, LABEL_DATABASE_IDENTITY, LABEL_PLAN, LABEL_POLICY,
24    PLAN_APPROVED_ANNOTATION, PLAN_REJECTED_ANNOTATION, PlanPhase, PlanReference, PolicyCondition,
25    PolicyPlanRef, PostgresPolicy, PostgresPolicyPlan, PostgresPolicyPlanSpec,
26    PostgresPolicyPlanStatus, SqlCompression, SqlRef,
27};
28use crate::k8s_names::{LabelValue, truncate_name_prefix};
29use crate::reconciler::ReconcileError;
30
31/// Result of plan creation — distinguishes genuinely new plans from
32/// deduplication hits so callers can decide whether to emit events.
33#[derive(Debug, Clone)]
34pub enum PlanCreationResult {
35    /// A new plan was created with the given name.
36    Created(String),
37    /// An existing plan with the same hash was found (deduplication).
38    Deduplicated(String),
39}
40
41impl PlanCreationResult {
42    /// Return the plan name regardless of variant.
43    pub fn plan_name(&self) -> &str {
44        match self {
45            PlanCreationResult::Created(name) | PlanCreationResult::Deduplicated(name) => name,
46        }
47    }
48
49    /// True when a new plan was actually created.
50    pub fn is_created(&self) -> bool {
51        matches!(self, PlanCreationResult::Created(_))
52    }
53}
54
55/// Maximum inline SQL size in plan status before spilling to a ConfigMap.
56const MAX_INLINE_SQL_BYTES: usize = 16 * 1024;
57
58/// ConfigMap binaryData key for gzip-compressed SQL content.
59const SQL_CONFIGMAP_GZIP_KEY: &str = "plan.sql.gz";
60
61/// Conservative stored-byte ceiling for SQL ConfigMaps. Kubernetes caps
62/// ConfigMap data at 1 MiB; this leaves room for metadata and future labels.
63const MAX_CONFIGMAP_SQL_BYTES: usize = 900 * 1024;
64
65/// Stale status-less plan and orphan ConfigMap grace period.
66const ORPHAN_GRACE_SECS: i64 = 60;
67
68/// Best-effort cleanup should never block a fresh reconcile for long.
69const CLEANUP_TIMEOUT_SECS: u64 = 5;
70
71/// Default maximum number of historical plans to retain per policy.
72const DEFAULT_MAX_PLANS: usize = 10;
73
74/// How recently a Failed plan must have been created (in seconds) for the
75/// dedup check to consider it a match. Plans older than this are ignored so
76/// that retries after the user fixes the environment are not blocked.
77const FAILED_PLAN_DEDUP_WINDOW_SECS: i64 = 120;
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80enum PlanSqlArtifact {
81    Inline(String),
82    CompressedConfigMap {
83        configmap_name: String,
84        key: String,
85        compressed_sql: Vec<u8>,
86    },
87    TruncatedInline(String),
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91struct PreparedPlanSql {
92    artifact: PlanSqlArtifact,
93    redacted_sql_hash: String,
94    original_bytes: usize,
95    stored_bytes: usize,
96}
97
98impl PreparedPlanSql {
99    fn sql_ref(&self) -> Option<SqlRef> {
100        match &self.artifact {
101            PlanSqlArtifact::CompressedConfigMap {
102                configmap_name,
103                key,
104                ..
105            } => Some(SqlRef {
106                name: configmap_name.clone(),
107                key: key.clone(),
108                compression: Some(SqlCompression::Gzip),
109            }),
110            PlanSqlArtifact::Inline(_) | PlanSqlArtifact::TruncatedInline(_) => None,
111        }
112    }
113
114    fn sql_inline(&self) -> Option<String> {
115        match &self.artifact {
116            PlanSqlArtifact::Inline(sql) | PlanSqlArtifact::TruncatedInline(sql) => {
117                Some(sql.clone())
118            }
119            PlanSqlArtifact::CompressedConfigMap { .. } => None,
120        }
121    }
122
123    fn is_truncated(&self) -> bool {
124        matches!(self.artifact, PlanSqlArtifact::TruncatedInline(_))
125    }
126}
127
128// ---------------------------------------------------------------------------
129// Plan approval check
130// ---------------------------------------------------------------------------
131
132/// Result of checking a plan's approval annotations.
133#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum PlanApprovalState {
135    Pending,
136    Approved,
137    Rejected,
138}
139
140/// Check the approval state of a plan by inspecting its annotations.
141///
142/// Rejection takes priority over approval: if both annotations are set,
143/// the plan is considered rejected.
144pub fn check_plan_approval(plan: &PostgresPolicyPlan) -> PlanApprovalState {
145    let annotations = plan.metadata.annotations.as_ref();
146
147    let rejected = annotations
148        .and_then(|a| a.get(PLAN_REJECTED_ANNOTATION))
149        .map(|v| v == "true")
150        .unwrap_or(false);
151
152    if rejected {
153        return PlanApprovalState::Rejected;
154    }
155
156    let approved = annotations
157        .and_then(|a| a.get(PLAN_APPROVED_ANNOTATION))
158        .map(|v| v == "true")
159        .unwrap_or(false);
160
161    if approved {
162        return PlanApprovalState::Approved;
163    }
164
165    PlanApprovalState::Pending
166}
167
168// ---------------------------------------------------------------------------
169// Plan creation
170// ---------------------------------------------------------------------------
171
172/// Create or deduplicate a `PostgresPolicyPlan` for the given policy and changes.
173///
174/// Returns the name of the plan resource (either existing or newly created).
175///
176/// This function:
177/// 1. Renders the full executable SQL from the changes
178/// 2. Computes SHA-256 of the full SQL (before any redaction/truncation)
179/// 3. Checks for an existing Pending plan with the same hash (dedup)
180/// 4. Persists the SQL preview artifact, if needed
181/// 5. Creates the new plan resource with ownerReferences
182/// 6. Updates the plan status
183/// 7. Marks any older Pending plans with a different hash as Superseded
184#[allow(clippy::too_many_arguments)]
185pub async fn create_or_update_plan(
186    client: &Client,
187    policy: &PostgresPolicy,
188    changes: &[pgroles_core::diff::Change],
189    sql_context: &pgroles_core::sql::SqlContext,
190    inspect_config: &pgroles_inspect::InspectConfig,
191    reconciliation_mode: CrdReconciliationMode,
192    database_identity: &str,
193    change_summary: &ChangeSummary,
194) -> Result<PlanCreationResult, ReconcileError> {
195    let namespace = policy.namespace().ok_or(ReconcileError::NoNamespace)?;
196    let policy_name = policy.name_any();
197    let generation = policy.metadata.generation.unwrap_or(0);
198
199    // 1. Render the full executable SQL (not redacted).
200    let full_sql = render_full_sql(changes, sql_context);
201
202    // 2. Compute SHA-256 hash of the full SQL.
203    let sql_hash = compute_sql_hash(&full_sql);
204
205    // 3. Count SQL statements (after wildcard expansion).
206    let sql_statement_count = full_sql.lines().filter(|l| !l.trim().is_empty()).count() as i64;
207
208    // 4. Render redacted SQL for display (passwords masked).
209    let redacted_sql = render_redacted_sql(changes, sql_context);
210
211    cleanup_old_plans_best_effort(client, policy, None).await;
212
213    let plans_api: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), &namespace);
214
215    // 4. List existing plans for this policy.
216    let selector = policy_selector(&policy_name);
217    // The label narrows server-side; owner UID is the exact filter.
218    let existing_plans: Vec<PostgresPolicyPlan> = plans_api
219        .list(&ListParams::default().labels_from(&selector))
220        .await?
221        .into_iter()
222        .filter(|plan| is_owned_by_policy(plan, policy))
223        .collect();
224
225    // 5. Check for duplicate pending plan with same hash.
226    for plan in &existing_plans {
227        if let Some(ref status) = plan.status
228            && status.phase == PlanPhase::Pending
229            && status.sql_hash.as_deref() == Some(&sql_hash)
230        {
231            // Identical plan already exists — return early (deduplicated).
232            let plan_name = plan.name_any();
233            info!(
234                plan = %plan_name,
235                policy = %policy_name,
236                "existing pending plan has identical SQL hash, skipping creation"
237            );
238            return Ok(PlanCreationResult::Deduplicated(plan_name));
239        }
240    }
241
242    // 5b. Check for recently-failed plan with the same hash. If a plan with
243    // this exact SQL already failed within the dedup window, creating another
244    // identical one is pointless — it would produce the same error. The window
245    // ensures we don't block retries after the user fixes the environment.
246    //
247    // Uses `status.failed_at` (not `creation_timestamp`) so that plans which
248    // waited for approval before failing are measured from the failure time.
249    let now_ts = now_epoch_secs();
250    for plan in &existing_plans {
251        if let Some(ref status) = plan.status
252            && status.phase == PlanPhase::Failed
253            && status.sql_hash.as_deref() == Some(&sql_hash)
254        {
255            let failed_ts = status
256                .failed_at
257                .as_deref()
258                .and_then(parse_rfc3339_epoch_secs)
259                .unwrap_or(0);
260            if failed_ts > 0 && now_ts - failed_ts < FAILED_PLAN_DEDUP_WINDOW_SECS {
261                let plan_name = plan.name_any();
262                info!(
263                    plan = %plan_name,
264                    policy = %policy_name,
265                    age_secs = now_ts - failed_ts,
266                    "recently-failed plan has identical SQL hash, skipping creation"
267                );
268                return Ok(PlanCreationResult::Deduplicated(plan_name));
269            }
270        }
271    }
272
273    // 6. Generate a plan name using timestamp plus SQL hash. The hash suffix
274    // makes same-second retries after content persistence failures idempotent.
275    let plan_name = generate_plan_name(&policy_name, &sql_hash);
276    let prepared_sql = prepare_plan_sql(&plan_name, &redacted_sql)?;
277
278    // 7. Persist SQL content before materialising the visible plan resource.
279    let sql_configmap_name = create_plan_sql_configmap(
280        client,
281        policy,
282        &namespace,
283        &policy_name,
284        database_identity,
285        &prepared_sql,
286    )
287    .await?;
288
289    // 8. Build ownerReference pointing to the parent policy.
290    let owner_ref = build_owner_reference(policy);
291
292    // 9. Create the plan resource.
293    let plan = PostgresPolicyPlan::new(
294        &plan_name,
295        PostgresPolicyPlanSpec {
296            policy_ref: PolicyPlanRef {
297                name: policy_name.clone(),
298            },
299            policy_generation: generation,
300            reconciliation_mode,
301            owned_roles: inspect_config.managed_roles.clone(),
302            owned_schemas: inspect_config.managed_schemas.clone(),
303            managed_database_identity: database_identity.to_string(),
304            origin: None,
305            scope: None,
306        },
307    );
308    let mut plan = plan;
309    plan.metadata.namespace = Some(namespace.clone());
310    plan.metadata.owner_references = Some(vec![owner_ref.clone()]);
311    plan.metadata.labels = Some(BTreeMap::from([
312        (LABEL_POLICY.to_string(), sanitize_label_value(&policy_name)),
313        (
314            LABEL_DATABASE_IDENTITY.to_string(),
315            sanitize_label_value(database_identity),
316        ),
317    ]));
318
319    // Annotations for quick visibility in kubectl describe / Lens.
320    let sql_preview = redacted_sql.lines().take(5).collect::<Vec<_>>().join("\n");
321    let summary_text = format!(
322        "{}R {}G {}D {}DP {}M",
323        change_summary.roles_created + change_summary.roles_altered,
324        change_summary.grants_added,
325        change_summary.default_privileges_set,
326        change_summary.roles_dropped,
327        change_summary.members_added,
328    );
329    plan.metadata.annotations = Some(BTreeMap::from([
330        ("pgroles.io/sql-preview".to_string(), sql_preview),
331        ("pgroles.io/summary".to_string(), summary_text),
332        (
333            "pgroles.io/sql-hash".to_string(),
334            sql_hash[..12].to_string(),
335        ),
336        (
337            "pgroles.io/redacted-sql-hash".to_string(),
338            prepared_sql.redacted_sql_hash[..12].to_string(),
339        ),
340        (
341            "pgroles.io/sql-original-bytes".to_string(),
342            prepared_sql.original_bytes.to_string(),
343        ),
344        (
345            "pgroles.io/sql-stored-bytes".to_string(),
346            prepared_sql.stored_bytes.to_string(),
347        ),
348    ]));
349
350    let (created_plan, created_new_plan) =
351        match plans_api.create(&PostParams::default(), &plan).await {
352            Ok(plan) => (plan, true),
353            Err(kube::Error::Api(api_err)) if api_err.code == 409 => {
354                let existing = plans_api.get(&plan_name).await?;
355                // The name collided, which is normally our own retry. Confirm
356                // ownership before patching anyone's status: plan names embed a
357                // 215-byte-truncated policy prefix, so two policies sharing that
358                // prefix can in principle collide, and this is otherwise the one
359                // mutation site the owner-UID discipline does not cover.
360                if !is_owned_by_policy(&existing, policy) {
361                    // Roll back only the ConfigMap this reconcile created. The
362                    // orphan reaper would collect it eventually — it carries our
363                    // UID — but leaving it is a pointless transient orphan. One
364                    // we merely adopted belongs to an earlier reconcile.
365                    rollback_plan_sql_configmap(client, &namespace, sql_configmap_name.as_ref())
366                        .await;
367                    return Err(ReconcileError::PlanSqlStorage(format!(
368                        "plan {plan_name} already exists and is owned by another policy"
369                    )));
370                }
371                if !should_patch_existing_plan_status(&existing) {
372                    return Ok(PlanCreationResult::Deduplicated(existing.name_any()));
373                }
374                (existing, false)
375            }
376            Err(err) => {
377                rollback_plan_sql_configmap(client, &namespace, sql_configmap_name.as_ref()).await;
378                return Err(err.into());
379            }
380        };
381    let plan_name = created_plan.name_any();
382
383    // 11. Update plan status.
384    let computed_message = if prepared_sql.is_truncated() {
385        format!(
386            "Plan computed with {} change(s); SQL preview truncated because compressed SQL exceeded Kubernetes ConfigMap limits",
387            change_summary.total
388        )
389    } else {
390        format!("Plan computed with {} change(s)", change_summary.total)
391    };
392    let plan_status = PostgresPolicyPlanStatus {
393        phase: PlanPhase::Pending,
394        conditions: vec![
395            PolicyCondition {
396                condition_type: "Computed".to_string(),
397                status: "True".to_string(),
398                reason: Some("PlanComputed".to_string()),
399                message: Some(computed_message),
400                last_transition_time: Some(crate::crd::now_rfc3339()),
401            },
402            PolicyCondition {
403                condition_type: "Approved".to_string(),
404                status: "False".to_string(),
405                reason: Some("PendingApproval".to_string()),
406                message: Some("Plan awaiting approval".to_string()),
407                last_transition_time: Some(crate::crd::now_rfc3339()),
408            },
409        ],
410        change_summary: Some(change_summary.clone()),
411        sql_ref: prepared_sql.sql_ref(),
412        sql_inline: prepared_sql.sql_inline(),
413        sql_truncated: prepared_sql.is_truncated(),
414        computed_at: Some(crate::crd::now_rfc3339()),
415        applied_at: None,
416        last_error: None,
417        sql_hash: Some(sql_hash),
418        applying_since: None,
419        failed_at: None,
420        sql_statements: Some(sql_statement_count),
421        redacted_sql_hash: Some(prepared_sql.redacted_sql_hash.clone()),
422        sql_original_bytes: Some(prepared_sql.original_bytes as i64),
423        sql_stored_bytes: Some(prepared_sql.stored_bytes as i64),
424    };
425
426    let status_patch = serde_json::json!({ "status": plan_status });
427    if let Err(err) = plans_api
428        .patch_status(
429            &plan_name,
430            &PatchParams::apply("pgroles-operator"),
431            &Patch::Merge(&status_patch),
432        )
433        .await
434    {
435        if created_new_plan {
436            delete_plan_best_effort(&plans_api, &plan_name).await;
437        }
438        rollback_plan_sql_configmap(client, &namespace, sql_configmap_name.as_ref()).await;
439        return Err(err.into());
440    }
441
442    // 12. Mark any existing Pending plans as Superseded after the new plan is
443    // fully visible. This avoids losing the current actionable plan if SQL
444    // persistence fails before the replacement is materialised.
445    for plan in &existing_plans {
446        if let Some(ref status) = plan.status
447            && status.phase == PlanPhase::Pending
448            && plan.name_any() != plan_name
449        {
450            let old_plan_name = plan.name_any();
451            info!(
452                plan = %old_plan_name,
453                policy = %policy_name,
454                "marking existing pending plan as Superseded"
455            );
456            let superseded_status = PostgresPolicyPlanStatus {
457                phase: PlanPhase::Superseded,
458                ..status.clone()
459            };
460            let patch = serde_json::json!({ "status": superseded_status });
461            plans_api
462                .patch_status(
463                    &old_plan_name,
464                    &PatchParams::apply("pgroles-operator"),
465                    &Patch::Merge(&patch),
466                )
467                .await?;
468        }
469    }
470
471    info!(
472        plan = %plan_name,
473        policy = %policy_name,
474        changes = change_summary.total,
475        "created new plan"
476    );
477
478    Ok(PlanCreationResult::Created(plan_name))
479}
480
481// ---------------------------------------------------------------------------
482// Plan execution
483// ---------------------------------------------------------------------------
484
485/// Execute an approved plan against the database.
486///
487/// Re-renders executable SQL from the reconciler's in-memory changes, executes
488/// it in a transaction, and updates the plan status to Applied or Failed.
489/// Persisted SQL on the plan is a redacted review artifact only; apply must not
490/// read it because large plans may store only a truncated preview.
491pub async fn execute_plan(
492    client: &Client,
493    plan: &PostgresPolicyPlan,
494    pool: &sqlx::PgPool,
495    sql_context: &pgroles_core::sql::SqlContext,
496    changes: &[pgroles_core::diff::Change],
497) -> Result<(), ReconcileError> {
498    let namespace = plan.namespace().ok_or(ReconcileError::NoNamespace)?;
499    let plan_name = plan.name_any();
500    let plans_api: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), &namespace);
501
502    // Update phase to Applying.
503    update_plan_phase(&plans_api, &plan_name, PlanPhase::Applying).await?;
504
505    // Execute the SQL in a transaction using the original changes (not stored SQL).
506    // This ensures we use the actual executable SQL including real passwords,
507    // not the redacted version stored in the plan.
508    let result = execute_changes_in_transaction(pool, changes, sql_context).await;
509
510    match result {
511        Ok(statements_executed) => {
512            // Update plan status to Applied.
513            let mut applied_status = plan.status.clone().unwrap_or_default();
514            applied_status.phase = PlanPhase::Applied;
515            applied_status.applied_at = Some(crate::crd::now_rfc3339());
516            applied_status.last_error = None;
517            set_plan_condition(
518                &mut applied_status.conditions,
519                "Approved",
520                "True",
521                "Approved",
522                "Plan approved and executed",
523            );
524
525            let patch = serde_json::json!({ "status": applied_status });
526            plans_api
527                .patch_status(
528                    &plan_name,
529                    &PatchParams::apply("pgroles-operator"),
530                    &Patch::Merge(&patch),
531                )
532                .await?;
533
534            info!(
535                plan = %plan_name,
536                statements = statements_executed,
537                "plan executed successfully"
538            );
539            Ok(())
540        }
541        Err(err) => {
542            // Update plan status to Failed.
543            let error_message = err.to_string();
544            let mut failed_status = plan.status.clone().unwrap_or_default();
545            failed_status.phase = PlanPhase::Failed;
546            failed_status.last_error = Some(error_message);
547            failed_status.failed_at = Some(crate::crd::now_rfc3339());
548
549            let patch = serde_json::json!({ "status": failed_status });
550            if let Err(status_err) = plans_api
551                .patch_status(
552                    &plan_name,
553                    &PatchParams::apply("pgroles-operator"),
554                    &Patch::Merge(&patch),
555                )
556                .await
557            {
558                tracing::warn!(
559                    plan = %plan_name,
560                    %status_err,
561                    "failed to update plan status to Failed"
562                );
563            }
564
565            Err(err)
566        }
567    }
568}
569
570fn prepare_plan_sql(
571    plan_name: &str,
572    redacted_sql: &str,
573) -> Result<PreparedPlanSql, ReconcileError> {
574    let original_bytes = redacted_sql.len();
575    let redacted_sql_hash = compute_sql_hash(redacted_sql);
576
577    if original_bytes <= MAX_INLINE_SQL_BYTES {
578        return Ok(PreparedPlanSql {
579            artifact: PlanSqlArtifact::Inline(redacted_sql.to_string()),
580            redacted_sql_hash,
581            original_bytes,
582            stored_bytes: original_bytes,
583        });
584    }
585
586    let compressed_sql = gzip_bytes(redacted_sql.as_bytes())?;
587    if compressed_sql.len() <= MAX_CONFIGMAP_SQL_BYTES {
588        let stored_bytes = compressed_sql.len();
589        return Ok(PreparedPlanSql {
590            artifact: PlanSqlArtifact::CompressedConfigMap {
591                configmap_name: format!("{plan_name}-sql"),
592                key: SQL_CONFIGMAP_GZIP_KEY.to_string(),
593                compressed_sql,
594            },
595            redacted_sql_hash,
596            original_bytes,
597            stored_bytes,
598        });
599    }
600
601    let truncated = truncate_utf8(
602        redacted_sql,
603        MAX_INLINE_SQL_BYTES,
604        "\n-- truncated: compressed SQL preview exceeded Kubernetes ConfigMap limits --",
605    );
606    let stored_bytes = truncated.len();
607    Ok(PreparedPlanSql {
608        artifact: PlanSqlArtifact::TruncatedInline(truncated),
609        redacted_sql_hash,
610        original_bytes,
611        stored_bytes,
612    })
613}
614
615fn gzip_bytes(bytes: &[u8]) -> Result<Vec<u8>, ReconcileError> {
616    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
617    encoder
618        .write_all(bytes)
619        .map_err(|err| ReconcileError::PlanSqlStorage(err.to_string()))?;
620    encoder
621        .finish()
622        .map_err(|err| ReconcileError::PlanSqlStorage(err.to_string()))
623}
624
625fn truncate_utf8(text: &str, max_bytes: usize, marker: &str) -> String {
626    if text.len() <= max_bytes {
627        return text.to_string();
628    }
629
630    let target_len = max_bytes.saturating_sub(marker.len());
631    let mut end = target_len.min(text.len());
632    while end > 0 && !text.is_char_boundary(end) {
633        end -= 1;
634    }
635
636    let mut truncated = text[..end].to_string();
637    truncated.push_str(marker);
638    truncated
639}
640
641/// A plan's SQL ConfigMap, and whether *this* reconcile is the one that created
642/// it.
643///
644/// Rollback may only delete a ConfigMap this invocation created. One adopted
645/// from a 409 was written by an earlier reconcile and may already back a plan
646/// that is pending approval; deleting it would leave that plan unapplyable.
647/// This mirrors the `created_new_plan` guard already used for the plan itself.
648struct PlanSqlConfigMap {
649    name: String,
650    created: bool,
651}
652
653async fn create_plan_sql_configmap(
654    client: &Client,
655    policy: &PostgresPolicy,
656    namespace: &str,
657    policy_name: &str,
658    database_identity: &str,
659    prepared_sql: &PreparedPlanSql,
660) -> Result<Option<PlanSqlConfigMap>, ReconcileError> {
661    let PlanSqlArtifact::CompressedConfigMap {
662        configmap_name,
663        key: _,
664        compressed_sql: _,
665    } = &prepared_sql.artifact
666    else {
667        return Ok(None);
668    };
669
670    let configmap = build_plan_sql_configmap_object(
671        policy,
672        namespace,
673        policy_name,
674        database_identity,
675        prepared_sql,
676    )?;
677
678    let configmaps_api: Api<ConfigMap> = Api::namespaced(client.clone(), namespace);
679    match configmaps_api
680        .create(&PostParams::default(), &configmap)
681        .await
682    {
683        Ok(_) => Ok(Some(PlanSqlConfigMap {
684            name: configmap_name.clone(),
685            created: true,
686        })),
687        Err(kube::Error::Api(api_err)) if api_err.code == 409 => {
688            let existing = configmaps_api.get(configmap_name).await?;
689            // Ownership first, hash second. The ConfigMap name embeds a
690            // truncated plan name, so two policies sharing that prefix can
691            // collide here, and identical SQL makes the hash check agree —
692            // adopting would then share one artifact between two policies,
693            // whose lifetime is tied to the *other* policy's garbage
694            // collection.
695            if is_owned_by_another_policy(&existing, policy) {
696                return Err(ReconcileError::PlanSqlStorage(format!(
697                    "plan SQL ConfigMap {configmap_name} is owned by another policy"
698                )));
699            }
700            validate_existing_sql_configmap(&existing, prepared_sql)?;
701            Ok(Some(PlanSqlConfigMap {
702                name: configmap_name.clone(),
703                created: false,
704            }))
705        }
706        Err(err) => Err(err.into()),
707    }
708}
709
710fn build_plan_sql_configmap_object(
711    policy: &PostgresPolicy,
712    namespace: &str,
713    policy_name: &str,
714    database_identity: &str,
715    prepared_sql: &PreparedPlanSql,
716) -> Result<ConfigMap, ReconcileError> {
717    let PlanSqlArtifact::CompressedConfigMap {
718        configmap_name,
719        key,
720        compressed_sql,
721    } = &prepared_sql.artifact
722    else {
723        return Err(ReconcileError::PlanSqlStorage(
724            "cannot build ConfigMap for inline plan SQL".to_string(),
725        ));
726    };
727
728    Ok(ConfigMap {
729        metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
730            name: Some(configmap_name.clone()),
731            namespace: Some(namespace.to_string()),
732            owner_references: Some(vec![build_owner_reference(policy)]),
733            labels: Some(BTreeMap::from([
734                (LABEL_POLICY.to_string(), sanitize_label_value(policy_name)),
735                (
736                    LABEL_DATABASE_IDENTITY.to_string(),
737                    sanitize_label_value(database_identity),
738                ),
739                (
740                    LABEL_PLAN.to_string(),
741                    plan_label_value(configmap_plan_name(configmap_name)),
742                ),
743            ])),
744            annotations: Some(BTreeMap::from([
745                ("pgroles.io/sql-compression".to_string(), "gzip".to_string()),
746                (
747                    "pgroles.io/redacted-sql-hash".to_string(),
748                    prepared_sql.redacted_sql_hash.clone(),
749                ),
750                (
751                    "pgroles.io/sql-original-bytes".to_string(),
752                    prepared_sql.original_bytes.to_string(),
753                ),
754                (
755                    "pgroles.io/sql-stored-bytes".to_string(),
756                    prepared_sql.stored_bytes.to_string(),
757                ),
758            ])),
759            ..Default::default()
760        },
761        binary_data: Some(BTreeMap::from([(
762            key.clone(),
763            ByteString(compressed_sql.clone()),
764        )])),
765        ..Default::default()
766    })
767}
768
769fn configmap_plan_name(configmap_name: &str) -> &str {
770    configmap_name
771        .strip_suffix("-sql")
772        .unwrap_or(configmap_name)
773}
774
775fn plan_label_value(plan_name: &str) -> String {
776    compute_sql_hash(plan_name)[..32].to_string()
777}
778
779fn validate_existing_sql_configmap(
780    configmap: &ConfigMap,
781    prepared_sql: &PreparedPlanSql,
782) -> Result<(), ReconcileError> {
783    let Some(annotations) = configmap.metadata.annotations.as_ref() else {
784        return Err(ReconcileError::PlanSqlStorage(format!(
785            "existing ConfigMap {} is missing SQL storage annotations",
786            configmap.name_any()
787        )));
788    };
789    let hash_matches = annotations
790        .get("pgroles.io/redacted-sql-hash")
791        .map(|hash| hash == &prepared_sql.redacted_sql_hash)
792        .unwrap_or(false);
793    if hash_matches {
794        Ok(())
795    } else {
796        Err(ReconcileError::PlanSqlStorage(format!(
797            "existing ConfigMap {} does not match computed SQL preview hash",
798            configmap.name_any()
799        )))
800    }
801}
802
803/// Execute SQL changes in a database transaction.
804///
805/// Returns the number of statements executed on success.
806pub(crate) async fn execute_changes_in_transaction(
807    pool: &sqlx::PgPool,
808    changes: &[pgroles_core::diff::Change],
809    sql_context: &pgroles_core::sql::SqlContext,
810) -> Result<usize, ReconcileError> {
811    let mut transaction = pool.begin().await?;
812    let mut statements_executed = 0usize;
813
814    for change in changes {
815        let is_sensitive = matches!(change, pgroles_core::diff::Change::SetPassword { .. });
816        for sql in pgroles_core::sql::render_statements_with_context(change, sql_context) {
817            if is_sensitive {
818                tracing::debug!("executing: ALTER ROLE ... PASSWORD [REDACTED]");
819            } else {
820                tracing::debug!(%sql, "executing");
821            }
822            sqlx::query(&sql).execute(transaction.as_mut()).await?;
823            statements_executed += 1;
824        }
825    }
826
827    transaction.commit().await?;
828    Ok(statements_executed)
829}
830
831// ---------------------------------------------------------------------------
832// Plan cleanup / retention
833// ---------------------------------------------------------------------------
834
835/// Best-effort cleanup wrapper used on hot reconciliation paths. Cleanup should
836/// reduce leaked resources, never block otherwise valid reconciliation.
837pub async fn cleanup_old_plans_best_effort(
838    client: &Client,
839    policy: &PostgresPolicy,
840    max_plans: Option<usize>,
841) {
842    match tokio::time::timeout(
843        Duration::from_secs(CLEANUP_TIMEOUT_SECS),
844        cleanup_old_plans(client, policy, max_plans),
845    )
846    .await
847    {
848        Ok(Ok(())) => {}
849        Ok(Err(err)) => tracing::warn!(%err, "failed to clean up old plans"),
850        Err(_) => tracing::warn!(
851            timeout_secs = CLEANUP_TIMEOUT_SECS,
852            "timed out cleaning up old plans"
853        ),
854    }
855}
856
857/// Clean up old plans for a policy, retaining at most `max_plans` terminal plans.
858///
859/// Terminal plans are those in Applied, Failed, Superseded, or Rejected phase;
860/// Pending, Approved, and Applying plans are retained. Status-less plans and
861/// SQL ConfigMaps older than a short grace period are treated as stale orphans.
862pub async fn cleanup_old_plans(
863    client: &Client,
864    policy: &PostgresPolicy,
865    max_plans: Option<usize>,
866) -> Result<(), ReconcileError> {
867    let namespace = policy.namespace().ok_or(ReconcileError::NoNamespace)?;
868    let policy_name = policy.name_any();
869    let max_plans = max_plans.unwrap_or(DEFAULT_MAX_PLANS);
870
871    let plans_api: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), &namespace);
872    let selector = policy_selector(&policy_name);
873    let existing_plans: Vec<PostgresPolicyPlan> = plans_api
874        .list(&ListParams::default().labels_from(&selector))
875        .await?
876        .into_iter()
877        .filter(|plan| is_owned_by_policy(plan, policy))
878        .collect();
879    let now_ts = now_epoch_secs();
880
881    for plan in existing_plans
882        .iter()
883        .filter(|plan| is_stale_statusless_plan(plan, now_ts))
884    {
885        let plan_name = plan.name_any();
886        info!(
887            plan = %plan_name,
888            policy = %policy_name,
889            "cleaning up stale status-less plan"
890        );
891        if let Err(err) = plans_api.delete(&plan_name, &DeleteParams::default()).await {
892            tracing::warn!(
893                plan = %plan_name,
894                %err,
895                "failed to delete stale status-less plan during cleanup"
896            );
897        }
898    }
899
900    // Collect terminal plans sorted by creation timestamp (oldest first).
901    let mut terminal_plans: Vec<&PostgresPolicyPlan> = existing_plans
902        .iter()
903        .filter(|plan| {
904            plan.status
905                .as_ref()
906                .map(|s| {
907                    matches!(
908                        s.phase,
909                        PlanPhase::Applied
910                            | PlanPhase::Failed
911                            | PlanPhase::Superseded
912                            | PlanPhase::Rejected
913                    )
914                })
915                .unwrap_or(false)
916        })
917        .collect();
918
919    if terminal_plans.len() > max_plans {
920        // Sort by creation timestamp ascending (oldest first).
921        terminal_plans.sort_by(|a, b| {
922            let a_time = a.metadata.creation_timestamp.as_ref();
923            let b_time = b.metadata.creation_timestamp.as_ref();
924            a_time.cmp(&b_time)
925        });
926
927        let plans_to_delete = terminal_plans.len() - max_plans;
928        for plan in terminal_plans.into_iter().take(plans_to_delete) {
929            let plan_name = plan.name_any();
930            info!(
931                plan = %plan_name,
932                policy = %policy_name,
933                "cleaning up old plan"
934            );
935            if let Err(err) = plans_api.delete(&plan_name, &DeleteParams::default()).await {
936                tracing::warn!(
937                    plan = %plan_name,
938                    %err,
939                    "failed to delete old plan during cleanup"
940                );
941            }
942        }
943    }
944
945    cleanup_orphan_sql_configmaps(
946        client,
947        &namespace,
948        policy,
949        &policy_name,
950        &existing_plans,
951        now_ts,
952    )
953    .await?;
954
955    Ok(())
956}
957
958// ---------------------------------------------------------------------------
959// Helpers
960// ---------------------------------------------------------------------------
961
962async fn cleanup_orphan_sql_configmaps(
963    client: &Client,
964    namespace: &str,
965    policy: &PostgresPolicy,
966    policy_name: &str,
967    existing_plans: &[PostgresPolicyPlan],
968    now_ts: i64,
969) -> Result<(), ReconcileError> {
970    let configmaps_api: Api<ConfigMap> = Api::namespaced(client.clone(), namespace);
971    let selector = policy_selector(policy_name);
972    // Filtering by owner UID here is what makes this loop safe: `existing_plans`
973    // is already restricted to this policy, so a colliding policy's ConfigMap
974    // would otherwise have an unknown plan label and be deleted as an orphan.
975    let configmaps: Vec<ConfigMap> = configmaps_api
976        .list(&ListParams::default().labels_from(&selector))
977        .await?
978        .into_iter()
979        .filter(|configmap| is_owned_by_policy(configmap, policy))
980        .collect();
981    let known_plan_labels: BTreeSet<String> = existing_plans
982        .iter()
983        .map(|plan| plan_label_value(&plan.name_any()))
984        .collect();
985    let known_plan_names: BTreeSet<String> =
986        existing_plans.iter().map(ResourceExt::name_any).collect();
987
988    for configmap in configmaps {
989        if !is_orphan_sql_configmap(&configmap, &known_plan_names, &known_plan_labels, now_ts) {
990            continue;
991        }
992
993        let configmap_name = configmap.name_any();
994        info!(
995            configmap = %configmap_name,
996            policy = %policy_name,
997            "cleaning up orphan plan SQL ConfigMap"
998        );
999        if let Err(err) = configmaps_api
1000            .delete(&configmap_name, &DeleteParams::default())
1001            .await
1002        {
1003            tracing::warn!(
1004                configmap = %configmap_name,
1005                %err,
1006                "failed to delete orphan plan SQL ConfigMap during cleanup"
1007            );
1008        }
1009    }
1010
1011    Ok(())
1012}
1013
1014fn is_orphan_sql_configmap(
1015    configmap: &ConfigMap,
1016    known_plan_names: &BTreeSet<String>,
1017    known_plan_labels: &BTreeSet<String>,
1018    now_ts: i64,
1019) -> bool {
1020    let Some(labels) = configmap.metadata.labels.as_ref() else {
1021        return false;
1022    };
1023    if !labels.contains_key(LABEL_POLICY) || !is_stale_object(configmap, now_ts) {
1024        return false;
1025    }
1026    if known_plan_names.contains(configmap_plan_name(&configmap.name_any())) {
1027        return false;
1028    }
1029    labels
1030        .get(LABEL_PLAN)
1031        .map(|plan_label| !known_plan_labels.contains(plan_label))
1032        .unwrap_or(true)
1033}
1034
1035fn should_patch_existing_plan_status(plan: &PostgresPolicyPlan) -> bool {
1036    plan.status
1037        .as_ref()
1038        .map(|status| status.phase == PlanPhase::Pending)
1039        .unwrap_or(true)
1040}
1041
1042fn is_stale_statusless_plan(plan: &PostgresPolicyPlan, now_ts: i64) -> bool {
1043    plan.status.is_none() && is_stale_object(plan, now_ts)
1044}
1045
1046fn is_stale_object<K>(resource: &K, now_ts: i64) -> bool
1047where
1048    K: Resource,
1049{
1050    resource
1051        .meta()
1052        .creation_timestamp
1053        .as_ref()
1054        .map(|timestamp| now_ts.saturating_sub(timestamp.0.as_second()) > ORPHAN_GRACE_SECS)
1055        .unwrap_or(false)
1056}
1057
1058async fn delete_plan_best_effort(plans_api: &Api<PostgresPolicyPlan>, plan_name: &str) {
1059    if let Err(err) = plans_api.delete(plan_name, &DeleteParams::default()).await {
1060        tracing::warn!(
1061            plan = %plan_name,
1062            %err,
1063            "failed to roll back plan after status update failure"
1064        );
1065    }
1066}
1067
1068/// Undo this reconcile's plan-SQL ConfigMap write, if there was one.
1069///
1070/// A no-op for a ConfigMap that already existed and was adopted: that one backs
1071/// an earlier plan, and deleting it on our failure path would break it.
1072async fn rollback_plan_sql_configmap(
1073    client: &Client,
1074    namespace: &str,
1075    configmap: Option<&PlanSqlConfigMap>,
1076) {
1077    if let Some(configmap) = configmap
1078        && configmap.created
1079    {
1080        delete_configmap_best_effort(client, namespace, &configmap.name).await;
1081    }
1082}
1083
1084async fn delete_configmap_best_effort(client: &Client, namespace: &str, configmap_name: &str) {
1085    let configmaps_api: Api<ConfigMap> = Api::namespaced(client.clone(), namespace);
1086    if let Err(err) = configmaps_api
1087        .delete(configmap_name, &DeleteParams::default())
1088        .await
1089    {
1090        tracing::warn!(
1091            configmap = %configmap_name,
1092            %err,
1093            "failed to roll back plan SQL ConfigMap"
1094        );
1095    }
1096}
1097
1098/// Render the full executable SQL from changes (including real passwords).
1099pub(crate) fn render_full_sql(
1100    changes: &[pgroles_core::diff::Change],
1101    sql_context: &pgroles_core::sql::SqlContext,
1102) -> String {
1103    changes
1104        .iter()
1105        .flat_map(|change| pgroles_core::sql::render_statements_with_context(change, sql_context))
1106        .collect::<Vec<_>>()
1107        .join("\n")
1108}
1109
1110/// Render redacted SQL for display (passwords replaced with [REDACTED]).
1111fn render_redacted_sql(
1112    changes: &[pgroles_core::diff::Change],
1113    sql_context: &pgroles_core::sql::SqlContext,
1114) -> String {
1115    changes
1116        .iter()
1117        .flat_map(|change| {
1118            if let pgroles_core::diff::Change::SetPassword { name, .. } = change {
1119                vec![format!(
1120                    "ALTER ROLE {} PASSWORD '[REDACTED]';",
1121                    pgroles_core::sql::quote_ident(name)
1122                )]
1123            } else {
1124                pgroles_core::sql::render_statements_with_context(change, sql_context)
1125            }
1126        })
1127        .collect::<Vec<_>>()
1128        .join("\n")
1129}
1130
1131/// Compute SHA-256 hash of the SQL string as a hex digest.
1132pub(crate) fn compute_sql_hash(sql: &str) -> String {
1133    use std::fmt::Write as _;
1134
1135    let mut hasher = Sha256::new();
1136    hasher.update(sql.as_bytes());
1137    let digest = hasher.finalize();
1138    let mut hex = String::with_capacity(digest.len() * 2);
1139    for byte in digest {
1140        write!(&mut hex, "{byte:02x}").expect("writing to a string should succeed");
1141    }
1142    hex
1143}
1144
1145/// Generate a plan name from policy name, current timestamp, and SQL hash.
1146///
1147/// Format: `{policy-name}-plan-{YYYYMMDD-HHMMSS}-{hash-prefix}`
1148///
1149/// The hash suffix makes retries within the same second idempotent if SQL
1150/// content persistence succeeds but plan creation fails.
1151fn generate_plan_name(policy_name: &str, sql_hash: &str) -> String {
1152    let timestamp = format_timestamp_compact();
1153    let suffix = &sql_hash[..12.min(sql_hash.len())];
1154    // Kubernetes names must be <= 253 chars and DNS-compatible.
1155    // Reserve 4 chars for the potential "-sql" ConfigMap suffix.
1156    let max_name_len = crate::k8s_names::MAX_RESOURCE_NAME_LENGTH - 4; // 249
1157    let max_prefix_len = max_name_len - "-plan-".len() - timestamp.len() - "-".len() - suffix.len();
1158    // Truncation can land on a `.` or `-`; appending `-plan-...` after a
1159    // trailing `.` would start a new DNS label with `-`, which the API server
1160    // rejects, so `truncate_name_prefix` trims the separators the cut exposes.
1161    let prefix = truncate_name_prefix(policy_name, max_prefix_len);
1162    format!("{prefix}-plan-{timestamp}-{suffix}")
1163}
1164
1165/// Format the current UTC time as `YYYYMMDD-HHMMSS`.
1166fn format_timestamp_compact() -> String {
1167    use std::time::SystemTime;
1168    let now = SystemTime::now()
1169        .duration_since(SystemTime::UNIX_EPOCH)
1170        .unwrap_or_default();
1171    let secs = now.as_secs();
1172    let (year, month, day) = crate::crd::days_to_date(secs / 86400);
1173    let remaining = secs % 86400;
1174    let hours = remaining / 3600;
1175    let minutes = (remaining % 3600) / 60;
1176    let seconds = remaining % 60;
1177    format!("{year:04}{month:02}{day:02}-{hours:02}{minutes:02}{seconds:02}")
1178}
1179
1180/// Sanitize a string for use as a Kubernetes label value.
1181///
1182/// See [`crate::k8s_names::LabelValue::sanitize`] for the rules. This is the
1183/// single builder for every label value the operator writes, and is also used
1184/// to build the selectors that read them back, so writes and lookups agree.
1185fn sanitize_label_value(value: &str) -> String {
1186    LabelValue::sanitize(value).into_string()
1187}
1188
1189/// Label selector matching every object owned by `policy_name`.
1190///
1191/// This narrows server-side but is **not** an identity: the label value is
1192/// truncated at 63 characters, so two policies sharing a 63-character prefix
1193/// select each other's objects. Always pair it with [`is_owned_by_policy`].
1194fn policy_selector(policy_name: &str) -> Selector {
1195    Expression::Equal(LABEL_POLICY.to_string(), sanitize_label_value(policy_name)).into()
1196}
1197
1198/// Is `resource` owned by `policy`, by controller-owner UID?
1199///
1200/// The exact ownership test. The `pgroles.io/policy` label is lossy, and a
1201/// truncated collision would otherwise let one policy adopt, supersede,
1202/// deduplicate against, or **delete** another policy's plans and plan-SQL
1203/// ConfigMaps. A UID is unique per object and per object lifetime, so this also
1204/// stops a same-named replacement policy from inheriting a deleted one's
1205/// objects.
1206///
1207/// Objects the operator did not create carry no matching owner reference and are
1208/// excluded, which is the safe direction for the deletion paths.
1209fn is_owned_by_policy<K: Resource>(resource: &K, policy: &PostgresPolicy) -> bool {
1210    let Some(policy_uid) = policy.metadata.uid.as_deref() else {
1211        // A policy with no UID cannot own anything; refuse to match rather than
1212        // treat an empty UID as a wildcard.
1213        return false;
1214    };
1215    resource
1216        .meta()
1217        .owner_references
1218        .as_deref()
1219        .unwrap_or_default()
1220        .iter()
1221        .any(|owner| owner.uid == policy_uid && owner.controller.unwrap_or(false))
1222}
1223
1224/// Does `resource` carry a controller owner that is *not* `policy`?
1225///
1226/// Deliberately not `!is_owned_by_policy`. An object with no controller owner at
1227/// all — an orphan left behind by `--cascade=orphan` — belongs to nobody, so it
1228/// stays adoptable and this returns false. Only a live claim by a *different*
1229/// policy blocks adoption.
1230///
1231/// Fails closed: a policy with no UID cannot prove ownership of anything, so
1232/// every owned object counts as another's.
1233fn is_owned_by_another_policy<K: Resource>(resource: &K, policy: &PostgresPolicy) -> bool {
1234    let policy_uid = policy.metadata.uid.as_deref();
1235    resource
1236        .meta()
1237        .owner_references
1238        .as_deref()
1239        .unwrap_or_default()
1240        .iter()
1241        .any(|owner| owner.controller.unwrap_or(false) && Some(owner.uid.as_str()) != policy_uid)
1242}
1243
1244/// Current time as Unix epoch seconds (for dedup window checks).
1245fn now_epoch_secs() -> i64 {
1246    std::time::SystemTime::now()
1247        .duration_since(std::time::UNIX_EPOCH)
1248        .unwrap_or_default()
1249        .as_secs() as i64
1250}
1251
1252/// Parse an RFC 3339 timestamp string to Unix epoch seconds.
1253/// Returns `None` if parsing fails.
1254fn parse_rfc3339_epoch_secs(rfc3339: &str) -> Option<i64> {
1255    // Use jiff (already a transitive dep via k8s-openapi) for RFC 3339 parsing.
1256    rfc3339
1257        .parse::<jiff::Timestamp>()
1258        .ok()
1259        .map(|t| t.as_second())
1260}
1261
1262/// Build an OwnerReference pointing from a plan to its parent policy.
1263fn build_owner_reference(policy: &PostgresPolicy) -> OwnerReference {
1264    OwnerReference {
1265        api_version: PostgresPolicy::api_version(&()).to_string(),
1266        kind: PostgresPolicy::kind(&()).to_string(),
1267        name: policy.name_any(),
1268        uid: policy.metadata.uid.clone().unwrap_or_default(),
1269        controller: Some(true),
1270        block_owner_deletion: Some(true),
1271    }
1272}
1273
1274/// Update the phase field on a plan's status.
1275///
1276/// When transitioning to `Applying`, also sets `applying_since` for stuck
1277/// plan detection.
1278async fn update_plan_phase(
1279    plans_api: &Api<PostgresPolicyPlan>,
1280    plan_name: &str,
1281    phase: PlanPhase,
1282) -> Result<(), ReconcileError> {
1283    let mut patch_value = serde_json::json!({ "status": { "phase": phase } });
1284    if phase == PlanPhase::Applying {
1285        patch_value["status"]["applying_since"] = serde_json::json!(crate::crd::now_rfc3339());
1286    }
1287    plans_api
1288        .patch_status(
1289            plan_name,
1290            &PatchParams::apply("pgroles-operator"),
1291            &Patch::Merge(&patch_value),
1292        )
1293        .await?;
1294    Ok(())
1295}
1296
1297/// Set or update a condition in a conditions list.
1298///
1299/// Preserves `last_transition_time` when the status value is unchanged
1300/// (only reason/message changed), matching Kubernetes condition conventions.
1301fn set_plan_condition(
1302    conditions: &mut Vec<PolicyCondition>,
1303    condition_type: &str,
1304    status: &str,
1305    reason: &str,
1306    message: &str,
1307) {
1308    let transition_time = if let Some(existing) = conditions
1309        .iter()
1310        .find(|c| c.condition_type == condition_type)
1311    {
1312        if existing.status == status {
1313            existing.last_transition_time.clone()
1314        } else {
1315            Some(crate::crd::now_rfc3339())
1316        }
1317    } else {
1318        Some(crate::crd::now_rfc3339())
1319    };
1320
1321    let condition = PolicyCondition {
1322        condition_type: condition_type.to_string(),
1323        status: status.to_string(),
1324        reason: Some(reason.to_string()),
1325        message: Some(message.to_string()),
1326        last_transition_time: transition_time,
1327    };
1328    if let Some(existing) = conditions
1329        .iter_mut()
1330        .find(|c| c.condition_type == condition_type)
1331    {
1332        *existing = condition;
1333    } else {
1334        conditions.push(condition);
1335    }
1336}
1337
1338/// Update the parent policy's `current_plan_ref` in status.
1339pub async fn update_policy_plan_ref(
1340    client: &Client,
1341    policy: &PostgresPolicy,
1342    plan_name: &str,
1343) -> Result<(), ReconcileError> {
1344    let namespace = policy.namespace().ok_or(ReconcileError::NoNamespace)?;
1345    let policy_api: Api<PostgresPolicy> = Api::namespaced(client.clone(), &namespace);
1346
1347    let patch = serde_json::json!({
1348        "status": {
1349            "current_plan_ref": PlanReference {
1350                name: plan_name.to_string(),
1351            }
1352        }
1353    });
1354
1355    policy_api
1356        .patch_status(
1357            &policy.name_any(),
1358            &PatchParams::apply("pgroles-operator"),
1359            &Patch::Merge(&patch),
1360        )
1361        .await?;
1362
1363    Ok(())
1364}
1365
1366/// Look up the current actionable plan for a policy, if any.
1367///
1368/// An actionable plan is one in `Pending` or `Approved` phase — i.e. a plan
1369/// that the reconciler should evaluate for approval/execution.
1370pub async fn get_current_actionable_plan(
1371    client: &Client,
1372    policy: &PostgresPolicy,
1373) -> Result<Option<PostgresPolicyPlan>, ReconcileError> {
1374    let namespace = policy.namespace().ok_or(ReconcileError::NoNamespace)?;
1375    let policy_name = policy.name_any();
1376
1377    let plans_api: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), &namespace);
1378    let selector = policy_selector(&policy_name);
1379    let existing_plans: Vec<PostgresPolicyPlan> = plans_api
1380        .list(&ListParams::default().labels_from(&selector))
1381        .await?
1382        .into_iter()
1383        .filter(|plan| is_owned_by_policy(plan, policy))
1384        .collect();
1385
1386    // Find the most recent actionable plan (Pending or Approved, by creation time).
1387    let mut pending_plans: Vec<PostgresPolicyPlan> = existing_plans
1388        .into_iter()
1389        .filter(|plan| {
1390            plan.status
1391                .as_ref()
1392                .map(|s| matches!(s.phase, PlanPhase::Pending | PlanPhase::Approved))
1393                .unwrap_or(false)
1394        })
1395        .collect();
1396
1397    pending_plans.sort_by(|a, b| {
1398        let a_time = a.metadata.creation_timestamp.as_ref();
1399        let b_time = b.metadata.creation_timestamp.as_ref();
1400        b_time.cmp(&a_time) // newest first
1401    });
1402
1403    Ok(pending_plans.into_iter().next())
1404}
1405
1406/// Look up the most recent plan for a policy in a given phase.
1407pub async fn get_plan_by_phase(
1408    client: &Client,
1409    policy: &PostgresPolicy,
1410    target_phase: PlanPhase,
1411) -> Result<Option<PostgresPolicyPlan>, ReconcileError> {
1412    let namespace = policy.namespace().ok_or(ReconcileError::NoNamespace)?;
1413    let policy_name = policy.name_any();
1414
1415    let plans_api: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), &namespace);
1416    let selector = policy_selector(&policy_name);
1417    let existing_plans: Vec<PostgresPolicyPlan> = plans_api
1418        .list(&ListParams::default().labels_from(&selector))
1419        .await?
1420        .into_iter()
1421        .filter(|plan| is_owned_by_policy(plan, policy))
1422        .collect();
1423
1424    let mut matching_plans: Vec<PostgresPolicyPlan> = existing_plans
1425        .into_iter()
1426        .filter(|plan| {
1427            plan.status
1428                .as_ref()
1429                .map(|s| s.phase == target_phase)
1430                .unwrap_or(false)
1431        })
1432        .collect();
1433
1434    matching_plans.sort_by(|a, b| {
1435        let a_time = a.metadata.creation_timestamp.as_ref();
1436        let b_time = b.metadata.creation_timestamp.as_ref();
1437        b_time.cmp(&a_time) // newest first
1438    });
1439
1440    Ok(matching_plans.into_iter().next())
1441}
1442
1443/// Mark a plan as Failed with a given error message.
1444pub async fn mark_plan_failed(
1445    client: &Client,
1446    plan: &PostgresPolicyPlan,
1447    error_message: &str,
1448) -> Result<(), ReconcileError> {
1449    let namespace = plan.namespace().ok_or(ReconcileError::NoNamespace)?;
1450    let plan_name = plan.name_any();
1451    let plans_api: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), &namespace);
1452
1453    let mut status = plan.status.clone().unwrap_or_default();
1454    status.phase = PlanPhase::Failed;
1455    status.last_error = Some(error_message.to_string());
1456    status.failed_at = Some(crate::crd::now_rfc3339());
1457
1458    let patch = serde_json::json!({ "status": status });
1459    plans_api
1460        .patch_status(
1461            &plan_name,
1462            &PatchParams::apply("pgroles-operator"),
1463            &Patch::Merge(&patch),
1464        )
1465        .await?;
1466
1467    info!(
1468        plan = %plan_name,
1469        "marked stuck Applying plan as Failed"
1470    );
1471
1472    Ok(())
1473}
1474
1475/// Mark a plan as Approved.
1476///
1477/// Callers provide `reason` and `message` to distinguish auto-approval from
1478/// manual approval in the plan's conditions.
1479pub async fn mark_plan_approved(
1480    client: &Client,
1481    plan: &PostgresPolicyPlan,
1482    reason: &str,
1483    message: &str,
1484) -> Result<(), ReconcileError> {
1485    let namespace = plan.namespace().ok_or(ReconcileError::NoNamespace)?;
1486    let plan_name = plan.name_any();
1487    let plans_api: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), &namespace);
1488
1489    let mut status = plan.status.clone().unwrap_or_default();
1490    status.phase = PlanPhase::Approved;
1491    set_plan_condition(&mut status.conditions, "Approved", "True", reason, message);
1492
1493    let patch = serde_json::json!({ "status": status });
1494    plans_api
1495        .patch_status(
1496            &plan_name,
1497            &PatchParams::apply("pgroles-operator"),
1498            &Patch::Merge(&patch),
1499        )
1500        .await?;
1501
1502    Ok(())
1503}
1504
1505/// Mark a plan as Rejected.
1506pub async fn mark_plan_rejected(
1507    client: &Client,
1508    plan: &PostgresPolicyPlan,
1509) -> Result<(), ReconcileError> {
1510    let namespace = plan.namespace().ok_or(ReconcileError::NoNamespace)?;
1511    let plan_name = plan.name_any();
1512    let plans_api: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), &namespace);
1513
1514    let mut status = plan.status.clone().unwrap_or_default();
1515    status.phase = PlanPhase::Rejected;
1516    set_plan_condition(
1517        &mut status.conditions,
1518        "Approved",
1519        "False",
1520        "Rejected",
1521        "Plan rejected via annotation",
1522    );
1523
1524    let patch = serde_json::json!({ "status": status });
1525    plans_api
1526        .patch_status(
1527            &plan_name,
1528            &PatchParams::apply("pgroles-operator"),
1529            &Patch::Merge(&patch),
1530        )
1531        .await?;
1532
1533    Ok(())
1534}
1535
1536/// Mark a plan as Superseded (database state changed since approval).
1537pub async fn mark_plan_superseded(
1538    client: &Client,
1539    plan: &PostgresPolicyPlan,
1540) -> Result<(), ReconcileError> {
1541    let namespace = plan.namespace().ok_or(ReconcileError::NoNamespace)?;
1542    let plan_name = plan.name_any();
1543    let plans_api: Api<PostgresPolicyPlan> = Api::namespaced(client.clone(), &namespace);
1544
1545    let mut status = plan.status.clone().unwrap_or_default();
1546    status.phase = PlanPhase::Superseded;
1547    set_plan_condition(
1548        &mut status.conditions,
1549        "Approved",
1550        "False",
1551        "Superseded",
1552        "Database state changed since plan was approved",
1553    );
1554
1555    let patch = serde_json::json!({ "status": status });
1556    plans_api
1557        .patch_status(
1558            &plan_name,
1559            &PatchParams::apply("pgroles-operator"),
1560            &Patch::Merge(&patch),
1561        )
1562        .await?;
1563
1564    Ok(())
1565}
1566
1567// ---------------------------------------------------------------------------
1568// Tests
1569// ---------------------------------------------------------------------------
1570
1571#[cfg(test)]
1572mod tests {
1573    use super::*;
1574    use crate::crd::CrdReconciliationMode;
1575    use base64::Engine as _;
1576    use flate2::read::GzDecoder;
1577    use std::io::Read;
1578
1579    /// A minimal spec — the ownership tests only care about metadata.
1580    fn test_policy_spec() -> crate::crd::PostgresPolicySpec {
1581        crate::crd::PostgresPolicySpec {
1582            connection: crate::crd::ConnectionSpec {
1583                secret_ref: Some(crate::crd::SecretReference {
1584                    name: "db-credentials".to_string(),
1585                }),
1586                secret_key: Some("DATABASE_URL".to_string()),
1587                params: None,
1588            },
1589            interval: "5m".to_string(),
1590            suspend: false,
1591            mode: crate::crd::PolicyMode::Apply,
1592            reconciliation_mode: CrdReconciliationMode::default(),
1593            default_owner: None,
1594            profiles: Default::default(),
1595            schemas: Vec::new(),
1596            roles: Vec::new(),
1597            grants: Vec::new(),
1598            default_privileges: Vec::new(),
1599            memberships: Vec::new(),
1600            retirements: Vec::new(),
1601            approval: None,
1602        }
1603    }
1604
1605    /// A policy with a known UID, for the ownership tests.
1606    fn policy_with_uid(name: &str, uid: &str) -> PostgresPolicy {
1607        let mut policy = PostgresPolicy::new(name, test_policy_spec());
1608        policy.metadata.namespace = Some("default".to_string());
1609        policy.metadata.uid = Some(uid.to_string());
1610        policy
1611    }
1612
1613    /// Two policies whose names share a 63-character prefix collapse to the same
1614    /// `pgroles.io/policy` label, so the selector cannot tell them apart. Only
1615    /// the owner UID can — and the selector result drives deletion.
1616    #[test]
1617    fn colliding_label_values_are_separated_by_owner_uid() {
1618        let prefix = "a".repeat(63);
1619        let first = policy_with_uid(&format!("{prefix}-one"), "uid-one");
1620        let second = policy_with_uid(&format!("{prefix}-two"), "uid-two");
1621
1622        // Precondition: the labels really are indistinguishable.
1623        assert_eq!(
1624            sanitize_label_value(&first.name_any()),
1625            sanitize_label_value(&second.name_any()),
1626        );
1627
1628        let mut plan = test_plan("plan-1", PlanPhase::Pending, None);
1629        plan.metadata.owner_references = Some(vec![build_owner_reference(&first)]);
1630
1631        assert!(is_owned_by_policy(&plan, &first));
1632        assert!(
1633            !is_owned_by_policy(&plan, &second),
1634            "a colliding policy must not claim another policy's plan"
1635        );
1636    }
1637
1638    /// The adoption gate is narrower than `!is_owned_by_policy`: it blocks only
1639    /// a live claim by a *different* policy. An unowned orphan stays adoptable,
1640    /// so recovering from a `--cascade=orphan` delete still works.
1641    #[test]
1642    fn only_a_rival_controller_owner_blocks_adoption() {
1643        let mine = policy_with_uid("orders", "uid-mine");
1644        let theirs = policy_with_uid("orders-other", "uid-theirs");
1645
1646        let mut ours = test_plan("plan-1", PlanPhase::Pending, None);
1647        ours.metadata.owner_references = Some(vec![build_owner_reference(&mine)]);
1648        assert!(!is_owned_by_another_policy(&ours, &mine));
1649
1650        let mut rival = test_plan("plan-2", PlanPhase::Pending, None);
1651        rival.metadata.owner_references = Some(vec![build_owner_reference(&theirs)]);
1652        assert!(is_owned_by_another_policy(&rival, &mine));
1653
1654        // An orphan belongs to nobody, so it does not block us.
1655        let orphan = test_plan("plan-3", PlanPhase::Pending, None);
1656        assert!(!is_owned_by_another_policy(&orphan, &mine));
1657
1658        // A non-controller owner reference is not a claim either.
1659        let mut non_controller = build_owner_reference(&theirs);
1660        non_controller.controller = Some(false);
1661        let mut referenced = test_plan("plan-4", PlanPhase::Pending, None);
1662        referenced.metadata.owner_references = Some(vec![non_controller]);
1663        assert!(!is_owned_by_another_policy(&referenced, &mine));
1664    }
1665
1666    /// Fail closed: without a UID we cannot prove anything is ours, so every
1667    /// claimed object must count as someone else's.
1668    #[test]
1669    fn a_policy_without_a_uid_can_adopt_nothing_owned() {
1670        let mut no_uid = policy_with_uid("orders", "uid-orders");
1671        no_uid.metadata.uid = None;
1672        let owner = policy_with_uid("orders", "uid-orders");
1673
1674        let mut claimed = test_plan("plan-1", PlanPhase::Pending, None);
1675        claimed.metadata.owner_references = Some(vec![build_owner_reference(&owner)]);
1676        assert!(is_owned_by_another_policy(&claimed, &no_uid));
1677
1678        // ...but an orphan is still nobody's.
1679        let orphan = test_plan("plan-2", PlanPhase::Pending, None);
1680        assert!(!is_owned_by_another_policy(&orphan, &no_uid));
1681    }
1682
1683    #[test]
1684    fn ownership_requires_a_controller_owner_reference() {
1685        let policy = policy_with_uid("orders", "uid-orders");
1686
1687        // No owner references at all — e.g. an object the operator did not create.
1688        let plan = test_plan("plan-1", PlanPhase::Pending, None);
1689        assert!(!is_owned_by_policy(&plan, &policy));
1690
1691        // Right UID, but not marked as the controller.
1692        let mut non_controller = build_owner_reference(&policy);
1693        non_controller.controller = Some(false);
1694        let mut plan = test_plan("plan-2", PlanPhase::Pending, None);
1695        plan.metadata.owner_references = Some(vec![non_controller]);
1696        assert!(!is_owned_by_policy(&plan, &policy));
1697    }
1698
1699    /// An empty UID must never act as a wildcard: a policy the API server has
1700    /// not assigned a UID to owns nothing.
1701    #[test]
1702    fn policy_without_uid_owns_nothing() {
1703        let mut policy = PostgresPolicy::new("orders", test_policy_spec());
1704        policy.metadata.namespace = Some("default".to_string());
1705        policy.metadata.uid = None;
1706
1707        let mut plan = test_plan("plan-1", PlanPhase::Pending, None);
1708        // `build_owner_reference` defaults a missing UID to the empty string.
1709        plan.metadata.owner_references = Some(vec![build_owner_reference(&policy)]);
1710
1711        assert!(!is_owned_by_policy(&plan, &policy));
1712    }
1713
1714    /// A replacement policy with the same name is a different object, so it must
1715    /// not inherit the deleted one's plans.
1716    #[test]
1717    fn recreated_policy_does_not_inherit_previous_plans() {
1718        let original = policy_with_uid("orders", "uid-original");
1719        let recreated = policy_with_uid("orders", "uid-recreated");
1720
1721        let mut plan = test_plan("plan-1", PlanPhase::Applied, None);
1722        plan.metadata.owner_references = Some(vec![build_owner_reference(&original)]);
1723
1724        assert!(is_owned_by_policy(&plan, &original));
1725        assert!(!is_owned_by_policy(&plan, &recreated));
1726    }
1727
1728    /// The ConfigMap cleanup path filters the same way, which is what stops it
1729    /// deleting a colliding policy's SQL ConfigMap as an "orphan".
1730    #[test]
1731    fn configmap_ownership_uses_owner_uid() {
1732        let prefix = "a".repeat(63);
1733        let mine = policy_with_uid(&format!("{prefix}-one"), "uid-one");
1734        let theirs = policy_with_uid(&format!("{prefix}-two"), "uid-two");
1735
1736        let configmap = ConfigMap {
1737            metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
1738                name: Some("plan-1-sql".to_string()),
1739                owner_references: Some(vec![build_owner_reference(&theirs)]),
1740                ..Default::default()
1741            },
1742            ..Default::default()
1743        };
1744
1745        assert!(is_owned_by_policy(&configmap, &theirs));
1746        assert!(
1747            !is_owned_by_policy(&configmap, &mine),
1748            "cleanup must not treat another policy's ConfigMap as its own"
1749        );
1750    }
1751
1752    fn test_plan(
1753        name: &str,
1754        phase: PlanPhase,
1755        annotations: Option<BTreeMap<String, String>>,
1756    ) -> PostgresPolicyPlan {
1757        let mut plan = PostgresPolicyPlan::new(
1758            name,
1759            PostgresPolicyPlanSpec {
1760                policy_ref: PolicyPlanRef {
1761                    name: "test-policy".to_string(),
1762                },
1763                policy_generation: 1,
1764                reconciliation_mode: CrdReconciliationMode::Authoritative,
1765                owned_roles: vec!["role-a".to_string()],
1766                owned_schemas: vec!["public".to_string()],
1767                managed_database_identity: "default/db/DATABASE_URL".to_string(),
1768                origin: None,
1769                scope: None,
1770            },
1771        );
1772        plan.metadata.namespace = Some("default".to_string());
1773        plan.metadata.annotations = annotations;
1774        plan.status = Some(PostgresPolicyPlanStatus {
1775            phase,
1776            ..Default::default()
1777        });
1778        plan
1779    }
1780
1781    #[test]
1782    fn check_plan_approval_pending_when_no_annotations() {
1783        let plan = test_plan("plan-1", PlanPhase::Pending, None);
1784        assert_eq!(check_plan_approval(&plan), PlanApprovalState::Pending);
1785    }
1786
1787    #[test]
1788    fn check_plan_approval_approved_with_annotation() {
1789        let annotations =
1790            BTreeMap::from([(PLAN_APPROVED_ANNOTATION.to_string(), "true".to_string())]);
1791        let plan = test_plan("plan-1", PlanPhase::Pending, Some(annotations));
1792        assert_eq!(check_plan_approval(&plan), PlanApprovalState::Approved);
1793    }
1794
1795    #[test]
1796    fn check_plan_approval_rejected_with_annotation() {
1797        let annotations =
1798            BTreeMap::from([(PLAN_REJECTED_ANNOTATION.to_string(), "true".to_string())]);
1799        let plan = test_plan("plan-1", PlanPhase::Pending, Some(annotations));
1800        assert_eq!(check_plan_approval(&plan), PlanApprovalState::Rejected);
1801    }
1802
1803    #[test]
1804    fn check_plan_approval_rejected_wins_over_approved() {
1805        let annotations = BTreeMap::from([
1806            (PLAN_APPROVED_ANNOTATION.to_string(), "true".to_string()),
1807            (PLAN_REJECTED_ANNOTATION.to_string(), "true".to_string()),
1808        ]);
1809        let plan = test_plan("plan-1", PlanPhase::Pending, Some(annotations));
1810        assert_eq!(check_plan_approval(&plan), PlanApprovalState::Rejected);
1811    }
1812
1813    #[test]
1814    fn check_plan_approval_non_true_value_is_pending() {
1815        let annotations =
1816            BTreeMap::from([(PLAN_APPROVED_ANNOTATION.to_string(), "false".to_string())]);
1817        let plan = test_plan("plan-1", PlanPhase::Pending, Some(annotations));
1818        assert_eq!(check_plan_approval(&plan), PlanApprovalState::Pending);
1819    }
1820
1821    #[test]
1822    fn compute_sql_hash_is_deterministic() {
1823        let sql = "CREATE ROLE test LOGIN;\nGRANT SELECT ON ALL TABLES IN SCHEMA public TO test;";
1824        let hash1 = compute_sql_hash(sql);
1825        let hash2 = compute_sql_hash(sql);
1826        assert_eq!(hash1, hash2);
1827        assert_eq!(hash1.len(), 64); // SHA-256 hex digest is 64 chars
1828    }
1829
1830    #[test]
1831    fn compute_sql_hash_differs_for_different_sql() {
1832        let hash1 = compute_sql_hash("CREATE ROLE a;");
1833        let hash2 = compute_sql_hash("CREATE ROLE b;");
1834        assert_ne!(hash1, hash2);
1835    }
1836
1837    #[test]
1838    fn compute_sql_hash_matches_pinned_fixture() {
1839        assert_eq!(
1840            compute_sql_hash("CREATE ROLE app LOGIN;"),
1841            "12a9743285d98ce73cfa9c840e943fc627d1fcbce22c5206fda1b21c84c1ac9c"
1842        );
1843    }
1844
1845    #[test]
1846    fn generate_plan_name_has_expected_format() {
1847        let hash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
1848        let name = generate_plan_name("my-policy", hash);
1849        assert!(name.starts_with("my-policy-plan-"));
1850        assert!(name.ends_with("-abcdef012345"));
1851        let suffix = name.strip_prefix("my-policy-plan-").unwrap();
1852        // YYYYMMDD-HHMMSS-hashprefix = 15 + 1 + 12 = 28 chars
1853        assert_eq!(suffix.len(), 28);
1854        assert_eq!(&suffix[8..9], "-");
1855        assert_eq!(&suffix[15..16], "-");
1856    }
1857
1858    #[test]
1859    fn generate_plan_name_is_idempotent_for_same_hash_in_same_second() {
1860        let hash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
1861        let name1 = generate_plan_name("my-policy", hash);
1862        let name2 = generate_plan_name("my-policy", hash);
1863        assert_eq!(name1, name2);
1864    }
1865
1866    #[test]
1867    fn generate_plan_name_truncates_on_utf8_boundary() {
1868        let hash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
1869        let name = generate_plan_name(&"é".repeat(140), hash);
1870        assert!(name.len() <= 249);
1871        assert!(name.ends_with("-abcdef012345"));
1872    }
1873
1874    /// The plan-SQL ConfigMap derives its name by appending `-sql` to the plan
1875    /// name and carries three label values, all from user-controlled input.
1876    /// `generate_plan_name` reserves exactly 4 bytes for that suffix, so a
1877    /// boundary-length policy name is where the reservation would be wrong.
1878    ///
1879    /// Covered here rather than end-to-end because the plan SQL only spills to a
1880    /// ConfigMap above `MAX_INLINE_SQL_BYTES`; forcing that in a cluster would
1881    /// mean generating 16 KiB of SQL to re-verify string composition.
1882    #[test]
1883    fn plan_sql_configmap_identifiers_are_valid_at_the_name_limit() {
1884        use crate::k8s_names::{is_valid_label_value, is_valid_resource_name};
1885
1886        let hash = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789";
1887        // A hostile database identity: NUL separators and `=` from identity_key.
1888        let identity = format!(
1889            "prod/params\0literal={}\0literal=appdb\05432",
1890            "h".repeat(80)
1891        );
1892
1893        for policy_name in [
1894            "orders".to_string(),
1895            "a.very-long-policy.name-with-dots.and-dashes.at-the-limit.xxxxx".to_string(),
1896            format!("{}.{}", "a".repeat(214), "b".repeat(38)),
1897            "a".repeat(253),
1898        ] {
1899            let plan_name = generate_plan_name(&policy_name, hash);
1900            let configmap_name = format!("{plan_name}-sql");
1901
1902            assert!(
1903                is_valid_resource_name(&configmap_name),
1904                "invalid ConfigMap name for policy {policy_name:?}: {configmap_name}"
1905            );
1906            assert!(
1907                configmap_name.len() <= crate::k8s_names::MAX_RESOURCE_NAME_LENGTH,
1908                "ConfigMap name over the limit: {} bytes",
1909                configmap_name.len()
1910            );
1911            // Round-trips back to the plan name the labels are keyed on.
1912            assert_eq!(configmap_plan_name(&configmap_name), plan_name);
1913
1914            for label in [
1915                sanitize_label_value(&policy_name),
1916                sanitize_label_value(&identity),
1917                plan_label_value(&plan_name),
1918            ] {
1919                assert!(
1920                    is_valid_label_value(&label),
1921                    "invalid label value {label:?} for policy {policy_name:?}"
1922                );
1923            }
1924        }
1925    }
1926
1927    #[test]
1928    fn plan_label_value_is_stable_and_label_safe_for_long_names() {
1929        let plan_name = "very-long-policy-name-".repeat(20);
1930        let label = plan_label_value(&plan_name);
1931        assert_eq!(label, plan_label_value(&plan_name));
1932        assert_eq!(label.len(), 32);
1933        assert!(label.chars().all(|ch| ch.is_ascii_hexdigit()));
1934    }
1935
1936    #[test]
1937    fn existing_non_pending_plan_status_is_not_repatched_on_create_conflict() {
1938        let approved = test_plan("plan-1", PlanPhase::Approved, None);
1939        let applying = test_plan("plan-1", PlanPhase::Applying, None);
1940        let applied = test_plan("plan-1", PlanPhase::Applied, None);
1941
1942        assert!(!should_patch_existing_plan_status(&approved));
1943        assert!(!should_patch_existing_plan_status(&applying));
1944        assert!(!should_patch_existing_plan_status(&applied));
1945    }
1946
1947    #[test]
1948    fn existing_pending_or_statusless_plan_can_be_patched_on_create_conflict() {
1949        let pending = test_plan("plan-1", PlanPhase::Pending, None);
1950        let mut statusless = pending.clone();
1951        statusless.status = None;
1952
1953        assert!(should_patch_existing_plan_status(&pending));
1954        assert!(should_patch_existing_plan_status(&statusless));
1955    }
1956
1957    #[test]
1958    fn prepare_plan_sql_keeps_small_sql_inline() {
1959        let prepared = prepare_plan_sql("plan-1", "CREATE ROLE app LOGIN;").unwrap();
1960
1961        assert!(matches!(prepared.artifact, PlanSqlArtifact::Inline(_)));
1962        assert_eq!(
1963            prepared.sql_inline(),
1964            Some("CREATE ROLE app LOGIN;".to_string())
1965        );
1966        assert!(prepared.sql_ref().is_none());
1967        assert!(!prepared.is_truncated());
1968    }
1969
1970    #[test]
1971    fn prepare_plan_sql_compresses_large_brownfield_sized_sql() {
1972        let sql = brownfield_sized_sql();
1973        assert!(sql.len() > 1_048_576);
1974
1975        let prepared = prepare_plan_sql("policy-plan-20260506-000000-abcdef012345", &sql).unwrap();
1976
1977        let PlanSqlArtifact::CompressedConfigMap {
1978            key,
1979            compressed_sql,
1980            ..
1981        } = &prepared.artifact
1982        else {
1983            panic!("expected compressed ConfigMap artifact");
1984        };
1985        assert_eq!(key, SQL_CONFIGMAP_GZIP_KEY);
1986        assert!(compressed_sql.len() < MAX_CONFIGMAP_SQL_BYTES);
1987        assert_eq!(gunzip(compressed_sql), sql);
1988        assert_eq!(
1989            prepared.sql_ref().unwrap().compression,
1990            Some(SqlCompression::Gzip)
1991        );
1992        assert_eq!(prepared.original_bytes, sql.len());
1993        assert_eq!(prepared.stored_bytes, compressed_sql.len());
1994    }
1995
1996    #[test]
1997    fn configmap_binary_data_serializes_with_one_base64_layer() {
1998        let sql = brownfield_sized_sql();
1999        let prepared = prepare_plan_sql("policy-plan-20260506-000000-abcdef012345", &sql).unwrap();
2000        let PlanSqlArtifact::CompressedConfigMap {
2001            key,
2002            compressed_sql,
2003            ..
2004        } = &prepared.artifact
2005        else {
2006            panic!("expected compressed ConfigMap artifact");
2007        };
2008        let configmap = ConfigMap {
2009            binary_data: Some(BTreeMap::from([(
2010                key.clone(),
2011                ByteString(compressed_sql.clone()),
2012            )])),
2013            ..Default::default()
2014        };
2015
2016        let encoded = serde_json::to_value(&configmap).unwrap()["binaryData"][key]
2017            .as_str()
2018            .unwrap()
2019            .to_string();
2020        let decoded = base64::engine::general_purpose::STANDARD
2021            .decode(encoded)
2022            .unwrap();
2023
2024        assert_eq!(decoded, *compressed_sql);
2025        assert_eq!(gunzip(&decoded), sql);
2026    }
2027
2028    #[test]
2029    fn prepare_plan_sql_truncates_when_compressed_sql_is_still_too_large() {
2030        let sql = deterministic_incompressible_sql(1_400_000);
2031        let prepared = prepare_plan_sql("policy-plan-20260506-000000-abcdef012345", &sql).unwrap();
2032
2033        let PlanSqlArtifact::TruncatedInline(preview) = &prepared.artifact else {
2034            panic!("expected truncated inline artifact");
2035        };
2036        assert!(preview.len() <= MAX_INLINE_SQL_BYTES);
2037        assert!(preview.contains("truncated"));
2038        assert!(prepared.sql_ref().is_none());
2039        assert!(prepared.is_truncated());
2040    }
2041
2042    #[test]
2043    fn sanitize_label_value_replaces_slashes() {
2044        let sanitized = sanitize_label_value("default/db-creds/DATABASE_URL");
2045        assert!(!sanitized.contains('/'));
2046        assert_eq!(sanitized, "default_db-creds_DATABASE_URL");
2047    }
2048
2049    #[test]
2050    fn sanitize_label_value_truncates_to_63_chars() {
2051        let long_value = "a".repeat(100);
2052        let sanitized = sanitize_label_value(&long_value);
2053        assert!(sanitized.len() <= 63);
2054    }
2055
2056    #[test]
2057    fn stale_policy_sql_configmap_without_plan_label_is_orphan() {
2058        let configmap = ConfigMap {
2059            metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
2060                labels: Some(BTreeMap::from([(
2061                    LABEL_POLICY.to_string(),
2062                    sanitize_label_value("test-policy"),
2063                )])),
2064                creation_timestamp: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
2065                    jiff::Timestamp::from_second(0).unwrap(),
2066                )),
2067                ..Default::default()
2068            },
2069            ..Default::default()
2070        };
2071
2072        assert!(is_orphan_sql_configmap(
2073            &configmap,
2074            &BTreeSet::new(),
2075            &BTreeSet::new(),
2076            ORPHAN_GRACE_SECS + 1
2077        ));
2078    }
2079
2080    #[test]
2081    fn stale_policy_sql_configmap_with_current_plan_name_is_not_orphan() {
2082        let plan_name = "test-policy-plan-20260506-000000-abcdef012345";
2083        let configmap = ConfigMap {
2084            metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
2085                name: Some(format!("{plan_name}-sql")),
2086                labels: Some(BTreeMap::from([
2087                    (
2088                        LABEL_POLICY.to_string(),
2089                        sanitize_label_value("test-policy"),
2090                    ),
2091                    (
2092                        LABEL_PLAN.to_string(),
2093                        sanitize_label_value("legacy-colliding-label"),
2094                    ),
2095                ])),
2096                creation_timestamp: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
2097                    jiff::Timestamp::from_second(0).unwrap(),
2098                )),
2099                ..Default::default()
2100            },
2101            ..Default::default()
2102        };
2103
2104        assert!(!is_orphan_sql_configmap(
2105            &configmap,
2106            &BTreeSet::from([plan_name.to_string()]),
2107            &BTreeSet::new(),
2108            ORPHAN_GRACE_SECS + 1
2109        ));
2110    }
2111
2112    #[test]
2113    fn stale_policy_sql_configmap_with_known_hash_plan_label_is_not_orphan() {
2114        let plan_name = "test-policy-plan-20260506-000000-abcdef012345";
2115        let plan_label = plan_label_value(plan_name);
2116        let configmap = ConfigMap {
2117            metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
2118                name: Some("different-plan-sql".to_string()),
2119                labels: Some(BTreeMap::from([
2120                    (
2121                        LABEL_POLICY.to_string(),
2122                        sanitize_label_value("test-policy"),
2123                    ),
2124                    (LABEL_PLAN.to_string(), plan_label.clone()),
2125                ])),
2126                creation_timestamp: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
2127                    jiff::Timestamp::from_second(0).unwrap(),
2128                )),
2129                ..Default::default()
2130            },
2131            ..Default::default()
2132        };
2133
2134        assert!(!is_orphan_sql_configmap(
2135            &configmap,
2136            &BTreeSet::new(),
2137            &BTreeSet::from([plan_label]),
2138            ORPHAN_GRACE_SECS + 1
2139        ));
2140    }
2141
2142    #[test]
2143    fn stale_policy_sql_configmap_with_only_legacy_colliding_label_is_orphan() {
2144        let plan_name =
2145            "very-long-policy-name-that-would-have-collided-plan-20260506-000000-abcdef012345";
2146        let legacy_label = sanitize_label_value(plan_name);
2147        let configmap = ConfigMap {
2148            metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
2149                name: Some("deleted-historical-plan-sql".to_string()),
2150                labels: Some(BTreeMap::from([
2151                    (
2152                        LABEL_POLICY.to_string(),
2153                        sanitize_label_value("test-policy"),
2154                    ),
2155                    (LABEL_PLAN.to_string(), legacy_label.clone()),
2156                ])),
2157                creation_timestamp: Some(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time(
2158                    jiff::Timestamp::from_second(0).unwrap(),
2159                )),
2160                ..Default::default()
2161            },
2162            ..Default::default()
2163        };
2164
2165        assert!(is_orphan_sql_configmap(
2166            &configmap,
2167            &BTreeSet::new(),
2168            &BTreeSet::new(),
2169            ORPHAN_GRACE_SECS + 1
2170        ));
2171    }
2172
2173    #[test]
2174    fn render_redacted_sql_masks_passwords() {
2175        let changes = vec![
2176            pgroles_core::diff::Change::CreateRole {
2177                name: "app".to_string(),
2178                state: pgroles_core::model::RoleState {
2179                    login: true,
2180                    ..pgroles_core::model::RoleState::default()
2181                },
2182            },
2183            pgroles_core::diff::Change::SetPassword {
2184                name: "app".to_string(),
2185                password: "super_secret".to_string(),
2186            },
2187        ];
2188        let ctx = pgroles_core::sql::SqlContext::default();
2189        let redacted = render_redacted_sql(&changes, &ctx);
2190
2191        assert!(redacted.contains("[REDACTED]"));
2192        assert!(!redacted.contains("super_secret"));
2193        assert!(redacted.contains("CREATE ROLE"));
2194    }
2195
2196    #[test]
2197    fn render_redacted_sql_password_only_plan() {
2198        // A plan whose only change is a password rotation still has to redact:
2199        // there is no surrounding DDL to dilute a leak.
2200        let changes = vec![pgroles_core::diff::Change::SetPassword {
2201            name: "db-user".to_string(),
2202            password: "my_secret_pw".to_string(),
2203        }];
2204        let ctx = pgroles_core::sql::SqlContext::default();
2205        let redacted = render_redacted_sql(&changes, &ctx);
2206
2207        assert!(redacted.contains("[REDACTED]"));
2208        assert!(!redacted.contains("my_secret_pw"));
2209    }
2210
2211    #[test]
2212    fn render_full_sql_includes_passwords() {
2213        let changes = vec![pgroles_core::diff::Change::SetPassword {
2214            name: "app".to_string(),
2215            password: "super_secret".to_string(),
2216        }];
2217        let ctx = pgroles_core::sql::SqlContext::default();
2218        let full = render_full_sql(&changes, &ctx);
2219
2220        assert!(full.contains("super_secret") || full.contains("SCRAM-SHA-256"));
2221    }
2222
2223    #[test]
2224    fn now_epoch_secs_returns_plausible_value() {
2225        let now = now_epoch_secs();
2226        // Should be after 2025-01-01 and before 2100-01-01.
2227        let y2025 = 1_735_689_600_i64;
2228        let y2100 = 4_102_444_800_i64;
2229        assert!(
2230            now > y2025 && now < y2100,
2231            "epoch secs {now} should be between 2025 and 2100"
2232        );
2233    }
2234
2235    fn brownfield_sized_sql() -> String {
2236        let mut sql = String::new();
2237        for schema in 0..33 {
2238            for profile in ["reader", "writer", "owner", "cdc"] {
2239                let role = format!("schema_{schema}_{profile}");
2240                sql.push_str(&format!(
2241                    "CREATE ROLE \"{role}\" LOGIN;\nCOMMENT ON ROLE \"{role}\" IS 'Generated from profile {profile} for brownfield migration schema {schema} with cdc ownership directives and review metadata';\n"
2242                ));
2243                for relkind in ["TABLES", "SEQUENCES", "FUNCTIONS"] {
2244                    sql.push_str(&format!(
2245                        "GRANT SELECT ON ALL {relkind} IN SCHEMA \"schema_{schema}\" TO \"{role}\";\n"
2246                    ));
2247                }
2248                for owner in 0..20 {
2249                    sql.push_str(&format!(
2250                        "ALTER DEFAULT PRIVILEGES FOR ROLE \"owner_{owner}\" IN SCHEMA \"schema_{schema}\" GRANT SELECT ON TABLES TO \"{role}\";\n"
2251                    ));
2252                }
2253            }
2254        }
2255        for member in 0..70 {
2256            sql.push_str(&format!(
2257                "GRANT \"group_{member}\" TO \"service_login_{}\";\n",
2258                member % 20
2259            ));
2260        }
2261        while sql.len() <= 1_100_000 {
2262            sql.push_str("-- brownfield migration padding for large plan regression\n");
2263        }
2264        sql
2265    }
2266
2267    fn deterministic_incompressible_sql(target_bytes: usize) -> String {
2268        let mut state = 0x1234_5678_u64;
2269        let mut sql = String::with_capacity(target_bytes);
2270        while sql.len() < target_bytes {
2271            state ^= state << 13;
2272            state ^= state >> 7;
2273            state ^= state << 17;
2274            let value = (state % 62) as u8;
2275            let ch = match value {
2276                0..=9 => b'0' + value,
2277                10..=35 => b'a' + (value - 10),
2278                _ => b'A' + (value - 36),
2279            };
2280            sql.push(ch as char);
2281            if sql.len().is_multiple_of(120) {
2282                sql.push('\n');
2283            }
2284        }
2285        sql
2286    }
2287
2288    fn gunzip(bytes: &[u8]) -> String {
2289        let mut decoder = GzDecoder::new(bytes);
2290        let mut decoded = String::new();
2291        decoder.read_to_string(&mut decoded).unwrap();
2292        decoded
2293    }
2294}