Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,8 @@ jobs:
wait_for_ready_status_reason plan-policy True Planned
wait_for_drift_status plan-policy True
kubectl get pgr plan-policy -o jsonpath='{.status.last_reconcile_mode}' | grep -qx "plan"
kubectl get pgr plan-policy -o jsonpath='{.status.planned_sql}' | grep -q 'CREATE ROLE \"plan-preview-user\"'
plan_name=$(wait_for_current_plan_ref plan-policy)
get_plan_sql "$plan_name" | grep -q 'CREATE ROLE "plan-preview-user"'
assert_role_absent plan-preview-user
wait_for_event_reason plan-policy DriftDetected

Expand Down
7 changes: 6 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.7.8] - 2026-06-04
## [0.7.9] - 2026-06-04

### Added

- **Externally managed roles can now be marked `external: true`.** External roles may still be referenced in grants, schema ownership, default privileges, and as members of managed roles, but pgroles will not create, alter, drop, password-manage, or manage memberships granted from those roles. This avoids breaking Cloud SQL IAM users and groups whose `LOGIN` attribute and provider memberships are owned outside pgroles. (#123)
- **Operator reconciles can now be requested immediately with `pgroles reconcile` or the `reconcile.pgroles.io/requestedAt` annotation.** The CLI annotates a `PostgresPolicy` and can optionally wait until `status.lastHandledReconcileAt` records that the operator successfully handled the request. The operator also includes the annotation value in its watch predicate, so changing only the request timestamp triggers a reconcile without mutating the policy spec. (#118)
- **`pgroles render-bundle` composes a policy bundle into a single flat manifest.** Validates and composes the bundle (rejecting scope/ownership conflicts up front), then emits the resulting `PolicyManifest` as YAML with a provenance header recording the source bundle basename, the manifest schema version (`pgroles.manifest.v1`), and the fragments it composed. The output round-trips through `pgroles validate -f` / `diff -f` / `apply -f`, so a bundle can be composed in CI and the rendered manifest wrapped into a `PostgresPolicy` resource in a GitOps repo. Pre-rendering keeps cross-team and cross-environment fragment composition available to operator users without adding operator-side CRDs. The renderer is byte-deterministic across machines: the header records only the bundle file's basename (never an absolute or `pwd`-relative path), and the YAML body is post-processed to strip serde-emitted optional defaults (empty optional sequences, `null` scalars, and the default `role_pattern`) so the file doesn't churn under unrelated upgrades. Required fields like `Membership.members`, `Grant.privileges`, and `DefaultPrivilege.grant`, plus named empty profiles, are preserved even when empty so the rendered manifest always re-parses. `--check <path>` compares against an existing rendered file and exits with code 2 on drift, suitable as a CI gate that catches stale checked-in renders. The new [bundle composition guide](https://hardbyte.github.io/pgroles/docs/bundle-composition/) documents when to use each of the three workflows (single manifest, CLI bundle for direct apply, rendered bundle for the operator). Use `--no-header` to omit the header and `--output <path>` to write to a file. (#92)

### Changed

- **Operator plans now require manual approval by default when `spec.approval` is omitted.** Set `approval: auto` explicitly to preserve immediate apply behavior. Deprecated `PostgresPolicy.status.planned_sql`, `planned_sql_truncated`, and `last_reconcile_time` were removed; use `PostgresPolicyPlan` resources for SQL review and `last_successful_reconcile_time` for the last successful reconcile timestamp. (#73)
- **The `PostgresPolicy` CRD now marks named arrays as Kubernetes map lists.** `spec.schemas` and `spec.roles` are keyed by `name`, and `spec.retirements` is keyed by `role`, improving server-side-apply and GitOps merge behavior without changing manifest authoring shape. (#126)

## [0.7.7] - 2026-05-18

### Added
Expand Down
8 changes: 4 additions & 4 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ members = [
resolver = "2"

[workspace.package]
version = "0.7.8"
version = "0.7.9"
edition = "2024"
authors = ["Brian Thorne <brian@thorne.link>"]
license = "MIT"
Expand Down Expand Up @@ -55,8 +55,8 @@ k8s-openapi = { version = "0.27.1", features = ["latest", "schemars"] }
schemars = "1"

# Internal crates
pgroles-core = { path = "crates/pgroles-core", version = "0.7.8" }
pgroles-inspect = { path = "crates/pgroles-inspect", version = "0.7.8" }
pgroles-core = { path = "crates/pgroles-core", version = "0.7.9" }
pgroles-inspect = { path = "crates/pgroles-inspect", version = "0.7.9" }

[profile.release]
strip = "symbols"
Expand Down
10 changes: 8 additions & 2 deletions charts/pgroles-operator/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ description: >-
Define roles, memberships, grants, and default privileges in YAML
and let the operator converge your database to the desired state.
type: application
version: 0.7.8
appVersion: "0.7.8"
version: 0.7.9
appVersion: "0.7.9"
Comment on lines +8 to +9

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Align chart version with the workspace release

With the chart bumped to 0.7.9, a v0.7.9 tag will pass the Helm workflow's chart/tag check and the chart will default the operator image tag to .Chart.AppVersion, but the Rust workspace in Cargo.toml is still 0.7.8 and the release workflow publishes/builds from that version. In a 0.7.9 release this leaves the Helm chart pointing at 0.7.9 while the packaged binaries/crates still report or publish as 0.7.8, and cargo publish can fail if 0.7.8 is already published.

Useful? React with 👍 / 👎.

icon: https://raw.githubusercontent.com/hardbyte/pgroles/main/docs/public/logo.svg
sources:
- https://github.com/hardbyte/pgroles
Expand All @@ -20,6 +20,12 @@ annotations:
description: 'PostgresPolicy roles can now be marked external: true so provider-managed roles remain referenceable without pgroles creating, altering, dropping, password-managing, or pruning memberships granted from them.'
- kind: added
description: 'PostgresPolicy resources now support immediate reconcile requests through the reconcile.pgroles.io/requestedAt annotation; pgroles reconcile can set it and optionally wait for status.lastHandledReconcileAt.'
- kind: changed
description: 'PostgresPolicy plans now require manual approval by default when spec.approval is omitted; set approval: auto for immediate apply.'
- kind: changed
description: 'PostgresPolicy status no longer includes deprecated planned_sql, planned_sql_truncated, or last_reconcile_time fields; use PostgresPolicyPlan and last_successful_reconcile_time instead.'
- kind: changed
description: 'PostgresPolicy CRD marks schemas, roles, and retirements as Kubernetes map lists for better server-side-apply merge behavior.'
artifacthub.io/category: database
artifacthub.io/operator: "true"
artifacthub.io/prerelease: "false"
Expand Down
34 changes: 16 additions & 18 deletions charts/pgroles-operator/crds/postgrespolicies.pgroles.io.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
"description": "Spec for a `PostgresPolicy` custom resource.\n\nDefines the desired state of PostgreSQL roles, grants, default privileges,\nand memberships for a single database connection.",
"properties": {
"approval": {
"default": "manual",
"description": "Approval mode for plans generated by this policy.",
"enum": [
"manual",
Expand Down Expand Up @@ -676,7 +677,11 @@
],
"type": "object"
},
"type": "array"
"type": "array",
"x-kubernetes-list-map-keys": [
"role"
],
"x-kubernetes-list-type": "map"
},
"roles": {
"default": [],
Expand Down Expand Up @@ -790,7 +795,11 @@
],
"type": "object"
},
"type": "array"
"type": "array",
"x-kubernetes-list-map-keys": [
"name"
],
"x-kubernetes-list-type": "map"
},
"schemas": {
"default": [],
Expand Down Expand Up @@ -824,7 +833,11 @@
],
"type": "object"
},
"type": "array"
"type": "array",
"x-kubernetes-list-map-keys": [
"name"
],
"x-kubernetes-list-type": "map"
},
"suspend": {
"default": false,
Expand Down Expand Up @@ -1003,11 +1016,6 @@
"nullable": true,
"type": "string"
},
"last_reconcile_time": {
"description": "Deprecated alias retained for compatibility with older status readers.",
"nullable": true,
"type": "string"
},
"last_successful_reconcile_time": {
"description": "ISO 8601 timestamp of the last successful reconciliation.",
"nullable": true,
Expand Down Expand Up @@ -1040,16 +1048,6 @@
},
"type": "array"
},
"planned_sql": {
"description": "Planned SQL for the last successful plan-mode reconcile.",
"nullable": true,
"type": "string"
},
"planned_sql_truncated": {
"default": false,
"description": "Whether `planned_sql` was truncated to fit safely in status.",
"type": "boolean"
},
"transient_failure_count": {
"default": 0,
"description": "Consecutive transient operational failures used for exponential backoff.",
Expand Down
100 changes: 72 additions & 28 deletions crates/pgroles-operator/src/crd.rs
Original file line number Diff line number Diff line change
Expand Up @@ -83,10 +83,18 @@ pub struct PostgresPolicySpec {

/// Schema bindings that expand profiles into concrete roles/grants.
#[serde(default)]
#[schemars(extend(
"x-kubernetes-list-type" = "map",
"x-kubernetes-list-map-keys" = ["name"]
))]
pub schemas: Vec<SchemaBinding>,

