Skip to content
Merged
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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,33 @@ spec:

If the cluster already ships a suitable default StorageClass, disable composition and have PSQLCluster/PSQLBranch consumers set `spec.storage.class` explicitly.

### Preview branch credentials

`PSQLBranch` first observes the source CNPG `Cluster` and waits for both its
Ready condition and `Cluster in healthy state` phase before creating a
snapshot. This prevents a newly-created source from being snapshotted while
CNPG is still initializing its data directory and primary identity.

The branch also inherits the exact `spec.imageName` admitted on that source
Cluster and latches it in `status.recoveryImageName`. Snapshot recovery must
use the same PostgreSQL major version, and retaining the admitted image keeps
the recovered branch independent of later source outages or upgrades. It also
prevents a newer CNPG operator default from silently trying to open an older
data directory. `spec.postgresql.version` is an explicit override and must
remain on the source snapshot's major version.

Snapshot recovery restores PostgreSQL data and roles, but not Kubernetes
Secrets. `PSQLBranch` therefore defaults to CloudNativePG-managed,
branch-local credentials: it creates `<branch-name>-app` for `spec.app.role`
and resets that recovered role's password. Set `spec.app.secretName` only when
the destination namespace already contains a compatible basic-auth Secret.

For migration jobs that need the `postgres` role, set
`spec.superuser.enabled: true`. CloudNativePG then creates
`<branch-name>-superuser`; `spec.superuser.secretName` selects a pre-existing
Secret instead. Connection Secret names and the branch service endpoint are
reported in `status.app` and `status.superuser`.

