From 00d468871a6302553bef4cc2a61c50421aa1893d Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 3 Sep 2026 15:48:13 -0500 Subject: [PATCH 1/8] feat: complete PSQLBranch recovery contract --- README.md | 14 + apis/psqlbranches/definition.yaml | 60 +++- examples/psqlbranches/cross-namespace.yaml | 17 +- examples/psqlbranches/preview-with-ttl.yaml | 9 + functions/branch/000-state-init.yaml.gotmpl | 33 ++ functions/branch/010-state-status.yaml.gotmpl | 120 ++++++- .../branch/100-source-snapshot.yaml.gotmpl | 31 +- .../branch/105-snapshot-contents.yaml.gotmpl | 103 ++++++ .../branch/110-branch-snapshot.yaml.gotmpl | 40 ++- functions/branch/200-cnpg-cluster.yaml.gotmpl | 50 +-- functions/branch/999-status.yaml.gotmpl | 9 + tests/test-branch/main.k | 339 ++++++++++++++---- .../observed/cross-namespace-ready.yaml | 132 +++++++ 13 files changed, 842 insertions(+), 115 deletions(-) create mode 100644 functions/branch/105-snapshot-contents.yaml.gotmpl create mode 100644 tests/test-branch/observed/cross-namespace-ready.yaml diff --git a/README.md b/README.md index edc7be7..4db4ad3 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,20 @@ 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 + +Snapshot recovery restores PostgreSQL data and roles, but not Kubernetes +Secrets. `PSQLBranch` therefore defaults to CloudNativePG-managed, +branch-local credentials: it creates `-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 +`-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 diff --git a/apis/psqlbranches/definition.yaml b/apis/psqlbranches/definition.yaml index 9420ff4..29dd81f 100644 --- a/apis/psqlbranches/definition.yaml +++ b/apis/psqlbranches/definition.yaml @@ -21,10 +21,10 @@ 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. @@ -134,6 +134,40 @@ spec: type: string default: psql + app: + description: | + Application database owner reconciled after snapshot recovery. + When secretName is empty, CloudNativePG generates a new + branch-local `-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 `-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 `-superuser` when enabled. + type: string + default: "" + postgresql: description: | Postgres version on the branch. **Must match the source's @@ -215,5 +249,23 @@ spec: 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 diff --git a/examples/psqlbranches/cross-namespace.yaml b/examples/psqlbranches/cross-namespace.yaml index bf1e903..2c2539f 100644 --- a/examples/psqlbranches/cross-namespace.yaml +++ b/examples/psqlbranches/cross-namespace.yaml @@ -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 @@ -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 diff --git a/examples/psqlbranches/preview-with-ttl.yaml b/examples/psqlbranches/preview-with-ttl.yaml index 4c669ef..d640764 100644 --- a/examples/psqlbranches/preview-with-ttl.yaml +++ b/examples/psqlbranches/preview-with-ttl.yaml @@ -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 diff --git a/functions/branch/000-state-init.yaml.gotmpl b/functions/branch/000-state-init.yaml.gotmpl index e61c9a1..f67d1f0 100644 --- a/functions/branch/000-state-init.yaml.gotmpl +++ b/functions/branch/000-state-init.yaml.gotmpl @@ -56,6 +56,7 @@ # 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 }} +{{- $branchSnapshotContentName := printf "%s-%s-content" $namespace $name }} # ============================================================================== # Branch sizing @@ -73,6 +74,35 @@ ) }} +# ============================================================================== +# 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 @@ -127,7 +157,10 @@ "kubernetesProviderConfigRef" $k8sProviderConfigRef "source" $source "crossNamespace" $crossNamespace + "branchSnapshotContentName" $branchSnapshotContentName "branch" $branch + "app" $app + "superuser" $superuser "postgresql" $postgresql "scaleToZero" $scaleToZero "ttl" $ttl diff --git a/functions/branch/010-state-status.yaml.gotmpl b/functions/branch/010-state-status.yaml.gotmpl index 0ebecaf..0b9f10b 100644 --- a/functions/branch/010-state-status.yaml.gotmpl +++ b/functions/branch/010-state-status.yaml.gotmpl @@ -13,6 +13,13 @@ {{- $clusterManifest := $clusterAtProvider.manifest | default dict }} {{- $clusterStatus := $clusterManifest.status | default dict }} {{- $clusterPhase := $clusterStatus.phase | default "" }} +{{- $clusterObjectReady := false }} +{{- range (($clusterResource.status | default dict).conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $clusterObjectReady = true }} + {{- end }} +{{- end }} +{{- $clusterReady := eq $clusterPhase "Cluster in healthy state" }} # Read the bound VolumeSnapshotContent name. Two paths: # - same-namespace: only `branch-snapshot` exists (it references the source @@ -30,6 +37,12 @@ {{- $branchManifest := $branchAtProvider.manifest | default dict }} {{- $branchStatus := $branchManifest.status | default dict }} {{- $branchContent := $branchStatus.boundVolumeSnapshotContentName | default "" }} +{{- $branchReady := false }} +{{- range (($branchResource.status | default dict).conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $branchReady = true }} + {{- end }} +{{- end }} {{- $snapEntrySource := get $observed "source-snapshot" | default dict }} {{- $sourceResource := $snapEntrySource.resource | default dict }} @@ -37,6 +50,71 @@ {{- $sourceManifest := $sourceAtProvider.manifest | default dict }} {{- $sourceStatus := $sourceManifest.status | default dict }} {{- $sourceContent := $sourceStatus.boundVolumeSnapshotContentName | default "" }} +{{- $sourceReady := false }} +{{- range (($sourceResource.status | default dict).conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $sourceReady = true }} + {{- end }} +{{- end }} + +# Cross-namespace static binding needs two distinct VolumeSnapshotContent +# resources. Observe the dynamically-created source content to obtain its CSI +# snapshotHandle, then import that handle into a branch-bound content object. +{{- $sourceContentEntry := get $observed "source-snapshot-content" | default dict }} +{{- $sourceContentResource := $sourceContentEntry.resource | default dict }} +{{- $sourceContentAtProvider := (($sourceContentResource.status | default dict).atProvider | default dict) }} +{{- $sourceContentManifest := $sourceContentAtProvider.manifest | default dict }} +{{- $sourceContentSpec := $sourceContentManifest.spec | default dict }} +{{- $sourceContentSource := $sourceContentSpec.source | default dict }} +{{- $sourceSnapshotHandle := $sourceContentSource.snapshotHandle | default "" }} +{{- $sourceSnapshotDriver := $sourceContentSpec.driver | default "" }} +{{- $sourceSnapshotVolumeMode := $sourceContentSpec.sourceVolumeMode | default "" }} +{{- $sourceSnapshotClassName := $sourceContentSpec.volumeSnapshotClassName | default "" }} +{{- $sourceContentReady := false }} +{{- range (($sourceContentResource.status | default dict).conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $sourceContentReady = true }} + {{- end }} +{{- end }} + +{{- $branchContentEntry := get $observed "branch-snapshot-content" | default dict }} +{{- $branchContentResource := $branchContentEntry.resource | default dict }} +{{- $branchContentReady := false }} +{{- range (($branchContentResource.status | default dict).conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $branchContentReady = true }} + {{- end }} +{{- end }} + +# Deletion-order locks are part of readiness. This keeps the XR from reporting +# fully ready during the reconcile where a healthy database exists but its +# Usage protections have only just been emitted. +{{- $usageClusterSnapshotEntry := get $observed "usage-cnpg-cluster-branch-snapshot" | default dict }} +{{- $usageClusterSnapshotResource := $usageClusterSnapshotEntry.resource | default dict }} +{{- $usageClusterSnapshotReady := false }} +{{- range (($usageClusterSnapshotResource.status | default dict).conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $usageClusterSnapshotReady = true }} + {{- end }} +{{- end }} + +{{- $usageSnapshotContentEntry := get $observed "usage-branch-snapshot-branch-content" | default dict }} +{{- $usageSnapshotContentResource := $usageSnapshotContentEntry.resource | default dict }} +{{- $usageSnapshotContentReady := false }} +{{- range (($usageSnapshotContentResource.status | default dict).conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $usageSnapshotContentReady = true }} + {{- end }} +{{- end }} + +{{- $usageContentSourceEntry := get $observed "usage-branch-content-source-snapshot" | default dict }} +{{- $usageContentSourceResource := $usageContentSourceEntry.resource | default dict }} +{{- $usageContentSourceReady := false }} +{{- range (($usageContentSourceResource.status | default dict).conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $usageContentSourceReady = true }} + {{- end }} +{{- end }} {{- $snapContent := $branchContent }} {{- if not $branchContent }} @@ -44,12 +122,50 @@ {{- end }} {{- $state = set $state "observed" (dict - "cluster" (dict "phase" $clusterPhase) + "cluster" (dict + "ready" $clusterReady + "objectReady" $clusterObjectReady + "phase" $clusterPhase + ) + "sourceSnapshot" (dict + "ready" $sourceReady + "content" $sourceContent + ) + "sourceSnapshotContent" (dict + "ready" $sourceContentReady + "handle" $sourceSnapshotHandle + "driver" $sourceSnapshotDriver + "sourceVolumeMode" $sourceSnapshotVolumeMode + "className" $sourceSnapshotClassName + ) + "branchSnapshotContent" (dict + "ready" $branchContentReady + ) + "branchSnapshot" (dict + "ready" $branchReady + "content" $branchContent + ) "snapshotContent" $snapContent ) }} +{{- $ready := and + $clusterObjectReady + $clusterReady + $branchReady + $usageClusterSnapshotReady + (or + (not $state.crossNamespace) + (and + $sourceReady + $sourceContentReady + $branchContentReady + $usageSnapshotContentReady + $usageContentSourceReady + ) + ) +}} {{- $state = set $state "status" (dict - "ready" false + "ready" $ready "bootstrapPhase" $clusterPhase "bootstrapMethod" "volumeSnapshot" "sourceSnapshotContent" $snapContent diff --git a/functions/branch/100-source-snapshot.yaml.gotmpl b/functions/branch/100-source-snapshot.yaml.gotmpl index 5141f67..9c28d89 100644 --- a/functions/branch/100-source-snapshot.yaml.gotmpl +++ b/functions/branch/100-source-snapshot.yaml.gotmpl @@ -6,8 +6,9 @@ # we need a VolumeSnapshot in the source's namespace (because # VolumeSnapshot.spec.source.persistentVolumeClaimName is same-namespace # only). The snapshot-controller binds this to a cluster-scoped -# VolumeSnapshotContent, which the branch-ns VolumeSnapshot then references -# statically (in 110-branch-snapshot). +# VolumeSnapshotContent. The composition observes its CSI snapshotHandle and +# imports that handle through a distinct branch-bound VolumeSnapshotContent +# before rendering the branch namespace's VolumeSnapshot. # # For same-namespace branching, this template is skipped — the branch-ns # snapshot references the source PVC directly. @@ -35,6 +36,9 @@ metadata: labels: {{ $state.labels | toJson }} spec: managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromCelQuery + celQuery: has(object.status) && has(object.status.readyToUse) && object.status.readyToUse == true forProvider: manifest: apiVersion: snapshot.storage.k8s.io/v1 @@ -51,3 +55,26 @@ spec: name: {{ $state.kubernetesProviderConfigRef.name }} kind: {{ $state.kubernetesProviderConfigRef.kind }} {{- end }} + +{{- if and $state.crossNamespace $state.observed.sourceSnapshot.ready $state.observed.branchSnapshotContent.ready }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-branch-content-before-source-snapshot + annotations: + {{ setResourceNameAnnotation "usage-branch-content-source-snapshot" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-source-snapshot + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-branch-snapshot-content +{{- end }} diff --git a/functions/branch/105-snapshot-contents.yaml.gotmpl b/functions/branch/105-snapshot-contents.yaml.gotmpl new file mode 100644 index 0000000..56861bd --- /dev/null +++ b/functions/branch/105-snapshot-contents.yaml.gotmpl @@ -0,0 +1,103 @@ +# code: language=yaml +# +# Cross-namespace snapshot import. +# +# VolumeSnapshotContent and VolumeSnapshot bind one-to-one. The content +# dynamically created for the source namespace cannot also bind to the branch +# namespace. Observe its CSI snapshotHandle, then create a distinct static +# VolumeSnapshotContent whose volumeSnapshotRef targets the branch snapshot. +# The imported content uses Retain so deleting it never deletes the shared +# physical snapshot; the source snapshot remains responsible for that cleanup. +# + +{{- if and $state.crossNamespace (ne $state.observed.sourceSnapshot.content "") }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-source-snapshot-content + annotations: + {{ setResourceNameAnnotation "source-snapshot-content" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: + - Observe + readiness: + policy: DeriveFromCelQuery + celQuery: has(object.status) && has(object.status.readyToUse) && object.status.readyToUse == true + forProvider: + manifest: + apiVersion: snapshot.storage.k8s.io/v1 + kind: VolumeSnapshotContent + metadata: + name: {{ $state.observed.sourceSnapshot.content }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} + +{{- $sourceContent := $state.observed.sourceSnapshotContent }} +{{- if and $state.crossNamespace (ne $sourceContent.handle "") (ne $sourceContent.driver "") }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-branch-snapshot-content + annotations: + {{ setResourceNameAnnotation "branch-snapshot-content" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromCelQuery + celQuery: has(object.status) && has(object.status.readyToUse) && object.status.readyToUse == true + forProvider: + manifest: + apiVersion: snapshot.storage.k8s.io/v1 + kind: VolumeSnapshotContent + metadata: + name: {{ $state.branchSnapshotContentName }} + labels: {{ $state.labels | toJson }} + spec: + deletionPolicy: Retain + driver: {{ $sourceContent.driver }} + source: + snapshotHandle: {{ $sourceContent.handle | quote }} + {{- if $sourceContent.sourceVolumeMode }} + sourceVolumeMode: {{ $sourceContent.sourceVolumeMode }} + {{- end }} + {{- if $sourceContent.className }} + volumeSnapshotClassName: {{ $sourceContent.className }} + {{- end }} + volumeSnapshotRef: + apiVersion: snapshot.storage.k8s.io/v1 + kind: VolumeSnapshot + name: {{ $state.name }}-snap + namespace: {{ $state.namespace }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} + +{{- if and $state.crossNamespace $state.observed.branchSnapshotContent.ready $state.observed.branchSnapshot.ready }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-branch-snapshot-before-branch-content + annotations: + {{ setResourceNameAnnotation "usage-branch-snapshot-branch-content" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-branch-snapshot-content + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-branch-snapshot +{{- end }} diff --git a/functions/branch/110-branch-snapshot.yaml.gotmpl b/functions/branch/110-branch-snapshot.yaml.gotmpl index 25dbab2..475ccff 100644 --- a/functions/branch/110-branch-snapshot.yaml.gotmpl +++ b/functions/branch/110-branch-snapshot.yaml.gotmpl @@ -10,6 +10,12 @@ # {{- $source := $state.source }} +{{- $sourceContent := $state.observed.sourceSnapshotContent }} +{{- $render := or + (not $state.crossNamespace) + (and (ne $sourceContent.handle "") (ne $sourceContent.driver "")) +}} +{{- if $render }} --- apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object @@ -20,6 +26,9 @@ metadata: labels: {{ $state.labels | toJson }} spec: managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromCelQuery + celQuery: has(object.status) && has(object.status.readyToUse) && object.status.readyToUse == true forProvider: manifest: apiVersion: snapshot.storage.k8s.io/v1 @@ -32,10 +41,9 @@ spec: volumeSnapshotClassName: {{ $source.snapshotClassName }} source: {{- if $state.crossNamespace }} - # Cross-ns branching — bind to the VolumeSnapshotContent created from - # the source-ns VolumeSnapshot. The content name is observed on the - # source-ns Object's status; static binding works once that exists. - volumeSnapshotContentName: {{ $state.observed.snapshotContent | quote }} + # Cross-ns branching — bind to the distinct, branch-owned static + # VolumeSnapshotContent that imports the source snapshotHandle. + volumeSnapshotContentName: {{ $state.branchSnapshotContentName | quote }} {{- else }} # Same-ns branching — reference the source PVC directly. persistentVolumeClaimName: {{ $source.pvcName }} @@ -43,3 +51,27 @@ spec: providerConfigRef: name: {{ $state.kubernetesProviderConfigRef.name }} kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} + +{{- if and $state.observed.branchSnapshot.ready $state.observed.cluster.objectReady }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-cnpg-cluster-before-branch-snapshot + annotations: + {{ setResourceNameAnnotation "usage-cnpg-cluster-branch-snapshot" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-branch-snapshot + by: + apiVersion: kubernetes.m.crossplane.io/v1alpha1 + kind: Object + resourceRef: + name: {{ $state.name }}-cnpg-cluster +{{- end }} diff --git a/functions/branch/200-cnpg-cluster.yaml.gotmpl b/functions/branch/200-cnpg-cluster.yaml.gotmpl index b81606b..eb8fe30 100644 --- a/functions/branch/200-cnpg-cluster.yaml.gotmpl +++ b/functions/branch/200-cnpg-cluster.yaml.gotmpl @@ -2,18 +2,14 @@ # # Branch CNPG Cluster — bootstraps from the branch-namespace VolumeSnapshot. # -# CNPG bootstrap.recovery.volumeSnapshots restores the entire Postgres data -# directory from the snapshot. Postgres roles + their hashed passwords are -# preserved, so the branch has the same `app` user as the source — but the -# K8s Secret holding that password is NOT auto-replicated to the branch -# namespace. App pods that need to connect should either: -# - Have a copy of the source's K8s Secret in the branch ns (manual or via -# ESO with the same remoteKey), or -# - Use a SecretReflector to mirror it -# This is documented in README. +# CNPG bootstrap.recovery.volumeSnapshots restores the source data directory. +# Kubernetes Secrets are not restored with it. By default CNPG generates a +# branch-local application Secret and updates the recovered owner's password; +# callers may instead name a Secret they manage themselves. # {{- $cnpg := $state.cnpg }} +{{- $render := ne $state.observed.branchSnapshot.content "" }} {{- $clusterAnnotations := dict }} {{- if $state.scaleToZero.enabled }} @@ -28,6 +24,7 @@ "hops.ops.com.ai/branch-of" $state.source.name ) }} +{{- if $render }} --- apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object @@ -38,6 +35,8 @@ metadata: labels: {{ $state.labels | toJson }} spec: managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromObject forProvider: manifest: apiVersion: postgresql.cnpg.io/v1 @@ -60,22 +59,26 @@ spec: {{- toYaml $cnpg.overrideAllValues | nindent 8 }} {{- else }} spec: + {{- $recovery := dict + "database" $state.app.database + "owner" $state.app.role + "volumeSnapshots" (dict + "storage" (dict + "name" (printf "%s-snap" $state.name) + "kind" "VolumeSnapshot" + "apiGroup" "snapshot.storage.k8s.io" + ) + ) + }} + {{- if not $state.app.cnpgManagedSecret }} + {{- $_ := set $recovery "secret" (dict "name" $state.app.secretName) }} + {{- end }} {{- /* CNPG postgres container resources. Memory request==limit so the buffer cache has a predictable ceiling. No CPU limit — query workloads burst. Override via spec.cnpg.values.resources. */}} {{- $clusterSpec := dict "instances" $state.branch.instances - "bootstrap" (dict - "recovery" (dict - "volumeSnapshots" (dict - "storage" (dict - "name" (printf "%s-snap" $state.name) - "kind" "VolumeSnapshot" - "apiGroup" "snapshot.storage.k8s.io" - ) - ) - ) - ) + "bootstrap" (dict "recovery" $recovery) "resources" (dict "requests" (dict "cpu" "100m" "memory" "512Mi") "limits" (dict "memory" "512Mi") @@ -88,6 +91,12 @@ spec: {{- if $state.postgresql.version }} {{- $_ := set $clusterSpec "imageName" (printf "ghcr.io/cloudnative-pg/postgresql:%s" $state.postgresql.version) }} {{- end }} + {{- if $state.superuser.enabled }} + {{- $_ := set $clusterSpec "enableSuperuserAccess" true }} + {{- if $state.superuser.providedSecretName }} + {{- $_ := set $clusterSpec "superuserSecret" (dict "name" $state.superuser.secretName) }} + {{- end }} + {{- end }} {{- /* Storage — size precedence: branch.storage.size > source.storage.size. Both empty = omit storage.size on the Cluster CR; CNPG's webhook rejects with a clear error rather than silently sizing wrong. @@ -118,3 +127,4 @@ spec: providerConfigRef: name: {{ $state.kubernetesProviderConfigRef.name }} kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} diff --git a/functions/branch/999-status.yaml.gotmpl b/functions/branch/999-status.yaml.gotmpl index 5900a57..100ca0d 100644 --- a/functions/branch/999-status.yaml.gotmpl +++ b/functions/branch/999-status.yaml.gotmpl @@ -12,3 +12,12 @@ status: bootstrapPhase: {{ $state.status.bootstrapPhase | quote }} bootstrapMethod: {{ $state.status.bootstrapMethod | quote }} sourceSnapshotContent: {{ $state.status.sourceSnapshotContent | quote }} + app: + secretName: {{ $state.app.secretName | quote }} + database: {{ $state.app.database | quote }} + host: {{ printf "%s-rw.%s.svc.cluster.local" $state.name $state.namespace | quote }} + port: 5432 + {{- if $state.superuser.enabled }} + superuser: + secretName: {{ $state.superuser.secretName | quote }} + {{- end }} diff --git a/tests/test-branch/main.k b/tests/test-branch/main.k index dd63c57..a3c0c35 100644 --- a/tests/test-branch/main.k +++ b/tests/test-branch/main.k @@ -4,10 +4,104 @@ import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 # ============================================================================== # Unit tests for PSQLBranch XRD # -# Verify: cross-ns vs same-ns composition, scaleToZero defaults, TTL annotation, -# overrideAllValues escape hatch, providerConfig defaults. +# Verify: staged cross-ns vs same-ns composition, recovery credentials, +# readiness/deletion ordering, sizing, lifecycle toggles, and provider defaults. # ============================================================================== +_ready_branch_snapshot = { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "observed-branch-snapshot" + annotations = { + "crossplane.io/composition-resource-name" = "branch-snapshot" + "gotemplating.fn.crossplane.io/composition-resource-name" = "branch-snapshot" + } + } + status = { + conditions = [{type = "Ready", status = "True"}] + atProvider.manifest.status = { + boundVolumeSnapshotContentName = "snapcontent-ready" + readyToUse = True + } + } +} + +_ready_source_snapshot = { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "observed-source-snapshot" + annotations = { + "crossplane.io/composition-resource-name" = "source-snapshot" + "gotemplating.fn.crossplane.io/composition-resource-name" = "source-snapshot" + } + } + status = { + conditions = [{type = "Ready", status = "True"}] + atProvider.manifest.status = { + boundVolumeSnapshotContentName = "snapcontent-ready" + readyToUse = True + } + } +} + +_ready_source_snapshot_content = { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "observed-source-snapshot-content" + annotations = { + "crossplane.io/composition-resource-name" = "source-snapshot-content" + "gotemplating.fn.crossplane.io/composition-resource-name" = "source-snapshot-content" + } + } + status = { + conditions = [{type = "Ready", status = "True"}] + atProvider.manifest = { + spec = { + driver = "ebs.csi.eks.amazonaws.com" + source.snapshotHandle = "snap-0123456789abcdef0" + sourceVolumeMode = "Filesystem" + volumeSnapshotClassName = "psql" + } + status.readyToUse = True + } + } +} + +_ready_branch_snapshot_content = { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "observed-branch-snapshot-content" + annotations = { + "crossplane.io/composition-resource-name" = "branch-snapshot-content" + "gotemplating.fn.crossplane.io/composition-resource-name" = "branch-snapshot-content" + } + } + status = { + conditions = [{type = "Ready", status = "True"}] + atProvider.manifest.status.readyToUse = True + } +} + +_ready_cnpg_cluster = { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "observed-cnpg-cluster" + annotations = { + "crossplane.io/composition-resource-name" = "cnpg-cluster" + "gotemplating.fn.crossplane.io/composition-resource-name" = "cnpg-cluster" + } + } + status = { + conditions = [{type = "Ready", status = "True"}] + atProvider.manifest.status.phase = "Cluster in healthy state" + } +} + _items = [ # ========================================================================== # Test 1: same-namespace branch — only branch-snapshot composed (no source- @@ -27,6 +121,7 @@ _items = [ source = {name = "src-app"} } } + observedResources = [_ready_branch_snapshot] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -67,10 +162,14 @@ _items = [ # the override path. spec = { instances = 1 - bootstrap.recovery.volumeSnapshots.storage = { - name = "br-same-snap" - kind = "VolumeSnapshot" - apiGroup = "snapshot.storage.k8s.io" + bootstrap.recovery = { + database = "app" + owner = "app" + volumeSnapshots.storage = { + name = "br-same-snap" + kind = "VolumeSnapshot" + apiGroup = "snapshot.storage.k8s.io" + } } } } @@ -80,16 +179,11 @@ _items = [ } # ========================================================================== - # Cross-namespace, first reconcile: source-snapshot has bound to its - # VolumeSnapshotContent, branch-snapshot's underlying VolumeSnapshot was - # rendered earlier with an empty volumeSnapshotContentName and hasn't - # bound yet. The state-status logic must read from source-snapshot - # (since branch-snapshot's bound content is still empty), so the - # branch-ns VolumeSnapshot now renders with the source's content name — - # closing the chicken-and-egg. + # Cross-namespace, second reconcile: once the source snapshot is bound, + # the branch snapshot is rendered with a non-empty static content ref. # ========================================================================== metav1alpha1.CompositionTest { - metadata.name = "cross-ns-prefers-source-content-when-branch-empty" + metadata.name = "cross-ns-imports-source-handle-into-branch-content" spec = { compositionPath = "apis/psqlbranches/composition.yaml" xrdPath = "apis/psqlbranches/definition.yaml" @@ -103,7 +197,6 @@ _items = [ } } observedResources = [ - # source-ns VolumeSnapshot (Object wrapper) — bound. { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" @@ -115,41 +208,44 @@ _items = [ "gotemplating.fn.crossplane.io/composition-resource-name" = "source-snapshot" } } - status.atProvider.manifest.status.boundVolumeSnapshotContentName = "snapcontent-from-source" + status = { + conditions = [{type = "Ready", status = "True"}] + atProvider.manifest.status = { + boundVolumeSnapshotContentName = "snapcontent-from-source" + readyToUse = True + } + } } - # branch-ns VolumeSnapshot (Object wrapper) — created with - # empty content reference on the prior reconcile, not bound. + _ready_source_snapshot_content + ] + assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" - metadata = { - name = "br-x-branch-snapshot" - namespace = "preview-pr-1" - annotations = { - "crossplane.io/composition-resource-name" = "branch-snapshot" - "gotemplating.fn.crossplane.io/composition-resource-name" = "branch-snapshot" + metadata.name = "br-x-branch-snapshot-content" + spec.forProvider.manifest.spec = { + deletionPolicy = "Retain" + driver = "ebs.csi.eks.amazonaws.com" + source.snapshotHandle = "snap-0123456789abcdef0" + volumeSnapshotRef = { + name = "br-x-snap" + namespace = "preview-pr-1" } } - status.atProvider.manifest.status.boundVolumeSnapshotContentName = "" } - ] - assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata.name = "br-x-branch-snapshot" - spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "snapcontent-from-source" + spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "preview-pr-1-br-x-content" } ] } } # ========================================================================== - # Cross-namespace, steady state: branch-snapshot has bound (its - # boundVolumeSnapshotContentName is populated). Reading branch's value - # is fine here — it will match source's. This locks in that the - # fallback doesn't override a populated branch content with the - # source's value. + # Cross-namespace steady state: both snapshots are bound. The recovered + # cluster may now be rendered from the branch-namespace snapshot. # ========================================================================== metav1alpha1.CompositionTest { metadata.name = "cross-ns-uses-branch-content-when-bound" @@ -166,39 +262,23 @@ _items = [ } } observedResources = [ + _ready_source_snapshot, + _ready_source_snapshot_content, + _ready_branch_snapshot_content, + _ready_branch_snapshot, + ] + assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" - metadata = { - name = "br-y-source-snapshot" - namespace = "preview-pr-2" - annotations = { - "crossplane.io/composition-resource-name" = "source-snapshot" - "gotemplating.fn.crossplane.io/composition-resource-name" = "source-snapshot" - } - } - status.atProvider.manifest.status.boundVolumeSnapshotContentName = "snapcontent-from-source" - } - { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - metadata = { - name = "br-y-branch-snapshot" - namespace = "preview-pr-2" - annotations = { - "crossplane.io/composition-resource-name" = "branch-snapshot" - "gotemplating.fn.crossplane.io/composition-resource-name" = "branch-snapshot" - } - } - status.atProvider.manifest.status.boundVolumeSnapshotContentName = "snapcontent-from-branch" + metadata.name = "br-y-branch-snapshot" + spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "preview-pr-2-br-y-content" } - ] - assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" - metadata.name = "br-y-branch-snapshot" - spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "snapcontent-from-branch" + metadata.name = "br-y-cnpg-cluster" + spec.forProvider.manifest.spec.bootstrap.recovery.volumeSnapshots.storage.name = "br-y-snap" } ] } @@ -225,6 +305,7 @@ _items = [ branch.storage.size = "150Gi" } } + observedResources = [_ready_branch_snapshot] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -256,6 +337,7 @@ _items = [ } } } + observedResources = [_ready_branch_snapshot] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -287,6 +369,7 @@ _items = [ postgresql.version = "17.4" } } + observedResources = [_ready_branch_snapshot] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -299,8 +382,7 @@ _items = [ } # ========================================================================== - # Test 2: cross-namespace branch — source-snapshot in source ns + branch- - # snapshot in branch ns + Cluster in branch ns. + # Test 2: cross-namespace first reconcile renders only the source snapshot. # ========================================================================== metav1alpha1.CompositionTest { metadata.name = "cross-namespace-branch-bridges-via-content" @@ -334,24 +416,6 @@ _items = [ spec.source.persistentVolumeClaimName = "src-app-1" } } - { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - metadata.name = "br-x-branch-snapshot" - spec.forProvider.manifest.metadata = { - name = "br-x-snap" - namespace = "preview-pr-1" - } - } - { - apiVersion = "kubernetes.m.crossplane.io/v1alpha1" - kind = "Object" - metadata.name = "br-x-cnpg-cluster" - spec.forProvider.manifest.metadata = { - name = "br-x" - namespace = "preview-pr-1" - } - } ] } } @@ -408,6 +472,7 @@ _items = [ source = {name = "src"} } } + observedResources = [_ready_branch_snapshot] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -445,6 +510,7 @@ _items = [ scaleToZero = {enabled = False} } } + observedResources = [_ready_branch_snapshot] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -477,6 +543,7 @@ _items = [ ttl = {enabled = True, after = "24h"} } } + observedResources = [_ready_branch_snapshot] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -508,6 +575,7 @@ _items = [ source = {name = "src", snapshotClassName = "longhorn-snap"} } } + observedResources = [_ready_branch_snapshot] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -540,6 +608,7 @@ _items = [ } } } + observedResources = [_ready_branch_snapshot] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -571,6 +640,7 @@ _items = [ source = {name = "src"} } } + observedResources = [_ready_branch_snapshot] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -584,6 +654,117 @@ _items = [ ] } } + + # ========================================================================== + # Recovery credentials: custom owner/database use a CNPG-managed local + # secret unless the caller explicitly supplies one. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "app-recovery-credentials-are-branch-local" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "harmony-api-db", namespace = "preview-pr-600"} + spec = { + clusterName = "production" + source = {name = "harmony-api-db"} + app = {role = "harmony_bootstrap", database = "app"} + superuser.enabled = True + } + } + observedResources = [_ready_branch_snapshot] + assertResources = [{ + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "harmony-api-db-cnpg-cluster" + spec.forProvider.manifest.spec = { + enableSuperuserAccess = True + bootstrap.recovery = { + owner = "harmony_bootstrap" + database = "app" + } + } + }] + } + } + + metav1alpha1.CompositionTest { + metadata.name = "explicit-recovery-secrets-flow-to-cnpg" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-secrets", namespace = "default"} + spec = { + clusterName = "production" + source = {name = "source"} + app.secretName = "app-creds" + superuser = {enabled = True, secretName = "postgres-creds"} + } + } + observedResources = [_ready_branch_snapshot] + assertResources = [{ + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-secrets-cnpg-cluster" + spec.forProvider.manifest.spec = { + bootstrap.recovery.secret.name = "app-creds" + enableSuperuserAccess = True + superuserSecret.name = "postgres-creds" + } + }] + } + } + + # ========================================================================== + # All three Usage locks appear only after their endpoints are observed + # Ready. The staged observed fixture separately exercises final status. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "ready-cross-namespace-branch-locks-deletion-order" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-ready", namespace = "preview-pr-1"} + spec = { + clusterName = "production" + source = {name = "source", namespace = "staging"} + } + } + observedResources = [ + _ready_source_snapshot, + _ready_source_snapshot_content, + _ready_branch_snapshot_content, + _ready_branch_snapshot, + _ready_cnpg_cluster, + ] + assertResources = [ + { + apiVersion = "protection.crossplane.io/v1beta1" + kind = "Usage" + metadata.name = "br-ready-delete-branch-content-before-source-snapshot" + } + { + apiVersion = "protection.crossplane.io/v1beta1" + kind = "Usage" + metadata.name = "br-ready-delete-branch-snapshot-before-branch-content" + } + { + apiVersion = "protection.crossplane.io/v1beta1" + kind = "Usage" + metadata.name = "br-ready-delete-cnpg-cluster-before-branch-snapshot" + } + ] + } + } ] items = _items diff --git a/tests/test-branch/observed/cross-namespace-ready.yaml b/tests/test-branch/observed/cross-namespace-ready.yaml new file mode 100644 index 0000000..ead1fcf --- /dev/null +++ b/tests/test-branch/observed/cross-namespace-ready.yaml @@ -0,0 +1,132 @@ +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: pr-142-source-snapshot + namespace: preview-pr-142 + annotations: + crossplane.io/composition-resource-name: source-snapshot + gotemplating.fn.crossplane.io/composition-resource-name: source-snapshot +status: + conditions: + - type: Ready + status: "True" + atProvider: + manifest: + status: + boundVolumeSnapshotContentName: source-content + readyToUse: true +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: pr-142-source-snapshot-content + namespace: preview-pr-142 + annotations: + crossplane.io/composition-resource-name: source-snapshot-content + gotemplating.fn.crossplane.io/composition-resource-name: source-snapshot-content +status: + conditions: + - type: Ready + status: "True" + atProvider: + manifest: + spec: + deletionPolicy: Delete + driver: ebs.csi.eks.amazonaws.com + source: + snapshotHandle: snap-0123456789abcdef0 + sourceVolumeMode: Filesystem + volumeSnapshotClassName: psql + status: + readyToUse: true +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: pr-142-branch-snapshot-content + namespace: preview-pr-142 + annotations: + crossplane.io/composition-resource-name: branch-snapshot-content + gotemplating.fn.crossplane.io/composition-resource-name: branch-snapshot-content +status: + conditions: + - type: Ready + status: "True" + atProvider: + manifest: + status: + readyToUse: true +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: pr-142-branch-snapshot + namespace: preview-pr-142 + annotations: + crossplane.io/composition-resource-name: branch-snapshot + gotemplating.fn.crossplane.io/composition-resource-name: branch-snapshot +status: + conditions: + - type: Ready + status: "True" + atProvider: + manifest: + status: + boundVolumeSnapshotContentName: preview-pr-142-pr-142-content + readyToUse: true +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: pr-142-cnpg-cluster + namespace: preview-pr-142 + annotations: + crossplane.io/composition-resource-name: cnpg-cluster + gotemplating.fn.crossplane.io/composition-resource-name: cnpg-cluster +status: + conditions: + - type: Ready + status: "True" + atProvider: + manifest: + status: + phase: Cluster in healthy state +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: pr-142-delete-branch-content-before-source-snapshot + namespace: preview-pr-142 + annotations: + crossplane.io/composition-resource-name: usage-branch-content-source-snapshot + gotemplating.fn.crossplane.io/composition-resource-name: usage-branch-content-source-snapshot +status: + conditions: + - type: Ready + status: "True" +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: pr-142-delete-branch-snapshot-before-branch-content + namespace: preview-pr-142 + annotations: + crossplane.io/composition-resource-name: usage-branch-snapshot-branch-content + gotemplating.fn.crossplane.io/composition-resource-name: usage-branch-snapshot-branch-content +status: + conditions: + - type: Ready + status: "True" +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: pr-142-delete-cnpg-cluster-before-branch-snapshot + namespace: preview-pr-142 + annotations: + crossplane.io/composition-resource-name: usage-cnpg-cluster-branch-snapshot + gotemplating.fn.crossplane.io/composition-resource-name: usage-cnpg-cluster-branch-snapshot +status: + conditions: + - type: Ready + status: "True" From e6123ffb0f1a012d901a7c9bc8e0a186d9ad9b05 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 3 Sep 2026 15:55:09 -0500 Subject: [PATCH 2/8] test: namespace PSQLBranch observed resources --- tests/test-branch/main.k | 69 ++++++++++++++++++++++++---------------- 1 file changed, 42 insertions(+), 27 deletions(-) diff --git a/tests/test-branch/main.k b/tests/test-branch/main.k index a3c0c35..d18c67b 100644 --- a/tests/test-branch/main.k +++ b/tests/test-branch/main.k @@ -8,11 +8,13 @@ import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 # readiness/deletion ordering, sizing, lifecycle toggles, and provider defaults. # ============================================================================== -_ready_branch_snapshot = { +_ready_branch_snapshot = lambda namespace { + { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata = { name = "observed-branch-snapshot" + namespace = namespace annotations = { "crossplane.io/composition-resource-name" = "branch-snapshot" "gotemplating.fn.crossplane.io/composition-resource-name" = "branch-snapshot" @@ -26,12 +28,15 @@ _ready_branch_snapshot = { } } } +} -_ready_source_snapshot = { +_ready_source_snapshot = lambda namespace { + { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata = { name = "observed-source-snapshot" + namespace = namespace annotations = { "crossplane.io/composition-resource-name" = "source-snapshot" "gotemplating.fn.crossplane.io/composition-resource-name" = "source-snapshot" @@ -45,12 +50,15 @@ _ready_source_snapshot = { } } } +} -_ready_source_snapshot_content = { +_ready_source_snapshot_content = lambda namespace { + { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata = { name = "observed-source-snapshot-content" + namespace = namespace annotations = { "crossplane.io/composition-resource-name" = "source-snapshot-content" "gotemplating.fn.crossplane.io/composition-resource-name" = "source-snapshot-content" @@ -69,12 +77,15 @@ _ready_source_snapshot_content = { } } } +} -_ready_branch_snapshot_content = { +_ready_branch_snapshot_content = lambda namespace { + { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata = { name = "observed-branch-snapshot-content" + namespace = namespace annotations = { "crossplane.io/composition-resource-name" = "branch-snapshot-content" "gotemplating.fn.crossplane.io/composition-resource-name" = "branch-snapshot-content" @@ -85,12 +96,15 @@ _ready_branch_snapshot_content = { atProvider.manifest.status.readyToUse = True } } +} -_ready_cnpg_cluster = { +_ready_cnpg_cluster = lambda namespace { + { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata = { name = "observed-cnpg-cluster" + namespace = namespace annotations = { "crossplane.io/composition-resource-name" = "cnpg-cluster" "gotemplating.fn.crossplane.io/composition-resource-name" = "cnpg-cluster" @@ -101,6 +115,7 @@ _ready_cnpg_cluster = { atProvider.manifest.status.phase = "Cluster in healthy state" } } +} _items = [ # ========================================================================== @@ -121,7 +136,7 @@ _items = [ source = {name = "src-app"} } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -216,7 +231,7 @@ _items = [ } } } - _ready_source_snapshot_content + _ready_source_snapshot_content("preview-pr-1") ] assertResources = [ { @@ -262,10 +277,10 @@ _items = [ } } observedResources = [ - _ready_source_snapshot, - _ready_source_snapshot_content, - _ready_branch_snapshot_content, - _ready_branch_snapshot, + _ready_source_snapshot("preview-pr-2"), + _ready_source_snapshot_content("preview-pr-2"), + _ready_branch_snapshot_content("preview-pr-2"), + _ready_branch_snapshot("preview-pr-2"), ] assertResources = [ { @@ -305,7 +320,7 @@ _items = [ branch.storage.size = "150Gi" } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -337,7 +352,7 @@ _items = [ } } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -369,7 +384,7 @@ _items = [ postgresql.version = "17.4" } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -472,7 +487,7 @@ _items = [ source = {name = "src"} } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -510,7 +525,7 @@ _items = [ scaleToZero = {enabled = False} } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -543,7 +558,7 @@ _items = [ ttl = {enabled = True, after = "24h"} } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -575,7 +590,7 @@ _items = [ source = {name = "src", snapshotClassName = "longhorn-snap"} } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -608,7 +623,7 @@ _items = [ } } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -640,7 +655,7 @@ _items = [ source = {name = "src"} } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -675,7 +690,7 @@ _items = [ superuser.enabled = True } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("preview-pr-600")] assertResources = [{ apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" @@ -707,7 +722,7 @@ _items = [ superuser = {enabled = True, secretName = "postgres-creds"} } } - observedResources = [_ready_branch_snapshot] + observedResources = [_ready_branch_snapshot("default")] assertResources = [{ apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" @@ -740,11 +755,11 @@ _items = [ } } observedResources = [ - _ready_source_snapshot, - _ready_source_snapshot_content, - _ready_branch_snapshot_content, - _ready_branch_snapshot, - _ready_cnpg_cluster, + _ready_source_snapshot("preview-pr-1"), + _ready_source_snapshot_content("preview-pr-1"), + _ready_branch_snapshot_content("preview-pr-1"), + _ready_branch_snapshot("preview-pr-1"), + _ready_cnpg_cluster("preview-pr-1"), ] assertResources = [ { From ff0f79a5ffea99eea7ef39a15e5045d8613e5a71 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 3 Sep 2026 15:58:29 -0500 Subject: [PATCH 3/8] test: preserve observed resource identities --- tests/test-branch/main.k | 64 ++++++++++++++++++++-------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/tests/test-branch/main.k b/tests/test-branch/main.k index d18c67b..1a6d46d 100644 --- a/tests/test-branch/main.k +++ b/tests/test-branch/main.k @@ -8,12 +8,12 @@ import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 # readiness/deletion ordering, sizing, lifecycle toggles, and provider defaults. # ============================================================================== -_ready_branch_snapshot = lambda namespace { +_ready_branch_snapshot = lambda namespace, name { { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata = { - name = "observed-branch-snapshot" + name = name namespace = namespace annotations = { "crossplane.io/composition-resource-name" = "branch-snapshot" @@ -30,12 +30,12 @@ _ready_branch_snapshot = lambda namespace { } } -_ready_source_snapshot = lambda namespace { +_ready_source_snapshot = lambda namespace, name { { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata = { - name = "observed-source-snapshot" + name = name namespace = namespace annotations = { "crossplane.io/composition-resource-name" = "source-snapshot" @@ -52,12 +52,12 @@ _ready_source_snapshot = lambda namespace { } } -_ready_source_snapshot_content = lambda namespace { +_ready_source_snapshot_content = lambda namespace, name { { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata = { - name = "observed-source-snapshot-content" + name = name namespace = namespace annotations = { "crossplane.io/composition-resource-name" = "source-snapshot-content" @@ -79,12 +79,12 @@ _ready_source_snapshot_content = lambda namespace { } } -_ready_branch_snapshot_content = lambda namespace { +_ready_branch_snapshot_content = lambda namespace, name { { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata = { - name = "observed-branch-snapshot-content" + name = name namespace = namespace annotations = { "crossplane.io/composition-resource-name" = "branch-snapshot-content" @@ -98,12 +98,12 @@ _ready_branch_snapshot_content = lambda namespace { } } -_ready_cnpg_cluster = lambda namespace { +_ready_cnpg_cluster = lambda namespace, name { { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata = { - name = "observed-cnpg-cluster" + name = name namespace = namespace annotations = { "crossplane.io/composition-resource-name" = "cnpg-cluster" @@ -136,7 +136,7 @@ _items = [ source = {name = "src-app"} } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-same-branch-snapshot")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -231,7 +231,7 @@ _items = [ } } } - _ready_source_snapshot_content("preview-pr-1") + _ready_source_snapshot_content("preview-pr-1", "br-x-source-snapshot-content") ] assertResources = [ { @@ -277,10 +277,10 @@ _items = [ } } observedResources = [ - _ready_source_snapshot("preview-pr-2"), - _ready_source_snapshot_content("preview-pr-2"), - _ready_branch_snapshot_content("preview-pr-2"), - _ready_branch_snapshot("preview-pr-2"), + _ready_source_snapshot("preview-pr-2", "br-y-source-snapshot"), + _ready_source_snapshot_content("preview-pr-2", "br-y-source-snapshot-content"), + _ready_branch_snapshot_content("preview-pr-2", "br-y-branch-snapshot-content"), + _ready_branch_snapshot("preview-pr-2", "br-y-branch-snapshot"), ] assertResources = [ { @@ -320,7 +320,7 @@ _items = [ branch.storage.size = "150Gi" } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-explicit-branch-snapshot")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -352,7 +352,7 @@ _items = [ } } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-inherit-branch-snapshot")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -384,7 +384,7 @@ _items = [ postgresql.version = "17.4" } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-pinned-branch-snapshot")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -487,7 +487,7 @@ _items = [ source = {name = "src"} } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-s2z-branch-snapshot")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -525,7 +525,7 @@ _items = [ scaleToZero = {enabled = False} } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-no-s2z-branch-snapshot")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -558,7 +558,7 @@ _items = [ ttl = {enabled = True, after = "24h"} } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-ttl-branch-snapshot")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -590,7 +590,7 @@ _items = [ source = {name = "src", snapshotClassName = "longhorn-snap"} } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-custom-vsc-branch-snapshot")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -623,7 +623,7 @@ _items = [ } } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-override-branch-snapshot")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -655,7 +655,7 @@ _items = [ source = {name = "src"} } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-pc-branch-snapshot")] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -690,7 +690,7 @@ _items = [ superuser.enabled = True } } - observedResources = [_ready_branch_snapshot("preview-pr-600")] + observedResources = [_ready_branch_snapshot("preview-pr-600", "harmony-api-db-branch-snapshot")] assertResources = [{ apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" @@ -722,7 +722,7 @@ _items = [ superuser = {enabled = True, secretName = "postgres-creds"} } } - observedResources = [_ready_branch_snapshot("default")] + observedResources = [_ready_branch_snapshot("default", "br-secrets-branch-snapshot")] assertResources = [{ apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" @@ -755,11 +755,11 @@ _items = [ } } observedResources = [ - _ready_source_snapshot("preview-pr-1"), - _ready_source_snapshot_content("preview-pr-1"), - _ready_branch_snapshot_content("preview-pr-1"), - _ready_branch_snapshot("preview-pr-1"), - _ready_cnpg_cluster("preview-pr-1"), + _ready_source_snapshot("preview-pr-1", "br-ready-source-snapshot"), + _ready_source_snapshot_content("preview-pr-1", "br-ready-source-snapshot-content"), + _ready_branch_snapshot_content("preview-pr-1", "br-ready-branch-snapshot-content"), + _ready_branch_snapshot("preview-pr-1", "br-ready-branch-snapshot"), + _ready_cnpg_cluster("preview-pr-1", "br-ready-cnpg-cluster"), ] assertResources = [ { From 307699756b9ec44b97accdab2f7089b60fad07ee Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 3 Sep 2026 16:02:27 -0500 Subject: [PATCH 4/8] fix: harden PSQLBranch resource identity --- functions/branch/000-state-init.yaml.gotmpl | 4 +- functions/branch/999-status.yaml.gotmpl | 6 +- tests/test-branch/main.k | 78 ++++++++++++++++++- .../observed/cross-namespace-ready.yaml | 2 +- 4 files changed, 84 insertions(+), 6 deletions(-) diff --git a/functions/branch/000-state-init.yaml.gotmpl b/functions/branch/000-state-init.yaml.gotmpl index f67d1f0..4d68296 100644 --- a/functions/branch/000-state-init.yaml.gotmpl +++ b/functions/branch/000-state-init.yaml.gotmpl @@ -56,7 +56,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 }} -{{- $branchSnapshotContentName := printf "%s-%s-content" $namespace $name }} +{{- $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 diff --git a/functions/branch/999-status.yaml.gotmpl b/functions/branch/999-status.yaml.gotmpl index 100ca0d..8ecce3c 100644 --- a/functions/branch/999-status.yaml.gotmpl +++ b/functions/branch/999-status.yaml.gotmpl @@ -12,12 +12,14 @@ status: bootstrapPhase: {{ $state.status.bootstrapPhase | quote }} bootstrapMethod: {{ $state.status.bootstrapMethod | quote }} sourceSnapshotContent: {{ $state.status.sourceSnapshotContent | quote }} +{{ if not $state.cnpg.overrideAllValues }} app: secretName: {{ $state.app.secretName | quote }} database: {{ $state.app.database | quote }} host: {{ printf "%s-rw.%s.svc.cluster.local" $state.name $state.namespace | quote }} port: 5432 - {{- if $state.superuser.enabled }} +{{ if $state.superuser.enabled }} superuser: secretName: {{ $state.superuser.secretName | quote }} - {{- end }} +{{ end }} +{{ end }} diff --git a/tests/test-branch/main.k b/tests/test-branch/main.k index 1a6d46d..4b92bb1 100644 --- a/tests/test-branch/main.k +++ b/tests/test-branch/main.k @@ -252,7 +252,81 @@ _items = [ apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata.name = "br-x-branch-snapshot" - spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "preview-pr-1-br-x-content" + spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "preview-pr-1-br-x-6a6f418e5232e5cb-content" + } + ] + } + } + + # ========================================================================== + # Cluster-scoped content names include a length-delimited identity hash, so + # delimiter-equivalent readable prefixes cannot collide. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "snapshot-content-name-hashes-namespace-boundary" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "c", namespace = "a-b"} + spec = { + clusterName = "prod" + source = {name = "source", namespace = "source"} + } + } + observedResources = [ + _ready_source_snapshot("a-b", "c-source-snapshot"), + _ready_source_snapshot_content("a-b", "c-source-snapshot-content"), + ] + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "c-branch-snapshot-content" + spec.forProvider.manifest.metadata.name = "a-b-c-8113510dd55cfcfd-content" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "c-branch-snapshot" + spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "a-b-c-8113510dd55cfcfd-content" + } + ] + } + } + + metav1alpha1.CompositionTest { + metadata.name = "snapshot-content-name-hashes-name-boundary" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "b-c", namespace = "a"} + spec = { + clusterName = "prod" + source = {name = "source", namespace = "source"} + } + } + observedResources = [ + _ready_source_snapshot("a", "b-c-source-snapshot"), + _ready_source_snapshot_content("a", "b-c-source-snapshot-content"), + ] + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "b-c-branch-snapshot-content" + spec.forProvider.manifest.metadata.name = "a-b-c-155b9abb65280743-content" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "b-c-branch-snapshot" + spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "a-b-c-155b9abb65280743-content" } ] } @@ -287,7 +361,7 @@ _items = [ apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata.name = "br-y-branch-snapshot" - spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "preview-pr-2-br-y-content" + spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "preview-pr-2-br-y-cb240488b9468ea4-content" } { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" diff --git a/tests/test-branch/observed/cross-namespace-ready.yaml b/tests/test-branch/observed/cross-namespace-ready.yaml index ead1fcf..44ccb58 100644 --- a/tests/test-branch/observed/cross-namespace-ready.yaml +++ b/tests/test-branch/observed/cross-namespace-ready.yaml @@ -72,7 +72,7 @@ status: atProvider: manifest: status: - boundVolumeSnapshotContentName: preview-pr-142-pr-142-content + boundVolumeSnapshotContentName: preview-pr-142-pr-142-5469d3f7f65881cc-content readyToUse: true --- apiVersion: kubernetes.m.crossplane.io/v1alpha1 From ae4d38a9f2b7436ce596aa1bb3e602e2e3401df8 Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 3 Sep 2026 17:43:57 -0500 Subject: [PATCH 5/8] fix: wait for healthy PSQL branch sources --- README.md | 5 + apis/psqlbranches/definition.yaml | 8 +- functions/branch/010-state-status.yaml.gotmpl | 23 ++++ .../branch/090-source-cluster.yaml.gotmpl | 33 +++++ .../branch/100-source-snapshot.yaml.gotmpl | 2 +- .../branch/110-branch-snapshot.yaml.gotmpl | 9 +- .../cluster/010-state-status.yaml.gotmpl | 2 +- .../cluster/200-cnpg-cluster.yaml.gotmpl | 2 + tests/test-branch/main.k | 130 ++++++++++++++++-- .../observed/cross-namespace-ready.yaml | 17 +++ 10 files changed, 211 insertions(+), 20 deletions(-) create mode 100644 functions/branch/090-source-cluster.yaml.gotmpl diff --git a/README.md b/README.md index 4db4ad3..f52697a 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,11 @@ If the cluster already ships a suitable default StorageClass, disable compositio ### 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. + Snapshot recovery restores PostgreSQL data and roles, but not Kubernetes Secrets. `PSQLBranch` therefore defaults to CloudNativePG-managed, branch-local credentials: it creates `-app` for `spec.app.role` diff --git a/apis/psqlbranches/definition.yaml b/apis/psqlbranches/definition.yaml index 29dd81f..9b0b766 100644 --- a/apis/psqlbranches/definition.yaml +++ b/apis/psqlbranches/definition.yaml @@ -30,7 +30,9 @@ spec: 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: @@ -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: diff --git a/functions/branch/010-state-status.yaml.gotmpl b/functions/branch/010-state-status.yaml.gotmpl index 0b9f10b..0fb5f88 100644 --- a/functions/branch/010-state-status.yaml.gotmpl +++ b/functions/branch/010-state-status.yaml.gotmpl @@ -21,6 +21,23 @@ {{- end }} {{- $clusterReady := eq $clusterPhase "Cluster in healthy state" }} +# The source must be healthy before either same- or cross-namespace snapshot +# creation. A source Object that merely exists is not sufficient: CNPG may +# still be initializing its data directory and primary identity. +{{- $sourceClusterEntry := get $observed "source-cluster" | default dict }} +{{- $sourceClusterResource := $sourceClusterEntry.resource | default dict }} +{{- $sourceClusterAtProvider := (($sourceClusterResource.status | default dict).atProvider | default dict) }} +{{- $sourceClusterManifest := $sourceClusterAtProvider.manifest | default dict }} +{{- $sourceClusterStatus := $sourceClusterManifest.status | default dict }} +{{- $sourceClusterPhase := $sourceClusterStatus.phase | default "" }} +{{- $sourceClusterObjectReady := false }} +{{- range (($sourceClusterResource.status | default dict).conditions | default list) }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $sourceClusterObjectReady = true }} + {{- end }} +{{- end }} +{{- $sourceClusterReady := and $sourceClusterObjectReady (eq $sourceClusterPhase "Cluster in healthy state") }} + # Read the bound VolumeSnapshotContent name. Two paths: # - same-namespace: only `branch-snapshot` exists (it references the source # PVC directly), so we read from it. @@ -127,6 +144,11 @@ "objectReady" $clusterObjectReady "phase" $clusterPhase ) + "sourceCluster" (dict + "ready" $sourceClusterReady + "objectReady" $sourceClusterObjectReady + "phase" $sourceClusterPhase + ) "sourceSnapshot" (dict "ready" $sourceReady "content" $sourceContent @@ -149,6 +171,7 @@ ) }} {{- $ready := and + $sourceClusterReady $clusterObjectReady $clusterReady $branchReady diff --git a/functions/branch/090-source-cluster.yaml.gotmpl b/functions/branch/090-source-cluster.yaml.gotmpl new file mode 100644 index 0000000..f2b9283 --- /dev/null +++ b/functions/branch/090-source-cluster.yaml.gotmpl @@ -0,0 +1,33 @@ +# code: language=yaml +# +# Observe the source CNPG Cluster before snapshotting its PVC. +# +# A PSQLCluster's Kubernetes Object can exist before CNPG finishes initdb and +# elects a healthy primary. Snapshotting during that window can capture an +# incomplete data directory that recovers without a consistent system ID. +# Observe-only management keeps the source outside this branch's lifecycle. +# + +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-source-cluster + annotations: + {{ setResourceNameAnnotation "source-cluster" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: + - Observe + readiness: + policy: DeriveFromObject + forProvider: + manifest: + apiVersion: postgresql.cnpg.io/v1 + kind: Cluster + metadata: + name: {{ $state.source.name }} + namespace: {{ $state.source.namespace }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} diff --git a/functions/branch/100-source-snapshot.yaml.gotmpl b/functions/branch/100-source-snapshot.yaml.gotmpl index 9c28d89..5824e32 100644 --- a/functions/branch/100-source-snapshot.yaml.gotmpl +++ b/functions/branch/100-source-snapshot.yaml.gotmpl @@ -24,7 +24,7 @@ # {{- $source := $state.source }} -{{- if $state.crossNamespace }} +{{- if and $state.crossNamespace $state.observed.sourceCluster.ready }} {{- $sourceSnapName := printf "%s-%s-src" $state.namespace $state.name }} --- apiVersion: kubernetes.m.crossplane.io/v1alpha1 diff --git a/functions/branch/110-branch-snapshot.yaml.gotmpl b/functions/branch/110-branch-snapshot.yaml.gotmpl index 475ccff..cf86292 100644 --- a/functions/branch/110-branch-snapshot.yaml.gotmpl +++ b/functions/branch/110-branch-snapshot.yaml.gotmpl @@ -11,9 +11,12 @@ {{- $source := $state.source }} {{- $sourceContent := $state.observed.sourceSnapshotContent }} -{{- $render := or - (not $state.crossNamespace) - (and (ne $sourceContent.handle "") (ne $sourceContent.driver "")) +{{- $render := and + $state.observed.sourceCluster.ready + (or + (not $state.crossNamespace) + (and (ne $sourceContent.handle "") (ne $sourceContent.driver "")) + ) }} {{- if $render }} --- diff --git a/functions/cluster/010-state-status.yaml.gotmpl b/functions/cluster/010-state-status.yaml.gotmpl index bc1b38a..de7bf40 100644 --- a/functions/cluster/010-state-status.yaml.gotmpl +++ b/functions/cluster/010-state-status.yaml.gotmpl @@ -54,6 +54,6 @@ ) }} {{- $state = set $state "status" (dict - "ready" false + "ready" (and $clusterReady $esReady) "clusterPhase" $clusterPhase ) }} diff --git a/functions/cluster/200-cnpg-cluster.yaml.gotmpl b/functions/cluster/200-cnpg-cluster.yaml.gotmpl index 795641b..c58c9b8 100644 --- a/functions/cluster/200-cnpg-cluster.yaml.gotmpl +++ b/functions/cluster/200-cnpg-cluster.yaml.gotmpl @@ -37,6 +37,8 @@ metadata: labels: {{ $state.labels | toJson }} spec: managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromObject forProvider: manifest: apiVersion: postgresql.cnpg.io/v1 diff --git a/tests/test-branch/main.k b/tests/test-branch/main.k index 4b92bb1..1bac6b7 100644 --- a/tests/test-branch/main.k +++ b/tests/test-branch/main.k @@ -117,7 +117,63 @@ _ready_cnpg_cluster = lambda namespace, name { } } +_ready_source_cluster = lambda namespace, name { + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = name + namespace = namespace + annotations = { + "crossplane.io/composition-resource-name" = "source-cluster" + "gotemplating.fn.crossplane.io/composition-resource-name" = "source-cluster" + } + } + status = { + conditions = [{type = "Ready", status = "True"}] + atProvider.manifest.status.phase = "Cluster in healthy state" + } +} +} + _items = [ + # ========================================================================== + # A branch starts by observing its source CNPG Cluster. Snapshot resources + # are gated until this Object reports both Ready=True and CNPG's healthy + # phase; a create-only Object condition is not a sufficient signal. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "observes-source-cluster-before-snapshotting" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-gated", namespace = "preview-pr-1"} + spec = { + clusterName = "production" + source = {name = "source", namespace = "staging"} + } + } + assertResources = [{ + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-gated-source-cluster" + spec = { + managementPolicies = ["Observe"] + readiness.policy = "DeriveFromObject" + forProvider.manifest = { + apiVersion = "postgresql.cnpg.io/v1" + kind = "Cluster" + metadata = {name = "source", namespace = "staging"} + } + providerConfigRef = {name = "production", kind = "ProviderConfig"} + } + }] + } + } + # ========================================================================== # Test 1: same-namespace branch — only branch-snapshot composed (no source- # snapshot bridging), references source PVC directly. @@ -136,7 +192,10 @@ _items = [ source = {name = "src-app"} } } - observedResources = [_ready_branch_snapshot("default", "br-same-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-same-source-cluster"), + _ready_branch_snapshot("default", "br-same-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -212,6 +271,7 @@ _items = [ } } observedResources = [ + _ready_source_cluster("preview-pr-1", "br-x-source-cluster"), { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" @@ -277,6 +337,7 @@ _items = [ } } observedResources = [ + _ready_source_cluster("a-b", "c-source-cluster"), _ready_source_snapshot("a-b", "c-source-snapshot"), _ready_source_snapshot_content("a-b", "c-source-snapshot-content"), ] @@ -312,6 +373,7 @@ _items = [ } } observedResources = [ + _ready_source_cluster("a", "b-c-source-cluster"), _ready_source_snapshot("a", "b-c-source-snapshot"), _ready_source_snapshot_content("a", "b-c-source-snapshot-content"), ] @@ -351,6 +413,7 @@ _items = [ } } observedResources = [ + _ready_source_cluster("preview-pr-2", "br-y-source-cluster"), _ready_source_snapshot("preview-pr-2", "br-y-source-snapshot"), _ready_source_snapshot_content("preview-pr-2", "br-y-source-snapshot-content"), _ready_branch_snapshot_content("preview-pr-2", "br-y-branch-snapshot-content"), @@ -394,7 +457,10 @@ _items = [ branch.storage.size = "150Gi" } } - observedResources = [_ready_branch_snapshot("default", "br-explicit-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-explicit-source-cluster"), + _ready_branch_snapshot("default", "br-explicit-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -426,7 +492,10 @@ _items = [ } } } - observedResources = [_ready_branch_snapshot("default", "br-inherit-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-inherit-source-cluster"), + _ready_branch_snapshot("default", "br-inherit-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -458,7 +527,10 @@ _items = [ postgresql.version = "17.4" } } - observedResources = [_ready_branch_snapshot("default", "br-pinned-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-pinned-source-cluster"), + _ready_branch_snapshot("default", "br-pinned-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -471,7 +543,8 @@ _items = [ } # ========================================================================== - # Test 2: cross-namespace first reconcile renders only the source snapshot. + # Test 2: once the source CNPG cluster is healthy, cross-namespace + # reconciliation renders the source snapshot. # ========================================================================== metav1alpha1.CompositionTest { metadata.name = "cross-namespace-branch-bridges-via-content" @@ -487,6 +560,9 @@ _items = [ source = {name = "src-app", namespace = "team-app"} } } + observedResources = [ + _ready_source_cluster("preview-pr-1", "br-x-source-cluster"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -530,6 +606,9 @@ _items = [ source = {name = "src-app", namespace = "team-app"} } } + observedResources = [ + _ready_source_cluster("other-team-preview", "br-x-source-cluster"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -561,7 +640,10 @@ _items = [ source = {name = "src"} } } - observedResources = [_ready_branch_snapshot("default", "br-s2z-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-s2z-source-cluster"), + _ready_branch_snapshot("default", "br-s2z-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -599,7 +681,10 @@ _items = [ scaleToZero = {enabled = False} } } - observedResources = [_ready_branch_snapshot("default", "br-no-s2z-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-no-s2z-source-cluster"), + _ready_branch_snapshot("default", "br-no-s2z-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -632,7 +717,10 @@ _items = [ ttl = {enabled = True, after = "24h"} } } - observedResources = [_ready_branch_snapshot("default", "br-ttl-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-ttl-source-cluster"), + _ready_branch_snapshot("default", "br-ttl-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -664,7 +752,10 @@ _items = [ source = {name = "src", snapshotClassName = "longhorn-snap"} } } - observedResources = [_ready_branch_snapshot("default", "br-custom-vsc-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-custom-vsc-source-cluster"), + _ready_branch_snapshot("default", "br-custom-vsc-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -697,7 +788,10 @@ _items = [ } } } - observedResources = [_ready_branch_snapshot("default", "br-override-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-override-source-cluster"), + _ready_branch_snapshot("default", "br-override-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -729,7 +823,10 @@ _items = [ source = {name = "src"} } } - observedResources = [_ready_branch_snapshot("default", "br-pc-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-pc-source-cluster"), + _ready_branch_snapshot("default", "br-pc-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -764,7 +861,10 @@ _items = [ superuser.enabled = True } } - observedResources = [_ready_branch_snapshot("preview-pr-600", "harmony-api-db-branch-snapshot")] + observedResources = [ + _ready_source_cluster("preview-pr-600", "harmony-api-db-source-cluster"), + _ready_branch_snapshot("preview-pr-600", "harmony-api-db-branch-snapshot"), + ] assertResources = [{ apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" @@ -796,7 +896,10 @@ _items = [ superuser = {enabled = True, secretName = "postgres-creds"} } } - observedResources = [_ready_branch_snapshot("default", "br-secrets-branch-snapshot")] + observedResources = [ + _ready_source_cluster("default", "br-secrets-source-cluster"), + _ready_branch_snapshot("default", "br-secrets-branch-snapshot"), + ] assertResources = [{ apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" @@ -829,6 +932,7 @@ _items = [ } } observedResources = [ + _ready_source_cluster("preview-pr-1", "br-ready-source-cluster"), _ready_source_snapshot("preview-pr-1", "br-ready-source-snapshot"), _ready_source_snapshot_content("preview-pr-1", "br-ready-source-snapshot-content"), _ready_branch_snapshot_content("preview-pr-1", "br-ready-branch-snapshot-content"), diff --git a/tests/test-branch/observed/cross-namespace-ready.yaml b/tests/test-branch/observed/cross-namespace-ready.yaml index 44ccb58..140a345 100644 --- a/tests/test-branch/observed/cross-namespace-ready.yaml +++ b/tests/test-branch/observed/cross-namespace-ready.yaml @@ -1,5 +1,22 @@ apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object +metadata: + name: pr-142-source-cluster + namespace: preview-pr-142 + annotations: + crossplane.io/composition-resource-name: source-cluster + gotemplating.fn.crossplane.io/composition-resource-name: source-cluster +status: + conditions: + - type: Ready + status: "True" + atProvider: + manifest: + status: + phase: Cluster in healthy state +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object metadata: name: pr-142-source-snapshot namespace: preview-pr-142 From e6509b616ac2125c5314f9145624fb10c554848a Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 3 Sep 2026 17:57:47 -0500 Subject: [PATCH 6/8] fix: require configured secrets for readiness --- .../cluster/010-state-status.yaml.gotmpl | 20 +++- .../cluster/100-external-secret.yaml.gotmpl | 2 + tests/test-cluster/main.k | 98 +++++++++++++++++++ 3 files changed, 115 insertions(+), 5 deletions(-) diff --git a/functions/cluster/010-state-status.yaml.gotmpl b/functions/cluster/010-state-status.yaml.gotmpl index de7bf40..c9de1c6 100644 --- a/functions/cluster/010-state-status.yaml.gotmpl +++ b/functions/cluster/010-state-status.yaml.gotmpl @@ -30,23 +30,33 @@ # Object Ready=true means the ExternalSecret CR was applied; SecretSynced # follows fast enough that we don't inspect the wrapped ES status further. # ============================================================================== -{{- $esResourceNames := list "external-secret-app" "external-secret-superuser" }} -{{- $esPresent := 0 }} +{{- $esResourceNames := list }} +{{- $appES := $state.app.externalSecret }} +{{- if and $appES.secretStore $appES.secretRef }} + {{- $esResourceNames = append $esResourceNames "external-secret-app" }} +{{- end }} +{{- $suES := $state.superuser.externalSecret }} +{{- if and $state.superuser.enabled $suES.secretStore $suES.secretRef }} + {{- $esResourceNames = append $esResourceNames "external-secret-superuser" }} +{{- end }} {{- $esReadyCount := 0 }} {{- range $name := $esResourceNames }} {{- $entry := get $observed $name | default dict }} {{- if $entry }} - {{- $esPresent = add $esPresent 1 }} {{- $resource := $entry.resource | default dict }} {{- $conditions := (($resource.status | default dict).conditions | default list) }} + {{- $resourceReady := false }} {{- range $conditions }} {{- if and (eq .type "Ready") (eq .status "True") }} - {{- $esReadyCount = add $esReadyCount 1 }} + {{- $resourceReady = true }} {{- end }} {{- end }} + {{- if $resourceReady }} + {{- $esReadyCount = add $esReadyCount 1 }} + {{- end }} {{- end }} {{- end }} -{{- $esReady := eq $esPresent $esReadyCount }} +{{- $esReady := eq (len $esResourceNames) $esReadyCount }} {{- $state = set $state "observed" (dict "cluster" (dict "ready" $clusterReady "phase" $clusterPhase) diff --git a/functions/cluster/100-external-secret.yaml.gotmpl b/functions/cluster/100-external-secret.yaml.gotmpl index b3b2bf3..f21f47a 100644 --- a/functions/cluster/100-external-secret.yaml.gotmpl +++ b/functions/cluster/100-external-secret.yaml.gotmpl @@ -24,6 +24,8 @@ metadata: labels: {{ .Labels | toJson }} spec: managementPolicies: {{ .ManagementPolicies | toJson }} + readiness: + policy: DeriveFromObject forProvider: manifest: apiVersion: external-secrets.io/v1 diff --git a/tests/test-cluster/main.k b/tests/test-cluster/main.k index d09a088..0f3c7d5 100644 --- a/tests/test-cluster/main.k +++ b/tests/test-cluster/main.k @@ -8,6 +8,41 @@ import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 # wiring (intent → mechanics) inside the composed Cluster + ExternalSecret. # ============================================================================== +_ready_cnpg_cluster = lambda namespace, name { + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = name + namespace = namespace + annotations = { + "crossplane.io/composition-resource-name" = "cnpg-cluster" + "gotemplating.fn.crossplane.io/composition-resource-name" = "cnpg-cluster" + } + } + status = { + conditions = [{type = "Ready", status = "True"}] + atProvider.manifest.status.phase = "Cluster in healthy state" + } + } +} + +_ready_external_secret = lambda namespace, name, resource_name { + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = name + namespace = namespace + annotations = { + "crossplane.io/composition-resource-name" = resource_name + "gotemplating.fn.crossplane.io/composition-resource-name" = resource_name + } + } + status.conditions = [{type = "Ready", status = "True"}] + } +} + _items = [ # ========================================================================== # Test 1: minimal claim renders only the Cluster — no ExternalSecret by @@ -285,6 +320,7 @@ _items = [ apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata.name = "es-app-external-secret-app" + spec.readiness.policy = "DeriveFromObject" spec.forProvider.manifest = { apiVersion = "external-secrets.io/v1" kind = "ExternalSecret" @@ -336,6 +372,68 @@ _items = [ } } + # A healthy CNPG Cluster is not sufficient when its configured app Secret + # has not synchronized yet. This is the first reconcile after the desired + # ExternalSecret Object is emitted. + metav1alpha1.CompositionTest { + metadata.name = "configured-external-secret-is-required-for-readiness" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "es-pending", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "5Gi"} + app.externalSecret = { + secretStore = {name = "hops-aws-secrets-manager"} + secretRef = {path = "my-cluster/es-pending"} + } + } + } + observedResources = [ + _ready_cnpg_cluster("default", "es-pending-cnpg-cluster"), + ] + assertResources = [{ + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "PSQLCluster" + status.ready = False + }] + } + } + + metav1alpha1.CompositionTest { + metadata.name = "configured-external-secret-allows-readiness-when-ready" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "es-ready", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "5Gi"} + app.externalSecret = { + secretStore = {name = "hops-aws-secrets-manager"} + secretRef = {path = "my-cluster/es-ready"} + } + } + } + observedResources = [ + _ready_cnpg_cluster("default", "es-ready-cnpg-cluster"), + _ready_external_secret("default", "es-ready-external-secret-app", "external-secret-app"), + ] + assertResources = [{ + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "PSQLCluster" + status.ready = True + }] + } + } + # ========================================================================== # Test 8: cnpg.overrideAllValues replaces the whole Cluster.spec. # ========================================================================== From 892b791d0cfd151fadfc61889bb989e00b9d6f7e Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 3 Sep 2026 19:53:06 -0500 Subject: [PATCH 7/8] fix: inherit PSQL branch source image --- README.md | 6 ++++++ apis/psqlbranches/definition.yaml | 11 +++++----- functions/branch/000-state-init.yaml.gotmpl | 4 ++-- functions/branch/010-state-status.yaml.gotmpl | 3 +++ functions/branch/200-cnpg-cluster.yaml.gotmpl | 21 ++++++++++++++----- tests/test-branch/main.k | 12 ++++++----- .../observed/cross-namespace-ready.yaml | 2 ++ 7 files changed, 41 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index f52697a..3b28a1e 100644 --- a/README.md +++ b/README.md @@ -101,6 +101,12 @@ 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. Snapshot recovery must use the same PostgreSQL major version, and +inheriting the image 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 `-app` for `spec.app.role` diff --git a/apis/psqlbranches/definition.yaml b/apis/psqlbranches/definition.yaml index 9b0b766..67bfe69 100644 --- a/apis/psqlbranches/definition.yaml +++ b/apis/psqlbranches/definition.yaml @@ -177,15 +177,14 @@ spec: 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: diff --git a/functions/branch/000-state-init.yaml.gotmpl b/functions/branch/000-state-init.yaml.gotmpl index 4d68296..5b759e6 100644 --- a/functions/branch/000-state-init.yaml.gotmpl +++ b/functions/branch/000-state-init.yaml.gotmpl @@ -107,8 +107,8 @@ # ============================================================================== # 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 diff --git a/functions/branch/010-state-status.yaml.gotmpl b/functions/branch/010-state-status.yaml.gotmpl index 0fb5f88..c2a5bc9 100644 --- a/functions/branch/010-state-status.yaml.gotmpl +++ b/functions/branch/010-state-status.yaml.gotmpl @@ -28,8 +28,10 @@ {{- $sourceClusterResource := $sourceClusterEntry.resource | default dict }} {{- $sourceClusterAtProvider := (($sourceClusterResource.status | default dict).atProvider | default dict) }} {{- $sourceClusterManifest := $sourceClusterAtProvider.manifest | default dict }} +{{- $sourceClusterSpec := $sourceClusterManifest.spec | default dict }} {{- $sourceClusterStatus := $sourceClusterManifest.status | default dict }} {{- $sourceClusterPhase := $sourceClusterStatus.phase | default "" }} +{{- $sourceClusterImageName := $sourceClusterSpec.imageName | default "" }} {{- $sourceClusterObjectReady := false }} {{- range (($sourceClusterResource.status | default dict).conditions | default list) }} {{- if and (eq .type "Ready") (eq .status "True") }} @@ -148,6 +150,7 @@ "ready" $sourceClusterReady "objectReady" $sourceClusterObjectReady "phase" $sourceClusterPhase + "imageName" $sourceClusterImageName ) "sourceSnapshot" (dict "ready" $sourceReady diff --git a/functions/branch/200-cnpg-cluster.yaml.gotmpl b/functions/branch/200-cnpg-cluster.yaml.gotmpl index eb8fe30..d5a62dc 100644 --- a/functions/branch/200-cnpg-cluster.yaml.gotmpl +++ b/functions/branch/200-cnpg-cluster.yaml.gotmpl @@ -9,7 +9,16 @@ # {{- $cnpg := $state.cnpg }} -{{- $render := ne $state.observed.branchSnapshot.content "" }} +{{- $sourceImageName := $state.observed.sourceCluster.imageName | default "" }} +{{- $hasImageContract := or + $cnpg.overrideAllValues + $state.postgresql.version + $sourceImageName +}} +{{- $render := and + (ne $state.observed.branchSnapshot.content "") + $hasImageContract +}} {{- $clusterAnnotations := dict }} {{- if $state.scaleToZero.enabled }} @@ -84,12 +93,14 @@ spec: "limits" (dict "memory" "512Mi") ) }} - {{- /* imageName only set when version is explicit. Empty = let CNPG - use its operator-default image (close-enough when source tracks - the same chart). Hardcoding e.g. "17" was a footgun: branches - off PG 15/16 sources would silently mismatch and fail recovery. */}} + {{- /* Snapshot recovery requires the same PostgreSQL major. An explicit + version remains an escape hatch; otherwise copy the exact image + admitted on the observed source Cluster. Never fall through to + CNPG's current operator default, which may have a newer major. */}} {{- if $state.postgresql.version }} {{- $_ := set $clusterSpec "imageName" (printf "ghcr.io/cloudnative-pg/postgresql:%s" $state.postgresql.version) }} + {{- else }} + {{- $_ := set $clusterSpec "imageName" $sourceImageName }} {{- end }} {{- if $state.superuser.enabled }} {{- $_ := set $clusterSpec "enableSuperuserAccess" true }} diff --git a/tests/test-branch/main.k b/tests/test-branch/main.k index 1bac6b7..cd3ed31 100644 --- a/tests/test-branch/main.k +++ b/tests/test-branch/main.k @@ -131,7 +131,10 @@ _ready_source_cluster = lambda namespace, name { } status = { conditions = [{type = "Ready", status = "True"}] - atProvider.manifest.status.phase = "Cluster in healthy state" + atProvider.manifest = { + spec.imageName = "ghcr.io/cloudnative-pg/postgresql:17" + status.phase = "Cluster in healthy state" + } } } } @@ -230,11 +233,10 @@ _items = [ "hops.ops.com.ai/branch-of" = "src-app" } } - # No imageName — when spec.postgresql.version is omitted, - # CNPG falls back to its operator-default image. See - # test "explicit-postgres-version-sets-imagename" for - # the override path. spec = { + # The default is the source Cluster's exact admitted + # image, not CNPG's potentially newer default. + imageName = "ghcr.io/cloudnative-pg/postgresql:17" instances = 1 bootstrap.recovery = { database = "app" diff --git a/tests/test-branch/observed/cross-namespace-ready.yaml b/tests/test-branch/observed/cross-namespace-ready.yaml index 140a345..cd2dc1a 100644 --- a/tests/test-branch/observed/cross-namespace-ready.yaml +++ b/tests/test-branch/observed/cross-namespace-ready.yaml @@ -12,6 +12,8 @@ status: status: "True" atProvider: manifest: + spec: + imageName: ghcr.io/cloudnative-pg/postgresql:17 status: phase: Cluster in healthy state --- From 47369c8d80112bb537d18f682b54536eee5bd68a Mon Sep 17 00:00:00 2001 From: Patrick Lee Scott Date: Thu, 3 Sep 2026 20:44:17 -0500 Subject: [PATCH 8/8] fix: preserve PSQL branch recovery lifecycle --- README.md | 10 +- apis/psqlbranches/definition.yaml | 3 + functions/branch/000-state-init.yaml.gotmpl | 2 + functions/branch/010-state-status.yaml.gotmpl | 20 ++- .../branch/100-source-snapshot.yaml.gotmpl | 7 +- .../branch/105-snapshot-contents.yaml.gotmpl | 7 +- .../branch/110-branch-snapshot.yaml.gotmpl | 6 +- functions/branch/200-cnpg-cluster.yaml.gotmpl | 28 ++-- functions/branch/999-status.yaml.gotmpl | 1 + tests/test-branch/main.k | 157 +++++++++++++++++- .../observed/snapshot-bound-not-ready.yaml | 36 ++++ .../source-unavailable-after-recovery.yaml | 62 +++++++ 12 files changed, 316 insertions(+), 23 deletions(-) create mode 100644 tests/test-branch/observed/snapshot-bound-not-ready.yaml create mode 100644 tests/test-branch/observed/source-unavailable-after-recovery.yaml diff --git a/README.md b/README.md index 3b28a1e..fa62b0e 100644 --- a/README.md +++ b/README.md @@ -102,10 +102,12 @@ 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. Snapshot recovery must use the same PostgreSQL major version, and -inheriting the image 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. +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, diff --git a/apis/psqlbranches/definition.yaml b/apis/psqlbranches/definition.yaml index 67bfe69..bdec6b4 100644 --- a/apis/psqlbranches/definition.yaml +++ b/apis/psqlbranches/definition.yaml @@ -249,6 +249,9 @@ 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 diff --git a/functions/branch/000-state-init.yaml.gotmpl b/functions/branch/000-state-init.yaml.gotmpl index 5b759e6..e9bc7ec 100644 --- a/functions/branch/000-state-init.yaml.gotmpl +++ b/functions/branch/000-state-init.yaml.gotmpl @@ -5,6 +5,7 @@ {{- $xr := getCompositeResource . }} {{- $spec := $xr.spec | default dict }} +{{- $status := $xr.status | default dict }} {{- $metadata := $xr.metadata | default dict }} # ============================================================================== @@ -167,6 +168,7 @@ "scaleToZero" $scaleToZero "ttl" $ttl "cnpg" $cnpg + "recoveryImageName" ($status.recoveryImageName | default "") "observed" (dict) "status" (dict) }} diff --git a/functions/branch/010-state-status.yaml.gotmpl b/functions/branch/010-state-status.yaml.gotmpl index c2a5bc9..490cc44 100644 --- a/functions/branch/010-state-status.yaml.gotmpl +++ b/functions/branch/010-state-status.yaml.gotmpl @@ -11,8 +11,10 @@ {{- $clusterResource := $clusterEntry.resource | default dict }} {{- $clusterAtProvider := (($clusterResource.status | default dict).atProvider | default dict) }} {{- $clusterManifest := $clusterAtProvider.manifest | default dict }} +{{- $clusterSpec := $clusterManifest.spec | default dict }} {{- $clusterStatus := $clusterManifest.status | default dict }} {{- $clusterPhase := $clusterStatus.phase | default "" }} +{{- $clusterImageName := $clusterSpec.imageName | default "" }} {{- $clusterObjectReady := false }} {{- range (($clusterResource.status | default dict).conditions | default list) }} {{- if and (eq .type "Ready") (eq .status "True") }} @@ -40,6 +42,22 @@ {{- end }} {{- $sourceClusterReady := and $sourceClusterObjectReady (eq $sourceClusterPhase "Cluster in healthy state") }} +# Latch the recovery image into XR status. An explicit version always wins. +# Otherwise retain the image already admitted for this branch, recover it from +# an existing branch Cluster for upgrade compatibility, and consult the live +# source only before the first branch Cluster is created. This prevents source +# outages or later source upgrades from deleting/upgrading a recovered branch. +{{- $recoveryImageName := $state.recoveryImageName | default "" }} +{{- if $state.postgresql.version }} + {{- $recoveryImageName = printf "ghcr.io/cloudnative-pg/postgresql:%s" $state.postgresql.version }} +{{- else if not $recoveryImageName }} + {{- $recoveryImageName = $clusterImageName }} +{{- end }} +{{- if and (not $recoveryImageName) $sourceClusterImageName }} + {{- $recoveryImageName = $sourceClusterImageName }} +{{- end }} +{{- $state = set $state "recoveryImageName" $recoveryImageName }} + # Read the bound VolumeSnapshotContent name. Two paths: # - same-namespace: only `branch-snapshot` exists (it references the source # PVC directly), so we read from it. @@ -174,7 +192,6 @@ ) }} {{- $ready := and - $sourceClusterReady $clusterObjectReady $clusterReady $branchReady @@ -195,4 +212,5 @@ "bootstrapPhase" $clusterPhase "bootstrapMethod" "volumeSnapshot" "sourceSnapshotContent" $snapContent + "recoveryImageName" $recoveryImageName ) }} diff --git a/functions/branch/100-source-snapshot.yaml.gotmpl b/functions/branch/100-source-snapshot.yaml.gotmpl index 5824e32..3c717af 100644 --- a/functions/branch/100-source-snapshot.yaml.gotmpl +++ b/functions/branch/100-source-snapshot.yaml.gotmpl @@ -56,7 +56,12 @@ spec: kind: {{ $state.kubernetesProviderConfigRef.kind }} {{- end }} -{{- if and $state.crossNamespace $state.observed.sourceSnapshot.ready $state.observed.branchSnapshotContent.ready }} +{{- $observed := $.observed.resources | default dict }} +{{- if and + $state.crossNamespace + (hasKey $observed "source-snapshot") + (hasKey $observed "branch-snapshot-content") +}} --- apiVersion: protection.crossplane.io/v1beta1 kind: Usage diff --git a/functions/branch/105-snapshot-contents.yaml.gotmpl b/functions/branch/105-snapshot-contents.yaml.gotmpl index 56861bd..4449179 100644 --- a/functions/branch/105-snapshot-contents.yaml.gotmpl +++ b/functions/branch/105-snapshot-contents.yaml.gotmpl @@ -79,7 +79,12 @@ spec: kind: {{ $state.kubernetesProviderConfigRef.kind }} {{- end }} -{{- if and $state.crossNamespace $state.observed.branchSnapshotContent.ready $state.observed.branchSnapshot.ready }} +{{- $observed := $.observed.resources | default dict }} +{{- if and + $state.crossNamespace + (hasKey $observed "branch-snapshot-content") + (hasKey $observed "branch-snapshot") +}} --- apiVersion: protection.crossplane.io/v1beta1 kind: Usage diff --git a/functions/branch/110-branch-snapshot.yaml.gotmpl b/functions/branch/110-branch-snapshot.yaml.gotmpl index cf86292..ec63dfc 100644 --- a/functions/branch/110-branch-snapshot.yaml.gotmpl +++ b/functions/branch/110-branch-snapshot.yaml.gotmpl @@ -56,7 +56,11 @@ spec: kind: {{ $state.kubernetesProviderConfigRef.kind }} {{- end }} -{{- if and $state.observed.branchSnapshot.ready $state.observed.cluster.objectReady }} +{{- $observed := $.observed.resources | default dict }} +{{- if and + (hasKey $observed "branch-snapshot") + (hasKey $observed "cnpg-cluster") +}} --- apiVersion: protection.crossplane.io/v1beta1 kind: Usage diff --git a/functions/branch/200-cnpg-cluster.yaml.gotmpl b/functions/branch/200-cnpg-cluster.yaml.gotmpl index d5a62dc..401a435 100644 --- a/functions/branch/200-cnpg-cluster.yaml.gotmpl +++ b/functions/branch/200-cnpg-cluster.yaml.gotmpl @@ -9,15 +9,21 @@ # {{- $cnpg := $state.cnpg }} -{{- $sourceImageName := $state.observed.sourceCluster.imageName | default "" }} +{{- $recoveryImageName := $state.recoveryImageName | default "" }} {{- $hasImageContract := or $cnpg.overrideAllValues - $state.postgresql.version - $sourceImageName + $recoveryImageName }} +{{- $clusterExists := hasKey ($.observed.resources | default dict) "cnpg-cluster" }} {{- $render := and - (ne $state.observed.branchSnapshot.content "") $hasImageContract + (or + $clusterExists + (and + $state.observed.branchSnapshot.ready + (ne $state.observed.branchSnapshot.content "") + ) + ) }} {{- $clusterAnnotations := dict }} @@ -93,15 +99,11 @@ spec: "limits" (dict "memory" "512Mi") ) }} - {{- /* Snapshot recovery requires the same PostgreSQL major. An explicit - version remains an escape hatch; otherwise copy the exact image - admitted on the observed source Cluster. Never fall through to - CNPG's current operator default, which may have a newer major. */}} - {{- if $state.postgresql.version }} - {{- $_ := set $clusterSpec "imageName" (printf "ghcr.io/cloudnative-pg/postgresql:%s" $state.postgresql.version) }} - {{- else }} - {{- $_ := set $clusterSpec "imageName" $sourceImageName }} - {{- end }} + {{- /* Snapshot recovery requires the same PostgreSQL major. The state + extractor latches either the explicit version or the source's + exact admitted image into XR status, then recovers it from an + existing branch Cluster if needed. Never use CNPG's default. */}} + {{- $_ := set $clusterSpec "imageName" $recoveryImageName }} {{- if $state.superuser.enabled }} {{- $_ := set $clusterSpec "enableSuperuserAccess" true }} {{- if $state.superuser.providedSecretName }} diff --git a/functions/branch/999-status.yaml.gotmpl b/functions/branch/999-status.yaml.gotmpl index 8ecce3c..3b0edc6 100644 --- a/functions/branch/999-status.yaml.gotmpl +++ b/functions/branch/999-status.yaml.gotmpl @@ -12,6 +12,7 @@ status: bootstrapPhase: {{ $state.status.bootstrapPhase | quote }} bootstrapMethod: {{ $state.status.bootstrapMethod | quote }} sourceSnapshotContent: {{ $state.status.sourceSnapshotContent | quote }} + recoveryImageName: {{ $state.status.recoveryImageName | quote }} {{ if not $state.cnpg.overrideAllValues }} app: secretName: {{ $state.app.secretName | quote }} diff --git a/tests/test-branch/main.k b/tests/test-branch/main.k index cd3ed31..9194ca7 100644 --- a/tests/test-branch/main.k +++ b/tests/test-branch/main.k @@ -139,6 +139,38 @@ _ready_source_cluster = lambda namespace, name { } } +_unready_object = lambda namespace, name, resource_name { + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = name + namespace = namespace + annotations = { + "crossplane.io/composition-resource-name" = resource_name + "gotemplating.fn.crossplane.io/composition-resource-name" = resource_name + } + } + status.conditions = [{type = "Ready", status = "False"}] + } +} + +_ready_usage = lambda namespace, name, resource_name { + { + apiVersion = "protection.crossplane.io/v1beta1" + kind = "Usage" + metadata = { + name = name + namespace = namespace + annotations = { + "crossplane.io/composition-resource-name" = resource_name + "gotemplating.fn.crossplane.io/composition-resource-name" = resource_name + } + } + status.conditions = [{type = "Ready", status = "True"}] + } +} + _items = [ # ========================================================================== # A branch starts by observing its source CNPG Cluster. Snapshot resources @@ -916,8 +948,7 @@ _items = [ } # ========================================================================== - # All three Usage locks appear only after their endpoints are observed - # Ready. The staged observed fixture separately exercises final status. + # All three Usage locks appear after their endpoints are observed. # ========================================================================== metav1alpha1.CompositionTest { metadata.name = "ready-cross-namespace-branch-locks-deletion-order" @@ -960,6 +991,128 @@ _items = [ ] } } + + # ========================================================================== + # Usage resources must remain desired while their endpoints exist even when + # those endpoints become unready during deletion. Readiness-gated Usages + # would disappear precisely when the deletion lock is needed. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "deletion-locks-survive-unready-endpoints" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-deleting", namespace = "preview-pr-1"} + spec = { + clusterName = "production" + source = {name = "source", namespace = "staging"} + } + } + observedResources = [ + _unready_object("preview-pr-1", "br-deleting-source-snapshot", "source-snapshot"), + _unready_object("preview-pr-1", "br-deleting-branch-snapshot-content", "branch-snapshot-content"), + _unready_object("preview-pr-1", "br-deleting-branch-snapshot", "branch-snapshot"), + _unready_object("preview-pr-1", "br-deleting-cnpg-cluster", "cnpg-cluster"), + ] + assertResources = [ + { + apiVersion = "protection.crossplane.io/v1beta1" + kind = "Usage" + metadata.name = "br-deleting-delete-branch-content-before-source-snapshot" + } + { + apiVersion = "protection.crossplane.io/v1beta1" + kind = "Usage" + metadata.name = "br-deleting-delete-branch-snapshot-before-branch-content" + } + { + apiVersion = "protection.crossplane.io/v1beta1" + kind = "Usage" + metadata.name = "br-deleting-delete-cnpg-cluster-before-branch-snapshot" + } + ] + } + } + + # ========================================================================== + # Once admitted, the recovery image is recoverable from the existing branch + # Cluster and remains desired even when both source and snapshot observations + # are temporarily absent. This prevents composition from deleting the DB. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "existing-branch-survives-source-observation-loss" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-survivor", namespace = "preview-pr-1"} + spec = { + clusterName = "production" + source = {name = "source", namespace = "staging"} + } + } + observedResources = [{ + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "br-survivor-cnpg-cluster" + namespace = "preview-pr-1" + annotations = { + "crossplane.io/composition-resource-name" = "cnpg-cluster" + "gotemplating.fn.crossplane.io/composition-resource-name" = "cnpg-cluster" + } + } + status = { + conditions = [{type = "Ready", status = "False"}] + atProvider.manifest.spec.imageName = "ghcr.io/cloudnative-pg/postgresql:17" + } + }] + assertResources = [{ + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-survivor-cnpg-cluster" + spec.forProvider.manifest.spec.imageName = "ghcr.io/cloudnative-pg/postgresql:17" + }] + } + } + + # ========================================================================== + # A recovered healthy branch remains application-ready if the source later + # becomes unhealthy. Source health gates snapshot staging, not steady state. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "healthy-branch-ready-with-unavailable-source" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-independent", namespace = "default"} + spec = { + clusterName = "production" + source = {name = "source"} + } + } + observedResources = [ + _unready_object("default", "br-independent-source-cluster", "source-cluster"), + _ready_branch_snapshot("default", "br-independent-branch-snapshot"), + _ready_cnpg_cluster("default", "br-independent-cnpg-cluster"), + _ready_usage("default", "br-independent-delete-cnpg-cluster-before-branch-snapshot", "usage-cnpg-cluster-branch-snapshot"), + ] + assertResources = [{ + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "PSQLBranch" + metadata.name = "br-independent" + status.ready = True + }] + } + } ] items = _items diff --git a/tests/test-branch/observed/snapshot-bound-not-ready.yaml b/tests/test-branch/observed/snapshot-bound-not-ready.yaml new file mode 100644 index 0000000..9311d33 --- /dev/null +++ b/tests/test-branch/observed/snapshot-bound-not-ready.yaml @@ -0,0 +1,36 @@ +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: pr-142-source-cluster + namespace: default + annotations: + crossplane.io/composition-resource-name: source-cluster + gotemplating.fn.crossplane.io/composition-resource-name: source-cluster +status: + conditions: + - type: Ready + status: "True" + atProvider: + manifest: + spec: + imageName: ghcr.io/cloudnative-pg/postgresql:17 + status: + phase: Cluster in healthy state +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: pr-142-branch-snapshot + namespace: default + annotations: + crossplane.io/composition-resource-name: branch-snapshot + gotemplating.fn.crossplane.io/composition-resource-name: branch-snapshot +status: + conditions: + - type: Ready + status: "False" + atProvider: + manifest: + status: + boundVolumeSnapshotContentName: snapcontent-bound-not-ready + readyToUse: false diff --git a/tests/test-branch/observed/source-unavailable-after-recovery.yaml b/tests/test-branch/observed/source-unavailable-after-recovery.yaml new file mode 100644 index 0000000..d78994d --- /dev/null +++ b/tests/test-branch/observed/source-unavailable-after-recovery.yaml @@ -0,0 +1,62 @@ +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: pr-142-source-cluster + namespace: default + annotations: + crossplane.io/composition-resource-name: source-cluster + gotemplating.fn.crossplane.io/composition-resource-name: source-cluster +status: + conditions: + - type: Ready + status: "False" +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: pr-142-branch-snapshot + namespace: default + annotations: + crossplane.io/composition-resource-name: branch-snapshot + gotemplating.fn.crossplane.io/composition-resource-name: branch-snapshot +status: + conditions: + - type: Ready + status: "True" + atProvider: + manifest: + status: + boundVolumeSnapshotContentName: snapcontent-ready + readyToUse: true +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: pr-142-cnpg-cluster + namespace: default + annotations: + crossplane.io/composition-resource-name: cnpg-cluster + gotemplating.fn.crossplane.io/composition-resource-name: cnpg-cluster +status: + conditions: + - type: Ready + status: "True" + atProvider: + manifest: + spec: + imageName: ghcr.io/cloudnative-pg/postgresql:17 + status: + phase: Cluster in healthy state +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: pr-142-delete-cnpg-cluster-before-branch-snapshot + namespace: default + annotations: + crossplane.io/composition-resource-name: usage-cnpg-cluster-branch-snapshot + gotemplating.fn.crossplane.io/composition-resource-name: usage-cnpg-cluster-branch-snapshot +status: + conditions: + - type: Ready + status: "True"