1use 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#[derive(Debug, Clone)]
34pub enum PlanCreationResult {
35 Created(String),
37 Deduplicated(String),
39}
40
41impl PlanCreationResult {
42 pub fn plan_name(&self) -> &str {
44 match self {
45 PlanCreationResult::Created(name) | PlanCreationResult::Deduplicated(name) => name,
46 }
47 }
48
49 pub fn is_created(&self) -> bool {
51 matches!(self, PlanCreationResult::Created(_))
52 }
53}
54
55const MAX_INLINE_SQL_BYTES: usize = 16 * 1024;
57
58const SQL_CONFIGMAP_GZIP_KEY: &str = "plan.sql.gz";
60
61const MAX_CONFIGMAP_SQL_BYTES: usize = 900 * 1024;
64
65const ORPHAN_GRACE_SECS: i64 = 60;
67
68const CLEANUP_TIMEOUT_SECS: u64 = 5;
70
71const DEFAULT_MAX_PLANS: usize = 10;
73
74const 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#[derive(Debug, Clone, PartialEq, Eq)]
134pub enum PlanApprovalState {
135 Pending,
136 Approved,
137 Rejected,
138}
139
140pub 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#[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 let full_sql = render_full_sql(changes, sql_context);
201
202 let sql_hash = compute_sql_hash(&full_sql);
204
205 let sql_statement_count = full_sql.lines().filter(|l| !l.trim().is_empty()).count() as i64;
207
208 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 let selector = policy_selector(&policy_name);
217 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 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 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 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 let plan_name = generate_plan_name(&policy_name, &sql_hash);
276 let prepared_sql = prepare_plan_sql(&plan_name, &redacted_sql)?;
277
278 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 let owner_ref = build_owner_reference(policy);
291
292 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 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 if !is_owned_by_policy(&existing, policy) {
361 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 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 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
481pub 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_plan_phase(&plans_api, &plan_name, PlanPhase::Applying).await?;
504
505 let result = execute_changes_in_transaction(pool, changes, sql_context).await;
509
510 match result {
511 Ok(statements_executed) => {
512 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 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
641struct 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 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
803pub(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
831pub 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
857pub 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 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 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
958async 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 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
1068async 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
1098pub(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
1110fn 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
1131pub(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
1145fn 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 let max_name_len = crate::k8s_names::MAX_RESOURCE_NAME_LENGTH - 4; let max_prefix_len = max_name_len - "-plan-".len() - timestamp.len() - "-".len() - suffix.len();
1158 let prefix = truncate_name_prefix(policy_name, max_prefix_len);
1162 format!("{prefix}-plan-{timestamp}-{suffix}")
1163}
1164
1165fn 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
1180fn sanitize_label_value(value: &str) -> String {
1186 LabelValue::sanitize(value).into_string()
1187}
1188
1189fn policy_selector(policy_name: &str) -> Selector {
1195 Expression::Equal(LABEL_POLICY.to_string(), sanitize_label_value(policy_name)).into()
1196}
1197
1198fn is_owned_by_policy<K: Resource>(resource: &K, policy: &PostgresPolicy) -> bool {
1210 let Some(policy_uid) = policy.metadata.uid.as_deref() else {
1211 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
1224fn 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
1244fn 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
1252fn parse_rfc3339_epoch_secs(rfc3339: &str) -> Option<i64> {
1255 rfc3339
1257 .parse::<jiff::Timestamp>()
1258 .ok()
1259 .map(|t| t.as_second())
1260}
1261
1262fn 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
1274async 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
1297fn 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
1338pub 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
1366pub 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 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) });
1402
1403 Ok(pending_plans.into_iter().next())
1404}
1405
1406pub 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) });
1439
1440 Ok(matching_plans.into_iter().next())
1441}
1442
1443pub 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
1475pub 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
1505pub 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
1536pub 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#[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 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 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 #[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 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 #[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 let orphan = test_plan("plan-3", PlanPhase::Pending, None);
1656 assert!(!is_owned_by_another_policy(&orphan, &mine));
1657
1658 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 #[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 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 let plan = test_plan("plan-1", PlanPhase::Pending, None);
1689 assert!(!is_owned_by_policy(&plan, &policy));
1690
1691 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 #[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 plan.metadata.owner_references = Some(vec![build_owner_reference(&policy)]);
1710
1711 assert!(!is_owned_by_policy(&plan, &policy));
1712 }
1713
1714 #[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 #[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); }
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 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 #[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 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 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 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 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}