```yaml
apiVersion: hops.ops.com.ai/v1alpha1
kind: PSQLStack
Expand Down
82 changes: 70 additions & 12 deletions apis/psqlbranches/definition.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,18 @@ spec:
new CNPG Cluster bootstrapped via `bootstrap.recovery.volumeSnapshots`.

Cross-namespace forks are supported (preview-pr-N namespace forking a
source in team-app namespace) via a bridging VolumeSnapshotContent —
the render template composes a VolumeSnapshot in the source namespace
AND a static-bound VolumeSnapshot in the branch namespace, then the
new Cluster references the branch-ns VolumeSnapshot for recovery.
source in team-app namespace) by observing the source snapshot's CSI
handle and importing it through a distinct branch-bound
VolumeSnapshotContent. The new Cluster then references the
branch-namespace VolumeSnapshot for recovery.

Same-namespace forks collapse this into a single VolumeSnapshot.

Required prerequisites on the target cluster: psql-stack (CNPG
operator), volume-snapshot-stack (snapshot-controller + CRDs), and
a source PSQLCluster with `branching.enabled: true`.
a healthy source PSQLCluster with `branching.enabled: true`. The
branch observes the source CNPG Cluster and does not create a
snapshot until it reports Ready and `Cluster in healthy state`.
type: object
properties:
spec:
Expand Down Expand Up @@ -69,7 +71,9 @@ spec:
description: |
Source PSQLCluster to fork. Cross-namespace supported — leave
`namespace` empty for same-namespace branching, or set it to
the team's namespace for preview-PR-style forks.
the team's namespace for preview-PR-style forks. The source
CNPG Cluster must report Ready and `Cluster in healthy state`
before any snapshot is created.
type: object
properties:
name:
Expand Down Expand Up @@ -134,20 +138,53 @@ spec:
type: string
default: psql

app:
description: |
Application database owner reconciled after snapshot recovery.
When secretName is empty, CloudNativePG generates a new
branch-local `<branch-name>-app` Secret and resets the
recovered owner's password to match it.
type: object
properties:
role:
description: PostgreSQL role that owns the application database. Defaults to "app".
type: string
default: app
database:
description: Application database name. Defaults to "app".
type: string
default: app
secretName:
description: Existing branch-local kubernetes.io/basic-auth Secret. Empty lets CloudNativePG generate `<branch-name>-app`.
type: string
default: ""

superuser:
description: Optional branch-local PostgreSQL superuser access for administrative or migration jobs.
type: object
properties:
enabled:
description: Enable password authentication for the postgres superuser. Defaults to false.
type: boolean
default: false
secretName:
description: Existing branch-local kubernetes.io/basic-auth Secret. Empty lets CloudNativePG generate `<branch-name>-superuser` when enabled.
type: string
default: ""

postgresql:
description: |
Postgres version on the branch. **Must match the source's
major version** for snapshot recovery to succeed — Postgres
doesn't downgrade or skip-upgrade across data dirs.
Intentionally has no default: omit to let CNPG use its
operator-default image (close-enough when source tracks
the same chart), or set explicitly to mirror the source's
version. A `default: "17"` here was a footgun — branches
off PG 15/16 sources would silently mismatch.
Intentionally has no default: when omitted, the branch
inherits the exact image admitted on the observed source
Cluster. Set explicitly only to override that image. The
selected PostgreSQL major must match the source snapshot.
type: object
properties:
version:
description: Postgres major version (e.g. "17"). Empty = let CNPG pick its default; mirror the source's spec.postgresql.version for safety.
description: PostgreSQL image tag (for example "17" or "17.4"). Empty inherits the observed source Cluster's exact image. An override must use the source snapshot's major version.
type: string

scaleToZero:
Expand Down Expand Up @@ -212,8 +249,29 @@ spec:
sourceSnapshotContent:
description: Name of the cluster-scoped VolumeSnapshotContent backing this branch (for cross-ns bridging visibility).
type: string
recoveryImageName:
description: Exact PostgreSQL image admitted for branch recovery and retained independently of later source availability.
type: string
expiresAt:
description: Computed deletion deadline when ttl.enabled is true.
type: string
app:
description: Branch-local application connection details.
type: object
properties:
secretName:
type: string
database:
type: string
host:
type: string
port:
type: integer
superuser:
description: Branch-local superuser connection details when enabled.
type: object
properties:
secretName:
type: string
required:
- spec
17 changes: 13 additions & 4 deletions examples/psqlbranches/cross-namespace.yaml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
# Cross-namespace branch — preview branch in `preview-pr-142` namespace
# forking a source PSQLCluster in `team-app` namespace.
#
# Composition: 1 VolumeSnapshot in `team-app` (of source PVC) + 1 bridging
# VolumeSnapshot in `preview-pr-142` (bound to the same VolumeSnapshotContent)
# + 1 CNPG Cluster in `preview-pr-142` (bootstrapped from the branch-ns
# VolumeSnapshot).
# Composition: 1 VolumeSnapshot in `team-app` (of source PVC), a distinct
# static VolumeSnapshotContent importing the source CSI snapshot handle,
# 1 VolumeSnapshot in `preview-pr-142` bound to that imported content, and
# 1 CNPG Cluster bootstrapped from the branch-namespace VolumeSnapshot.
#
apiVersion: hops.ops.com.ai/v1alpha1
kind: PSQLBranch
Expand All @@ -16,3 +16,12 @@ spec:
source:
name: my-app
namespace: team-app # cross-namespace: branch ns differs from source ns
storage:
size: 10Gi # mirror the source PVC size
postgresql:
version: "17" # must match the source major version
app:
role: app
database: app # CNPG creates pr-142-app and resets this role
superuser:
enabled: true # CNPG creates pr-142-superuser in the branch ns
9 changes: 9 additions & 0 deletions examples/psqlbranches/preview-with-ttl.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,15 @@ spec:
source:
name: orders
namespace: payments
storage:
size: 100Gi # must be at least the source PVC size
postgresql:
version: "17" # must match the source major version
app:
role: orders_app
database: orders
superuser:
enabled: true # branch-local secret for preview migrations
scaleToZero:
enabled: true
idleTimeout: 5m # very aggressive — preview envs hibernate quickly
Expand Down
41 changes: 39 additions & 2 deletions functions/branch/000-state-init.yaml.gotmpl
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

{{- $xr := getCompositeResource . }}
{{- $spec := $xr.spec | default dict }}
{{- $status := $xr.status | default dict }}
{{- $metadata := $xr.metadata | default dict }}

# ==============================================================================
Expand Down Expand Up @@ -56,6 +57,9 @@
# Cross-namespace bridging gate — render the source-ns VolumeSnapshot only when
# the source is in a different namespace from the branch.
{{- $crossNamespace := ne $sourceNamespace $namespace }}
{{- $branchIdentity := printf "%d:%s%d:%s" (len $namespace) $namespace (len $name) $name }}
{{- $branchIdentityHash := $branchIdentity | sha256sum | trunc 16 }}
{{- $branchSnapshotContentName := printf "%.40s-%.40s-%s-content" $namespace $name $branchIdentityHash }}

# ==============================================================================
# Branch sizing
Expand All @@ -73,10 +77,39 @@
)
}}

# ==============================================================================
# Recovery credentials
# ==============================================================================
{{- $appSpec := $spec.app | default dict }}
{{- $appSecretName := $appSpec.secretName | default "" }}
{{- $appCnpgManagedSecret := not $appSecretName }}
{{- if $appCnpgManagedSecret }}
{{- $appSecretName = printf "%s-app" $name }}
{{- end }}
{{- $app := dict
"role" ($appSpec.role | default "app")
"database" ($appSpec.database | default "app")
"secretName" $appSecretName
"cnpgManagedSecret" $appCnpgManagedSecret
}}

{{- $superuserSpec := $spec.superuser | default dict }}
{{- $superuserEnabled := false }}
{{- if hasKey $superuserSpec "enabled" }}
{{- $superuserEnabled = $superuserSpec.enabled }}
{{- end }}
{{- $superuserProvidedSecretName := $superuserSpec.secretName | default "" }}
{{- $superuserSecretName := $superuserProvidedSecretName | default (printf "%s-superuser" $name) }}
{{- $superuser := dict
"enabled" $superuserEnabled
"secretName" $superuserSecretName
"providedSecretName" $superuserProvidedSecretName
}}

# ==============================================================================
# Postgres version — must match source's major for snapshot recovery to
# succeed. No default: omit `imageName` downstream so CNPG falls back to its
# operator default (matches the chart's pinned PG when source tracks it).
# succeed. No default: downstream inherits the exact image admitted on the
# observed source Cluster, avoiding drift when the CNPG default major changes.
# ==============================================================================
{{- $pgSpec := $spec.postgresql | default dict }}
{{- $postgresql := dict
Expand Down Expand Up @@ -127,11 +160,15 @@
"kubernetesProviderConfigRef" $k8sProviderConfigRef
"source" $source
"crossNamespace" $crossNamespace
"branchSnapshotContentName" $branchSnapshotContentName
"branch" $branch
"app" $app
"superuser" $superuser
"postgresql" $postgresql
"scaleToZero" $scaleToZero
"ttl" $ttl
"cnpg" $cnpg
"recoveryImageName" ($status.recoveryImageName | default "")
"observed" (dict)
"status" (dict)
}}
Loading