/// One-off role definitions.
#[serde(default)]
#[schemars(extend(
"x-kubernetes-list-type" = "map",
"x-kubernetes-list-map-keys" = ["name"]
))]
pub roles: Vec<RoleSpec>,

/// One-off grants.
Expand All @@ -103,14 +111,19 @@ pub struct PostgresPolicySpec {

/// Explicit role-retirement workflows for roles that should be removed.
#[serde(default)]
#[schemars(extend(
"x-kubernetes-list-type" = "map",
"x-kubernetes-list-map-keys" = ["role"]
))]
pub retirements: Vec<RoleRetirement>,

/// Approval mode for plans: `auto` or `manual`.
/// When `manual`, plans require explicit approval before execution.
/// When `auto`, plans are approved and applied immediately.
/// When omitted, inferred from `mode`: `apply` → `auto`, `plan` → `manual`.
/// This ensures backward compatibility for existing `mode: apply` users.
/// Only affects `mode: apply`; `mode: plan` never executes SQL.
/// When omitted, defaults to `manual`.
#[serde(default, skip_serializing_if = "Option::is_none")]
#[schemars(extend("default" = "manual"))]
pub approval: Option<ApprovalMode>,
}

Expand Down Expand Up @@ -152,15 +165,11 @@ pub enum ApprovalMode {
}

