diff --git a/.github/workflows/on-pr.yaml b/.github/workflows/on-pr.yaml index 65429f9..4d40c9a 100644 --- a/.github/workflows/on-pr.yaml +++ b/.github/workflows/on-pr.yaml @@ -27,28 +27,72 @@ permissions: jobs: validate: - uses: unbounded-tech/workflows-crossplane/.github/workflows/validate.yaml@v2.20.0 + uses: hops-ops/workflows-crossplane/.github/workflows/validate.yaml@v3.0.0 with: examples: | [ - { "example": "examples/psqlstacks/minimal.yaml" }, - { "example": "examples/psqlstacks/standard.yaml" } + { "example": "examples/psqlstacks/minimal.yaml", "api_path": "apis/psqlstacks" }, + { "example": "examples/psqlstacks/standard.yaml", "api_path": "apis/psqlstacks" }, + { "example": "examples/psqlclusters/minimal.yaml", "api_path": "apis/psqlclusters" }, + { "example": "examples/psqlclusters/standard.yaml","api_path": "apis/psqlclusters" }, + { "example": "examples/psqlbranches/same-namespace.yaml", "api_path": "apis/psqlbranches" } ] - api_path: apis/psqlstacks error_on_missing_schemas: true test: - uses: unbounded-tech/workflows-crossplane/.github/workflows/test.yaml@v2.20.0 + uses: hops-ops/workflows-crossplane/.github/workflows/test.yaml@v3.0.0 e2e: - uses: unbounded-tech/workflows-crossplane/.github/workflows/e2e.yaml@v2.20.0 + uses: hops-ops/workflows-crossplane/.github/workflows/e2e.yaml@v3.0.0 + with: + # E2E provisions a real EKS Auto Mode cluster (mirror of aws-observe-stack) + # so the `psql` StorageClass + VolumeSnapshotClass land on a real + # ebs.csi.eks.amazonaws.com driver — same code path that runs on + # pat-local. Kind has no snapshot-capable CSI, so the prior kind-only + # e2e couldn't exercise PSQLBranch's snapshot/fork chain. + aws: true + aws-use-oidc: true + aws-account-id: "034489662075" + aws-region: us-east-2 + aws-role-duration-seconds: 7200 + write-env-files: true + env-vars: | + { + "ADMIN_ROLE_ARN": "${{ vars.ADMIN_ROLE_ARN }}", + "PRIVATE_SUBNET_ID_A": "${{ vars.PRIVATE_SUBNET_ID_A }}", + "PRIVATE_SUBNET_ID_B": "${{ vars.PRIVATE_SUBNET_ID_B }}" + } + debug-resource-types: | + [ + "psqlstacks.hops.ops.com.ai", + "psqlclusters.hops.ops.com.ai", + "psqlbranches.hops.ops.com.ai", + "volumesnapshotstacks.hops.ops.com.ai", + "certstacks.hops.ops.com.ai", + "autoeksclusters.aws.hops.ops.com.ai" + ] + # AutoEKSCluster outlives the manifests' cascade delete; the workflow + # tears it down separately. VolumeSnapshotStack + CertStack are + # colocated with the cluster — drop them together so their Helm + # releases on the EKS cluster are uninstalled before the cluster goes. + delete-extra-resources: | + [ + "volumesnapshotstacks.hops.ops.com.ai", + "certstacks.hops.ops.com.ai", + "autoeksclusters.aws.hops.ops.com.ai" + ] + # 90 min: ~10–15 min EKS Auto Mode cold start, then platform install + + # CNPG bootstrap + snapshot fork. + timeout-minutes: 90 + cleanup-timeout-minutes: 30 + delete-extra-resources-timeout-minutes: 30 publish: needs: - validate - test - e2e - uses: unbounded-tech/workflows-crossplane/.github/workflows/publish.yaml@v2.20.0 + uses: hops-ops/workflows-crossplane/.github/workflows/publish.yaml@v3.0.0 secrets: inherit with: tag: pr-${{ github.event.pull_request.number }}-${{ github.sha }} diff --git a/.github/workflows/on-push-main.yaml b/.github/workflows/on-push-main.yaml index 063182b..1880825 100644 --- a/.github/workflows/on-push-main.yaml +++ b/.github/workflows/on-push-main.yaml @@ -23,21 +23,27 @@ permissions: jobs: validate: - uses: unbounded-tech/workflows-crossplane/.github/workflows/validate.yaml@v2.20.0 + uses: unbounded-tech/workflows-crossplane/.github/workflows/validate.yaml@feat/multi-api-support with: examples: | [ - { "example": "examples/psqlstacks/minimal.yaml" }, - { "example": "examples/psqlstacks/standard.yaml" } + { "example": "examples/psqlstacks/minimal.yaml", "api_path": "apis/psqlstacks" }, + { "example": "examples/psqlstacks/standard.yaml", "api_path": "apis/psqlstacks" }, + { "example": "examples/psqlclusters/minimal.yaml", "api_path": "apis/psqlclusters" }, + { "example": "examples/psqlclusters/standard.yaml","api_path": "apis/psqlclusters" }, + { "example": "examples/psqlbranches/same-namespace.yaml", "api_path": "apis/psqlbranches" } ] - api_path: apis/psqlstacks error_on_missing_schemas: true test: - uses: unbounded-tech/workflows-crossplane/.github/workflows/test.yaml@v2.20.0 + uses: unbounded-tech/workflows-crossplane/.github/workflows/test.yaml@feat/multi-api-support e2e: - uses: unbounded-tech/workflows-crossplane/.github/workflows/e2e.yaml@v2.20.0 + uses: unbounded-tech/workflows-crossplane/.github/workflows/e2e.yaml@feat/multi-api-support + with: + # See on-pr.yaml — 90 min for the full Ready chain. + timeout-minutes: 90 + cleanup-timeout-minutes: 30 version-and-tag: name: Version and Tag diff --git a/.gitignore b/.gitignore index c7d6289..923dadd 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,8 @@ _output/ # Temporary files .tmp/ -# E2E test credentials (never commit secrets) +# E2E test credentials + per-run env (never commit secrets) **/aws-creds tests/**/secrets/ +tests/**/env/ +apis/**/configuration.yaml diff --git a/Makefile b/Makefile index 8e68960..2e84af2 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,7 @@ SHELL := /bin/bash PACKAGE ?= psql-stack +# Default XRD_DIR for legacy single-API targets; multi-API targets derive per-example. XRD_DIR := apis/psqlstacks COMPOSITION := $(XRD_DIR)/composition.yaml DEFINITION := $(XRD_DIR)/definition.yaml @@ -8,6 +9,10 @@ EXAMPLE_DEFAULT := examples/psqlstacks/standard.yaml RENDER_TESTS := $(wildcard tests/test-*) E2E_TESTS := $(wildcard tests/e2etest-*) +# Multi-API support: examples//.yaml maps to apis//. +# Helper macro: api_dir_for(example_path) → apis/ +api-dir = apis/$(word 2,$(subst /, ,$(1))) + clean: rm -rf _output rm -rf .up @@ -17,9 +22,13 @@ build: # Examples list - mirrors GitHub Actions workflow # Format: example_path::observed_resources_path (observed_resources_path is optional) +# api_path is derived from example_path via the api-dir macro (examples//... → apis//). EXAMPLES := \ examples/psqlstacks/minimal.yaml:: \ - examples/psqlstacks/standard.yaml:: + examples/psqlstacks/standard.yaml:: \ + examples/psqlclusters/minimal.yaml:: \ + examples/psqlclusters/standard.yaml:: \ + examples/psqlbranches/same-namespace.yaml:: # Render all examples (parallel execution, output shown per-job when complete) render\:all: @@ -28,14 +37,17 @@ render\:all: for entry in $(EXAMPLES); do \ example=$${entry%%::*}; \ observed=$${entry#*::}; \ + api_dir=$$(echo "$$example" | awk -F/ '{print "apis/" $$2}'); \ + composition="$$api_dir/composition.yaml"; \ + definition="$$api_dir/definition.yaml"; \ outfile="$$tmpdir/$$(echo $$entry | tr '/:' '__')"; \ ( \ if [ -n "$$observed" ]; then \ echo "=== Rendering $$example with observed-resources $$observed ==="; \ - up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example --observed-resources=$$observed; \ + up composition render --xrd=$$definition $$composition $$example --observed-resources=$$observed; \ else \ - echo "=== Rendering $$example ==="; \ - up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example; \ + echo "=== Rendering $$example (api=$$api_dir) ==="; \ + up composition render --xrd=$$definition $$composition $$example; \ fi; \ echo "" \ ) > "$$outfile" 2>&1 & \ @@ -58,18 +70,21 @@ validate\:all: for entry in $(EXAMPLES); do \ example=$${entry%%::*}; \ observed=$${entry#*::}; \ + api_dir=$$(echo "$$example" | awk -F/ '{print "apis/" $$2}'); \ + composition="$$api_dir/composition.yaml"; \ + definition="$$api_dir/definition.yaml"; \ outfile="$$tmpdir/$$(echo $$entry | tr '/:' '__')"; \ ( \ if [ -n "$$observed" ]; then \ echo "=== Validating $$example with observed-resources $$observed ==="; \ - up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example \ + up composition render --xrd=$$definition $$composition $$example \ --observed-resources=$$observed --include-full-xr --quiet | \ - crossplane beta validate $(XRD_DIR) --error-on-missing-schemas -; \ + crossplane beta validate $$api_dir --error-on-missing-schemas -; \ else \ - echo "=== Validating $$example ==="; \ - up composition render --xrd=$(DEFINITION) $(COMPOSITION) $$example \ + echo "=== Validating $$example (api=$$api_dir) ==="; \ + up composition render --xrd=$$definition $$composition $$example \ --include-full-xr --quiet | \ - crossplane beta validate $(XRD_DIR) --error-on-missing-schemas -; \ + crossplane beta validate $$api_dir --error-on-missing-schemas -; \ fi; \ echo "" \ ) > "$$outfile" 2>&1 & \ diff --git a/README.md b/README.md index d9a50ac..9e3a631 100644 --- a/README.md +++ b/README.md @@ -1,32 +1,38 @@ # psql-stack -PostgreSQL management stack deploying StackGres and Atlas Operator as Helm releases with safe deletion ordering. +PostgreSQL platform layer on top of [CloudNativePG](https://cloudnative-pg.io/). Composes the CNPG operator, the [`cnpg-i-scale-to-zero` plugin](https://github.com/xataio/cnpg-i-scale-to-zero), the [Atlas Operator](https://atlasgo.io/integrations/kubernetes/operator) (declarative schema migrations), a paired `psql` `StorageClass` + `VolumeSnapshotClass` that PSQLClusters and PSQLBranches default to. -## Why psql-stack? +This is the **platform layer** — it does not create any serving Postgres clusters. Per-app DBs live in [`PSQLCluster`](../../psql-cluster/), ephemeral forks in [`PSQLBranch`](../../psql-branch/). -**Without psql-stack:** -- Manual Helm installs of StackGres and Atlas on every cluster -- No guaranteed deletion order — removing StackGres before Atlas leaves orphaned migration state -- Inconsistent operator versions and configuration across environments -- No declarative, reviewable representation of your database tooling +## Design -**With psql-stack:** -- Single claim deploys both operators with production defaults -- Deletion ordering enforced via Usage resources — Atlas is always removed before StackGres -- Consistent configuration across clusters with customizable Helm values -- Crossplane manages lifecycle, drift detection, and rollback +- **Paired StorageClass + VolumeSnapshotClass, both named `psql`.** Snapshots only work when the snapshotter driver matches the underlying StorageClass provisioner, so the stack composes both with the same CSI driver value. PSQLClusters and PSQLBranches default `spec.storage.class: psql` (XRD default), so consumer manifests don't have to know the driver. Default driver/provisioner is `ebs.csi.eks.amazonaws.com` (EKS Auto Mode); override `storageClass.provisioner` + `snapshotClass.driver` together for non-EKS targets (kind, self-managed Longhorn, etc.). +- **No NodePool / node-prep.** Components run wherever the cluster's scheduler puts them. Auto Mode handles node provisioning end-to-end. -## What Gets Deployed +If you need replicated CoW storage (true block-level branches with delta-only economics), that's a separate concern — see `aws-storage-stack` for self-managed nodes that can host Longhorn or similar. The default psql-stack stays on the AWS-blessed Auto Mode path. -- **StackGres Operator** — Full PostgreSQL lifecycle management with native Citus support for distributed PostgreSQL via `SGShardedCluster` CRDs -- **Atlas Operator** — Declarative database schema migrations via `AtlasMigration` and `AtlasSchema` CRDs -- **Usage resource** — Ensures Atlas is deleted before StackGres to prevent orphaned migration state +## Components -## The Journey +| Component | Default | Purpose | +|---|---|---| +| **CNPG operator** | always-on | The CNCF Postgres operator. CRDs include `Cluster`, `Backup`, `Pooler`, `ScheduledBackup`. | +| **cnpg-i-scale-to-zero plugin** | on (`spec.scaleToZeroPlugin.enabled: true`) | Auto-hibernates idle CNPG `Cluster`s. **Requires cert-manager** (provided by [`aws-cert-stack`](../../aws/cert/)). | +| **Atlas operator** | always-on | Declarative schema migrations via `AtlasMigration` / `AtlasSchema` CRDs. | +| **StorageClass** | on (`spec.storageClass.enabled: true`) | Named `psql` by default. Provisioner: `ebs.csi.eks.amazonaws.com` with `type: gp3`. PSQLCluster + PSQLBranch reference it as their default `spec.storage.class`. | +| **VolumeSnapshotClass** | on (`spec.snapshotClass.enabled: true`) | Named `psql` by default. Driver matches `storageClass.provisioner`. PSQLBranch references it for snapshot/fork. | +| **HA mode** | off (`spec.ha.enabled: false`) | When enabled: 3 replicas + `topologySpreadConstraints` by zone on CNPG, Atlas, S2Z plugin. | -### Stage 1: Getting Started +## Prerequisites -Deploy the stack on a single cluster with defaults. StackGres gets the REST API enabled, Atlas gets dev DB prewarming, and everything lands in the `stackgres` namespace. +- **A working CSI driver** on the cluster matching `storageClass.provisioner`. EKS Auto Mode provides `ebs.csi.eks.amazonaws.com` automatically. For other targets, override `storageClass.provisioner` (and the matching `snapshotClass.driver`) to whatever the target cluster has — e.g. `hostpath.csi.k8s.io` for kind, `driver.longhorn.io` for self-managed Longhorn. +- **VolumeSnapshot CRDs + snapshot-controller** (`snapshot.storage.k8s.io`). EKS Auto Mode ships the snapshot CRDs but **not** the cluster-wide snapshot-controller. Without one, the composed VolumeSnapshotClass is inert and PSQLBranch snapshots will never reach `ReadyToUse`. Install [`volume-snapshot-stack`](../volume-snapshot/) (also in this org) — it composes the upstream snapshot-controller via the piraeus-charts Helm chart and is the canonical CRD installer for the cluster. +- **cert-manager** (only when `scaleToZeroPlugin.enabled` — the plugin uses cert-manager Issuer+Certificate for its gRPC TLS). Provided by [`aws-cert-stack`](../../aws/cert/). + +## Stages + +### Stage 1: Default install + +Deploy with all defaults. CNPG + Atlas + S2Z + a `psql` VolumeSnapshotClass for EBS. ```yaml apiVersion: hops.ops.com.ai/v1alpha1 @@ -38,9 +44,9 @@ spec: clusterName: my-cluster ``` -### Stage 2: Team Usage +### Stage 2: Production posture -Add labels for ownership tracking and customize Helm values per operator. +HA on; per-component value tweaks; team labels for cost allocation. ```yaml apiVersion: hops.ops.com.ai/v1alpha1 @@ -50,21 +56,21 @@ metadata: namespace: default spec: clusterName: production-cluster - namespace: stackgres + namespace: cnpg-system labels: team: platform - stackgresOperator: - values: - deploy: - restapi: true + ha: + enabled: true + replicas: 3 + topologySpreadByZone: true atlasOperator: values: prewarmDevDB: true ``` -### Stage 3: Multi-Cluster / Advanced +### Stage 3: Non-EKS cluster -Override namespaces per component, use a `ClusterProviderConfig`, or fully replace chart defaults. +Override the SC + VSC driver together (they must match for snapshots to work). Example: self-managed cluster running Longhorn. ```yaml apiVersion: hops.ops.com.ai/v1alpha1 @@ -73,24 +79,20 @@ metadata: name: psql namespace: default spec: - clusterName: production-cluster + clusterName: edge helmProviderConfigRef: - name: production-cluster - kind: ClusterProviderConfig - stackgresOperator: - namespace: stackgres-system - atlasOperator: - namespace: atlas-system - overrideAllValues: - prewarmDevDB: false - extraEnvs: - - name: ATLAS_NO_UPDATE_NOTIFIER - value: "true" + name: default + storageClass: + provisioner: driver.longhorn.io + parameters: + numberOfReplicas: "3" + snapshotClass: + driver: driver.longhorn.io ``` -### Local Development +### Stage 4: Opt out of the composed StorageClass -For local clusters (e.g. kind, k3d), point at the default Helm provider: +If the cluster already ships a suitable default StorageClass, disable composition and have PSQLCluster/PSQLBranch consumers set `spec.storage.class` explicitly. ```yaml apiVersion: hops.ops.com.ai/v1alpha1 @@ -99,82 +101,105 @@ metadata: name: psql namespace: default spec: - clusterName: local + clusterName: shared-cluster helmProviderConfigRef: name: default + storageClass: + enabled: false ``` -## Spec Reference +### Stage 5: Local / no-snapshot cluster -| Field | Type | Required | Default | Description | -|-------|------|----------|---------|-------------| -| `clusterName` | string | Yes | — | Target cluster name. Used as default for `helmProviderConfigRef.name` | -| `namespace` | string | No | `stackgres` | Shared namespace for both operators | -| `labels` | object | No | — | Custom labels merged with defaults | -| `managementPolicies` | string[] | No | `["*"]` | Crossplane management policies | -| `helmProviderConfigRef.name` | string | No | `clusterName` | Helm ProviderConfig name | -| `helmProviderConfigRef.kind` | enum | No | `ProviderConfig` | `ProviderConfig` or `ClusterProviderConfig` | -| `stackgresOperator.name` | string | No | `stackgres-operator` | Helm release name | -| `stackgresOperator.namespace` | string | No | shared `namespace` | Namespace override | -| `stackgresOperator.values` | object | No | — | Helm values merged with chart defaults | -| `stackgresOperator.overrideAllValues` | object | No | — | Helm values replacing all defaults | -| `atlasOperator.name` | string | No | `atlas-operator` | Helm release name | -| `atlasOperator.namespace` | string | No | shared `namespace` | Namespace override | -| `atlasOperator.values` | object | No | — | Helm values merged with chart defaults | -| `atlasOperator.overrideAllValues` | object | No | — | Helm values replacing all defaults | - -### Helm Values Merging - -Each operator supports two modes: - -- **`values`** — Merged with chart defaults. Use this to tweak individual settings. -- **`overrideAllValues`** — Replaces all defaults entirely. Use this when you need full control. - -If both are set, `overrideAllValues` wins. - -**Chart defaults for StackGres:** -```yaml -deploy: - operator: true - restapi: true -``` +For dev clusters without a snapshot-controller, disable the VSC composition. PSQLBranch won't function (it needs the VSC), but PSQLCluster still works. -**Chart defaults for Atlas:** ```yaml -prewarmDevDB: true +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLStack +metadata: + name: psql + namespace: default +spec: + clusterName: local + helmProviderConfigRef: + name: default + snapshotClass: + enabled: false + scaleToZeroPlugin: + enabled: false ``` -## Status +## Spec Reference -| Field | Type | Description | -|-------|------|-------------| -| `status.ready` | boolean | `true` when both operators are healthy | +| Field | Type | Default | Description | +|---|---|---|---| +| `clusterName` | string | _required_ | Target cluster name; default for `helmProviderConfigRef.name`, `kubernetesProviderConfigRef.name`, and label values | +| `namespace` | string | `cnpg-system` | Shared namespace for CNPG, S2Z plugin, and Atlas | +| `labels` | object | — | Custom labels merged with stack defaults | +| `managementPolicies` | string[] | `["*"]` | Crossplane management policies | +| `helmProviderConfigRef.name` | string | `clusterName` | Helm ProviderConfig name | +| `helmProviderConfigRef.kind` | enum | `ProviderConfig` | `ProviderConfig` or `ClusterProviderConfig` | +| `kubernetesProviderConfigRef.name` | string | `clusterName` | Kubernetes ProviderConfig name | +| `kubernetesProviderConfigRef.kind` | enum | `ProviderConfig` | Same as above | +| **HA mode** | | | | +| `ha.enabled` | bool | `false` | Stack-wide HA toggle | +| `ha.replicas` | int | `3` | Replica count for HA-able platform components | +| `ha.topologySpreadByZone` | bool | `true` | Add `topologySpreadConstraint` with `topologyKey=topology.kubernetes.io/zone`, `maxSkew=1`, `whenUnsatisfiable=ScheduleAnyway` | +| **CNPG operator** | | | | +| `cnpg.name` | string | `cloudnative-pg` | Helm release name | +| `cnpg.chartVersion` | string | `0.27.1` | CNPG Helm chart version (tracks CNPG 1.29.x) | +| `cnpg.values` | object | — | Helm values merged with chart defaults | +| `cnpg.overrideAllValues` | object | — | Helm values that replace all defaults | +| **Scale-to-zero plugin** | | | | +| `scaleToZeroPlugin.enabled` | bool | `true` | Install the plugin (zero-cost when no `Cluster` opts in) | +| `scaleToZeroPlugin.version` | string | `v0.1.7` | Plugin release tag | +| `scaleToZeroPlugin.namespace` | string | shared `namespace` | Override | +| **Atlas operator** | | | | +| `atlasOperator.name` | string | `atlas-operator` | Helm release name | +| `atlasOperator.namespace` | string | shared `namespace` | Override | +| `atlasOperator.values` | object | — | Helm values merged with chart defaults | +| `atlasOperator.overrideAllValues` | object | — | Helm values that replace all defaults | +| **Storage class** | | | | +| `storageClass.enabled` | bool | `true` | Compose the StorageClass | +| `storageClass.name` | string | `psql` | StorageClass name (PSQLCluster + PSQLBranch reference this) | +| `storageClass.provisioner` | string | `ebs.csi.eks.amazonaws.com` | CSI driver name. Must match `snapshotClass.driver` | +| `storageClass.reclaimPolicy` | enum | `Delete` | `Delete` or `Retain` | +| `storageClass.volumeBindingMode` | enum | `WaitForFirstConsumer` | `Immediate` or `WaitForFirstConsumer` | +| `storageClass.allowVolumeExpansion` | bool | `true` | Online PVC expansion (CNPG resizes via the same field on its `Cluster` CR) | +| `storageClass.parameters` | object | `{type: gp3}` | Provisioner-specific parameters | +| **Snapshot class** | | | | +| `snapshotClass.enabled` | bool | `true` | Compose the VolumeSnapshotClass | +| `snapshotClass.name` | string | `psql` | VolumeSnapshotClass name (PSQLBranch references this) | +| `snapshotClass.driver` | string | `ebs.csi.eks.amazonaws.com` | CSI driver. Must match `storageClass.provisioner` | +| `snapshotClass.deletionPolicy` | enum | `Delete` | `Delete` or `Retain` | +| `snapshotClass.parameters` | object | — | Driver-specific parameters | ## Composed Resources -| Resource | Kind | Purpose | -|----------|------|---------| -| `stackgres-operator` | `helm.m.crossplane.io/Release` | StackGres Helm release | -| `atlas-operator` | `helm.m.crossplane.io/Release` | Atlas Operator Helm release | -| `usage-sg-atlas` | `protection.crossplane.io/Usage` | Deletion ordering (created once both operators are ready) | +| Resource | Kind | When | +|---|---|---| +| `cloudnative-pg` | `helm.m.crossplane.io/Release` | always | +| `atlas-operator` | `helm.m.crossplane.io/Release` | always | +| 9× `-s2z-*` | `kubernetes.m.crossplane.io/Object` | `scaleToZeroPlugin.enabled: true` | +| `-storageclass` | `kubernetes.m.crossplane.io/Object` | `storageClass.enabled: true` | +| `-volumesnapshotclass` | `kubernetes.m.crossplane.io/Object` | `snapshotClass.enabled: true` | +| Various `Usage` | `protection.crossplane.io/Usage` | when both ends Ready (deletion ordering) | ## Dependencies | Kind | Package | Version | -|------|---------|---------| -| Function | `crossplane-contrib/function-auto-ready` | `>=v0.6.0` | +|---|---|---| +| Function | `crossplane-contrib/function-auto-ready` | `>=v0.6.2` | | Provider | `crossplane-contrib/provider-helm` | `>=v1` | +| Provider | `crossplane-contrib/provider-kubernetes` | `>=v1` | ## Development ```bash -make render # Render all examples -make validate # Validate against Crossplane schemas -make test # Run unit tests (KCL) -make e2e # Run E2E tests (requires cluster) -make build # Build the package -make render:minimal # Render a single example -make validate:standard +make render # Render all examples +make validate # Validate against Crossplane schemas +make test # KCL render tests (assert composed resource shapes) +make build # Build the package +make render:standard # Render a single example ``` ## License diff --git a/apis/psqlbranches/composition.yaml b/apis/psqlbranches/composition.yaml new file mode 100644 index 0000000..846a4b8 --- /dev/null +++ b/apis/psqlbranches/composition.yaml @@ -0,0 +1,16 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: psqlbranches.hops.ops.com.ai +spec: + compositeTypeRef: + apiVersion: hops.ops.com.ai/v1alpha1 + kind: PSQLBranch + mode: Pipeline + pipeline: + - functionRef: + name: hops-ops-psql-stackbranch + step: branch + - functionRef: + name: crossplane-contrib-function-auto-ready + step: crossplane-contrib-function-auto-ready diff --git a/apis/psqlbranches/definition.yaml b/apis/psqlbranches/definition.yaml new file mode 100644 index 0000000..9420ff4 --- /dev/null +++ b/apis/psqlbranches/definition.yaml @@ -0,0 +1,219 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: psqlbranches.hops.ops.com.ai +spec: + group: hops.ops.com.ai + names: + kind: PSQLBranch + plural: psqlbranches + scope: Namespaced + versions: + - name: v1alpha1 + referenceable: true + served: true + schema: + openAPIV3Schema: + description: | + PSQLBranch is an ephemeral CoW fork of a PSQLCluster, designed for + preview environments (one branch per PR) and short-lived data + branches. Composes a CSI VolumeSnapshot of the source's PVC plus a + 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. + + 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`. + type: object + properties: + spec: + description: PSQLBranchSpec defines the desired state. + type: object + properties: + clusterName: + description: Name of the target cluster. Used as default for kubernetesProviderConfigRef.name and label values. + type: string + labels: + description: Custom labels merged with stack defaults; applied to every composed resource. + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true + managementPolicies: + description: Crossplane managementPolicies. Defaults to ["*"]. + type: array + items: + type: string + default: + - "*" + kubernetesProviderConfigRef: + description: Reference to the Kubernetes ProviderConfig used to apply composed resources. Defaults to clusterName. + type: object + properties: + name: + type: string + kind: + type: string + enum: + - ProviderConfig + - ClusterProviderConfig + + source: + 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. + type: object + properties: + name: + description: Source PSQLCluster name (required). + type: string + namespace: + description: Source PSQLCluster namespace. Empty = same as branch. + type: string + default: "" + pvcName: + description: | + Source PVC name to snapshot. Empty = derive from CNPG + naming convention (`-1` for the primary + instance). + type: string + default: "" + snapshotClassName: + description: VolumeSnapshotClass to use for the snapshot. Defaults to "psql" (composed by psql-stack). + type: string + default: psql + storage: + description: | + Source PVC sizing. The branch composition has no + automatic visibility into the source PSQLCluster's + storage shape, so when `branch.storage.size` is empty + the composition falls back to the size declared here. + Set this to the source's `spec.storage.size` so the + branch PVC is at least as large as the source — CNPG + can't restore a snapshot into a smaller PVC. + type: object + properties: + size: + description: Source PVC size, e.g. "100Gi". Used as the branch size when `branch.storage.size` is empty. + type: string + default: "" + required: + - name + + branch: + description: Branch cluster sizing. + type: object + properties: + instances: + description: Branch instance count. Defaults to 1 (HA on branches is unusual). + type: integer + default: 1 + storage: + description: Branch PVC sizing. `size` must be ≥ the source PVC size for snapshot recovery to succeed (CNPG/EBS can't shrink during recovery). + type: object + properties: + size: + description: | + Branch PVC size, e.g. "10Gi". Empty = fall back to + `source.storage.size`. If both are empty the + composition omits storage.size on the Cluster CR + and CNPG's webhook will reject — set one or both + to a value at least as large as the source PVC. + type: string + default: "" + class: + description: Branch StorageClass. Defaults to "psql" — the StorageClass composed by PSQLStack. Override only when forking onto a different driver than the source. + type: string + default: psql + + 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. + 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. + type: string + + scaleToZero: + description: | + Auto-hibernate the branch after `idleTimeout` of no client + activity. Default-on with aggressive timeout — preview-PR + branches should hibernate fast to keep cost flat. + type: object + properties: + enabled: + type: boolean + default: true + idleTimeout: + description: Idle duration before hibernate. Defaults to "10m" (more aggressive than PSQLCluster's 30m). + type: string + default: 10m + + ttl: + description: | + Recorded intent for branch expiration. v0 does NOT enforce + the TTL automatically — pair with an external cleanup + mechanism (Kyverno cleanupPolicies, ArgoCD ApplicationSet + with pullRequest generator that deletes on PR close, etc.). + Status reports `expiresAt` (creation + after) for tooling. + type: object + properties: + enabled: + type: boolean + default: false + after: + description: Duration after which the branch should be deleted. Defaults to "168h" (7 days). + type: string + default: 168h + + cnpg: + description: Direct passthrough into the composed Cluster CR's spec. Use for novel CNPG features not covered by the toggles above. + type: object + properties: + values: + description: Merged into the Cluster.spec. + type: object + x-kubernetes-preserve-unknown-fields: true + overrideAllValues: + description: Replaces the entire Cluster.spec. + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - clusterName + - source + status: + description: PSQLBranchStatus defines the observed state. + type: object + properties: + ready: + type: boolean + bootstrapPhase: + description: Current branch CNPG Cluster `.status.phase`. + type: string + bootstrapMethod: + description: Bootstrap method actually used. v0 always "volumeSnapshot". + type: string + sourceSnapshotContent: + description: Name of the cluster-scoped VolumeSnapshotContent backing this branch (for cross-ns bridging visibility). + type: string + expiresAt: + description: Computed deletion deadline when ttl.enabled is true. + type: string + required: + - spec diff --git a/apis/psqlclusters/composition.yaml b/apis/psqlclusters/composition.yaml new file mode 100644 index 0000000..d122120 --- /dev/null +++ b/apis/psqlclusters/composition.yaml @@ -0,0 +1,16 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: psqlclusters.hops.ops.com.ai +spec: + compositeTypeRef: + apiVersion: hops.ops.com.ai/v1alpha1 + kind: PSQLCluster + mode: Pipeline + pipeline: + - functionRef: + name: hops-ops-psql-stackcluster + step: cluster + - functionRef: + name: crossplane-contrib-function-auto-ready + step: crossplane-contrib-function-auto-ready diff --git a/apis/psqlclusters/definition.yaml b/apis/psqlclusters/definition.yaml new file mode 100644 index 0000000..8dbdcc0 --- /dev/null +++ b/apis/psqlclusters/definition.yaml @@ -0,0 +1,326 @@ +apiVersion: apiextensions.crossplane.io/v2 +kind: CompositeResourceDefinition +metadata: + name: psqlclusters.hops.ops.com.ai +spec: + group: hops.ops.com.ai + names: + kind: PSQLCluster + plural: psqlclusters + scope: Namespaced + versions: + - name: v1alpha1 + referenceable: true + served: true + schema: + openAPIV3Schema: + description: | + PSQLCluster is the per-app Postgres primitive. Composes a CloudNativePG + `Cluster` plus the wiring (annotations, plugin entries, labels) required + for the platform integrations (scale-to-zero, branching, monitoring, HA). + + Companion to `psql-stack` (which provides the CNPG operator + S2Z plugin + substrate; required as a target-cluster prerequisite) and to `psql-branch` + (which composes ephemeral CoW forks of a PSQLCluster via VolumeSnapshot). + + Intent-first surface: integration toggles (`scaleToZero.enabled`, + `branching.enabled`, `monitoring.enabled`, `ha.enabled`) translate to + CNPG mechanics inside the render template — consumer manifests don't + reach for `cnpg.overrideAllValues` unless their use case is genuinely + novel. + type: object + properties: + spec: + description: PSQLClusterSpec defines the desired state. + type: object + properties: + clusterName: + description: Name of the target cluster. Used as default for kubernetesProviderConfigRef.name and label values. + type: string + labels: + description: Custom labels merged with stack defaults; applied to every composed resource. + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true + managementPolicies: + description: Crossplane managementPolicies applied to every composed resource. Defaults to ["*"]. + type: array + items: + type: string + default: + - "*" + kubernetesProviderConfigRef: + description: Reference to the Kubernetes ProviderConfig used to apply the Cluster CR + ExternalSecret. Defaults to clusterName. + type: object + properties: + name: + type: string + kind: + type: string + enum: + - ProviderConfig + - ClusterProviderConfig + + # ============================================================ + # Sizing + # ============================================================ + instances: + description: Number of Postgres instances. Default 1 (single-node). HA mode bumps to ha.replicas. + type: integer + default: 1 + storage: + description: Postgres data PVC sizing. Online expansion is supported by CNPG when allowVolumeExpansion is true on the StorageClass (the stack's `psql` SC sets this) — bumping `size` upward grows the volume in place with no downtime. + type: object + properties: + size: + description: PVC size, e.g. "10Gi". Required. + type: string + class: + description: StorageClass name. Defaults to "psql" — the StorageClass composed by PSQLStack. Override only to opt out of the stack's paired SC + VSC (e.g. for a different driver on the same cluster). + type: string + default: psql + required: + - size + + # ============================================================ + # Postgres + # ============================================================ + postgresql: + description: Postgres version + tuning parameters. + type: object + properties: + version: + description: Postgres major version. Defaults to "17". + type: string + default: "17" + parameters: + description: postgres.conf parameters (e.g. `shared_buffers`, `max_connections`). + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true + + # ============================================================ + # Application user — the role your app connects as. + # + # Three modes, in order of typical use: + # 1. Omit `app` (or set only role/database) — CNPG generates a + # basic-auth Secret named `-app` and owns + # its lifecycle. Zero-config; use for ephemeral or kind-only + # setups. + # 2. Set `app.externalSecret` — composition renders an ESO + # ExternalSecret pulling from the named (Cluster)SecretStore + # and writes the K8s Secret CNPG reads. Common production + # shape on platforms with External Secrets Operator wired up. + # 3. Set `app.secretName` (without `externalSecret`) — BYO; you + # pre-create the named K8s Secret with `username`+`password` + # keys before the cluster bootstraps. + # ============================================================ + app: + description: Application user — the Postgres role your app connects as. CNPG uses these credentials for `bootstrap.initdb`. + type: object + properties: + role: + description: Postgres role name owning the application database. Defaults to "app". + type: string + default: app + database: + description: Application database name. Defaults to "app". + type: string + default: app + secretName: + description: K8s Secret holding the app role's credentials. Setting this opts into BYO mode — pre-create the Secret with `username`+`password` keys. Leave unset (and omit `externalSecret`) to let CNPG auto-generate `-app`. + type: string + default: "" + externalSecret: + description: Source the credentials via External Secrets Operator. Omit to either BYO the K8s Secret named `secretName`, or — when `secretName` is also unset — let CNPG auto-generate. + type: object + properties: + secretStore: + description: Reference to the SecretStore or ClusterSecretStore providing the value. + type: object + properties: + kind: + description: Store kind. Use ClusterSecretStore for cluster-scoped stores (the platform default), SecretStore for namespaced stores in this XR's namespace. + type: string + enum: + - ClusterSecretStore + - SecretStore + default: ClusterSecretStore + name: + description: Name of the SecretStore or ClusterSecretStore. + type: string + namespace: + description: Namespace of the SecretStore (ignored for ClusterSecretStore). Defaults to the PSQLCluster's namespace. + type: string + required: + - name + secretRef: + description: Where in the secrets backend the credentials live. The remote value must be a JSON blob with `username` and `password` properties — both are extracted into the resulting K8s Secret. + type: object + properties: + path: + description: Path/key in the secrets backend (e.g. AWS Secrets Manager `my-cluster/app`). + type: string + required: + - path + required: + - secretStore + - secretRef + + # ============================================================ + # Postgres superuser — optional. Omit the block to let CNPG + # auto-generate the superuser secret at `-superuser`. + # Set when tooling needs the superuser password sourced from a + # specific secrets backend (e.g. schema migration platforms). + # ============================================================ + superuser: + description: Optional postgres superuser credentials. Omit the block to let CNPG auto-generate the superuser secret. + type: object + properties: + secretName: + description: K8s Secret holding the superuser credentials. Defaults to "-superuser". + type: string + default: "" + externalSecret: + description: Source the superuser credentials via External Secrets Operator. Omit to BYO the K8s Secret named `secretName`. + type: object + properties: + secretStore: + description: Reference to the SecretStore or ClusterSecretStore providing the value. + type: object + properties: + kind: + description: Store kind. Use ClusterSecretStore for cluster-scoped stores (the platform default), SecretStore for namespaced stores in this XR's namespace. + type: string + enum: + - ClusterSecretStore + - SecretStore + default: ClusterSecretStore + name: + description: Name of the SecretStore or ClusterSecretStore. + type: string + namespace: + description: Namespace of the SecretStore (ignored for ClusterSecretStore). Defaults to the PSQLCluster's namespace. + type: string + required: + - name + secretRef: + description: Where in the secrets backend the credentials live. The remote value must be a JSON blob with `username` and `password` properties — both are extracted into the resulting K8s Secret. + type: object + properties: + path: + description: Path/key in the secrets backend (e.g. AWS Secrets Manager `my-cluster/superuser`). + type: string + required: + - path + required: + - secretStore + - secretRef + + # ============================================================ + # Cross-stack integration toggles + # ============================================================ + scaleToZero: + description: Auto-hibernate the cluster after `idleTimeout` of no client activity. Requires the cnpg-i-scale-to-zero plugin on the target cluster (provided by psql-stack). When `ha.enabled` is also true, the cluster will not actually scale to zero (HA implies always-available); a Warning condition is emitted on the XR but the annotation is rendered for tooling that reads it. + type: object + properties: + enabled: + type: boolean + default: false + idleTimeout: + description: How long the cluster must be idle before hibernating. Defaults to "30m". + type: string + default: 30m + + branching: + description: Mark this cluster as a fork-source. PSQLBranch finds branchable clusters by reading the labels emitted here. Default-on (cheap when unused — just metadata). + type: object + properties: + enabled: + type: boolean + default: true + snapshotClassName: + description: VolumeSnapshotClass name composed by psql-stack (default "psql"). PSQLBranch references this name to fork the cluster's PVC. + type: string + default: psql + + monitoring: + description: Add Prometheus scrape configuration. CNPG operator handles the actual PodMonitor creation when `monitoring.enablePodMonitor` is set on the Cluster CR. + type: object + properties: + enabled: + type: boolean + default: true + + # ============================================================ + # HA + # ============================================================ + ha: + description: Stack-wide HA. Bumps instances to `replicas` (default 3) and adds zonal topology spread. + type: object + properties: + enabled: + type: boolean + default: false + replicas: + type: integer + default: 3 + topologySpreadByZone: + type: boolean + default: true + + # ============================================================ + # Escape hatches + # ============================================================ + cnpg: + description: Direct passthrough into the composed Cluster CR's spec. Use for novel CNPG features not yet covered by the intent-first surface above. + type: object + properties: + values: + description: Merged into the Cluster.spec — adds to / overrides individual fields rendered by the template. + type: object + x-kubernetes-preserve-unknown-fields: true + overrideAllValues: + description: Replaces the entire Cluster.spec — total escape hatch. + type: object + x-kubernetes-preserve-unknown-fields: true + required: + - clusterName + - storage + status: + description: PSQLClusterStatus defines the observed state. + type: object + properties: + ready: + description: Overall readiness — true once the CNPG Cluster reaches `Cluster in healthy state` AND the ExternalSecret (if composed) is SecretSynced. + type: boolean + clusterPhase: + description: Current CNPG Cluster `.status.phase` (e.g. "Cluster in healthy state"). + type: string + app: + description: Connection details for the application user. Other XRs can reference this to wire connection strings without hardcoding. + type: object + properties: + secretName: + description: K8s Secret holding the app role's credentials. + type: string + database: + description: Application database name. + type: string + host: + description: Service hostname for read-write connections (CNPG `-rw` service). + type: string + port: + description: Postgres port. + type: integer + superuser: + description: Connection details for the postgres superuser. + type: object + properties: + secretName: + description: K8s Secret holding the superuser credentials (CNPG-managed if `spec.superuser` was not set). + type: string + required: + - spec diff --git a/apis/psqlstacks/composition.yaml b/apis/psqlstacks/composition.yaml index fd35e11..ce963cc 100644 --- a/apis/psqlstacks/composition.yaml +++ b/apis/psqlstacks/composition.yaml @@ -9,8 +9,8 @@ spec: mode: Pipeline pipeline: - functionRef: - name: hops-ops-psql-stackrender - step: render + name: hops-ops-psql-stackstack + step: stack - functionRef: name: crossplane-contrib-function-auto-ready step: crossplane-contrib-function-auto-ready diff --git a/apis/psqlstacks/definition.yaml b/apis/psqlstacks/definition.yaml index baac1fe..5799467 100644 --- a/apis/psqlstacks/definition.yaml +++ b/apis/psqlstacks/definition.yaml @@ -14,7 +14,21 @@ spec: served: true schema: openAPIV3Schema: - description: PSQLStack deploys the StackGres operator (with Citus support) and Atlas Operator for PostgreSQL database management and schema migrations. + description: | + PSQLStack is the platform layer for Postgres-on-Kubernetes. It composes + CloudNativePG (the operator), the cnpg-i-scale-to-zero plugin, the Atlas + Operator (declarative schema migrations), a `psql` StorageClass that + PSQLClusters and PSQLBranches default to, and a `psql` VolumeSnapshotClass + that PSQLBranch uses as a stable target for forking primaries. + + The StorageClass + VolumeSnapshotClass are paired by design: snapshots + can only be taken of PVCs whose CSI driver matches the snapshot driver, + so the stack composes both with the same driver and exposes them under + the shared name `psql`. Default driver is `ebs.csi.eks.amazonaws.com` + (EKS Auto Mode); override `storageClass.provisioner` + `snapshotClass.driver` + together for non-EKS targets (e.g. `hostpath.csi.k8s.io` for kind tests, + `driver.longhorn.io` for self-managed). Per-app serving clusters live in + PSQLCluster; ephemeral forks in PSQLBranch. type: object properties: spec: @@ -22,57 +36,103 @@ spec: type: object properties: clusterName: - description: Name of the target cluster. Used as default for helmProviderConfigRef.name and resource naming. + description: Name of the target cluster. Used as the default for helmProviderConfigRef.name, kubernetesProviderConfigRef.name, and label values. type: string + namespace: + description: Shared namespace for the CNPG operator, scale-to-zero plugin, and Atlas operator. Per-component namespace overrides take precedence. Defaults to cnpg-system. + type: string + labels: + description: Custom labels merged with the stack's default labels and applied to every composed resource. + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true managementPolicies: - description: Management policies for all composed resources. Defaults to ["*"]. + description: Crossplane managementPolicies applied to every composed resource. Defaults to ["*"]. type: array items: type: string default: - "*" - labels: - description: Custom labels applied to all managed resources. - type: object - additionalProperties: - type: string - x-kubernetes-preserve-unknown-fields: true helmProviderConfigRef: - description: Reference to the Helm ProviderConfig. Defaults to clusterName. + description: Reference to the Helm ProviderConfig used for CNPG and Atlas releases. Defaults to clusterName. type: object properties: name: - description: Name of the Helm ProviderConfig. type: string kind: - description: Kind of the ProviderConfig. Defaults to ProviderConfig. type: string enum: - ProviderConfig - ClusterProviderConfig - namespace: - description: Shared namespace for all components. Defaults to stackgres. Per-component namespace overrides this. - type: string - stackgresOperator: - description: Configuration for the StackGres operator component. + kubernetesProviderConfigRef: + description: Reference to the Kubernetes ProviderConfig used for the scale-to-zero plugin manifests and the VolumeSnapshotClass. Defaults to clusterName. type: object properties: name: - description: Release name. Defaults to stackgres-operator. + type: string + kind: + type: string + enum: + - ProviderConfig + - ClusterProviderConfig + ha: + description: | + Stack-wide HA mode. When enabled, sets replicaCount + topology spread + by zone on every HA-able platform component (CNPG operator, Atlas + operator, scale-to-zero plugin Deployment). Per-component values + blocks can still override. + type: object + properties: + enabled: + type: boolean + default: false + replicas: + type: integer + default: 3 + topologySpreadByZone: + type: boolean + default: true + cnpg: + description: CloudNativePG operator Helm release configuration. + type: object + properties: + name: + description: Helm release name. Defaults to cloudnative-pg. type: string namespace: description: Namespace override for this component. type: string + chartVersion: + description: CNPG Helm chart version. Defaults to 0.27.1 (tracks CNPG 1.29.x). + type: string values: - description: Helm values merged with chart defaults. + description: Helm values merged with the stack's chart defaults. type: object x-kubernetes-preserve-unknown-fields: true overrideAllValues: description: Helm values that replace all defaults for this component. type: object x-kubernetes-preserve-unknown-fields: true + scaleToZeroPlugin: + description: | + cnpg-i-scale-to-zero plugin (github.com/xataio/cnpg-i-scale-to-zero). + Installed via the upstream release manifest. Zero cost when no + PSQLCluster opts into scale-to-zero. Requires cert-manager on the + target cluster (provided by aws-cert-stack). + type: object + properties: + enabled: + type: boolean + default: true + version: + description: Plugin release tag. Defaults to v0.1.7. + type: string + namespace: + description: Namespace override. Defaults to the shared namespace. + type: string atlasOperator: - description: Configuration for the Atlas Operator component (database schema migrations). + description: Atlas Operator Helm release configuration. Manages declarative database schema migrations. type: object properties: name: @@ -82,13 +142,101 @@ spec: description: Namespace override for this component. type: string values: - description: Helm values merged with chart defaults. + description: Helm values merged with the stack's chart defaults. type: object x-kubernetes-preserve-unknown-fields: true overrideAllValues: description: Helm values that replace all defaults for this component. type: object x-kubernetes-preserve-unknown-fields: true + storageClass: + description: | + StorageClass that PSQLCluster and PSQLBranch default their PVCs to. + Pairs with `snapshotClass` — VolumeSnapshot only works when the + snapshotter driver matches the StorageClass provisioner, so the + stack composes both with the same CSI driver under the shared + name "psql". Default provisioner is `ebs.csi.eks.amazonaws.com` + with `type: gp3` parameters (EKS Auto Mode's managed driver). + For non-EKS targets, override `provisioner` + `parameters` to + match (e.g. `hostpath.csi.k8s.io` with empty parameters for kind, + `driver.longhorn.io` for self-managed Longhorn). + type: object + properties: + enabled: + description: Compose the StorageClass. Defaults to true. + type: boolean + default: true + name: + description: StorageClass name. Defaults to "psql" — PSQLCluster + PSQLBranch reference this name. + type: string + default: psql + provisioner: + description: CSI driver name. Defaults to ebs.csi.eks.amazonaws.com (EKS Auto Mode). Must match snapshotClass.driver for VolumeSnapshot to work. + type: string + default: ebs.csi.eks.amazonaws.com + reclaimPolicy: + description: Reclaim policy. Defaults to Delete. + type: string + enum: + - Delete + - Retain + default: Delete + volumeBindingMode: + description: Volume binding mode. Defaults to WaitForFirstConsumer (matches EKS Auto Mode's gp3 default and is correct for zonal CSI drivers). + type: string + enum: + - Immediate + - WaitForFirstConsumer + default: WaitForFirstConsumer + allowVolumeExpansion: + description: Allow online PVC expansion. Defaults to true (CNPG resizes via the same field on the Cluster CR). + type: boolean + default: true + parameters: + description: | + Provisioner-specific parameters. Defaults to `{type: gp3}` for + the EBS provisioner. When overriding `provisioner`, set this + to whatever the new provisioner expects (e.g. `{}` for + hostpath.csi.k8s.io, driver-specific keys for Longhorn). + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true + snapshotClass: + description: | + VolumeSnapshotClass that PSQLBranch references when forking a + PSQLCluster's PVC. Pairs with `storageClass`: the driver must + match the StorageClass provisioner. Default driver is + `ebs.csi.eks.amazonaws.com` (EKS Auto Mode); override for + other CSI providers (`hostpath.csi.k8s.io` on kind, + `driver.longhorn.io` for self-managed Longhorn, etc.). + type: object + properties: + enabled: + description: Compose the VolumeSnapshotClass. Defaults to true. + type: boolean + default: true + name: + description: VolumeSnapshotClass name. Defaults to "psql" — PSQLBranch references this name. + type: string + default: psql + driver: + description: CSI driver. Defaults to ebs.csi.eks.amazonaws.com. Must match storageClass.provisioner. + type: string + default: ebs.csi.eks.amazonaws.com + deletionPolicy: + description: Snapshot deletion policy. Defaults to Delete. + type: string + enum: + - Delete + - Retain + default: Delete + parameters: + description: Driver-specific parameters passed to the VolumeSnapshotClass. + type: object + additionalProperties: + type: string + x-kubernetes-preserve-unknown-fields: true required: - clusterName status: @@ -96,7 +244,7 @@ spec: type: object properties: ready: - description: Overall readiness of the PostgreSQL stack. + description: Overall readiness — true once every enabled component is Ready (CNPG, Atlas, the scale-to-zero plugin if `scaleToZeroPlugin.enabled`, the StorageClass if `storageClass.enabled`, and the VolumeSnapshotClass if `snapshotClass.enabled`). Disabled components are treated as Ready. type: boolean required: - spec diff --git a/examples/psqlbranches/cross-namespace.yaml b/examples/psqlbranches/cross-namespace.yaml new file mode 100644 index 0000000..bf1e903 --- /dev/null +++ b/examples/psqlbranches/cross-namespace.yaml @@ -0,0 +1,18 @@ +# 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). +# +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLBranch +metadata: + name: pr-142 + namespace: preview-pr-142 +spec: + clusterName: production-cluster + source: + name: my-app + namespace: team-app # cross-namespace: branch ns differs from source ns diff --git a/examples/psqlbranches/preview-with-ttl.yaml b/examples/psqlbranches/preview-with-ttl.yaml new file mode 100644 index 0000000..4c669ef --- /dev/null +++ b/examples/psqlbranches/preview-with-ttl.yaml @@ -0,0 +1,22 @@ +# Preview-PR posture — aggressive S2Z + 7-day TTL recorded for external +# cleanup tooling (Kyverno cleanupPolicies, ArgoCD sync windows, etc.). +# +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLBranch +metadata: + name: pr-142 + namespace: preview-pr-142 +spec: + clusterName: production-cluster + labels: + pr: "142" + team: payments + source: + name: orders + namespace: payments + scaleToZero: + enabled: true + idleTimeout: 5m # very aggressive — preview envs hibernate quickly + ttl: + enabled: true + after: 168h # 7d diff --git a/examples/psqlbranches/same-namespace.yaml b/examples/psqlbranches/same-namespace.yaml new file mode 100644 index 0000000..e0281c8 --- /dev/null +++ b/examples/psqlbranches/same-namespace.yaml @@ -0,0 +1,17 @@ +# Same-namespace branch — fork a PSQLCluster in the same namespace. +# Composition collapses to: 1 VolumeSnapshot (referencing source PVC directly) +# + 1 CNPG Cluster bootstrapped from it. +# +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLBranch +metadata: + name: pr-142 + namespace: default +spec: + clusterName: my-cluster + source: + name: my-app # source PSQLCluster in same namespace + storage: + size: 10Gi # mirror source's spec.storage.size — branch + # PVC must be ≥ source PVC for CNPG recovery + diff --git a/examples/psqlclusters/minimal.yaml b/examples/psqlclusters/minimal.yaml new file mode 100644 index 0000000..4579d89 --- /dev/null +++ b/examples/psqlclusters/minimal.yaml @@ -0,0 +1,12 @@ +# Minimal claim: single Postgres instance, ESO-managed superuser, branching on, +# monitoring on. Sub-10-line claim for typical app workloads. +# +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLCluster +metadata: + name: my-app + namespace: default +spec: + clusterName: my-cluster + storage: + size: 10Gi diff --git a/examples/psqlclusters/standard.yaml b/examples/psqlclusters/standard.yaml new file mode 100644 index 0000000..8ef59d3 --- /dev/null +++ b/examples/psqlclusters/standard.yaml @@ -0,0 +1,35 @@ +# Standard production posture: HA across zones, scaleToZero opted in for +# off-hours savings, custom postgres tuning, team labels. +# +# Note: when both ha.enabled and scaleToZero.enabled are true, the cluster +# won't actually scale to zero (HA implies always-available); a warning +# Condition flags this on the XR status. The annotation is still rendered +# for tooling that reads it. +# +apiVersion: hops.ops.com.ai/v1alpha1 +kind: PSQLCluster +metadata: + name: orders + namespace: orders +spec: + clusterName: production-cluster + labels: + team: payments + instances: 3 + storage: + size: 100Gi + postgresql: + version: "17" + parameters: + shared_buffers: "1GB" + effective_cache_size: "3GB" + ha: + enabled: true + replicas: 3 + topologySpreadByZone: true + scaleToZero: + enabled: false + branching: + enabled: true + monitoring: + enabled: true diff --git a/examples/psqlstacks/local.yaml b/examples/psqlstacks/local.yaml index 4c3c403..115aed5 100644 --- a/examples/psqlstacks/local.yaml +++ b/examples/psqlstacks/local.yaml @@ -1,3 +1,9 @@ +# Local: dev cluster without a CSI snapshot controller. +# +# Disables the VolumeSnapshotClass composition (PSQLBranch won't work but +# PSQLCluster still does), and points at a local Helm/Kubernetes +# ProviderConfig instead of the per-cluster ProviderConfig pattern. +# apiVersion: hops.ops.com.ai/v1alpha1 kind: PSQLStack metadata: @@ -7,3 +13,9 @@ spec: clusterName: local helmProviderConfigRef: name: default + kubernetesProviderConfigRef: + name: default + snapshotClass: + enabled: false + scaleToZeroPlugin: + enabled: false diff --git a/examples/psqlstacks/minimal.yaml b/examples/psqlstacks/minimal.yaml index 1959e6c..88aaf5f 100644 --- a/examples/psqlstacks/minimal.yaml +++ b/examples/psqlstacks/minimal.yaml @@ -1,3 +1,15 @@ +# Minimal: defaults across the board. +# +# Composes: +# - CloudNativePG operator (Helm) +# - cnpg-i-scale-to-zero plugin (set of Objects, requires cert-manager) +# - Atlas operator (Helm) +# - VolumeSnapshotClass `psql` (driver: ebs.csi.eks.amazonaws.com) +# +# PSQLClusters target the cluster's existing default StorageClass +# (gp3 on EKS Auto Mode, standard on kind/k3d, etc.). PSQLBranch +# references the `psql` VolumeSnapshotClass for forking primaries. +# apiVersion: hops.ops.com.ai/v1alpha1 kind: PSQLStack metadata: diff --git a/examples/psqlstacks/standard.yaml b/examples/psqlstacks/standard.yaml index db1fab6..3a0d102 100644 --- a/examples/psqlstacks/standard.yaml +++ b/examples/psqlstacks/standard.yaml @@ -1,3 +1,5 @@ +# Standard: production posture with HA + atlas tuning + team labels. +# apiVersion: hops.ops.com.ai/v1alpha1 kind: PSQLStack metadata: @@ -5,13 +7,15 @@ metadata: namespace: default spec: clusterName: production-cluster - namespace: stackgres + namespace: cnpg-system labels: team: platform - stackgresOperator: - values: - deploy: - restapi: true + ha: + enabled: true # 3 replicas + topology spread by zone on + replicas: 3 # all platform components (CNPG, Atlas, + topologySpreadByZone: true # S2Z plugin Deployment). + scaleToZeroPlugin: + enabled: true atlasOperator: values: prewarmDevDB: true diff --git a/functions/branch/000-state-init.yaml.gotmpl b/functions/branch/000-state-init.yaml.gotmpl new file mode 100644 index 0000000..e61c9a1 --- /dev/null +++ b/functions/branch/000-state-init.yaml.gotmpl @@ -0,0 +1,137 @@ +# code: language=yaml +# +# Initialize $state with spec defaults +# + +{{- $xr := getCompositeResource . }} +{{- $spec := $xr.spec | default dict }} +{{- $metadata := $xr.metadata | default dict }} + +# ============================================================================== +# Core defaults +# ============================================================================== +{{- $name := $metadata.name }} +{{- $namespace := $metadata.namespace | default "default" }} +{{- $clusterName := $spec.clusterName }} +{{- $managementPolicies := $spec.managementPolicies | default (list "*") }} + +{{- $defaultLabels := dict + "hops.ops.com.ai/managed" "true" + "hops.ops.com.ai/psql-branch" $name +}} +{{- $labels := merge $defaultLabels ($spec.labels | default dict) }} + +{{- $k8sProviderConfigRef := $spec.kubernetesProviderConfigRef | default dict }} +{{- $k8sProviderConfigRef = dict + "name" ($k8sProviderConfigRef.name | default $clusterName) + "kind" ($k8sProviderConfigRef.kind | default "ProviderConfig") +}} + +# ============================================================================== +# Source — the PSQLCluster being forked +# Default pvcName: "-1" (CNPG's primary instance naming). +# Default snapshotClassName: "psql" (composed by psql-stack). +# ============================================================================== +{{- $sourceSpec := $spec.source }} +{{- $sourceName := $sourceSpec.name }} +{{- $sourceNamespace := $sourceSpec.namespace | default "" }} +{{- if not $sourceNamespace }} + {{- $sourceNamespace = $namespace }} +{{- end }} +{{- $sourcePvcName := $sourceSpec.pvcName | default "" }} +{{- if not $sourcePvcName }} + {{- $sourcePvcName = printf "%s-1" $sourceName }} +{{- end }} +{{- $sourceStorageSpec := $sourceSpec.storage | default dict }} +{{- $source := dict + "name" $sourceName + "namespace" $sourceNamespace + "pvcName" $sourcePvcName + "snapshotClassName" ($sourceSpec.snapshotClassName | default "psql") + "storage" (dict + "size" ($sourceStorageSpec.size | default "") + ) +}} + +# 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 }} + +# ============================================================================== +# Branch sizing +# ============================================================================== +{{- $branchSpec := $spec.branch | default dict }} +{{- $branchStorageSpec := $branchSpec.storage | default dict }} +# Branch size — empty here is intentional. The cnpg-cluster template falls +# back to source.storage.size when the branch size is unset; the previous +# `default "10Gi"` was a footgun (sources >10Gi silently mis-sized). +{{- $branch := dict + "instances" ($branchSpec.instances | default 1) + "storage" (dict + "size" ($branchStorageSpec.size | default "") + "class" ($branchStorageSpec.class | default "psql") + ) +}} + +# ============================================================================== +# 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). +# ============================================================================== +{{- $pgSpec := $spec.postgresql | default dict }} +{{- $postgresql := dict + "version" ($pgSpec.version | default "") +}} + +# ============================================================================== +# Integration toggles +# ============================================================================== +{{- $s2zSpec := $spec.scaleToZero | default dict }} +{{- $s2zEnabled := true }} +{{- if hasKey $s2zSpec "enabled" }} + {{- $s2zEnabled = $s2zSpec.enabled }} +{{- end }} +{{- $scaleToZero := dict + "enabled" $s2zEnabled + "idleTimeout" ($s2zSpec.idleTimeout | default "10m") +}} + +{{- $ttlSpec := $spec.ttl | default dict }} +{{- $ttlEnabled := false }} +{{- if hasKey $ttlSpec "enabled" }} + {{- $ttlEnabled = $ttlSpec.enabled }} +{{- end }} +{{- $ttl := dict + "enabled" $ttlEnabled + "after" ($ttlSpec.after | default "168h") +}} + +# ============================================================================== +# Escape hatch +# ============================================================================== +{{- $cnpgSpec := $spec.cnpg | default dict }} +{{- $cnpg := dict + "values" ($cnpgSpec.values | default dict) + "overrideAllValues" ($cnpgSpec.overrideAllValues | default dict) +}} + +# ============================================================================== +# Initialize $state +# ============================================================================== +{{- $state := dict + "name" $name + "namespace" $namespace + "clusterName" $clusterName + "managementPolicies" $managementPolicies + "labels" $labels + "kubernetesProviderConfigRef" $k8sProviderConfigRef + "source" $source + "crossNamespace" $crossNamespace + "branch" $branch + "postgresql" $postgresql + "scaleToZero" $scaleToZero + "ttl" $ttl + "cnpg" $cnpg + "observed" (dict) + "status" (dict) +}} diff --git a/functions/branch/010-state-status.yaml.gotmpl b/functions/branch/010-state-status.yaml.gotmpl new file mode 100644 index 0000000..0ebecaf --- /dev/null +++ b/functions/branch/010-state-status.yaml.gotmpl @@ -0,0 +1,56 @@ +# code: language=yaml +# +# Extract observed state from composed resources +# + +{{- $observed := $.observed.resources | default dict }} + +# Branch CNPG Cluster phase — wraps the same provider-kubernetes Object pattern +# as PSQLCluster. +{{- $clusterEntry := get $observed "cnpg-cluster" | default dict }} +{{- $clusterResource := $clusterEntry.resource | default dict }} +{{- $clusterAtProvider := (($clusterResource.status | default dict).atProvider | default dict) }} +{{- $clusterManifest := $clusterAtProvider.manifest | default dict }} +{{- $clusterStatus := $clusterManifest.status | default dict }} +{{- $clusterPhase := $clusterStatus.phase | default "" }} + +# Read the bound VolumeSnapshotContent name. Two paths: +# - same-namespace: only `branch-snapshot` exists (it references the source +# PVC directly), so we read from it. +# - cross-namespace: both `source-snapshot` (in source ns) and +# `branch-snapshot` (in branch ns) exist. The branch-ns snapshot can only +# bind once it learns the source's content name (propagated through this +# status field into 110-branch-snapshot's render), so reading branch-ns +# first creates a chicken-and-egg: empty branch content overwrites the +# populated source content. Prefer branch-ns only when its content is +# non-empty; otherwise fall back to source-ns. +{{- $snapEntryBranch := get $observed "branch-snapshot" | default dict }} +{{- $branchResource := $snapEntryBranch.resource | default dict }} +{{- $branchAtProvider := (($branchResource.status | default dict).atProvider | default dict) }} +{{- $branchManifest := $branchAtProvider.manifest | default dict }} +{{- $branchStatus := $branchManifest.status | default dict }} +{{- $branchContent := $branchStatus.boundVolumeSnapshotContentName | default "" }} + +{{- $snapEntrySource := get $observed "source-snapshot" | default dict }} +{{- $sourceResource := $snapEntrySource.resource | default dict }} +{{- $sourceAtProvider := (($sourceResource.status | default dict).atProvider | default dict) }} +{{- $sourceManifest := $sourceAtProvider.manifest | default dict }} +{{- $sourceStatus := $sourceManifest.status | default dict }} +{{- $sourceContent := $sourceStatus.boundVolumeSnapshotContentName | default "" }} + +{{- $snapContent := $branchContent }} +{{- if not $branchContent }} + {{- $snapContent = $sourceContent }} +{{- end }} + +{{- $state = set $state "observed" (dict + "cluster" (dict "phase" $clusterPhase) + "snapshotContent" $snapContent +) }} + +{{- $state = set $state "status" (dict + "ready" false + "bootstrapPhase" $clusterPhase + "bootstrapMethod" "volumeSnapshot" + "sourceSnapshotContent" $snapContent +) }} diff --git a/functions/branch/100-source-snapshot.yaml.gotmpl b/functions/branch/100-source-snapshot.yaml.gotmpl new file mode 100644 index 0000000..5141f67 --- /dev/null +++ b/functions/branch/100-source-snapshot.yaml.gotmpl @@ -0,0 +1,53 @@ +# code: language=yaml +# +# Source-namespace VolumeSnapshot — cross-namespace branching only. +# +# When the source PSQLCluster is in a different namespace from the branch, +# 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). +# +# For same-namespace branching, this template is skipped — the branch-ns +# snapshot references the source PVC directly. +# +# Naming: the underlying VolumeSnapshot lives in the SOURCE namespace, but +# its identity belongs to the BRANCH XR. Two PSQLBranch XRs with the same +# `metadata.name` in different branch namespaces (e.g. `preview-pr-1` in +# `team-a-preview` and `team-b-preview`) would otherwise collide on a single +# `-src` in the shared source ns. Prefix with the branch's own +# namespace to make the name (sourceNS, branchNS, branchName)-unique. +# K8s names are bound by RFC 1123 subdomain (253 chars) — concatenation is +# safe at any reasonable namespace/branch length. +# + +{{- $source := $state.source }} +{{- if $state.crossNamespace }} +{{- $sourceSnapName := printf "%s-%s-src" $state.namespace $state.name }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-source-snapshot + annotations: + {{ setResourceNameAnnotation "source-snapshot" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: snapshot.storage.k8s.io/v1 + kind: VolumeSnapshot + metadata: + name: {{ $sourceSnapName }} + namespace: {{ $source.namespace }} + labels: {{ $state.labels | toJson }} + spec: + volumeSnapshotClassName: {{ $source.snapshotClassName }} + source: + persistentVolumeClaimName: {{ $source.pvcName }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} diff --git a/functions/branch/110-branch-snapshot.yaml.gotmpl b/functions/branch/110-branch-snapshot.yaml.gotmpl new file mode 100644 index 0000000..25dbab2 --- /dev/null +++ b/functions/branch/110-branch-snapshot.yaml.gotmpl @@ -0,0 +1,45 @@ +# code: language=yaml +# +# Branch-namespace VolumeSnapshot — bound to either the source PVC directly +# (same-namespace branching) or to the VolumeSnapshotContent created from +# the source-namespace VolumeSnapshot (cross-namespace branching). +# +# The branch's CNPG Cluster references this VolumeSnapshot for +# `bootstrap.recovery.volumeSnapshots.storage.name` — recovery requires +# the VolumeSnapshot to be in the same namespace as the new Cluster. +# + +{{- $source := $state.source }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-branch-snapshot + annotations: + {{ setResourceNameAnnotation "branch-snapshot" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: snapshot.storage.k8s.io/v1 + kind: VolumeSnapshot + metadata: + name: {{ $state.name }}-snap + namespace: {{ $state.namespace }} + labels: {{ $state.labels | toJson }} + 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 }} + {{- else }} + # Same-ns branching — reference the source PVC directly. + persistentVolumeClaimName: {{ $source.pvcName }} + {{- end }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} diff --git a/functions/branch/200-cnpg-cluster.yaml.gotmpl b/functions/branch/200-cnpg-cluster.yaml.gotmpl new file mode 100644 index 0000000..1e65b22 --- /dev/null +++ b/functions/branch/200-cnpg-cluster.yaml.gotmpl @@ -0,0 +1,113 @@ +# code: language=yaml +# +# 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 := $state.cnpg }} + +{{- $clusterAnnotations := dict }} +{{- if $state.scaleToZero.enabled }} + {{- $_ := set $clusterAnnotations "cnpg-i-scale-to-zero.xata.io/idle-timeout" $state.scaleToZero.idleTimeout }} +{{- end }} +{{- if $state.ttl.enabled }} + {{- $_ := set $clusterAnnotations "hops.ops.com.ai/ttl" $state.ttl.after }} +{{- end }} + +# Branch-only labels — distinguish from PSQLCluster's `branching-source` label. +{{- $clusterLabels := merge $state.labels (dict + "hops.ops.com.ai/branch-of" $state.source.name +) }} + +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-cnpg-cluster + annotations: + {{ setResourceNameAnnotation "cnpg-cluster" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: postgresql.cnpg.io/v1 + kind: Cluster + metadata: + name: {{ $state.name }} + namespace: {{ $state.namespace }} + {{- if $clusterAnnotations }} + annotations: + {{- range $k, $v := $clusterAnnotations }} + {{ $k | quote }}: {{ $v | quote }} + {{- end }} + {{- end }} + labels: + {{- range $k, $v := $clusterLabels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- if $cnpg.overrideAllValues }} + spec: + {{- toYaml $cnpg.overrideAllValues | nindent 8 }} + {{- else }} + spec: + {{- $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" + ) + ) + ) + ) + }} + {{- /* 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) }} + {{- 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. + Hardcoding 10Gi here was a footgun for sources >10Gi (CNPG/EBS + can't shrink during recovery). class only set when provided. */}} + {{- $size := $state.branch.storage.size }} + {{- if not $size }} + {{- $size = $state.source.storage.size }} + {{- end }} + {{- $storage := dict }} + {{- if $size }} + {{- $_ := set $storage "size" $size }} + {{- end }} + {{- if $state.branch.storage.class }} + {{- $_ := set $storage "storageClass" $state.branch.storage.class }} + {{- end }} + {{- $_ := set $clusterSpec "storage" $storage }} + {{- if $state.scaleToZero.enabled }} + {{- $_ := set $clusterSpec "plugins" (list (dict + "name" "cnpg-i-scale-to-zero.xata.io" + "enabled" true + )) }} + {{- end }} + {{- $_ := set $clusterSpec "monitoring" (dict "enablePodMonitor" true) }} + {{- $mergedClusterSpec := mergeOverwrite $clusterSpec ($cnpg.values | default dict) }} + {{- toYaml $mergedClusterSpec | nindent 8 }} + {{- end }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} diff --git a/functions/branch/999-status.yaml.gotmpl b/functions/branch/999-status.yaml.gotmpl new file mode 100644 index 0000000..5900a57 --- /dev/null +++ b/functions/branch/999-status.yaml.gotmpl @@ -0,0 +1,14 @@ +# code: language=yaml +# +# Output XR status +# + +{{- $xr := getCompositeResource . }} +--- +apiVersion: {{ $xr.apiVersion }} +kind: {{ $xr.kind }} +status: + ready: {{ $state.status.ready }} + bootstrapPhase: {{ $state.status.bootstrapPhase | quote }} + bootstrapMethod: {{ $state.status.bootstrapMethod | quote }} + sourceSnapshotContent: {{ $state.status.sourceSnapshotContent | quote }} diff --git a/functions/cluster/000-state-init.yaml.gotmpl b/functions/cluster/000-state-init.yaml.gotmpl new file mode 100644 index 0000000..d2dc67b --- /dev/null +++ b/functions/cluster/000-state-init.yaml.gotmpl @@ -0,0 +1,199 @@ +# code: language=yaml +# +# Initialize $state with spec defaults +# + +{{- $xr := getCompositeResource . }} +{{- $spec := $xr.spec | default dict }} +{{- $metadata := $xr.metadata | default dict }} + +# ============================================================================== +# Core defaults +# ============================================================================== +{{- $name := $metadata.name }} +{{- $namespace := $metadata.namespace | default "default" }} +{{- $clusterName := $spec.clusterName }} +{{- $managementPolicies := $spec.managementPolicies | default (list "*") }} + +# Labels +{{- $defaultLabels := dict + "hops.ops.com.ai/managed" "true" + "hops.ops.com.ai/psql-cluster" $name +}} +{{- $labels := merge $defaultLabels ($spec.labels | default dict) }} + +# Kubernetes provider config (defaults to clusterName) +{{- $k8sProviderConfigRef := $spec.kubernetesProviderConfigRef | default dict }} +{{- $k8sProviderConfigRef = dict + "name" ($k8sProviderConfigRef.name | default $clusterName) + "kind" ($k8sProviderConfigRef.kind | default "ProviderConfig") +}} + +# ============================================================================== +# HA — flips instances to ha.replicas + zonal topology spread. +# When ha.enabled and scaleToZero.enabled are both true, the cluster won't +# actually scale to zero (HA implies always-available); a Warning condition +# gets emitted on the XR but the annotation is still rendered for tooling. +# ============================================================================== +{{- $haSpec := $spec.ha | default dict }} +{{- $haEnabled := false }} +{{- if hasKey $haSpec "enabled" }} + {{- $haEnabled = $haSpec.enabled }} +{{- end }} +{{- $haReplicas := $haSpec.replicas | default 3 }} +{{- $haSpreadByZone := true }} +{{- if hasKey $haSpec "topologySpreadByZone" }} + {{- $haSpreadByZone = $haSpec.topologySpreadByZone }} +{{- end }} +{{- $ha := dict + "enabled" $haEnabled + "replicas" $haReplicas + "topologySpreadByZone" $haSpreadByZone +}} + +# Resolve effective instance count: HA wins +{{- $instances := $spec.instances | default 1 }} +{{- if $haEnabled }} + {{- $instances = $haReplicas }} +{{- end }} + +# ============================================================================== +# Storage +# ============================================================================== +{{- $storageSpec := $spec.storage }} +{{- $storage := dict + "size" $storageSpec.size + "class" ($storageSpec.class | default "psql") +}} + +# ============================================================================== +# Postgres +# ============================================================================== +{{- $pgSpec := $spec.postgresql | default dict }} +{{- $postgresql := dict + "version" ($pgSpec.version | default "17") + "parameters" ($pgSpec.parameters | default dict) +}} + +# ============================================================================== +# Application user — always present. Wires CNPG's bootstrap.initdb shape. +# Three modes: +# - Neither `externalSecret` nor `secretName` set → CNPG auto-generates +# a Secret named `-app` and owns its lifecycle. Composition +# omits `bootstrap.initdb.secret` so CNPG fills it in. `cnpgManagedSecret` +# flag drives the omission downstream. +# - `externalSecret` set → composition renders an ESO ExternalSecret that +# writes the K8s Secret CNPG reads. +# - `secretName` set (without `externalSecret`) → BYO; consumer pre-creates +# the named Secret with `username`+`password`. +# ============================================================================== +{{- $appSpec := $spec.app | default dict }} +{{- $appRole := $appSpec.role | default "app" }} +{{- $appDatabase := $appSpec.database | default "app" }} +{{- $appExternalSecret := $appSpec.externalSecret | default dict }} +{{- $appExternalSecretSet := and $appExternalSecret.secretStore $appExternalSecret.secretRef }} +{{- $appSecretNameRaw := $appSpec.secretName }} +{{- $appSecretName := $appSecretNameRaw }} +{{- if not $appSecretName }} + {{- $appSecretName = printf "%s-app" $name }} +{{- end }} +{{- $appCNPGManagedSecret := and (not $appSecretNameRaw) (not $appExternalSecretSet) }} +{{- $app := dict + "role" $appRole + "database" $appDatabase + "secretName" $appSecretName + "cnpgManagedSecret" $appCNPGManagedSecret + "externalSecret" $appExternalSecret +}} + +# ============================================================================== +# Superuser — optional. Render only when the user explicitly opts in by +# setting `spec.superuser.secretName` or `spec.superuser.externalSecret`. +# Otherwise CNPG auto-generates the superuser secret at `-superuser`. +# ============================================================================== +{{- $suSpec := $spec.superuser | default dict }} +{{- $suEnabled := false }} +{{- if or $suSpec.secretName $suSpec.externalSecret }} + {{- $suEnabled = true }} +{{- end }} +{{- $suSecretName := $suSpec.secretName }} +{{- if not $suSecretName }} + {{- $suSecretName = printf "%s-superuser" $name }} +{{- end }} +{{- $superuser := dict + "enabled" $suEnabled + "secretName" $suSecretName + "externalSecret" ($suSpec.externalSecret | default dict) +}} + +# ============================================================================== +# Integration toggles +# ============================================================================== +{{- $s2zSpec := $spec.scaleToZero | default dict }} +{{- $s2zEnabled := false }} +{{- if hasKey $s2zSpec "enabled" }} + {{- $s2zEnabled = $s2zSpec.enabled }} +{{- end }} +{{- $scaleToZero := dict + "enabled" $s2zEnabled + "idleTimeout" ($s2zSpec.idleTimeout | default "30m") +}} + +{{- $branchSpec := $spec.branching | default dict }} +{{- $branchEnabled := true }} +{{- if hasKey $branchSpec "enabled" }} + {{- $branchEnabled = $branchSpec.enabled }} +{{- end }} +{{- $branching := dict + "enabled" $branchEnabled + "snapshotClassName" ($branchSpec.snapshotClassName | default "psql") +}} + +{{- $monSpec := $spec.monitoring | default dict }} +{{- $monEnabled := true }} +{{- if hasKey $monSpec "enabled" }} + {{- $monEnabled = $monSpec.enabled }} +{{- end }} +{{- $monitoring := dict "enabled" $monEnabled }} + +# ============================================================================== +# Escape hatch +# ============================================================================== +{{- $cnpgSpec := $spec.cnpg | default dict }} +{{- $cnpg := dict + "values" ($cnpgSpec.values | default dict) + "overrideAllValues" ($cnpgSpec.overrideAllValues | default dict) +}} + +# ============================================================================== +# Initialize $state +# ============================================================================== +{{- $state := dict + "name" $name + "namespace" $namespace + "clusterName" $clusterName + "managementPolicies" $managementPolicies + "labels" $labels + "kubernetesProviderConfigRef" $k8sProviderConfigRef + "instances" $instances + "storage" $storage + "postgresql" $postgresql + "app" $app + "superuser" $superuser + "scaleToZero" $scaleToZero + "branching" $branching + "monitoring" $monitoring + "ha" $ha + "cnpg" $cnpg + "observed" (dict) + "status" (dict) + "warnings" (list) +}} + +# ============================================================================== +# Validate cross-toggle combinations +# ============================================================================== +{{- if and $haEnabled $s2zEnabled }} + {{- $warnings := append $state.warnings "scaleToZero+ha both enabled: HA implies always-available, the cluster will not actually scale to zero" }} + {{- $state = set $state "warnings" $warnings }} +{{- end }} diff --git a/functions/cluster/010-state-status.yaml.gotmpl b/functions/cluster/010-state-status.yaml.gotmpl new file mode 100644 index 0000000..bc1b38a --- /dev/null +++ b/functions/cluster/010-state-status.yaml.gotmpl @@ -0,0 +1,59 @@ +# code: language=yaml +# +# Extract observed state from composed resources +# + +{{- $observed := $.observed.resources | default dict }} + +# ============================================================================== +# Cluster status — read CNPG Cluster's `.status.phase` from the wrapped Object's +# `.status.atProvider.manifest.status.phase`. The Object's Ready=true means the +# manifest applied; the wrapped Cluster's phase tells us if Postgres is healthy. +# ============================================================================== +{{- $clusterEntry := get $observed "cnpg-cluster" | default dict }} +{{- $clusterResource := $clusterEntry.resource | default dict }} +{{- $clusterAtProvider := (($clusterResource.status | default dict).atProvider | default dict) }} +{{- $clusterManifest := $clusterAtProvider.manifest | default dict }} +{{- $clusterStatus := $clusterManifest.status | default dict }} +{{- $clusterPhase := $clusterStatus.phase | default "" }} +{{- $clusterReady := false }} +{{- if eq $clusterPhase "Cluster in healthy state" }} + {{- $clusterReady = true }} +{{- end }} + +# ============================================================================== +# ExternalSecret status — the composition emits two distinct Object resources +# (`external-secret-app` and `external-secret-superuser`) whenever the +# corresponding `*.externalSecret` block is set. Aggregate readiness across +# both: ready=true when every present ES Object reports Ready=true, and +# ready=true when neither exists (nothing to wait on, BYO/auto-gen path). +# 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 }} +{{- $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) }} + {{- range $conditions }} + {{- if and (eq .type "Ready") (eq .status "True") }} + {{- $esReadyCount = add $esReadyCount 1 }} + {{- end }} + {{- end }} + {{- end }} +{{- end }} +{{- $esReady := eq $esPresent $esReadyCount }} + +{{- $state = set $state "observed" (dict + "cluster" (dict "ready" $clusterReady "phase" $clusterPhase) + "externalSecret" (dict "ready" $esReady) +) }} + +{{- $state = set $state "status" (dict + "ready" false + "clusterPhase" $clusterPhase +) }} diff --git a/functions/cluster/100-external-secret.yaml.gotmpl b/functions/cluster/100-external-secret.yaml.gotmpl new file mode 100644 index 0000000..b3b2bf3 --- /dev/null +++ b/functions/cluster/100-external-secret.yaml.gotmpl @@ -0,0 +1,92 @@ +# code: language=yaml +# +# ExternalSecrets — source Postgres role passwords from a SecretStore / +# ClusterSecretStore via External Secrets Operator and write K8s Secrets +# that the CNPG Cluster references. +# +# Rendered when the consumer sets `app.externalSecret` (and optionally +# `superuser.externalSecret`). When omitted, the consumer is in BYO mode +# and must pre-create the K8s Secret named `` with `username` + +# `password` keys. +# +# The remote value at `secretRef.path` must be a JSON blob with `username` +# and `password` fields — both are extracted into the resulting K8s Secret. +# + +{{- define "psqlcluster.externalSecret" -}} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ .ResourceName }} + annotations: + {{ .ResourceNameAnnotation }} + labels: {{ .Labels | toJson }} +spec: + managementPolicies: {{ .ManagementPolicies | toJson }} + forProvider: + manifest: + apiVersion: external-secrets.io/v1 + kind: ExternalSecret + metadata: + name: {{ .SecretName }} + namespace: {{ .Namespace }} + labels: {{ .Labels | toJson }} + spec: + refreshInterval: 1h + secretStoreRef: + name: {{ .SecretStore.name }} + kind: {{ .SecretStore.kind | default "ClusterSecretStore" }} + target: + name: {{ .SecretName }} + creationPolicy: Owner + template: + type: kubernetes.io/basic-auth + data: + username: "{{ "{{" }} .username {{ "}}" }}" + password: "{{ "{{" }} .password {{ "}}" }}" + data: + - secretKey: username + remoteRef: + key: {{ .SecretRef.path | quote }} + property: username + - secretKey: password + remoteRef: + key: {{ .SecretRef.path | quote }} + property: password + providerConfigRef: + name: {{ .ProviderConfigName }} + kind: {{ .ProviderConfigKind }} +{{- end }} + +{{- $appES := $state.app.externalSecret }} +{{- if and $appES.secretStore $appES.secretRef }} +{{- template "psqlcluster.externalSecret" (dict + "ResourceName" (printf "%s-external-secret-app" $state.name) + "ResourceNameAnnotation" (setResourceNameAnnotation "external-secret-app") + "Labels" $state.labels + "ManagementPolicies" $state.managementPolicies + "Namespace" $state.namespace + "SecretName" $state.app.secretName + "SecretStore" $appES.secretStore + "SecretRef" $appES.secretRef + "ProviderConfigName" $state.kubernetesProviderConfigRef.name + "ProviderConfigKind" $state.kubernetesProviderConfigRef.kind +) }} +{{- end }} + +{{- $suES := $state.superuser.externalSecret }} +{{- if and $state.superuser.enabled $suES.secretStore $suES.secretRef }} +{{- template "psqlcluster.externalSecret" (dict + "ResourceName" (printf "%s-external-secret-superuser" $state.name) + "ResourceNameAnnotation" (setResourceNameAnnotation "external-secret-superuser") + "Labels" $state.labels + "ManagementPolicies" $state.managementPolicies + "Namespace" $state.namespace + "SecretName" $state.superuser.secretName + "SecretStore" $suES.secretStore + "SecretRef" $suES.secretRef + "ProviderConfigName" $state.kubernetesProviderConfigRef.name + "ProviderConfigKind" $state.kubernetesProviderConfigRef.kind +) }} +{{- end }} diff --git a/functions/cluster/200-cnpg-cluster.yaml.gotmpl b/functions/cluster/200-cnpg-cluster.yaml.gotmpl new file mode 100644 index 0000000..1769e03 --- /dev/null +++ b/functions/cluster/200-cnpg-cluster.yaml.gotmpl @@ -0,0 +1,116 @@ +# code: language=yaml +# +# CNPG Cluster CR — the core composed resource. +# +# Wrapped in a Crossplane Object (provider-kubernetes) so we can apply it +# without provider-helm and so its lifecycle is owned by the PSQLCluster XR. +# +# Translates the intent-first integration toggles into CNPG mechanics: +# - scaleToZero.enabled → annotation + plugins entry +# - branching.enabled → labels for PSQLBranch consumption +# - monitoring.enabled → spec.monitoring.enablePodMonitor +# - ha.enabled → spec.instances + spec.affinity.topologyKey +# + +{{- $cnpg := $state.cnpg }} + +# Annotations on the Cluster CR (for plugin tooling) +{{- $clusterAnnotations := dict }} +{{- if $state.scaleToZero.enabled }} + {{- $_ := set $clusterAnnotations "cnpg-i-scale-to-zero.xata.io/idle-timeout" $state.scaleToZero.idleTimeout }} +{{- end }} + +# Labels — stack defaults + branching markers (when enabled) read by PSQLBranch +{{- $clusterLabels := merge $state.labels (dict) }} +{{- if $state.branching.enabled }} + {{- $_ := set $clusterLabels "hops.ops.com.ai/branching-source" "true" }} + {{- $_ := set $clusterLabels "hops.ops.com.ai/snapshot-class" $state.branching.snapshotClassName }} +{{- end }} + +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-cnpg-cluster + annotations: + {{ setResourceNameAnnotation "cnpg-cluster" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: postgresql.cnpg.io/v1 + kind: Cluster + metadata: + name: {{ $state.name }} + namespace: {{ $state.namespace }} + {{- if $clusterAnnotations }} + annotations: + {{- range $k, $v := $clusterAnnotations }} + {{ $k | quote }}: {{ $v | quote }} + {{- end }} + {{- end }} + labels: + {{- range $k, $v := $clusterLabels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- if $cnpg.overrideAllValues }} + spec: + {{- toYaml $cnpg.overrideAllValues | nindent 8 }} + {{- else }} + spec: + {{- /* Build the Cluster.spec from intent-first state, then mergeOverwrite + with $cnpg.values for fine-grained passthrough overrides. */}} + {{- /* When neither app.externalSecret nor an explicit app.secretName + is set, omit bootstrap.initdb.secret so CNPG auto-generates and + owns a basic-auth Secret at -app. Otherwise point + CNPG at the secret the consumer or ESO is providing. */}} + {{- $initdb := dict + "database" $state.app.database + "owner" $state.app.role + }} + {{- if not $state.app.cnpgManagedSecret }} + {{- $_ := set $initdb "secret" (dict "name" $state.app.secretName) }} + {{- end }} + {{- $clusterSpec := dict + "instances" $state.instances + "imageName" (printf "ghcr.io/cloudnative-pg/postgresql:%s" $state.postgresql.version) + "bootstrap" (dict "initdb" $initdb) + "storage" (dict + "size" $state.storage.size + ) + "monitoring" (dict + "enablePodMonitor" $state.monitoring.enabled + ) + }} + {{- if $state.superuser.enabled }} + {{- $_ := set $clusterSpec "superuserSecret" (dict "name" $state.superuser.secretName) }} + {{- end }} + {{- if $state.storage.class }} + {{- $_ := set $clusterSpec.storage "storageClass" $state.storage.class }} + {{- end }} + {{- if $state.postgresql.parameters }} + {{- $_ := set $clusterSpec "postgresql" (dict "parameters" $state.postgresql.parameters) }} + {{- end }} + {{- if $state.scaleToZero.enabled }} + {{- $_ := set $clusterSpec "plugins" (list (dict + "name" "cnpg-i-scale-to-zero.xata.io" + "enabled" true + )) }} + {{- end }} + {{- if $state.ha.enabled }} + {{- $affinity := dict + "enablePodAntiAffinity" true + "podAntiAffinityType" "preferred" + }} + {{- if $state.ha.topologySpreadByZone }} + {{- $_ := set $affinity "topologyKey" "topology.kubernetes.io/zone" }} + {{- end }} + {{- $_ := set $clusterSpec "affinity" $affinity }} + {{- end }} + {{- $mergedClusterSpec := mergeOverwrite $clusterSpec ($cnpg.values | default dict) }} + {{- toYaml $mergedClusterSpec | nindent 8 }} + {{- end }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} diff --git a/functions/cluster/999-status.yaml.gotmpl b/functions/cluster/999-status.yaml.gotmpl new file mode 100644 index 0000000..44517a4 --- /dev/null +++ b/functions/cluster/999-status.yaml.gotmpl @@ -0,0 +1,36 @@ +# code: language=yaml +# +# Output XR status + Warning conditions for cross-toggle conflicts. +# + +{{- $xr := getCompositeResource . }} +{{- /* CNPG always exposes the rw service at -rw..svc.cluster.local */}} +{{- $appHost := printf "%s-rw.%s.svc.cluster.local" $state.name $state.namespace }} +{{- /* Superuser secret location: user-provided if opted in, else CNPG's auto-generated `-superuser` */}} +{{- $superuserSecretName := printf "%s-superuser" $state.name }} +{{- if $state.superuser.enabled }} + {{- $superuserSecretName = $state.superuser.secretName }} +{{- end }} +--- +apiVersion: {{ $xr.apiVersion }} +kind: {{ $xr.kind }} +status: + ready: {{ $state.status.ready }} + clusterPhase: {{ $state.status.clusterPhase | quote }} + app: + secretName: {{ $state.app.secretName | quote }} + database: {{ $state.app.database | quote }} + host: {{ $appHost | quote }} + port: 5432 + superuser: + secretName: {{ $superuserSecretName | quote }} + {{- if $state.warnings }} + conditions: + {{- range $i, $msg := $state.warnings }} + - type: ConfigurationWarning + status: "True" + reason: ConflictingToggles + message: {{ $msg | quote }} + lastTransitionTime: "1970-01-01T00:00:00Z" + {{- end }} + {{- end }} diff --git a/functions/render/000-state-init.yaml.gotmpl b/functions/render/000-state-init.yaml.gotmpl deleted file mode 100644 index 6295148..0000000 --- a/functions/render/000-state-init.yaml.gotmpl +++ /dev/null @@ -1,62 +0,0 @@ -# code: language=yaml -# -# Initialize $state with spec defaults -# - -{{- $xr := getCompositeResource . }} -{{- $spec := $xr.spec | default dict }} -{{- $metadata := $xr.metadata | default dict }} - -# ============================================================================== -# Core defaults -# ============================================================================== -{{- $name := $metadata.name | default "psql" }} -{{- $clusterName := $spec.clusterName | default $name }} -{{- $namespace := $spec.namespace | default "stackgres" }} -{{- $managementPolicies := $spec.managementPolicies | default (list "*") }} - -# Labels -{{- $defaultLabels := dict - "hops.ops.com.ai/managed" "true" - "hops.ops.com.ai/psql" $name -}} -{{- $labels := merge $defaultLabels ($spec.labels | default dict) }} - -# Helm provider config (defaults to clusterName) -{{- $helmProviderConfigRef := $spec.helmProviderConfigRef | default dict }} -{{- $helmProviderConfigRef = dict - "name" ($helmProviderConfigRef.name | default $clusterName) - "kind" ($helmProviderConfigRef.kind | default "ProviderConfig") -}} - -# ============================================================================== -# Per-component configuration -# ============================================================================== -{{- $sg := $spec.stackgresOperator | default dict }} -{{- $atlas := $spec.atlasOperator | default dict }} - -# ============================================================================== -# Initialize $state -# ============================================================================== -{{- $state := dict - "name" $name - "clusterName" $clusterName - "namespace" $namespace - "managementPolicies" $managementPolicies - "labels" $labels - "helmProviderConfigRef" $helmProviderConfigRef - "stackgresOperator" (dict - "name" ($sg.name | default "stackgres-operator") - "namespace" ($sg.namespace | default $namespace) - "values" ($sg.values | default dict) - "overrideAllValues" ($sg.overrideAllValues | default dict) - ) - "atlasOperator" (dict - "name" ($atlas.name | default "atlas-operator") - "namespace" ($atlas.namespace | default $namespace) - "values" ($atlas.values | default dict) - "overrideAllValues" ($atlas.overrideAllValues | default dict) - ) - "observed" (dict) - "status" (dict) -}} diff --git a/functions/render/200-stackgres-operator.yaml.gotmpl b/functions/render/200-stackgres-operator.yaml.gotmpl deleted file mode 100644 index fb15bb3..0000000 --- a/functions/render/200-stackgres-operator.yaml.gotmpl +++ /dev/null @@ -1,74 +0,0 @@ -# code: language=yaml -# -# Helm Release: stackgres-operator -# -# StackGres provides a full PostgreSQL platform on Kubernetes with native -# Citus support via SGShardedCluster CRDs. -# - -{{- $sg := $state.stackgresOperator }} - ---- -apiVersion: helm.m.crossplane.io/v1beta1 -kind: Release -metadata: - name: {{ $sg.name }} - annotations: - {{ setResourceNameAnnotation "stackgres-operator" }} - labels: {{ $state.labels | toJson }} -spec: - managementPolicies: {{ $state.managementPolicies | toJson }} - forProvider: - chart: - name: stackgres-operator - repository: https://stackgres.io/downloads/stackgres-k8s/stackgres/helm/ - url: https://stackgres.io/downloads/stackgres-k8s/stackgres/1.18.4/helm/stackgres-operator.tgz - version: 1.18.4 - namespace: {{ $sg.namespace }} - {{- if $sg.overrideAllValues }} - values: - {{- toYaml $sg.overrideAllValues | nindent 6 }} - {{- else }} - {{- /* Chart defaults */}} - {{- $chartDefaults := dict - "deploy" (dict - "operator" true - "restapi" true - ) - }} - {{- $mergedValues := mergeOverwrite $chartDefaults ($sg.values | default dict) }} - values: - {{- toYaml $mergedValues | nindent 6 }} - {{- end }} - rollbackLimit: 3 - providerConfigRef: - name: {{ $state.helmProviderConfigRef.name }} - kind: {{ $state.helmProviderConfigRef.kind }} - -# ============================================================================== -# Usage: Protect stackgres-operator from deletion until atlas-operator is deleted -# ============================================================================== -# Atlas manages schema migrations against StackGres-managed databases. -# Delete Atlas operator first to avoid orphaned migration state. -{{- if and $state.observed.stackgresOperator.ready $state.observed.atlasOperator.ready }} ---- -apiVersion: protection.crossplane.io/v1beta1 -kind: Usage -metadata: - name: {{ $state.name }}-delete-atlas-operator-before-stackgres-operator - annotations: - {{ setResourceNameAnnotation "usage-sg-atlas" }} - labels: {{ $state.labels | toJson }} -spec: - replayDeletion: true - of: - apiVersion: helm.m.crossplane.io/v1beta1 - kind: Release - resourceRef: - name: {{ $sg.name }} - by: - apiVersion: helm.m.crossplane.io/v1beta1 - kind: Release - resourceRef: - name: {{ $state.atlasOperator.name }} -{{- end }} diff --git a/functions/stack/000-state-init.yaml.gotmpl b/functions/stack/000-state-init.yaml.gotmpl new file mode 100644 index 0000000..fa61e1f --- /dev/null +++ b/functions/stack/000-state-init.yaml.gotmpl @@ -0,0 +1,148 @@ +# code: language=yaml +# +# Initialize $state with spec defaults +# + +{{- $xr := getCompositeResource . }} +{{- $spec := $xr.spec | default dict }} +{{- $metadata := $xr.metadata | default dict }} + +# ============================================================================== +# Core defaults +# ============================================================================== +{{- $name := $metadata.name | default "psql" }} +{{- $clusterName := $spec.clusterName | default $name }} +{{- $namespace := $spec.namespace | default "cnpg-system" }} +{{- $managementPolicies := $spec.managementPolicies | default (list "*") }} + +# Labels +{{- $defaultLabels := dict + "hops.ops.com.ai/managed" "true" + "hops.ops.com.ai/psql" $name +}} +{{- $labels := merge $defaultLabels ($spec.labels | default dict) }} + +# Helm provider config (defaults to clusterName) +{{- $helmProviderConfigRef := $spec.helmProviderConfigRef | default dict }} +{{- $helmProviderConfigRef = dict + "name" ($helmProviderConfigRef.name | default $clusterName) + "kind" ($helmProviderConfigRef.kind | default "ProviderConfig") +}} + +# Kubernetes provider config (defaults to clusterName) +{{- $k8sProviderConfigRef := $spec.kubernetesProviderConfigRef | default dict }} +{{- $k8sProviderConfigRef = dict + "name" ($k8sProviderConfigRef.name | default $clusterName) + "kind" ($k8sProviderConfigRef.kind | default "ProviderConfig") +}} + +# ============================================================================== +# HA mode — stack-wide replica + topology-spread defaults injected into each +# platform component. Per-component values blocks can still override. +# ============================================================================== +{{- $haSpec := $spec.ha | default dict }} +{{- $haEnabled := false }} +{{- if hasKey $haSpec "enabled" }} + {{- $haEnabled = $haSpec.enabled }} +{{- end }} +{{- $haReplicas := $haSpec.replicas | default 3 }} +{{- $haSpreadByZone := true }} +{{- if hasKey $haSpec "topologySpreadByZone" }} + {{- $haSpreadByZone = $haSpec.topologySpreadByZone }} +{{- end }} +{{- $ha := dict + "enabled" $haEnabled + "replicas" $haReplicas + "topologySpreadByZone" $haSpreadByZone +}} + +# ============================================================================== +# Per-component configuration +# ============================================================================== +{{- $cnpg := $spec.cnpg | default dict }} +{{- $s2z := $spec.scaleToZeroPlugin | default dict }} +{{- $s2zEnabled := true }} +{{- if hasKey $s2z "enabled" }} + {{- $s2zEnabled = $s2z.enabled }} +{{- end }} +{{- $atlas := $spec.atlasOperator | default dict }} + +# ============================================================================== +# StorageClass + VolumeSnapshotClass — paired storage-layer composition. +# +# PSQLCluster + PSQLBranch default their PVCs to the composed StorageClass; +# PSQLBranch references the VolumeSnapshotClass when forking primaries. The +# two CSI drivers MUST match (snapshotter only operates on PVCs whose +# provisioner matches its driver), so they default to the same value +# (ebs.csi.eks.amazonaws.com — EKS Auto Mode's managed EBS CSI driver). +# Override both together for non-EKS targets (hostpath.csi.k8s.io for kind, +# driver.longhorn.io for self-managed Longhorn, etc.). +# ============================================================================== +{{- $storageSpec := $spec.storageClass | default dict }} +{{- $storageEnabled := true }} +{{- if hasKey $storageSpec "enabled" }} + {{- $storageEnabled = $storageSpec.enabled }} +{{- end }} +{{- $storageAllowExpansion := true }} +{{- if hasKey $storageSpec "allowVolumeExpansion" }} + {{- $storageAllowExpansion = $storageSpec.allowVolumeExpansion }} +{{- end }} +{{- $defaultStorageParams := dict "type" "gp3" }} +{{- $storageClass := dict + "enabled" $storageEnabled + "name" ($storageSpec.name | default "psql") + "provisioner" ($storageSpec.provisioner | default "ebs.csi.eks.amazonaws.com") + "reclaimPolicy" ($storageSpec.reclaimPolicy | default "Delete") + "volumeBindingMode" ($storageSpec.volumeBindingMode | default "WaitForFirstConsumer") + "allowVolumeExpansion" $storageAllowExpansion + "parameters" ($storageSpec.parameters | default $defaultStorageParams) +}} + +{{- $snapshotSpec := $spec.snapshotClass | default dict }} +{{- $snapshotEnabled := true }} +{{- if hasKey $snapshotSpec "enabled" }} + {{- $snapshotEnabled = $snapshotSpec.enabled }} +{{- end }} +{{- $snapshotClass := dict + "enabled" $snapshotEnabled + "name" ($snapshotSpec.name | default "psql") + "driver" ($snapshotSpec.driver | default "ebs.csi.eks.amazonaws.com") + "deletionPolicy" ($snapshotSpec.deletionPolicy | default "Delete") + "parameters" ($snapshotSpec.parameters | default dict) +}} + +# ============================================================================== +# Initialize $state +# ============================================================================== +{{- $state := dict + "name" $name + "clusterName" $clusterName + "namespace" $namespace + "managementPolicies" $managementPolicies + "labels" $labels + "helmProviderConfigRef" $helmProviderConfigRef + "kubernetesProviderConfigRef" $k8sProviderConfigRef + "ha" $ha + "cnpg" (dict + "name" ($cnpg.name | default "cloudnative-pg") + "namespace" ($cnpg.namespace | default $namespace) + "chartVersion" ($cnpg.chartVersion | default "0.27.1") + "values" ($cnpg.values | default dict) + "overrideAllValues" ($cnpg.overrideAllValues | default dict) + ) + "scaleToZeroPlugin" (dict + "enabled" $s2zEnabled + "version" ($s2z.version | default "v0.1.7") + "namespace" ($s2z.namespace | default $namespace) + ) + "atlasOperator" (dict + "name" ($atlas.name | default "atlas-operator") + "namespace" ($atlas.namespace | default $namespace) + "values" ($atlas.values | default dict) + "overrideAllValues" ($atlas.overrideAllValues | default dict) + ) + "storageClass" $storageClass + "snapshotClass" $snapshotClass + "observed" (dict) + "status" (dict) +}} diff --git a/functions/render/010-state-status.yaml.gotmpl b/functions/stack/010-state-status.yaml.gotmpl similarity index 74% rename from functions/render/010-state-status.yaml.gotmpl rename to functions/stack/010-state-status.yaml.gotmpl index fbe3ef3..1d4b1e1 100644 --- a/functions/render/010-state-status.yaml.gotmpl +++ b/functions/stack/010-state-status.yaml.gotmpl @@ -1,6 +1,6 @@ # code: language=yaml # -# Extract observed state from all composed resources and compute status +# Extract observed state from composed resources and compute status # {{- $observed := $.observed.resources | default dict }} @@ -10,7 +10,7 @@ # ============================================================================== {{- $checkReady := dict }} -{{- range $key := list "stackgres-operator" "atlas-operator" }} +{{- range $key := list "cnpg-operator" "cnpg-scale-to-zero" "atlas-operator" "storageclass" "volumesnapshotclass" }} {{- $entry := get $observed $key | default dict }} {{- $resource := $entry.resource | default dict }} {{- $status := $resource.status | default dict }} @@ -27,8 +27,11 @@ # Set observed state # ============================================================================== {{- $state = set $state "observed" (dict - "stackgresOperator" (dict "ready" (get $checkReady "stackgres-operator")) + "cnpg" (dict "ready" (get $checkReady "cnpg-operator")) + "scaleToZeroPlugin" (dict "ready" (get $checkReady "cnpg-scale-to-zero")) "atlasOperator" (dict "ready" (get $checkReady "atlas-operator")) + "storageClass" (dict "ready" (get $checkReady "storageclass")) + "snapshotClass" (dict "ready" (get $checkReady "volumesnapshotclass")) ) }} # ============================================================================== diff --git a/functions/stack/170-volumesnapshotclass.yaml.gotmpl b/functions/stack/170-volumesnapshotclass.yaml.gotmpl new file mode 100644 index 0000000..92fa940 --- /dev/null +++ b/functions/stack/170-volumesnapshotclass.yaml.gotmpl @@ -0,0 +1,47 @@ +# code: language=yaml +# +# VolumeSnapshotClass — the stack's only storage-layer composition. +# +# PSQLBranch references this VSC by name to fork PSQLCluster primaries via +# CSI snapshots. The stack does NOT compose a StorageClass — it relies on +# whatever the target cluster already provides (gp3 on EKS Auto Mode, +# standard on kind/k3d, etc.). +# +# Default driver: ebs.csi.eks.amazonaws.com (EKS Auto Mode's managed EBS CSI +# driver; sourced from $state.snapshotClass.driver in 000-state-init.yaml.gotmpl). +# Override $spec.snapshotClass.driver for non-AWS / non-EBS providers +# (e.g. ebs.csi.aws.com for self-managed EBS, driver.longhorn.io, +# hostpath.csi.k8s.io). +# + +{{- $snap := $state.snapshotClass }} +{{- if $snap.enabled }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-volumesnapshotclass + annotations: + {{ setResourceNameAnnotation "volumesnapshotclass" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: snapshot.storage.k8s.io/v1 + kind: VolumeSnapshotClass + metadata: + name: {{ $snap.name }} + labels: {{ $state.labels | toJson }} + driver: {{ $snap.driver | quote }} + deletionPolicy: {{ $snap.deletionPolicy }} + {{- if $snap.parameters }} + parameters: + {{- range $k, $v := $snap.parameters }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- end }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} diff --git a/functions/stack/180-storageclass.yaml.gotmpl b/functions/stack/180-storageclass.yaml.gotmpl new file mode 100644 index 0000000..c377e37 --- /dev/null +++ b/functions/stack/180-storageclass.yaml.gotmpl @@ -0,0 +1,49 @@ +# code: language=yaml +# +# StorageClass — paired with the VolumeSnapshotClass composed in 170-. +# +# PSQLCluster's PVC and PSQLBranch's restored PVC both default to this class +# (XRD default: "psql"). Snapshots only work when the snapshotter driver +# matches the StorageClass provisioner, so the two are composed together +# with the same CSI driver value. +# +# Default provisioner: ebs.csi.eks.amazonaws.com (EKS Auto Mode's managed EBS +# CSI driver), parameters {type: gp3}, volumeBindingMode WaitForFirstConsumer +# (correct for zonal CSI drivers — late-binds the PVC to a node so EBS volumes +# land in the same AZ as the consuming pod). Override +# $spec.storageClass.{provisioner,parameters} for non-EBS providers. +# + +{{- $sc := $state.storageClass }} +{{- if $sc.enabled }} +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-storageclass + annotations: + {{ setResourceNameAnnotation "storageclass" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: storage.k8s.io/v1 + kind: StorageClass + metadata: + name: {{ $sc.name }} + labels: {{ $state.labels | toJson }} + provisioner: {{ $sc.provisioner | quote }} + reclaimPolicy: {{ $sc.reclaimPolicy }} + volumeBindingMode: {{ $sc.volumeBindingMode }} + allowVolumeExpansion: {{ $sc.allowVolumeExpansion }} + {{- if $sc.parameters }} + parameters: + {{- range $k, $v := $sc.parameters }} + {{ $k }}: {{ $v | quote }} + {{- end }} + {{- end }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} +{{- end }} diff --git a/functions/stack/200-cnpg-operator.yaml.gotmpl b/functions/stack/200-cnpg-operator.yaml.gotmpl new file mode 100644 index 0000000..e1a442c --- /dev/null +++ b/functions/stack/200-cnpg-operator.yaml.gotmpl @@ -0,0 +1,91 @@ +# code: language=yaml +# +# Helm Release: cloudnative-pg operator +# +# CloudNativePG is a CNCF PostgreSQL operator for Kubernetes covering the +# full lifecycle of PostgreSQL clusters (HA, streaming replication, +# VolumeSnapshot backups, Barman cloud, pg_basebackup clones, CNPG-I +# plugin protocol for extensions like scale-to-zero). +# +# Chart: https://cloudnative-pg.github.io/charts (chart name: cloudnative-pg) +# Upstream: https://github.com/cloudnative-pg/cloudnative-pg +# + +{{- $cnpg := $state.cnpg }} + +--- +apiVersion: helm.m.crossplane.io/v1beta1 +kind: Release +metadata: + name: {{ $cnpg.name }} + annotations: + {{ setResourceNameAnnotation "cnpg-operator" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + chart: + name: cloudnative-pg + repository: https://cloudnative-pg.github.io/charts + version: {{ $cnpg.chartVersion | quote }} + namespace: {{ $cnpg.namespace }} + {{- if $cnpg.overrideAllValues }} + values: + {{- toYaml $cnpg.overrideAllValues | nindent 6 }} + {{- else }} + {{- /* Chart defaults — Auto Mode handles scheduling, no nodeSelector/ + tolerations injected. */}} + {{- $chartDefaults := dict }} + {{- if $state.ha.enabled }} + {{- $_ := set $chartDefaults "replicaCount" $state.ha.replicas }} + {{- if $state.ha.topologySpreadByZone }} + {{- $_ := set $chartDefaults "topologySpreadConstraints" (list (dict + "maxSkew" 1 + "topologyKey" "topology.kubernetes.io/zone" + "whenUnsatisfiable" "ScheduleAnyway" + "labelSelector" (dict "matchLabels" (dict "app.kubernetes.io/name" "cloudnative-pg")) + )) }} + {{- end }} + {{- end }} + {{- $mergedValues := mergeOverwrite $chartDefaults ($cnpg.values | default dict) }} + values: + {{- toYaml $mergedValues | nindent 6 }} + {{- end }} + rollbackLimit: 3 + providerConfigRef: + name: {{ $state.helmProviderConfigRef.name }} + kind: {{ $state.helmProviderConfigRef.kind }} + +# ============================================================================== +# Usage: Protect cnpg-operator from deletion until atlas-operator is deleted. +# Atlas manages schema migrations against CNPG-managed databases; deleting CNPG +# first would orphan Atlas's migration state. +# +# Gate on observed *existence* (both Releases present in $.observed.resources), +# NOT readiness. If Atlas is still progressing or in error and the user deletes +# the stack at that moment, the Usage must already exist — otherwise CNPG could +# be torn down first and orphan Atlas. +# ============================================================================== +{{- $observed := $.observed.resources | default dict }} +{{- if and (hasKey $observed "cnpg-operator") (hasKey $observed "atlas-operator") }} +--- +apiVersion: protection.crossplane.io/v1beta1 +kind: Usage +metadata: + name: {{ $state.name }}-delete-atlas-operator-before-cnpg + annotations: + {{ setResourceNameAnnotation "usage-cnpg-atlas" }} + labels: {{ $state.labels | toJson }} +spec: + replayDeletion: true + of: + apiVersion: helm.m.crossplane.io/v1beta1 + kind: Release + resourceRef: + name: {{ $cnpg.name }} + by: + apiVersion: helm.m.crossplane.io/v1beta1 + kind: Release + resourceRef: + name: {{ $state.atlasOperator.name }} +{{- end }} diff --git a/functions/stack/210-cnpg-scale-to-zero.yaml.gotmpl b/functions/stack/210-cnpg-scale-to-zero.yaml.gotmpl new file mode 100644 index 0000000..969016d --- /dev/null +++ b/functions/stack/210-cnpg-scale-to-zero.yaml.gotmpl @@ -0,0 +1,385 @@ +# code: language=yaml +# +# cnpg-i-scale-to-zero plugin install +# +# Materializes the upstream release manifest as a set of Crossplane Objects. +# Source: https://github.com/xataio/cnpg-i-scale-to-zero/releases/download/$VERSION/manifest.yaml +# (renovate: datasource=github-releases depName=xataio/cnpg-i-scale-to-zero) +# +# Prereq: cert-manager must already be installed on the target cluster — the +# plugin uses cert-manager Certificate + Issuer to provision the gRPC server +# and client TLS material. In hops-ops, cert-manager is provided by +# the dns-stack (deploy that first). +# +# When PSQLClusters opt into scale-to-zero (via annotations + plugins:[] entry), +# the operator routes lifecycle hooks here over gRPC; the plugin injects a +# sidecar (image $state.scaleToZeroPlugin.version) into each cluster pod that +# monitors connection activity and toggles spec.instances=0 on idle. +# + +{{- $s2z := $state.scaleToZeroPlugin }} +{{- if $s2z.enabled }} + +{{- $ns := $s2z.namespace }} +{{- $ver := $s2z.version }} +{{- $pluginImage := printf "ghcr.io/xataio/cnpg-i-scale-to-zero:%s" $ver }} +{{- $sidecarImage := printf "ghcr.io/xataio/cnpg-i-scale-to-zero-sidecar:%s" $ver }} + +# ServiceAccount +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-s2z-serviceaccount + annotations: + {{ setResourceNameAnnotation "s2z-serviceaccount" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: v1 + kind: ServiceAccount + metadata: + name: cnpg-scale-to-zero-plugin + namespace: {{ $ns }} + labels: {{ $state.labels | toJson }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +# ClusterRole — sidecar's required permissions on CNPG Clusters / ScheduledBackups +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-s2z-clusterrole + annotations: + {{ setResourceNameAnnotation "s2z-clusterrole" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRole + metadata: + name: cnpg-scale-to-zero-sidecar-role + labels: {{ $state.labels | toJson }} + rules: + - apiGroups: ["postgresql.cnpg.io"] + resources: ["clusters", "scheduledbackups"] + verbs: ["get", "list", "watch", "update", "patch"] + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +# ClusterRoleBinding +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-s2z-clusterrolebinding + annotations: + {{ setResourceNameAnnotation "s2z-clusterrolebinding" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: rbac.authorization.k8s.io/v1 + kind: ClusterRoleBinding + metadata: + name: cnpg-scale-to-zero-plugin-binding + labels: {{ $state.labels | toJson }} + roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cnpg-scale-to-zero-sidecar-role + subjects: + - kind: ServiceAccount + name: cnpg-scale-to-zero-plugin + namespace: {{ $ns }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +# Config Secret — sidecar image reference (paired with plugin version) +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-s2z-config-secret + annotations: + {{ setResourceNameAnnotation "s2z-config-secret" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: v1 + kind: Secret + metadata: + name: scale-to-zero-config + namespace: {{ $ns }} + labels: {{ $state.labels | toJson }} + type: Opaque + stringData: + SIDECAR_IMAGE: {{ $sidecarImage | quote }} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +# Self-signed Issuer (cert-manager) for gRPC TLS +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-s2z-issuer + annotations: + {{ setResourceNameAnnotation "s2z-issuer" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: cert-manager.io/v1 + kind: Issuer + metadata: + name: cnpg-scale-to-zero-selfsigned-issuer + namespace: {{ $ns }} + labels: {{ $state.labels | toJson }} + spec: + selfSigned: {} + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +# Server Certificate — gRPC server TLS (used by plugin Deployment) +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-s2z-cert-server + annotations: + {{ setResourceNameAnnotation "s2z-cert-server" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: cert-manager.io/v1 + kind: Certificate + metadata: + name: scaletozero-server + namespace: {{ $ns }} + labels: {{ $state.labels | toJson }} + spec: + commonName: scale-to-zero + dnsNames: + - scale-to-zero + - scale-to-zero.{{ $ns }} + - scale-to-zero.{{ $ns }}.svc + - scale-to-zero.{{ $ns }}.svc.cluster.local + duration: 2160h + renewBefore: 360h + isCA: false + issuerRef: + group: cert-manager.io + kind: Issuer + name: cnpg-scale-to-zero-selfsigned-issuer + secretName: scaletozero-server-tls + usages: + - server auth + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +# Client Certificate — gRPC client TLS (used by CNPG operator to call plugin) +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-s2z-cert-client + annotations: + {{ setResourceNameAnnotation "s2z-cert-client" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: cert-manager.io/v1 + kind: Certificate + metadata: + name: scaletozero-client + namespace: {{ $ns }} + labels: {{ $state.labels | toJson }} + spec: + commonName: scaletozero-client + duration: 2160h + renewBefore: 360h + isCA: false + issuerRef: + group: cert-manager.io + kind: Issuer + name: cnpg-scale-to-zero-selfsigned-issuer + secretName: scaletozero-client-tls + usages: + - client auth + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +# Service — exposes plugin gRPC endpoint to CNPG operator +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-s2z-service + annotations: + {{ setResourceNameAnnotation "s2z-service" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: v1 + kind: Service + metadata: + name: scale-to-zero + namespace: {{ $ns }} + annotations: + cnpg.io/pluginClientSecret: scaletozero-client-tls + cnpg.io/pluginPort: "9090" + cnpg.io/pluginServerSecret: scaletozero-server-tls + labels: + {{- range $k, $v := $state.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + app: scale-to-zero + cnpg.io/pluginName: cnpg-i-scale-to-zero.xata.io + spec: + ports: + - name: grpc + port: 9090 + protocol: TCP + targetPort: 9090 + selector: + app: scale-to-zero + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +# Deployment — the plugin's gRPC server +--- +apiVersion: kubernetes.m.crossplane.io/v1alpha1 +kind: Object +metadata: + name: {{ $state.name }}-s2z-deployment + annotations: + {{ setResourceNameAnnotation "cnpg-scale-to-zero" }} + labels: {{ $state.labels | toJson }} +spec: + managementPolicies: {{ $state.managementPolicies | toJson }} + forProvider: + manifest: + apiVersion: apps/v1 + kind: Deployment + metadata: + name: scale-to-zero + namespace: {{ $ns }} + labels: + {{- range $k, $v := $state.labels }} + {{ $k }}: {{ $v | quote }} + {{- end }} + app: scale-to-zero + spec: + replicas: {{ if $state.ha.enabled }}{{ $state.ha.replicas }}{{ else }}1{{ end }} + selector: + matchLabels: + app: scale-to-zero + template: + metadata: + labels: + app: scale-to-zero + spec: + serviceAccountName: cnpg-scale-to-zero-plugin + {{- if and $state.ha.enabled $state.ha.topologySpreadByZone }} + topologySpreadConstraints: + - maxSkew: 1 + topologyKey: topology.kubernetes.io/zone + whenUnsatisfiable: ScheduleAnyway + labelSelector: + matchLabels: + app: scale-to-zero + {{- end }} + securityContext: + runAsNonRoot: true + runAsUser: 10001 + runAsGroup: 10001 + fsGroup: 10001 + containers: + - name: cnpg-i-scale-to-zero + image: {{ $pluginImage | quote }} + args: + - plugin + - --server-cert=/server/tls.crt + - --server-key=/server/tls.key + - --client-cert=/client/tls.crt + - --server-address=:9090 + env: + - name: LOG_LEVEL + value: info + - name: SIDECAR_CPU_REQUEST + value: 50m + - name: SIDECAR_CPU_LIMIT + value: 200m + - name: SIDECAR_MEMORY_REQUEST + value: 64Mi + - name: SIDECAR_MEMORY_LIMIT + value: 64Mi + envFrom: + - secretRef: + name: scale-to-zero-config + ports: + - name: grpc + containerPort: 9090 + protocol: TCP + livenessProbe: + tcpSocket: + port: 9090 + initialDelaySeconds: 10 + periodSeconds: 30 + timeoutSeconds: 5 + failureThreshold: 3 + readinessProbe: + tcpSocket: + port: 9090 + initialDelaySeconds: 5 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 3 + resources: + requests: + cpu: 100m + memory: 128Mi + limits: + cpu: 500m + memory: 128Mi + volumeMounts: + - name: server + mountPath: /server + - name: client + mountPath: /client + volumes: + - name: server + secret: + secretName: scaletozero-server-tls + - name: client + secret: + secretName: scaletozero-client-tls + providerConfigRef: + name: {{ $state.kubernetesProviderConfigRef.name }} + kind: {{ $state.kubernetesProviderConfigRef.kind }} + +{{- end }} diff --git a/functions/render/210-atlas-operator.yaml.gotmpl b/functions/stack/220-atlas-operator.yaml.gotmpl similarity index 69% rename from functions/render/210-atlas-operator.yaml.gotmpl rename to functions/stack/220-atlas-operator.yaml.gotmpl index 9cbb1dc..ab8f902 100644 --- a/functions/render/210-atlas-operator.yaml.gotmpl +++ b/functions/stack/220-atlas-operator.yaml.gotmpl @@ -31,6 +31,17 @@ spec: {{- $chartDefaults := dict "prewarmDevDB" true }} + {{- if $state.ha.enabled }} + {{- $_ := set $chartDefaults "replicaCount" $state.ha.replicas }} + {{- if $state.ha.topologySpreadByZone }} + {{- $_ := set $chartDefaults "topologySpreadConstraints" (list (dict + "maxSkew" 1 + "topologyKey" "topology.kubernetes.io/zone" + "whenUnsatisfiable" "ScheduleAnyway" + "labelSelector" (dict "matchLabels" (dict "app.kubernetes.io/name" "atlas-operator")) + )) }} + {{- end }} + {{- end }} {{- $mergedValues := mergeOverwrite $chartDefaults ($atlas.values | default dict) }} values: {{- toYaml $mergedValues | nindent 6 }} diff --git a/functions/render/999-status.yaml.gotmpl b/functions/stack/999-status.yaml.gotmpl similarity index 100% rename from functions/render/999-status.yaml.gotmpl rename to functions/stack/999-status.yaml.gotmpl diff --git a/tests/e2etest-psql/main.k b/tests/e2etest-psql/main.k index bececbf..e95370c 100644 --- a/tests/e2etest-psql/main.k +++ b/tests/e2etest-psql/main.k @@ -1,20 +1,55 @@ +import base64 +import file import datetime import math -import models.ai.com.ops.hops.v1alpha1 as stacksv1alpha1 +import models.ai.com.ops.hops.v1alpha1 as hopsv1alpha1 import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 # ============================================================================== -# E2E Test for Psql (Kubernetes-only) +# Unified E2E Test for psql-stack — full Ready integration on a real EKS cluster. # -# Both components run in-cluster: Helm releases only. -# No cloud credentials needed - uses InjectedIdentity for Helm provider. +# Local-fidelity pattern (mirrors aws-observe-stack): the kind cluster the +# runner spins up is the *management* cluster. The actual XRs target an +# ephemeral EKS Auto Mode cluster provisioned during the test, where the +# `psql` StorageClass + VolumeSnapshotClass composed by PSQLStack land on a +# real `ebs.csi.eks.amazonaws.com` driver — same code path that runs on +# pat-local. No fake CSI on kind; snapshots and forks actually work. +# +# Chain: +# 1. extraResources: AutoEKSCluster XR provisions the EKS cluster +# (subnets reused from the persistent hops-test network created by the +# aws-network e2e test). aws-auto-eks-cluster auto-creates Helm + +# Kubernetes ProviderConfigs named after the cluster. +# 2. extraResources: VolumeSnapshotStack XR installs the cluster-wide +# snapshot-controller via Helm into the new cluster. +# 3. manifests: PSQLStack reconciles → Helm-installs CNPG + Atlas + S2Z + +# `psql` StorageClass + `psql` VolumeSnapshotClass. +# 4. manifests: PSQLCluster reconciles → CNPG provisions a real Postgres +# cluster on a real EBS-backed PVC. +# 5. manifests: PSQLBranch reconciles → snapshots the source PVC via the +# composed `psql` VSC, restores into a new CNPG cluster bootstrapped +# from the snapshot. # # Run: make e2e # Run without cleanup: up test run tests/e2etest-psql --e2e --skip-control-plane-cleanup # ============================================================================== +# Workflow writes credentials + env vars to these files (write-env-files: true). +_creds = file.read("secrets/aws-creds") +_base64_creds = base64.encode(_creds) +_region = file.read("env/AWS_REGION").strip() +_account_id = file.read("env/AWS_ACCOUNT_ID").strip() +_admin_role_arn = file.read("env/ADMIN_ROLE_ARN").strip() +_private_subnet_ids = [ + file.read("env/PRIVATE_SUBNET_ID_A").strip() + file.read("env/PRIVATE_SUBNET_ID_B").strip() +] + _now = str(int(math.floor(datetime.ticks()))) _test_name = "e2e-psql-" + _now +_cluster_name = "e2e-psql-cluster-" + _now +_branch_name = "e2e-psql-branch-" + _now +_namespace = "default" _items = [ metav1alpha1.E2ETest { @@ -24,80 +59,297 @@ _items = [ autoUpgrade.channel = "Rapid" } defaultConditions = ["Ready"] - timeoutSeconds = 1800 # 30 minutes - 2 Helm charts + CRDs - cleanupTimeoutSeconds = 900 # 15 minutes + # 90 minutes — EKS Auto Mode cold start is ~10–15min; CNPG bootstrap + # + snapshot fork add another ~10min on top of the platform install. + timeoutSeconds = 5400 + # 30 minutes for cleanup (delete-extra-resources tears the cluster + # down; that takes ~10–15min on its own). + cleanupTimeoutSeconds = 1800 skipDelete = False - # ============================================================== - # extraResources: RBAC + Helm ProviderConfig - # These are NOT deleted during cleanup - # ============================================================== + # ================================================================== + # initResources: dependency Configuration packages + # Installed BEFORE manifests, NOT deleted during cleanup. + # ================================================================== + initResources = [ + # aws-auto-eks-cluster — provides the AutoEKSCluster XRD. + { + apiVersion = "pkg.crossplane.io/v1" + kind = "Configuration" + metadata.name = "aws-auto-eks-cluster" + spec.package = "ghcr.io/hops-ops/aws-auto-eks-cluster:v0.11.0" + } + # volume-snapshot-stack — provides the VolumeSnapshotStack XRD + # (installs the cluster-wide snapshot-controller). PSQLBranch's + # VolumeSnapshot only reaches readyToUse=true with this running. + { + apiVersion = "pkg.crossplane.io/v1" + kind = "Configuration" + metadata.name = "hops-ops-volume-snapshot-stack" + spec.package = "ghcr.io/hops-ops/volume-snapshot-stack:v0.1.0" + } + # cert-stack — installs cert-manager onto the target cluster. + # PSQLStack's scaleToZeroPlugin uses cert-manager Issuer + + # Certificate for the plugin's gRPC mTLS pair, so without this + # the stack stays NotReady on `s2z-cert-{client,server,issuer}`. + { + apiVersion = "pkg.crossplane.io/v1" + kind = "Configuration" + metadata.name = "hops-ops-cert-stack" + spec.package = "ghcr.io/hops-ops/cert-stack:v0.1.0" + } + ] + + # ================================================================== + # extraResources: AWS creds + ProviderConfigs + the EKS cluster XR + # itself. NOT deleted during cleanup (the cluster XR is torn down + # separately via delete-extra-resources in the workflow so it + # outlives the manifests' cascade delete). + # ================================================================== extraResources = [ - # Grant cluster-admin to all SAs in crossplane-system namespace - # Required for Helm provider to create namespaces and install charts + # AWS credentials secret (sourced from the OIDC-assumed role). { - apiVersion = "rbac.authorization.k8s.io/v1" - kind = "ClusterRoleBinding" - metadata.name = "crossplane-system-cluster-admin" - roleRef = { - apiGroup = "rbac.authorization.k8s.io" - kind = "ClusterRole" - name = "cluster-admin" - } - subjects = [ - { - kind = "Group" - name = "system:serviceaccounts:crossplane-system" - apiGroup = "rbac.authorization.k8s.io" - } - ] + apiVersion = "v1" + kind = "Secret" + metadata = { + name = "aws-creds" + namespace = _namespace + } + data = { + creds = _base64_creds + } } - # Helm ProviderConfig - uses in-cluster identity + # AWS ProviderConfig — for AutoEKSCluster's IAM/EKS resources. { - apiVersion = "helm.m.crossplane.io/v1beta1" + apiVersion = "aws.m.upbound.io/v1beta1" kind = "ProviderConfig" metadata = { name = "default" - namespace = "default" + namespace = _namespace } spec = { credentials = { - source = "InjectedIdentity" + source = "Secret" + secretRef = { + namespace = _namespace + name = "aws-creds" + key = "creds" + } + } + } + } + # Kubernetes ProviderConfig "default" — used by aws-auto-eks-cluster + # to apply the per-cluster Helm + Kubernetes ProviderConfigs into + # the management cluster (kind). Cluster-targeting XRs reference + # the per-cluster ProviderConfigs (named after _test_name). + { + apiVersion: "kubernetes.m.crossplane.io/v1alpha1" + kind: "ProviderConfig" + metadata: { + name: "default" + namespace: "default" + } + spec: { + credentials: { + source: "InjectedIdentity" + } + } + } + # DeploymentRuntimeConfig for provider-kubernetes — gives the + # provider a known SA name we can grant cluster-admin on the + # management cluster. + { + apiVersion: "pkg.crossplane.io/v1beta1" + kind: "DeploymentRuntimeConfig" + metadata: { + name: "crossplane-contrib-provider-kubernetes" + } + spec: { + serviceAccountTemplate: { + metadata: { + name: "crossplane-contrib-provider-kubernetes" + } + } + } + } + { + apiVersion: "v1" + kind: "ServiceAccount" + metadata: { + name: "crossplane-contrib-provider-kubernetes" + namespace: "crossplane-system" + } + } + { + apiVersion: "rbac.authorization.k8s.io/v1" + kind: "ClusterRoleBinding" + metadata: { + name: "crossplane-contrib-provider-kubernetes-admin" + } + roleRef: { + apiGroup: "rbac.authorization.k8s.io" + kind: "ClusterRole" + name: "cluster-admin" + } + subjects: [ + { + kind: "ServiceAccount" + name: "crossplane-contrib-provider-kubernetes" + namespace: "crossplane-system" + } + ] + } + # Pin the K8s provider so the runtime config takes effect. + { + apiVersion: "pkg.crossplane.io/v1" + kind: "Provider" + metadata: { + name: "crossplane-contrib-provider-kubernetes" + } + spec: { + package: "xpkg.crossplane.io/crossplane-contrib/provider-kubernetes:v1.2.0" + runtimeConfigRef: { + name: "crossplane-contrib-provider-kubernetes" + } + } + } + # The ephemeral EKS cluster. Reuses the persistent hops-test + # network's private subnets so we don't pay VPC create cost + # per PR. AutoEKSCluster auto-creates Helm + Kubernetes + # ProviderConfigs named _test_name once Ready. + { + apiVersion = "aws.hops.ops.com.ai/v1alpha1" + kind = "AutoEKSCluster" + metadata = { + name = _test_name + namespace = _namespace + } + spec = { + adminRoleArn = _admin_role_arn + clusterName = _test_name + region = _region + accountId = _account_id + version = "1.35" + subnetIds = _private_subnet_ids + providerConfigRef = { + name = "default" + kind = "ProviderConfig" + } + kubernetesProviderConfigRef = { + name = "default" + kind = "ProviderConfig" + } + tags = { + "e2etest" = "true" + "test-run" = _now + "repo": "https://github.com/hops-ops/psql-stack" + } + publicAccess = True + nodeConfig = { + enabled = True + } + oidc = { + enabled = False + } + } + } + # snapshot-controller on the new cluster — gates VolumeSnapshot + # readiness for PSQLBranch's fork. clusterName = _test_name + # routes Helm installs through the per-cluster ProviderConfig + # auto-created by AutoEKSCluster. + { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "VolumeSnapshotStack" + metadata = { + name = "snapshot" + namespace = _namespace + } + spec = { + clusterName = _test_name + labels = { + "hops.ops.com.ai/e2etest" = "true" + "hops.ops.com.ai/test-run" = _now + } + } + } + # cert-manager on the new cluster — gates the s2z plugin's TLS + # pair (Issuer + client/server Certificates). Without this, + # PSQLStack stays NotReady on s2z-cert-* objects. + { + apiVersion = "hops.ops.com.ai/v1alpha1" + kind = "CertStack" + metadata = { + name = "cert" + namespace = _namespace + } + spec = { + clusterName = _test_name + labels = { + "hops.ops.com.ai/e2etest" = "true" + "hops.ops.com.ai/test-run" = _now } } } ] - # ============================================================== - # manifests: Psql XR - # Deleted during cleanup (unless --skip-delete) - # ============================================================== + # ================================================================== + # manifests: the three psql-stack XRs. Each targets the EKS cluster + # via clusterName=_test_name, which the compositions translate into + # providerConfigRef.name on the per-cluster ProviderConfigs created + # by AutoEKSCluster. Cleaned up cascade-deletes the CNPG primaries + + # branch + their PVCs/snapshots; the cluster itself stays up until + # the workflow's delete-extra-resources step tears it down. + # ================================================================== manifests = [ - stacksv1alpha1.PSQLStack { + hopsv1alpha1.PSQLStack { metadata = { name = _test_name - namespace = "default" + namespace = _namespace } spec = { - clusterName = "default" - namespace = "stackgres" + clusterName = _test_name labels = { "hops.ops.com.ai/e2etest" = "true" "hops.ops.com.ai/test-run" = _now } - helmProviderConfigRef = { - name = "default" - kind = "ProviderConfig" + } + } + hopsv1alpha1.PSQLCluster { + metadata = { + name = _cluster_name + namespace = _namespace + } + spec = { + clusterName = _test_name + labels = { + "hops.ops.com.ai/e2etest" = "true" + "hops.ops.com.ai/test-run" = _now } - # Minimal resource requests for kind cluster - stackgresOperator.values = { - deploy = { - operator = True - restapi = True - } + storage = {size = "1Gi"} + # No app block — CNPG auto-generates `-app`. + } + } + hopsv1alpha1.PSQLBranch { + metadata = { + name = _branch_name + namespace = _namespace + } + spec = { + clusterName = _test_name + labels = { + "hops.ops.com.ai/e2etest" = "true" + "hops.ops.com.ai/test-run" = _now } - atlasOperator.values = { - prewarmDevDB = False + # source.storage.size mirrors the PSQLCluster's + # `spec.storage.size = "1Gi"` above. The branch + # composition has no automatic visibility into the + # source cluster's PVC capacity, so this explicit + # mirror lets the rendered Cluster CR set the + # required storage.size without forcing the consumer + # to also set `branch.storage.size`. + source = { + name = _cluster_name + storage = {size = "1Gi"} } } } diff --git a/tests/test-render/kcl.mod b/tests/test-branch/kcl.mod similarity index 100% rename from tests/test-render/kcl.mod rename to tests/test-branch/kcl.mod diff --git a/tests/test-branch/main.k b/tests/test-branch/main.k new file mode 100644 index 0000000..4d3a76c --- /dev/null +++ b/tests/test-branch/main.k @@ -0,0 +1,585 @@ +import models.ai.com.ops.hops.v1alpha1 as stacksv1alpha1 +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. +# ============================================================================== + +_items = [ + # ========================================================================== + # Test 1: same-namespace branch — only branch-snapshot composed (no source- + # snapshot bridging), references source PVC directly. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "same-namespace-branch-direct-pvc-reference" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-same", namespace = "default"} + spec = { + clusterName = "my-cluster" + source = {name = "src-app"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-same-branch-snapshot" + spec.forProvider.manifest = { + apiVersion = "snapshot.storage.k8s.io/v1" + kind = "VolumeSnapshot" + metadata = { + name = "br-same-snap" + namespace = "default" + } + spec = { + volumeSnapshotClassName = "psql" + source.persistentVolumeClaimName = "src-app-1" + } + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-same-cnpg-cluster" + spec.forProvider.manifest = { + apiVersion = "postgresql.cnpg.io/v1" + kind = "Cluster" + metadata = { + name = "br-same" + namespace = "default" + labels = { + "hops.ops.com.ai/managed" = "true" + "hops.ops.com.ai/psql-branch" = "br-same" + "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 = { + instances = 1 + bootstrap.recovery.volumeSnapshots.storage = { + name = "br-same-snap" + kind = "VolumeSnapshot" + apiGroup = "snapshot.storage.k8s.io" + } + } + } + } + ] + } + } + + # ========================================================================== + # 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. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "cross-ns-prefers-source-content-when-branch-empty" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-x", namespace = "preview-pr-1"} + spec = { + clusterName = "prod" + source = {name = "src-app", namespace = "team-app"} + } + } + observedResources = [ + # source-ns VolumeSnapshot (Object wrapper) — bound. + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "br-x-source-snapshot" + 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" + } + # branch-ns VolumeSnapshot (Object wrapper) — created with + # empty content reference on the prior reconcile, not bound. + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "br-x-branch-snapshot" + annotations = { + "crossplane.io/composition-resource-name" = "branch-snapshot" + "gotemplating.fn.crossplane.io/composition-resource-name" = "branch-snapshot" + } + } + 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" + } + ] + } + } + + # ========================================================================== + # 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. + # ========================================================================== + 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 = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata = { + name = "br-y-source-snapshot" + 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" + 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" + } + ] + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-y-branch-snapshot" + spec.forProvider.manifest.spec.source.volumeSnapshotContentName = "snapcontent-from-branch" + } + ] + } + } + + # ========================================================================== + # Storage size precedence: branch.storage.size wins over source.storage.size. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "branch-storage-size-overrides-source-size" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-explicit", namespace = "default"} + spec = { + clusterName = "my-cluster" + source = { + name = "src-app" + storage = {size = "100Gi"} + } + branch.storage.size = "150Gi" + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-explicit-cnpg-cluster" + spec.forProvider.manifest.spec.storage.size = "150Gi" + } + ] + } + } + + # ========================================================================== + # Storage size precedence: source.storage.size used when branch size empty. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "branch-storage-size-falls-back-to-source-size" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-inherit", namespace = "default"} + spec = { + clusterName = "my-cluster" + source = { + name = "src-app" + storage = {size = "100Gi"} + } + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-inherit-cnpg-cluster" + spec.forProvider.manifest.spec.storage.size = "100Gi" + } + ] + } + } + + # ========================================================================== + # Test 1b: explicit spec.postgresql.version sets imageName. Branches MUST + # match the source's major version (snapshot recovery is binary-compatible + # only within a major) — pinning the minor here is the typical use case. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "explicit-postgres-version-sets-imagename" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-pinned", namespace = "default"} + spec = { + clusterName = "my-cluster" + source = {name = "src-app"} + postgresql.version = "17.4" + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-pinned-cnpg-cluster" + spec.forProvider.manifest.spec.imageName = "ghcr.io/cloudnative-pg/postgresql:17.4" + } + ] + } + } + + # ========================================================================== + # Test 2: cross-namespace branch — source-snapshot in source ns + branch- + # snapshot in branch ns + Cluster in branch ns. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "cross-namespace-branch-bridges-via-content" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-x", namespace = "preview-pr-1"} + spec = { + clusterName = "prod" + source = {name = "src-app", namespace = "team-app"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-x-source-snapshot" + spec.forProvider.manifest = { + apiVersion = "snapshot.storage.k8s.io/v1" + kind = "VolumeSnapshot" + # Name prefixed with branch namespace so two branches + # with the same XR name in different namespaces don't + # collide on a single `-src` in the shared source ns. + metadata = { + name = "preview-pr-1-br-x-src" + namespace = "team-app" + } + 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" + } + } + ] + } + } + + # ========================================================================== + # Test 2b: same branch XR name (`br-x`) in a DIFFERENT branch namespace + # (`other-team-preview` vs `preview-pr-1` above) produces a different + # source-snapshot name in the shared source ns. Locks in the + # collision-avoidance contract: source-ns name encodes the branch's own + # namespace. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "cross-ns-source-snapshot-name-distinguishes-branch-ns" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-x", namespace = "other-team-preview"} + spec = { + clusterName = "prod" + source = {name = "src-app", namespace = "team-app"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-x-source-snapshot" + spec.forProvider.manifest.metadata = { + name = "other-team-preview-br-x-src" + namespace = "team-app" + } + } + ] + } + } + + # ========================================================================== + # Test 3: scaleToZero default-on; renders annotation + plugin entry. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "scaletozero-default-on-renders-annotation-and-plugin" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-s2z", namespace = "default"} + spec = { + clusterName = "my-cluster" + source = {name = "src"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-s2z-cnpg-cluster" + spec.forProvider.manifest = { + metadata.annotations = { + "cnpg-i-scale-to-zero.xata.io/idle-timeout" = "10m" + } + spec.plugins = [{ + name = "cnpg-i-scale-to-zero.xata.io" + enabled = True + }] + } + } + ] + } + } + + # ========================================================================== + # Test 4: scaleToZero.enabled=false suppresses annotation + plugin. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "scaletozero-disabled-strips-plugin" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-no-s2z", namespace = "default"} + spec = { + clusterName = "my-cluster" + source = {name = "src"} + scaleToZero = {enabled = False} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-no-s2z-cnpg-cluster" + spec.forProvider.manifest.metadata = { + name = "br-no-s2z" + namespace = "default" + } + } + ] + } + } + + # ========================================================================== + # Test 5: ttl.enabled=true adds the hops/ttl annotation on the Cluster CR. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "ttl-enabled-adds-annotation" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-ttl", namespace = "default"} + spec = { + clusterName = "my-cluster" + source = {name = "src"} + ttl = {enabled = True, after = "24h"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-ttl-cnpg-cluster" + spec.forProvider.manifest.metadata.annotations = { + "cnpg-i-scale-to-zero.xata.io/idle-timeout" = "10m" + "hops.ops.com.ai/ttl" = "24h" + } + } + ] + } + } + + # ========================================================================== + # Test 6: custom snapshotClassName flows through to the VolumeSnapshot. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "custom-snapshot-class-flows-through" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-custom-vsc", namespace = "default"} + spec = { + clusterName = "my-cluster" + source = {name = "src", snapshotClassName = "longhorn-snap"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-custom-vsc-branch-snapshot" + spec.forProvider.manifest.spec.volumeSnapshotClassName = "longhorn-snap" + } + ] + } + } + + # ========================================================================== + # Test 7: cnpg.overrideAllValues replaces the entire Cluster.spec. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "cnpg-override-all-values-replaces-cluster-spec" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-override", namespace = "default"} + spec = { + clusterName = "my-cluster" + source = {name = "src"} + cnpg.overrideAllValues = { + instances = 5 + imageName = "custom/postgres:99" + } + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-override-cnpg-cluster" + spec.forProvider.manifest.spec = { + instances = 5 + imageName = "custom/postgres:99" + } + } + ] + } + } + + # ========================================================================== + # Test 8: kubernetesProviderConfigRef defaults to clusterName. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "k8s-provider-config-defaults-to-cluster-name" + spec = { + compositionPath = "apis/psqlbranches/composition.yaml" + xrdPath = "apis/psqlbranches/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLBranch { + metadata = {name = "br-pc", namespace = "default"} + spec = { + clusterName = "prod-cluster" + source = {name = "src"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "br-pc-cnpg-cluster" + spec.providerConfigRef = { + name = "prod-cluster" + kind = "ProviderConfig" + } + } + ] + } + } +] + +items = _items diff --git a/tests/test-render/model b/tests/test-branch/model similarity index 100% rename from tests/test-render/model rename to tests/test-branch/model diff --git a/tests/test-cluster/kcl.mod b/tests/test-cluster/kcl.mod new file mode 100644 index 0000000..e2b40d0 --- /dev/null +++ b/tests/test-cluster/kcl.mod @@ -0,0 +1,6 @@ +[package] +name = "test-render" +version = "0.0.1" + +[dependencies] +models = { path = "./model" } diff --git a/tests/test-cluster/main.k b/tests/test-cluster/main.k new file mode 100644 index 0000000..c0dcd43 --- /dev/null +++ b/tests/test-cluster/main.k @@ -0,0 +1,475 @@ +import models.ai.com.ops.hops.v1alpha1 as stacksv1alpha1 +import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 + +# ============================================================================== +# Unit tests for PSQLCluster XRD +# +# Philosophy: test YOUR API, not CNPG's. Verify spec fields produce expected +# wiring (intent → mechanics) inside the composed Cluster + ExternalSecret. +# ============================================================================== + +_items = [ + # ========================================================================== + # Test 1: minimal claim renders only the Cluster — no ExternalSecret by + # default. Bootstrap wires the app role/database; `secret` is omitted so + # CNPG auto-generates `-app` and owns it. Branching labels + # on by default, monitoring on, no plugins, no affinity, no custom storage + # class. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "minimal-renders-cluster-only" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "test-app", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "10Gi"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "test-app-cnpg-cluster" + spec.forProvider.manifest = { + apiVersion = "postgresql.cnpg.io/v1" + kind = "Cluster" + metadata = { + name = "test-app" + namespace = "default" + labels = { + "hops.ops.com.ai/managed" = "true" + "hops.ops.com.ai/psql-cluster" = "test-app" + "hops.ops.com.ai/branching-source" = "true" + "hops.ops.com.ai/snapshot-class" = "psql" + } + } + spec = { + instances = 1 + imageName = "ghcr.io/cloudnative-pg/postgresql:17" + bootstrap.initdb = { + database = "app" + owner = "app" + } + storage.size = "10Gi" + monitoring.enablePodMonitor = True + } + } + } + ] + } + } + + # ========================================================================== + # Test 2: ha.enabled=true bumps instances + adds zonal anti-affinity. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "ha-enabled-bumps-instances-and-affinity" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "ha-app", namespace = "default"} + spec = { + clusterName = "prod" + storage = {size = "20Gi"} + ha = {enabled = True, replicas = 3, topologySpreadByZone = True} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "ha-app-cnpg-cluster" + spec.forProvider.manifest.spec = { + instances = 3 + affinity = { + enablePodAntiAffinity = True + podAntiAffinityType = "preferred" + topologyKey = "topology.kubernetes.io/zone" + } + } + } + ] + } + } + + # ========================================================================== + # Test 3: scaleToZero.enabled=true adds annotation + plugins entry. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "scaletozero-enabled-adds-annotation-and-plugin" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "s2z-app", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "5Gi"} + scaleToZero = {enabled = True, idleTimeout = "10m"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "s2z-app-cnpg-cluster" + spec.forProvider.manifest = { + metadata.annotations = { + "cnpg-i-scale-to-zero.xata.io/idle-timeout" = "10m" + } + spec.plugins = [{ + name = "cnpg-i-scale-to-zero.xata.io" + enabled = True + }] + } + } + ] + } + } + + # ========================================================================== + # Test 4: branching.enabled=false strips the branching-source labels. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "branching-disabled-strips-labels" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "no-branch", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "5Gi"} + branching = {enabled = False} + } + } + # Stack labels remain; branching-* labels do not. + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "no-branch-cnpg-cluster" + spec.forProvider.manifest.metadata.labels = { + "hops.ops.com.ai/managed" = "true" + "hops.ops.com.ai/psql-cluster" = "no-branch" + } + } + ] + } + } + + # ========================================================================== + # Test 5: storage.class set passes through to spec.storage.storageClass. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "storage-class-passes-through" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "sc-app", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "5Gi", class = "gp3"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "sc-app-cnpg-cluster" + spec.forProvider.manifest.spec.storage = { + size = "5Gi" + storageClass = "gp3" + } + } + ] + } + } + + # ========================================================================== + # Test 6: postgresql.parameters pass through. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "postgresql-parameters-pass-through" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "tuned-app", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "5Gi"} + postgresql = { + version = "17" + parameters = { + shared_buffers = "256MB" + max_connections = "200" + } + } + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "tuned-app-cnpg-cluster" + spec.forProvider.manifest.spec.postgresql.parameters = { + shared_buffers = "256MB" + max_connections = "200" + } + } + ] + } + } + + # ========================================================================== + # Test 7: app.externalSecret renders an ESO ExternalSecret pulling from + # the named ClusterSecretStore. Default behavior (Test 1) doesn't render + # one — this asserts the opt-in path. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "external-secret-renders-when-opted-in" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "es-app", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "5Gi"} + app = { + externalSecret = { + secretStore = { + kind = "ClusterSecretStore" + name = "hops-aws-secrets-manager" + } + secretRef = { + path = "my-cluster/es-app" + } + } + } + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "es-app-cnpg-cluster" + spec.forProvider.manifest.spec.bootstrap.initdb = { + database = "app" + owner = "app" + secret.name = "es-app-app" + } + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "es-app-external-secret-app" + spec.forProvider.manifest = { + apiVersion = "external-secrets.io/v1" + kind = "ExternalSecret" + metadata.name = "es-app-app" + spec = { + refreshInterval = "1h" + secretStoreRef = { + name = "hops-aws-secrets-manager" + kind = "ClusterSecretStore" + } + # Both username + password must be mapped from the + # JSON blob at secretRef.path. CNPG reads this + # Secret as kubernetes.io/basic-auth — a single + # remoteRef without `property` would leave one of + # the two keys empty and CNPG would fail to + # bootstrap the role. Lock in the full shape so + # future drift is caught. + data = [ + { + secretKey = "username" + remoteRef = { + key = "my-cluster/es-app" + property = "username" + } + } + { + secretKey = "password" + remoteRef = { + key = "my-cluster/es-app" + property = "password" + } + } + ] + target = { + name = "es-app-app" + creationPolicy = "Owner" + template = { + type = "kubernetes.io/basic-auth" + data = { + username = "{{ .username }}" + password = "{{ .password }}" + } + } + } + } + } + } + ] + } + } + + # ========================================================================== + # Test 8: cnpg.overrideAllValues replaces the whole Cluster.spec. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "cnpg-override-replaces-cluster-spec" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "override-app", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "5Gi"} # required, but overridden by cnpg.overrideAllValues + cnpg.overrideAllValues = { + instances = 5 + imageName = "custom/postgres:99" + storage = {size = "999Gi"} + } + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "override-app-cnpg-cluster" + spec.forProvider.manifest.spec = { + instances = 5 + imageName = "custom/postgres:99" + storage.size = "999Gi" + } + } + ] + } + } + + # ========================================================================== + # Test 9: BYO mode — explicit `app.secretName` (no externalSecret) wires + # CNPG at the named Secret. Consumer is responsible for pre-creating it. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "byo-secret-name-wires-cnpg-at-named-secret" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "byo-app", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "5Gi"} + app = { + secretName = "my-precreated-secret" + } + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "byo-app-cnpg-cluster" + spec.forProvider.manifest.spec.bootstrap.initdb = { + database = "app" + owner = "app" + secret.name = "my-precreated-secret" + } + } + ] + } + } + + # ========================================================================== + # Test 10: kubernetesProviderConfigRef defaults to clusterName. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "k8s-provider-config-defaults-to-cluster-name" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "p-app", namespace = "default"} + spec = { + clusterName = "prod-cluster" + storage = {size = "5Gi"} + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "p-app-cnpg-cluster" + spec.providerConfigRef = { + name = "prod-cluster" + kind = "ProviderConfig" + } + } + ] + } + } + + # ========================================================================== + # monitoring.enabled=false propagates to the Cluster CR. Default is true + # (covered by Test 1's monitoring.enablePodMonitor = True assertion); this + # locks in that explicit `false` is honored — the hasKey guard in + # state-init distinguishes "explicitly off" from "absent". + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "monitoring-disabled-when-explicit-false" + spec = { + compositionPath = "apis/psqlclusters/composition.yaml" + xrdPath = "apis/psqlclusters/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLCluster { + metadata = {name = "no-mon", namespace = "default"} + spec = { + clusterName = "my-cluster" + storage = {size = "10Gi"} + monitoring.enabled = False + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "no-mon-cnpg-cluster" + spec.forProvider.manifest.spec.monitoring.enablePodMonitor = False + } + ] + } + } +] + +items = _items diff --git a/tests/test-cluster/model b/tests/test-cluster/model new file mode 120000 index 0000000..faff6e4 --- /dev/null +++ b/tests/test-cluster/model @@ -0,0 +1 @@ +../../.up/kcl/models \ No newline at end of file diff --git a/tests/test-render/main.k b/tests/test-render/main.k deleted file mode 100644 index 0aa1b7c..0000000 --- a/tests/test-render/main.k +++ /dev/null @@ -1,303 +0,0 @@ -import models.ai.com.ops.hops.v1alpha1 as stacksv1alpha1 -import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 -import models.k8s.apimachinery.pkg.apis.meta.v1 as metav1 - -# ============================================================================== -# Unit tests for Psql XRD -# -# Philosophy: Test YOUR API, not the provider's API. -# - render/validate already checks provider schema correctness -# - Tests should verify XRD spec fields produce expected behavior -# ============================================================================== - -_items = [ - # ========================================================================== - # Test 1: Minimal input renders both Helm Releases - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "minimal-renders-all-components" - spec = { - compositionPath = "apis/psqlstacks/composition.yaml" - xrdPath = "apis/psqlstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = stacksv1alpha1.PSQLStack { - metadata.name = "test-psql" - spec.clusterName = "my-cluster" - } - assertResources = [ - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "stackgres-operator" - } - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "atlas-operator" - } - ] - } - } - - # ========================================================================== - # Test 2: Custom labels are merged with defaults - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "custom-labels-merged-with-defaults" - spec = { - compositionPath = "apis/psqlstacks/composition.yaml" - xrdPath = "apis/psqlstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = stacksv1alpha1.PSQLStack { - metadata.name = "labeled-psql" - spec = { - clusterName = "my-cluster" - labels = {team = "platform", environment = "staging"} - } - } - assertResources = [ - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata = { - name = "stackgres-operator" - labels = { - "hops.ops.com.ai/managed" = "true" - "hops.ops.com.ai/psql" = "labeled-psql" - team = "platform" - environment = "staging" - } - } - } - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata = { - name = "atlas-operator" - labels = { - "hops.ops.com.ai/managed" = "true" - "hops.ops.com.ai/psql" = "labeled-psql" - team = "platform" - environment = "staging" - } - } - } - ] - } - } - - # ========================================================================== - # Test 3: overrideAllValues on StackGres replaces all defaults - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "stackgres-override-all-values-replaces-defaults" - spec = { - compositionPath = "apis/psqlstacks/composition.yaml" - xrdPath = "apis/psqlstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = stacksv1alpha1.PSQLStack { - metadata.name = "override-test" - spec = { - clusterName = "my-cluster" - stackgresOperator = { - overrideAllValues = { - deploy = {operator = True, restapi = False} - } - } - } - } - assertResources = [ - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "stackgres-operator" - spec.forProvider.values = { - deploy = {operator = True, restapi = False} - } - } - ] - } - } - - # ========================================================================== - # Test 4: StackGres user values merge with defaults - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "stackgres-values-merge-with-defaults" - spec = { - compositionPath = "apis/psqlstacks/composition.yaml" - xrdPath = "apis/psqlstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = stacksv1alpha1.PSQLStack { - metadata.name = "sg-merge" - spec = { - clusterName = "my-cluster" - stackgresOperator = { - values = { - grafana = {autoEmbed = True} - } - } - } - } - assertResources = [ - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "stackgres-operator" - spec.forProvider.values = { - deploy = {operator = True, restapi = True} - grafana = {autoEmbed = True} - } - } - ] - } - } - - # ========================================================================== - # Test 5: Atlas Operator user values merge with defaults - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "atlas-values-merge-with-defaults" - spec = { - compositionPath = "apis/psqlstacks/composition.yaml" - xrdPath = "apis/psqlstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = stacksv1alpha1.PSQLStack { - metadata.name = "atlas-merge" - spec = { - clusterName = "my-cluster" - atlasOperator = { - values = { - prewarmDevDB = False - } - } - } - } - assertResources = [ - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "atlas-operator" - spec.forProvider.values = { - prewarmDevDB = False - } - } - ] - } - } - - # ========================================================================== - # Test 6: Custom namespace propagates to both components - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "custom-namespace-propagates" - spec = { - compositionPath = "apis/psqlstacks/composition.yaml" - xrdPath = "apis/psqlstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = stacksv1alpha1.PSQLStack { - metadata.name = "ns-test" - spec = { - clusterName = "my-cluster" - namespace = "database-system" - } - } - assertResources = [ - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "stackgres-operator" - spec.forProvider.namespace = "database-system" - } - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "atlas-operator" - spec.forProvider.namespace = "database-system" - } - ] - } - } - - # ========================================================================== - # Test 7: Per-component namespace overrides shared namespace - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "per-component-namespace-override" - spec = { - compositionPath = "apis/psqlstacks/composition.yaml" - xrdPath = "apis/psqlstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = stacksv1alpha1.PSQLStack { - metadata.name = "ns-override" - spec = { - clusterName = "my-cluster" - namespace = "stackgres" - atlasOperator = { - namespace = "atlas-system" - } - } - } - assertResources = [ - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "stackgres-operator" - spec.forProvider.namespace = "stackgres" - } - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "atlas-operator" - spec.forProvider.namespace = "atlas-system" - } - ] - } - } - - # ========================================================================== - # Test 8: Helm provider config ref defaults to clusterName - # ========================================================================== - metav1alpha1.CompositionTest { - metadata.name = "helm-provider-config-defaults-to-cluster-name" - spec = { - compositionPath = "apis/psqlstacks/composition.yaml" - xrdPath = "apis/psqlstacks/definition.yaml" - timeoutSeconds = 60 - validate = False - xr = stacksv1alpha1.PSQLStack { - metadata.name = "provider-test" - spec.clusterName = "prod-cluster" - } - assertResources = [ - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "stackgres-operator" - spec.providerConfigRef = { - name = "prod-cluster" - kind = "ProviderConfig" - } - } - { - apiVersion = "helm.m.crossplane.io/v1beta1" - kind = "Release" - metadata.name = "atlas-operator" - spec.providerConfigRef = { - name = "prod-cluster" - kind = "ProviderConfig" - } - } - ] - } - } -] - -items = _items diff --git a/tests/test-stack/kcl.mod b/tests/test-stack/kcl.mod new file mode 100644 index 0000000..e2b40d0 --- /dev/null +++ b/tests/test-stack/kcl.mod @@ -0,0 +1,6 @@ +[package] +name = "test-render" +version = "0.0.1" + +[dependencies] +models = { path = "./model" } diff --git a/tests/test-stack/main.k b/tests/test-stack/main.k new file mode 100644 index 0000000..c7e8815 --- /dev/null +++ b/tests/test-stack/main.k @@ -0,0 +1,537 @@ +import models.ai.com.ops.hops.v1alpha1 as stacksv1alpha1 +import models.io.upbound.dev.meta.v1alpha1 as metav1alpha1 + +# ============================================================================== +# Unit tests for PSQLStack XRD +# +# Philosophy: test YOUR API, not the provider's API. +# - render/validate already checks provider schema correctness +# - Tests should verify XRD spec fields produce expected behavior +# ============================================================================== + +_items = [ + # ========================================================================== + # Test 1: minimal claim renders the platform operators (CNPG, Atlas), the + # scale-to-zero plugin (default-on), the StorageClass and VolumeSnapshotClass + # (default name: "psql", paired driver/provisioner: ebs.csi.eks.amazonaws.com). + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "minimal-renders-platform-stack" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "test-psql" + spec.clusterName = "my-cluster" + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "cloudnative-pg" + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "atlas-operator" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "test-psql-storageclass" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "test-psql-volumesnapshotclass" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "test-psql-s2z-deployment" + } + ] + } + } + + # ========================================================================== + # Test 2: custom labels merge with the stack's defaults on every composed + # Release. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "custom-labels-merged-with-defaults" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "labeled-psql" + spec = { + clusterName = "my-cluster" + labels = {team = "platform", environment = "staging"} + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata = { + name = "cloudnative-pg" + labels = { + "hops.ops.com.ai/managed" = "true" + "hops.ops.com.ai/psql" = "labeled-psql" + team = "platform" + environment = "staging" + } + } + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata = { + name = "atlas-operator" + labels = { + "hops.ops.com.ai/managed" = "true" + "hops.ops.com.ai/psql" = "labeled-psql" + team = "platform" + environment = "staging" + } + } + } + ] + } + } + + # ========================================================================== + # Test 3: spec.cnpg.overrideAllValues replaces all default values + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "cnpg-override-all-values-replaces-defaults" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "override-test" + spec = { + clusterName = "my-cluster" + cnpg = { + overrideAllValues = { + replicaCount = 2 + config = {data = {LOG_LEVEL = "debug"}} + } + } + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "cloudnative-pg" + spec.forProvider.values = { + replicaCount = 2 + config = {data = {LOG_LEVEL = "debug"}} + } + } + ] + } + } + + # ========================================================================== + # Test 4: spec.atlasOperator.values merge with chart defaults + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "atlas-values-merge-with-defaults" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "atlas-merge" + spec = { + clusterName = "my-cluster" + atlasOperator = { + values = { + prewarmDevDB = False + } + } + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "atlas-operator" + spec.forProvider.values = { + prewarmDevDB = False + } + } + ] + } + } + + # ========================================================================== + # Test 5: custom namespace propagates to all platform operators + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "custom-namespace-propagates" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "ns-test" + spec = { + clusterName = "my-cluster" + namespace = "database-system" + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "cloudnative-pg" + spec.forProvider.namespace = "database-system" + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "atlas-operator" + spec.forProvider.namespace = "database-system" + } + ] + } + } + + # ========================================================================== + # Test 6: per-component namespace overrides the shared namespace + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "per-component-namespace-override" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "ns-override" + spec = { + clusterName = "my-cluster" + namespace = "cnpg-system" + atlasOperator = { + namespace = "atlas-system" + } + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "cloudnative-pg" + spec.forProvider.namespace = "cnpg-system" + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "atlas-operator" + spec.forProvider.namespace = "atlas-system" + } + ] + } + } + + # ========================================================================== + # Test 7: helmProviderConfigRef defaults to clusterName + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "helm-provider-config-defaults-to-cluster-name" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "provider-test" + spec.clusterName = "prod-cluster" + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "cloudnative-pg" + spec.providerConfigRef = { + name = "prod-cluster" + kind = "ProviderConfig" + } + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "atlas-operator" + spec.providerConfigRef = { + name = "prod-cluster" + kind = "ProviderConfig" + } + } + ] + } + } + + # ========================================================================== + # Test 8: spec.scaleToZeroPlugin.enabled=false suppresses the plugin install + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "scale-to-zero-plugin-can-be-disabled" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "no-s2z" + spec = { + clusterName = "my-cluster" + scaleToZeroPlugin = {enabled = False} + } + } + # Positive presence: cnpg + atlas + SC + VSC still composed. + # Absence of s2z-* resources is implicit (composition gates them). + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "cloudnative-pg" + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "atlas-operator" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "no-s2z-storageclass" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "no-s2z-volumesnapshotclass" + } + ] + } + } + + # ========================================================================== + # Test 9: ha.enabled=true sets replicaCount on CNPG and Atlas Helm releases. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "ha-enabled-injects-replica-count" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "ha-on" + spec = { + clusterName = "prod" + ha = {enabled = True, replicas = 3} + } + } + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "cloudnative-pg" + spec.forProvider.values.replicaCount = 3 + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "atlas-operator" + spec.forProvider.values.replicaCount = 3 + } + ] + } + } + + # ========================================================================== + # Test 10: spec.snapshotClass settings flow through to the composed VSC. + # Default driver = ebs.csi.aws.com; override to verify it propagates. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "snapshotclass-driver-flows-through" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "vsc-tweaks" + spec = { + clusterName = "my-cluster" + snapshotClass = { + driver = "driver.longhorn.io" + deletionPolicy = "Retain" + } + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "vsc-tweaks-volumesnapshotclass" + spec.forProvider.manifest = { + apiVersion = "snapshot.storage.k8s.io/v1" + kind = "VolumeSnapshotClass" + metadata = { + name = "psql" + labels = { + "hops.ops.com.ai/managed" = "true" + "hops.ops.com.ai/psql" = "vsc-tweaks" + } + } + driver = "driver.longhorn.io" + deletionPolicy = "Retain" + } + } + ] + } + } + + # ========================================================================== + # Test 11: snapshotClass.enabled=false suppresses the VSC composition. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "snapshotclass-can-be-disabled" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "no-vsc" + spec = { + clusterName = "my-cluster" + snapshotClass = {enabled = False} + } + } + # Cnpg + atlas + SC still composed. No vsc-* Object expected (implicit). + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "cloudnative-pg" + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "atlas-operator" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "no-vsc-storageclass" + } + ] + } + } + + # ========================================================================== + # Test 12: storageClass settings flow through to the composed StorageClass. + # Override provisioner + parameters to verify they propagate (e.g., for + # non-EKS targets that use a different CSI driver). + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "storageclass-provisioner-flows-through" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "sc-tweaks" + spec = { + clusterName = "my-cluster" + storageClass = { + provisioner = "driver.longhorn.io" + parameters = {numberOfReplicas = "3"} + reclaimPolicy = "Retain" + } + } + } + assertResources = [ + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "sc-tweaks-storageclass" + spec.forProvider.manifest = { + apiVersion = "storage.k8s.io/v1" + kind = "StorageClass" + metadata = { + name = "psql" + labels = { + "hops.ops.com.ai/managed" = "true" + "hops.ops.com.ai/psql" = "sc-tweaks" + } + } + provisioner = "driver.longhorn.io" + reclaimPolicy = "Retain" + volumeBindingMode = "WaitForFirstConsumer" + allowVolumeExpansion = True + parameters = {numberOfReplicas = "3"} + } + } + ] + } + } + + # ========================================================================== + # Test 13: storageClass.enabled=false suppresses the SC composition. + # Use case: cluster already ships a suitable default SC; opt out of the + # stack composing one. PSQLCluster/PSQLBranch consumers must then set + # `spec.storage.class` explicitly. + # ========================================================================== + metav1alpha1.CompositionTest { + metadata.name = "storageclass-can-be-disabled" + spec = { + compositionPath = "apis/psqlstacks/composition.yaml" + xrdPath = "apis/psqlstacks/definition.yaml" + timeoutSeconds = 60 + validate = False + xr = stacksv1alpha1.PSQLStack { + metadata.name = "no-sc" + spec = { + clusterName = "my-cluster" + storageClass = {enabled = False} + } + } + # Cnpg + atlas + VSC still composed. No sc-* Object expected (implicit). + assertResources = [ + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "cloudnative-pg" + } + { + apiVersion = "helm.m.crossplane.io/v1beta1" + kind = "Release" + metadata.name = "atlas-operator" + } + { + apiVersion = "kubernetes.m.crossplane.io/v1alpha1" + kind = "Object" + metadata.name = "no-sc-volumesnapshotclass" + } + ] + } + } +] + +items = _items diff --git a/tests/test-stack/model b/tests/test-stack/model new file mode 120000 index 0000000..faff6e4 --- /dev/null +++ b/tests/test-stack/model @@ -0,0 +1 @@ +../../.up/kcl/models \ No newline at end of file diff --git a/upbound.yaml b/upbound.yaml index 612c6b7..9ff08d9 100644 --- a/upbound.yaml +++ b/upbound.yaml @@ -12,18 +12,26 @@ spec: kind: Provider package: xpkg.crossplane.io/crossplane-contrib/provider-helm version: '>=v1' - description: PostgreSQL stack deploying StackGres operator (with Citus support) - and Atlas Operator for schema migrations as Helm releases. + - apiVersion: pkg.crossplane.io/v1 + kind: Provider + package: xpkg.crossplane.io/crossplane-contrib/provider-kubernetes + version: '>=v1' + description: PostgreSQL stack deploying CloudNativePG, the cnpg-i-scale-to-zero + plugin, and the Atlas Operator as Helm releases, with an optional dedicated Karpenter + NodePool. Platform layer — per-app serving clusters live in PSQLCluster. license: Apache-2.0 maintainer: Patrick Lee Scott readme: | # psql-stack - Deploys a PostgreSQL management stack with correct deletion ordering. - Composes StackGres operator and Atlas Operator as direct Helm releases. + Deploys a PostgreSQL platform stack with correct deletion ordering. + Composes CloudNativePG, the cnpg-i-scale-to-zero plugin, and the Atlas + Operator as direct Helm releases / applied manifests. - StackGres provides full PostgreSQL lifecycle management with native Citus - support via SGShardedCluster CRDs. Atlas Operator provides declarative - database schema migrations via AtlasMigration and AtlasSchema CRDs. + CloudNativePG is a CNCF operator that manages the full lifecycle of + PostgreSQL clusters on Kubernetes. The cnpg-i-scale-to-zero plugin + (github.com/xataio/cnpg-i-scale-to-zero) automatically hibernates + inactive clusters. Atlas Operator provides declarative database schema + migrations via AtlasMigration and AtlasSchema CRDs. repository: ghcr.io/hops-ops/psql-stack source: github.com/hops-ops/psql-stack