diff --git a/README.md b/README.md index edc7be7..fa62b0e 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,33 @@ spec: If the cluster already ships a suitable default StorageClass, disable composition and have PSQLCluster/PSQLBranch consumers set `spec.storage.class` explicitly. +### Preview branch credentials + +`PSQLBranch` first observes the source CNPG `Cluster` and waits for both its +Ready condition and `Cluster in healthy state` phase before creating a +snapshot. This prevents a newly-created source from being snapshotted while +CNPG is still initializing its data directory and primary identity. + +The branch also inherits the exact `spec.imageName` admitted on that source +Cluster and latches it in `status.recoveryImageName`. Snapshot recovery must +use the same PostgreSQL major version, and retaining the admitted image keeps +the recovered branch independent of later source outages or upgrades. It also +prevents a newer CNPG operator default from silently trying to open an older +data directory. `spec.postgresql.version` is an explicit override and must +remain on the source snapshot's major version. + +Snapshot recovery restores PostgreSQL data and roles, but not Kubernetes +Secrets. `PSQLBranch` therefore defaults to CloudNativePG-managed, +branch-local credentials: it creates `-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..bdec6b4 100644 --- a/apis/psqlbranches/definition.yaml +++ b/apis/psqlbranches/definition.yaml @@ -21,16 +21,18 @@ spec: new CNPG Cluster bootstrapped via `bootstrap.recovery.volumeSnapshots`. Cross-namespace forks are supported (preview-pr-N namespace forking a - source in team-app namespace) via a bridging VolumeSnapshotContent — - the render template composes a VolumeSnapshot in the source namespace - AND a static-bound VolumeSnapshot in the branch namespace, then the - new Cluster references the branch-ns VolumeSnapshot for recovery. + source in team-app namespace) by observing the source snapshot's CSI + handle and importing it through a distinct branch-bound + VolumeSnapshotContent. The new Cluster then references the + branch-namespace VolumeSnapshot for recovery. Same-namespace forks collapse this into a single VolumeSnapshot. Required prerequisites on the target cluster: psql-stack (CNPG operator), volume-snapshot-stack (snapshot-controller + CRDs), and - a source PSQLCluster with `branching.enabled: true`. + a healthy source PSQLCluster with `branching.enabled: true`. The + branch observes the source CNPG Cluster and does not create a + snapshot until it reports Ready and `Cluster in healthy state`. type: object properties: spec: @@ -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: @@ -134,20 +138,53 @@ spec: type: string default: psql + app: + description: | + Application database owner reconciled after snapshot recovery. + When secretName is empty, CloudNativePG generates a new + branch-local `-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 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: @@ -212,8 +249,29 @@ spec: sourceSnapshotContent: description: Name of the cluster-scoped VolumeSnapshotContent backing this branch (for cross-ns bridging visibility). type: string + recoveryImageName: + description: Exact PostgreSQL image admitted for branch recovery and retained independently of later source availability. + type: string expiresAt: description: Computed deletion deadline when ttl.enabled is true. type: string + app: + description: Branch-local application connection details. + type: object + properties: + secretName: + type: string + database: + type: string + host: + type: string + port: + type: integer + superuser: + description: Branch-local superuser connection details when enabled. + type: object + properties: + secretName: + type: string required: - spec 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..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 }} # ============================================================================== @@ -56,6 +57,9 @@ # Cross-namespace bridging gate — render the source-ns VolumeSnapshot only when # the source is in a different namespace from the branch. {{- $crossNamespace := ne $sourceNamespace $namespace }} +{{- $branchIdentity := printf "%d:%s%d:%s" (len $namespace) $namespace (len $name) $name }} +{{- $branchIdentityHash := $branchIdentity | sha256sum | trunc 16 }} +{{- $branchSnapshotContentName := printf "%.40s-%.40s-%s-content" $namespace $name $branchIdentityHash }} # ============================================================================== # Branch sizing @@ -73,10 +77,39 @@ ) }} +# ============================================================================== +# Recovery credentials +# ============================================================================== +{{- $appSpec := $spec.app | default dict }} +{{- $appSecretName := $appSpec.secretName | default "" }} +{{- $appCnpgManagedSecret := not $appSecretName }} +{{- if $appCnpgManagedSecret }} + {{- $appSecretName = printf "%s-app" $name }} +{{- end }} +{{- $app := dict + "role" ($appSpec.role | default "app") + "database" ($appSpec.database | default "app") + "secretName" $appSecretName + "cnpgManagedSecret" $appCnpgManagedSecret +}} + +{{- $superuserSpec := $spec.superuser | default dict }} +{{- $superuserEnabled := false }} +{{- if hasKey $superuserSpec "enabled" }} + {{- $superuserEnabled = $superuserSpec.enabled }} +{{- end }} +{{- $superuserProvidedSecretName := $superuserSpec.secretName | default "" }} +{{- $superuserSecretName := $superuserProvidedSecretName | default (printf "%s-superuser" $name) }} +{{- $superuser := dict + "enabled" $superuserEnabled + "secretName" $superuserSecretName + "providedSecretName" $superuserProvidedSecretName +}} + # ============================================================================== # Postgres version — must match source's major for snapshot recovery to -# succeed. No default: omit `imageName` downstream so CNPG falls back to its -# operator default (matches the chart's pinned PG when source tracks it). +# succeed. No default: downstream inherits the exact image admitted on the +# observed source Cluster, avoiding drift when the CNPG default major changes. # ============================================================================== {{- $pgSpec := $spec.postgresql | default dict }} {{- $postgresql := dict @@ -127,11 +160,15 @@ "kubernetesProviderConfigRef" $k8sProviderConfigRef "source" $source "crossNamespace" $crossNamespace + "branchSnapshotContentName" $branchSnapshotContentName "branch" $branch + "app" $app + "superuser" $superuser "postgresql" $postgresql "scaleToZero" $scaleToZero "ttl" $ttl "cnpg" $cnpg + "recoveryImageName" ($status.recoveryImageName | default "") "observed" (dict) "status" (dict) }} diff --git a/functions/branch/010-state-status.yaml.gotmpl b/functions/branch/010-state-status.yaml.gotmpl index 0ebecaf..490cc44 100644 --- a/functions/branch/010-state-status.yaml.gotmpl +++ b/functions/branch/010-state-status.yaml.gotmpl @@ -11,8 +11,52 @@ {{- $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") }} + {{- $clusterObjectReady = true }} + {{- end }} +{{- 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 }} +{{- $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") }} + {{- $sourceClusterObjectReady = true }} + {{- end }} +{{- 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 @@ -30,6 +74,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 +87,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,13 +159,58 @@ {{- end }} {{- $state = set $state "observed" (dict - "cluster" (dict "phase" $clusterPhase) + "cluster" (dict + "ready" $clusterReady + "objectReady" $clusterObjectReady + "phase" $clusterPhase + ) + "sourceCluster" (dict + "ready" $sourceClusterReady + "objectReady" $sourceClusterObjectReady + "phase" $sourceClusterPhase + "imageName" $sourceClusterImageName + ) + "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 + "recoveryImageName" $recoveryImageName ) }} 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 5141f67..3c717af 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. @@ -23,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 @@ -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,31 @@ spec: name: {{ $state.kubernetesProviderConfigRef.name }} kind: {{ $state.kubernetesProviderConfigRef.kind }} {{- end }} + +{{- $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 +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..4449179 --- /dev/null +++ b/functions/branch/105-snapshot-contents.yaml.gotmpl @@ -0,0 +1,108 @@ +# 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 }} + +{{- $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 +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..ec63dfc 100644 --- a/functions/branch/110-branch-snapshot.yaml.gotmpl +++ b/functions/branch/110-branch-snapshot.yaml.gotmpl @@ -10,6 +10,15 @@ # {{- $source := $state.source }} +{{- $sourceContent := $state.observed.sourceSnapshotContent }} +{{- $render := and + $state.observed.sourceCluster.ready + (or + (not $state.crossNamespace) + (and (ne $sourceContent.handle "") (ne $sourceContent.driver "")) + ) +}} +{{- if $render }} --- apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object @@ -20,6 +29,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 +44,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 +54,31 @@ spec: providerConfigRef: name: {{ $state.kubernetesProviderConfigRef.name }} kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} + +{{- $observed := $.observed.resources | default dict }} +{{- if and + (hasKey $observed "branch-snapshot") + (hasKey $observed "cnpg-cluster") +}} +--- +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..401a435 100644 --- a/functions/branch/200-cnpg-cluster.yaml.gotmpl +++ b/functions/branch/200-cnpg-cluster.yaml.gotmpl @@ -2,18 +2,29 @@ # # 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 }} +{{- $recoveryImageName := $state.recoveryImageName | default "" }} +{{- $hasImageContract := or + $cnpg.overrideAllValues + $recoveryImageName +}} +{{- $clusterExists := hasKey ($.observed.resources | default dict) "cnpg-cluster" }} +{{- $render := and + $hasImageContract + (or + $clusterExists + (and + $state.observed.branchSnapshot.ready + (ne $state.observed.branchSnapshot.content "") + ) + ) +}} {{- $clusterAnnotations := dict }} {{- if $state.scaleToZero.enabled }} @@ -28,6 +39,7 @@ "hops.ops.com.ai/branch-of" $state.source.name ) }} +{{- if $render }} --- apiVersion: kubernetes.m.crossplane.io/v1alpha1 kind: Object @@ -38,6 +50,8 @@ metadata: labels: {{ $state.labels | toJson }} spec: managementPolicies: {{ $state.managementPolicies | toJson }} + readiness: + policy: DeriveFromObject forProvider: manifest: apiVersion: postgresql.cnpg.io/v1 @@ -60,33 +74,41 @@ 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") ) }} - {{- /* 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. */}} - {{- if $state.postgresql.version }} - {{- $_ := set $clusterSpec "imageName" (printf "ghcr.io/cloudnative-pg/postgresql:%s" $state.postgresql.version) }} + {{- /* 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 }} + {{- $_ := 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 @@ -118,3 +140,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..3b0edc6 100644 --- a/functions/branch/999-status.yaml.gotmpl +++ b/functions/branch/999-status.yaml.gotmpl @@ -12,3 +12,15 @@ 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 }} + 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 }} +{{ end }} diff --git a/functions/cluster/010-state-status.yaml.gotmpl b/functions/cluster/010-state-status.yaml.gotmpl index bc1b38a..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) @@ -54,6 +64,6 @@ ) }} {{- $state = set $state "status" (dict - "ready" false + "ready" (and $clusterReady $esReady) "clusterPhase" $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/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 dd63c57..9194ca7 100644 --- a/tests/test-branch/main.k +++ b/tests/test-branch/main.k @@ -4,11 +4,211 @@ 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 = lambda namespace, name { + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = name + namespace = namespace + 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 = lambda namespace, name { + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = name + namespace = namespace + 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 = lambda namespace, name { + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = name + namespace = namespace + 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 = lambda namespace, name { + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = name + namespace = namespace + 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 = 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_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 = { + spec.imageName = "ghcr.io/cloudnative-pg/postgresql:17" + status.phase = "Cluster in healthy state" + } + } +} +} + +_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 + # 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. @@ -27,6 +227,10 @@ _items = [ source = {name = "src-app"} } } + observedResources = [ + _ready_source_cluster("default", "br-same-source-cluster"), + _ready_branch_snapshot("default", "br-same-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -61,16 +265,19 @@ _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.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 +287,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 +305,7 @@ _items = [ } } observedResources = [ - # source-ns VolumeSnapshot (Object wrapper) — bound. + _ready_source_cluster("preview-pr-1", "br-x-source-cluster"), { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" @@ -115,90 +317,154 @@ _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("preview-pr-1", "br-x-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-6a6f418e5232e5cb-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. + # Cluster-scoped content names include a length-delimited identity hash, so + # delimiter-equivalent readable prefixes cannot collide. # ========================================================================== metav1alpha1.CompositionTest { - metadata.name = "cross-ns-uses-branch-content-when-bound" + 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 = "br-y", namespace = "preview-pr-2"} + metadata = {name = "c", namespace = "a-b"} spec = { clusterName = "prod" - source = {name = "src-app", namespace = "team-app"} + source = {name = "source", namespace = "source"} } } 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"), + ] + 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" + 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 = "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 = "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_cluster("a", "b-c-source-cluster"), + _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" + } + ] + } + } + + # ========================================================================== + # 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" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-y", namespace = "preview-pr-2"} + spec = { + clusterName = "prod" + source = {name = "src-app", namespace = "team-app"} } + } + 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"), + _ready_branch_snapshot("preview-pr-2", "br-y-branch-snapshot"), ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" kind = "Object" metadata.name = "br-y-branch-snapshot" - spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "snapcontent-from-branch" + spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "preview-pr-2-br-y-cb240488b9468ea4-content" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-y-cnpg-cluster" + spec.forProvider.manifest.spec.bootstrap.recovery.volumeSnapshots.storage.name = "br-y-snap" } ] } @@ -225,6 +491,10 @@ _items = [ branch.storage.size = "150Gi" } } + observedResources = [ + _ready_source_cluster("default", "br-explicit-source-cluster"), + _ready_branch_snapshot("default", "br-explicit-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -256,6 +526,10 @@ _items = [ } } } + observedResources = [ + _ready_source_cluster("default", "br-inherit-source-cluster"), + _ready_branch_snapshot("default", "br-inherit-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -287,6 +561,10 @@ _items = [ postgresql.version = "17.4" } } + observedResources = [ + _ready_source_cluster("default", "br-pinned-source-cluster"), + _ready_branch_snapshot("default", "br-pinned-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -299,8 +577,8 @@ _items = [ } # ========================================================================== - # Test 2: cross-namespace branch — source-snapshot in source ns + branch- - # snapshot in branch ns + Cluster in branch ns. + # 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" @@ -316,6 +594,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" @@ -334,24 +615,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" - } - } ] } } @@ -377,6 +640,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" @@ -408,6 +674,10 @@ _items = [ source = {name = "src"} } } + observedResources = [ + _ready_source_cluster("default", "br-s2z-source-cluster"), + _ready_branch_snapshot("default", "br-s2z-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -445,6 +715,10 @@ _items = [ scaleToZero = {enabled = False} } } + 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" @@ -477,6 +751,10 @@ _items = [ ttl = {enabled = True, after = "24h"} } } + observedResources = [ + _ready_source_cluster("default", "br-ttl-source-cluster"), + _ready_branch_snapshot("default", "br-ttl-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -508,6 +786,10 @@ _items = [ source = {name = "src", snapshotClassName = "longhorn-snap"} } } + 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" @@ -540,6 +822,10 @@ _items = [ } } } + observedResources = [ + _ready_source_cluster("default", "br-override-source-cluster"), + _ready_branch_snapshot("default", "br-override-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -571,6 +857,10 @@ _items = [ source = {name = "src"} } } + observedResources = [ + _ready_source_cluster("default", "br-pc-source-cluster"), + _ready_branch_snapshot("default", "br-pc-branch-snapshot"), + ] assertResources = [ { apiVersion = "kubernetes.m.crossplane.io/v1alpha1" @@ -584,6 +874,245 @@ _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_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" + 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_source_cluster("default", "br-secrets-source-cluster"), + _ready_branch_snapshot("default", "br-secrets-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 after their endpoints are observed. + # ========================================================================== + 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_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"), + _ready_branch_snapshot("preview-pr-1", "br-ready-branch-snapshot"), + _ready_cnpg_cluster("preview-pr-1", "br-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" + } + ] + } + } + + # ========================================================================== + # 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/cross-namespace-ready.yaml b/tests/test-branch/observed/cross-namespace-ready.yaml new file mode 100644 index 0000000..cd2dc1a --- /dev/null +++ b/tests/test-branch/observed/cross-namespace-ready.yaml @@ -0,0 +1,151 @@ +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: + 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-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-5469d3f7f65881cc-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" 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" 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. # ==========================================================================