diff --git a/test/integration/manifests/postgres-versions.yaml b/test/integration/manifests/postgres-versions.yaml new file mode 100644 index 0000000..a2e61a6 --- /dev/null +++ b/test/integration/manifests/postgres-versions.yaml @@ -0,0 +1,352 @@ +--- +# 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. +# +# 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: + name: postgres-14-connection + namespace: default +type: Opaque +stringData: + connectionString: "postgresql://dbuo_admin:dbuo_admin_pw@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://dbuo_admin:dbuo_admin_pw@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://dbuo_admin:dbuo_admin_pw@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://dbuo_admin:dbuo_admin_pw@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://dbuo_admin:dbuo_admin_pw@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 + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init +--- +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 + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init +--- +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 + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init +--- +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 + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init +--- +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 + volumeMounts: + - name: init-sql + mountPath: /docker-entrypoint-initdb.d + resources: + requests: + memory: "128Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "300m" + volumes: + - name: init-sql + configMap: + name: postgres-versions-init 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