From b75ba24ec55b84af69978cf1fd40b30630b04615 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 16:23:14 +0200 Subject: [PATCH 1/2] test(integration): add PostgreSQL 14/15/16/17/18 compatibility matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deploy 5 versioned PostgreSQL instances (postgres-14 through postgres-18) in the kind cluster and run a focused matrix test exercising the CREATE USER + GRANT + CREATE DATABASE OWNER path for each version. PG 16+ tightened GRANT defaults to NOINHERIT, NOSET, NOADMIN. A regression in the operator's admin-grant statement that omits WITH INHERIT TRUE, SET TRUE will now fail loudly on PG 16/17/18 while still passing on PG 14/15. The matrix is light — one Database CR per version, asserting Ready phase and secret presence — so it stays cheap (~1 GB RAM, ~30-60s extra CI time) while pinning behaviour across the supported version range. --- .../manifests/postgres-versions.yaml | 297 ++++++++++++++++++ test/integration/postgres_versions_test.go | 118 +++++++ test/integration/scripts/setup-cluster.sh | 32 +- 3 files changed, 437 insertions(+), 10 deletions(-) create mode 100644 test/integration/manifests/postgres-versions.yaml create mode 100644 test/integration/postgres_versions_test.go diff --git a/test/integration/manifests/postgres-versions.yaml b/test/integration/manifests/postgres-versions.yaml new file mode 100644 index 0000000..1324e55 --- /dev/null +++ b/test/integration/manifests/postgres-versions.yaml @@ -0,0 +1,297 @@ +--- +# Per-version PostgreSQL deployments for the PG-compat integration matrix. +# Each version gets its own Deployment, Service (ClusterIP), and admin +# Secret in the `default` namespace consumed by spec.connectionString.kubernetes. +# The deployments live in `databases`; the connection-string Secret lives +# in `default` because that is the namespace the Database CR is created in +# during the integration tests. + +apiVersion: v1 +kind: Secret +metadata: + name: postgres-14-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://postgres:postgres123@postgres-14.databases.svc.cluster.local:5432/postgres?sslmode=disable" +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-15-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://postgres:postgres123@postgres-15.databases.svc.cluster.local:5432/postgres?sslmode=disable" +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-16-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://postgres:postgres123@postgres-16.databases.svc.cluster.local:5432/postgres?sslmode=disable" +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-17-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://postgres:postgres123@postgres-17.databases.svc.cluster.local:5432/postgres?sslmode=disable" +--- +apiVersion: v1 +kind: Secret +metadata: + name: postgres-18-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://postgres:postgres123@postgres-18.databases.svc.cluster.local:5432/postgres?sslmode=disable" +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-14 + namespace: databases +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres-14 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-15 + namespace: databases +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres-15 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-16 + namespace: databases +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres-16 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-17 + namespace: databases +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres-17 +--- +apiVersion: v1 +kind: Service +metadata: + name: postgres-18 + namespace: databases +spec: + type: ClusterIP + ports: + - port: 5432 + targetPort: 5432 + selector: + app: postgres-18 +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-14 + namespace: databases +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-14 + template: + metadata: + labels: + app: postgres-14 + spec: + containers: + - name: postgres + image: postgres:14-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + value: postgres123 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: postgres + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-15 + namespace: databases +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-15 + template: + metadata: + labels: + app: postgres-15 + spec: + containers: + - name: postgres + image: postgres:15-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + value: postgres123 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: postgres + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-16 + namespace: databases +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-16 + template: + metadata: + labels: + app: postgres-16 + spec: + containers: + - name: postgres + image: postgres:16-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + value: postgres123 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: postgres + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-17 + namespace: databases +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-17 + template: + metadata: + labels: + app: postgres-17 + spec: + containers: + - name: postgres + image: postgres:17-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + value: postgres123 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: postgres + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgres-18 + namespace: databases +spec: + replicas: 1 + selector: + matchLabels: + app: postgres-18 + template: + metadata: + labels: + app: postgres-18 + spec: + containers: + - name: postgres + image: postgres:18-alpine + imagePullPolicy: IfNotPresent + ports: + - containerPort: 5432 + env: + - name: POSTGRES_PASSWORD + value: postgres123 + - name: POSTGRES_USER + value: postgres + - name: POSTGRES_DB + value: postgres + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" diff --git a/test/integration/postgres_versions_test.go b/test/integration/postgres_versions_test.go new file mode 100644 index 0000000..010d0d0 --- /dev/null +++ b/test/integration/postgres_versions_test.go @@ -0,0 +1,118 @@ +// Copyright 2025 OpzKit +// +// Licensed under the MIT License. +// See LICENSE file in the project root for full license information. + +//go:build integration +// +build integration + +package integration + +import ( + "context" + "fmt" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/service/secretsmanager" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + + databasev1alpha1 "opzkit/database-user-operator/api/v1alpha1" +) + +// PostgreSQL version compatibility matrix. +// Each version runs as its own Deployment+Service (postgres-XX) in the +// `databases` namespace, with a corresponding admin Secret +// (postgres-XX-connection) in the `default` namespace. The setup script +// applies test/integration/manifests/postgres-versions.yaml. +// +// PG 16+ tightened GRANT defaults to NOINHERIT, NOSET, NOADMIN. The +// CREATE DATABASE ... OWNER step in CreateDatabase requires the executor +// to hold SET on the owner role, so the operator's GRANT path needs +// "WITH INHERIT TRUE, SET TRUE" for vanilla PG 16/17/18. PG 14 and 15 +// pre-date that change and pass either way. A regression in the GRANT +// statement therefore shows up as a failure on PG 16+ here. +var _ = Describe("PostgreSQL Version Compatibility", func() { + const namespace = "default" + + var ( + smClient *secretsmanager.Client + awsCtx context.Context + ) + + BeforeEach(func() { + awsCtx = context.Background() + customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) { + return aws.Endpoint{URL: "http://localhost:14566", SigningRegion: "us-east-1"}, nil + }) + cfg, err := config.LoadDefaultConfig( + awsCtx, + config.WithRegion("us-east-1"), + config.WithEndpointResolverWithOptions(customResolver), + config.WithCredentialsProvider(aws.CredentialsProviderFunc(func(ctx context.Context) (aws.Credentials, error) { + return aws.Credentials{AccessKeyID: "test", SecretAccessKey: "test"}, nil + })), + ) + Expect(err).NotTo(HaveOccurred()) + smClient = secretsmanager.NewFromConfig(cfg) + }) + + type pgCase struct { + version string + secretRefName string + } + + cases := []pgCase{ + {version: "14", secretRefName: "postgres-14-connection"}, + {version: "15", secretRefName: "postgres-15-connection"}, + {version: "16", secretRefName: "postgres-16-connection"}, + {version: "17", secretRefName: "postgres-17-connection"}, + {version: "18", secretRefName: "postgres-18-connection"}, + } + + for _, c := range cases { + c := c + It(fmt.Sprintf("Should create database, user, and secret on PostgreSQL %s", c.version), func() { + dbName := fmt.Sprintf("test-pg%s-%s", c.version, randomString(5)) + secretName := fmt.Sprintf("test/databases/%s/credentials", dbName) + + By(fmt.Sprintf("Creating Database CR targeting postgres-%s", c.version)) + createDatabase(namespace, dbName, databasev1alpha1.DatabaseSpec{ + Engine: databasev1alpha1.DatabaseEnginePostgres, + DatabaseName: fmt.Sprintf("pg%sdb", c.version), + Username: fmt.Sprintf("pg%suser", c.version), + ConnectionStringSecretRef: &databasev1alpha1.SecretKeyReference{ + Name: c.secretRefName, + Key: "connectionString", + }, + SecretName: secretName, + }) + + By("Waiting for the Database to reach Ready (proves CREATE USER + GRANT + CREATE DATABASE OWNER all succeed)") + waitForDatabaseCreated(namespace, dbName) + + By("Verifying status reflects success") + db, err := getDatabase(namespace, dbName) + Expect(err).NotTo(HaveOccurred()) + Expect(db.Status.DatabaseCreated).To(BeTrue()) + Expect(db.Status.UserCreated).To(BeTrue()) + Expect(db.Status.ActualUsername).To(Equal(fmt.Sprintf("pg%suser", c.version))) + + By("Verifying credentials in AWS Secrets Manager") + Eventually(func() error { + _, err := smClient.GetSecretValue(awsCtx, &secretsmanager.GetSecretValueInput{ + SecretId: aws.String(secretName), + }) + return err + }, timeout, interval).Should(Succeed()) + + By("Cleaning up") + retainFalse := false + db.Spec.RetainOnDelete = &retainFalse + Expect(k8sClient.Update(ctx, db)).Should(Succeed()) + Expect(deleteDatabase(namespace, dbName)).Should(Succeed()) + waitForDatabaseDeleted(namespace, dbName) + }) + } +}) diff --git a/test/integration/scripts/setup-cluster.sh b/test/integration/scripts/setup-cluster.sh index 4610ab4..d8657ba 100755 --- a/test/integration/scripts/setup-cluster.sh +++ b/test/integration/scripts/setup-cluster.sh @@ -111,8 +111,9 @@ wait_for_deployment() { } # Deploy all services in parallel -echo "Deploying PostgreSQL, MySQL, LocalStack..." +echo "Deploying PostgreSQL (incl. version matrix 14/15/16/17/18), MySQL, LocalStack..." kubectl apply -f "${PROJECT_ROOT}/test/integration/manifests/postgres.yaml" & +kubectl apply -f "${PROJECT_ROOT}/test/integration/manifests/postgres-versions.yaml" & kubectl apply -f "${PROJECT_ROOT}/test/integration/manifests/mysql.yaml" & kubectl apply -f "${PROJECT_ROOT}/test/integration/manifests/localstack.yaml" & wait @@ -123,17 +124,28 @@ echo "Waiting for all deployments to be available..." LOG_DIR=$(mktemp -d -t deploy-wait.XXXXXX) trap 'rm -rf "${LOG_DIR}"' EXIT -wait_for_deployment postgres databases 300 > "${LOG_DIR}/postgres.log" 2>&1 & -PG_PID=$! -wait_for_deployment mysql databases 300 > "${LOG_DIR}/mysql.log" 2>&1 & -MY_PID=$! -wait_for_deployment localstack databases 300 > "${LOG_DIR}/localstack.log" 2>&1 & -LS_PID=$! +declare -a WAIT_PIDS +declare -a WAIT_NAMES + +start_wait() { + local name=$1 + local ns=$2 + wait_for_deployment "${name}" "${ns}" 300 > "${LOG_DIR}/${name}.log" 2>&1 & + WAIT_PIDS+=("$!") + WAIT_NAMES+=("${name}") +} + +start_wait postgres databases +for v in 14 15 16 17 18; do + start_wait "postgres-${v}" databases +done +start_wait mysql databases +start_wait localstack databases failed=0 -for pid_name in "postgres:${PG_PID}" "mysql:${MY_PID}" "localstack:${LS_PID}"; do - name=${pid_name%%:*} - pid=${pid_name##*:} +for i in "${!WAIT_PIDS[@]}"; do + name=${WAIT_NAMES[$i]} + pid=${WAIT_PIDS[$i]} if wait "${pid}"; then echo " ✓ ${name}" else From 59e8d41d1191a25eef2377aedf5f549cadb04bd9 Mon Sep 17 00:00:00 2001 From: Peter Svensson Date: Thu, 7 May 2026 16:36:01 +0200 Subject: [PATCH 2/2] test(integration): use non-superuser admin in PG version matrix Connecting as the built-in 'postgres' superuser bypasses the SET ROLE check entirely (superusers can become any role unconditionally), so the original matrix passed even on PG 16/17/18 where the bug class is supposed to bite. Bootstrap each PG instance with a non-superuser 'dbuo_admin' role (LOGIN, CREATEDB, CREATEROLE, NOSUPERUSER, NOINHERIT) via a ConfigMap-mounted /docker-entrypoint-initdb.d/init.sql. Repoint the per-version connection-string Secrets at this admin. This mirrors managed-PG providers (Scaleway managed RDB, self-hosted PG 16+ without RDS-style event triggers) where the admin is not a superuser, so the operator's GRANT path is actually exercised. --- .../manifests/postgres-versions.yaml | 65 +++++++++++++++++-- 1 file changed, 60 insertions(+), 5 deletions(-) diff --git a/test/integration/manifests/postgres-versions.yaml b/test/integration/manifests/postgres-versions.yaml index 1324e55..a2e61a6 100644 --- a/test/integration/manifests/postgres-versions.yaml +++ b/test/integration/manifests/postgres-versions.yaml @@ -5,7 +5,27 @@ # The deployments live in `databases`; the connection-string Secret lives # in `default` because that is the namespace the Database CR is created in # during the integration tests. +# +# Each PG instance bootstraps a NON-SUPERUSER admin role (`dbuo_admin`) +# with CREATEDB + CREATEROLE. The PG-version-matrix test connects as +# this role so PG 16+'s NOINHERIT / NOSET GRANT defaults are actually +# observable. Connecting as the built-in `postgres` superuser would +# bypass the SET ROLE check entirely, papering over the bug class this +# matrix is meant to catch. +apiVersion: v1 +kind: ConfigMap +metadata: + name: postgres-versions-init + namespace: databases +data: + init.sql: | + -- Non-superuser admin for the operator. Mirrors managed-PG providers + -- (Scaleway managed RDB, self-hosted PG 16+ without RDS-style event + -- triggers) where the admin role has CREATEDB+CREATEROLE but not + -- SUPERUSER, so the SET ROLE / WITH SET grant defaults actually bite. + CREATE ROLE dbuo_admin WITH LOGIN PASSWORD 'dbuo_admin_pw' CREATEDB CREATEROLE NOSUPERUSER NOINHERIT; +--- apiVersion: v1 kind: Secret metadata: @@ -13,7 +33,7 @@ metadata: namespace: default type: Opaque stringData: - connectionString: "postgresql://postgres:postgres123@postgres-14.databases.svc.cluster.local:5432/postgres?sslmode=disable" + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@postgres-14.databases.svc.cluster.local:5432/postgres?sslmode=disable" --- apiVersion: v1 kind: Secret @@ -22,7 +42,7 @@ metadata: namespace: default type: Opaque stringData: - connectionString: "postgresql://postgres:postgres123@postgres-15.databases.svc.cluster.local:5432/postgres?sslmode=disable" + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@postgres-15.databases.svc.cluster.local:5432/postgres?sslmode=disable" --- apiVersion: v1 kind: Secret @@ -31,7 +51,7 @@ metadata: namespace: default type: Opaque stringData: - connectionString: "postgresql://postgres:postgres123@postgres-16.databases.svc.cluster.local:5432/postgres?sslmode=disable" + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@postgres-16.databases.svc.cluster.local:5432/postgres?sslmode=disable" --- apiVersion: v1 kind: Secret @@ -40,7 +60,7 @@ metadata: namespace: default type: Opaque stringData: - connectionString: "postgresql://postgres:postgres123@postgres-17.databases.svc.cluster.local:5432/postgres?sslmode=disable" + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@postgres-17.databases.svc.cluster.local:5432/postgres?sslmode=disable" --- apiVersion: v1 kind: Secret @@ -49,7 +69,7 @@ metadata: namespace: default type: Opaque stringData: - connectionString: "postgresql://postgres:postgres123@postgres-18.databases.svc.cluster.local:5432/postgres?sslmode=disable" + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@postgres-18.databases.svc.cluster.local:5432/postgres?sslmode=disable" --- apiVersion: v1 kind: Service @@ -144,6 +164,9 @@ spec: value: postgres - name: POSTGRES_DB value: postgres + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d resources: requests: memory: "128Mi" @@ -151,6 +174,10 @@ spec: limits: memory: "256Mi" cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init --- apiVersion: apps/v1 kind: Deployment @@ -180,6 +207,9 @@ spec: value: postgres - name: POSTGRES_DB value: postgres + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d resources: requests: memory: "128Mi" @@ -187,6 +217,10 @@ spec: limits: memory: "256Mi" cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init --- apiVersion: apps/v1 kind: Deployment @@ -216,6 +250,9 @@ spec: value: postgres - name: POSTGRES_DB value: postgres + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d resources: requests: memory: "128Mi" @@ -223,6 +260,10 @@ spec: limits: memory: "256Mi" cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init --- apiVersion: apps/v1 kind: Deployment @@ -252,6 +293,9 @@ spec: value: postgres - name: POSTGRES_DB value: postgres + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d resources: requests: memory: "128Mi" @@ -259,6 +303,10 @@ spec: limits: memory: "256Mi" cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init --- apiVersion: apps/v1 kind: Deployment @@ -288,6 +336,9 @@ spec: value: postgres - name: POSTGRES_DB value: postgres + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d resources: requests: memory: "128Mi" @@ -295,3 +346,7 @@ spec: limits: memory: "256Mi" cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init