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 },
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 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 if !is_owned_by_policy(&existing, policy) {
359 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 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 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
479pub 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_plan_phase(&plans_api, &plan_name, PlanPhase::Applying).await?;
502
503 let result = execute_changes_in_transaction(pool, changes, sql_context).await;
507
508 match result {
509 Ok(statements_executed) => {
510 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 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
639struct 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 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
801async 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
829pub 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
855pub 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 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 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
956async 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 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
1066async 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
1096pub(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
1108fn 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
1129pub(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
1143fn 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 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();
1156 let prefix = truncate_name_prefix(policy_name, max_prefix_len);
1160 format!("{prefix}-plan-{timestamp}-{suffix}")
1161}
1162
1163fn 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
1178fn sanitize_label_value(value: &str) -> String {
1184 LabelValue::sanitize(value).into_string()
1185}
1186
1187fn policy_selector(policy_name: &str) -> Selector {
1193 Expression::Equal(LABEL_POLICY.to_string(), sanitize_label_value(policy_name)).into()
1194}
1195
1196fn is_owned_by_policy<K: Resource>(resource: &K, policy: &PostgresPolicy) -> bool {
1208 let Some(policy_uid) = policy.metadata.uid.as_deref() else {
1209 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
1222fn 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
1242fn 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
1250fn parse_rfc3339_epoch_secs(rfc3339: &str) -> Option<i64> {
1253 rfc3339
1255 .parse::<jiff::Timestamp>()
1256 .ok()
1257 .map(|t| t.as_second())
1258}
1259
1260fn 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
1272async 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
1295fn 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
1336pub 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
1364pub 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 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) });
1400
1401 Ok(pending_plans.into_iter().next())
1402}
1403
1404pub 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) });
1437
1438 Ok(matching_plans.into_iter().next())
1439}
1440
1441pub 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
1473pub 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
1503pub 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
1534pub 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#[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 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 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 #[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 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 #[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 let orphan = test_plan("plan-3", PlanPhase::Pending, None);
1654 assert!(!is_owned_by_another_policy(&orphan, &mine));
1655
1656 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 #[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 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 let plan = test_plan("plan-1", PlanPhase::Pending, None);
1687 assert!(!is_owned_by_policy(&plan, &policy));
1688
1689 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 #[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 plan.metadata.owner_references = Some(vec![build_owner_reference(&policy)]);
1708
1709 assert!(!is_owned_by_policy(&plan, &policy));
1710 }
1711
1712 #[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 #[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); }
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 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 #[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 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 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 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}