impl PostgresPolicySpec {
/// Resolve the effective approval mode, inferring from `mode` when not set.
/// `apply` → `Auto` (backward compat), `plan` → `Manual`.
/// Resolve the effective approval mode. Omitted approval defaults to manual.
pub fn effective_approval(&self) -> ApprovalMode {
match &self.approval {
Some(mode) => mode.clone(),
None => match self.mode {
PolicyMode::Apply => ApprovalMode::Auto,
PolicyMode::Plan => ApprovalMode::Manual,
},
None => ApprovalMode::Manual,
}
}
}
Expand Down Expand Up @@ -809,10 +818,6 @@ pub struct PostgresPolicyStatus {
#[serde(default)]
pub last_successful_reconcile_time: Option<String>,

/// Deprecated alias retained for compatibility with older status readers.
#[serde(default)]
pub last_reconcile_time: Option<String>,

/// Last force-reconcile annotation value handled by the operator.
#[serde(default, rename = "lastHandledReconcileAt")]
pub last_handled_reconcile_at: Option<String>,
Expand All @@ -825,14 +830,6 @@ pub struct PostgresPolicyStatus {
#[serde(default)]
pub last_reconcile_mode: Option<PolicyMode>,

/// Planned SQL for the last successful plan-mode reconcile.
#[serde(default)]
pub planned_sql: Option<String>,

/// Whether `planned_sql` was truncated to fit safely in status.
#[serde(default)]
pub planned_sql_truncated: bool,

/// Canonical identity of the managed database target.
#[serde(default)]
pub managed_database_identity: Option<String>,
Expand Down Expand Up @@ -3032,7 +3029,6 @@ params:
status.observed_generation = generation;
status.last_attempted_generation = generation;
status.last_successful_reconcile_time = Some(now_rfc3339());
status.last_reconcile_time = Some(now_rfc3339());
status.change_summary = Some(summary);
status.last_error = None;

Expand All @@ -3051,7 +3047,6 @@ params:

// Verify timestamps set
assert!(status.last_successful_reconcile_time.is_some());
assert!(status.last_reconcile_time.is_some());

// Verify summary
let summary = status.change_summary.as_ref().unwrap();
Expand Down Expand Up @@ -3763,6 +3758,35 @@ retirements:
assert!(yaml.contains("pgplan"), "should have shortname pgplan");
}

#[test]
fn postgres_policy_crd_marks_named_lists_as_maps() {
let crd = PostgresPolicy::crd();
let value = serde_yaml::to_value(&crd).expect("CRD should serialize to YAML value");
let spec_properties = &value["spec"]["versions"][0]["schema"]["openAPIV3Schema"]["properties"]
["spec"]["properties"];

for (field_name, map_key) in [
("schemas", "name"),
("roles", "name"),
("retirements", "role"),
] {
let field_schema = &spec_properties[field_name];
assert_eq!(
field_schema["x-kubernetes-list-type"], "map",
"spec.{field_name} should be a Kubernetes map-list"
);
assert_eq!(
field_schema["x-kubernetes-list-map-keys"][0], map_key,
"spec.{field_name} should be keyed by {map_key}"
);
}

assert_eq!(
spec_properties["approval"]["default"], "manual",
"spec.approval should advertise the runtime default in the CRD schema"
);
}

#[test]
fn plan_phase_display() {
assert_eq!(PlanPhase::Pending.to_string(), "Pending");
Expand All @@ -3779,7 +3803,7 @@ retirements:
}

#[test]
fn effective_approval_infers_from_mode() {
fn effective_approval_defaults_to_manual() {
let base = PostgresPolicySpec {
connection: ConnectionSpec {
secret_ref: Some(SecretReference {
Expand All @@ -3803,10 +3827,8 @@ retirements:
approval: None,
};

// apply mode with no explicit approval → Auto
assert_eq!(base.effective_approval(), ApprovalMode::Auto);
assert_eq!(base.effective_approval(), ApprovalMode::Manual);

// plan mode with no explicit approval → Manual
let plan = PostgresPolicySpec {
mode: PolicyMode::Plan,
..base.clone()
Expand Down Expand Up @@ -3870,8 +3892,8 @@ retirements:
);
assert_eq!(
spec.effective_approval(),
ApprovalMode::Auto,
"effective_approval should infer Auto from apply mode"
ApprovalMode::Manual,
"effective_approval should default to Manual when omitted"
);
}

Expand All @@ -3891,6 +3913,28 @@ retirements:
);
}

#[test]
fn status_with_removed_legacy_fields_still_deserializes() {
let json = serde_json::json!({
"conditions": [],
"last_reconcile_time": "2026-06-01T00:00:00Z",
"last_successful_reconcile_time": "2026-06-01T00:00:00Z",
"planned_sql": "CREATE ROLE \"legacy\";",
"planned_sql_truncated": false,
"owned_roles": [],
"owned_schemas": []
});

let status: PostgresPolicyStatus = serde_json::from_value(json)
.expect("old stored status fields should not block deserialization");

assert_eq!(
status.last_successful_reconcile_time.as_deref(),
Some("2026-06-01T00:00:00Z")
);
assert!(status.current_plan_ref.is_none());
}

#[test]
fn effective_approval_explicit_auto_overrides_plan_mode() {
let spec = PostgresPolicySpec {
Expand Down
Loading
Loading