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