From 483683d9e80a376785e603e0c4139d490db7a4df Mon Sep 17 00:00:00 2001 From: Saraj Krishna Singh Date: Sat, 9 May 2026 16:30:00 +0000 Subject: [PATCH] feat(crossplane): add PostgresDatabase XRD and composition - XRD with clean claim interface for app teams - Composition: RDSInstance + DBSubnetGroup - Namespace-label patch: multiAZ=true in production namespaces - deletionPolicy defaults to Orphan - Subnet group naming includes claim namespace to prevent collisions Signed-off-by: Saraj Krishna Singh --- compositions/postgresql/README.md | 75 ++++++++++++ compositions/postgresql/composition.yaml | 143 +++++++++++++++++++++++ compositions/postgresql/xrd.yaml | 71 +++++++++++ 3 files changed, 289 insertions(+) create mode 100644 compositions/postgresql/README.md create mode 100644 compositions/postgresql/composition.yaml create mode 100644 compositions/postgresql/xrd.yaml diff --git a/compositions/postgresql/README.md b/compositions/postgresql/README.md new file mode 100644 index 0000000..c2c9575 --- /dev/null +++ b/compositions/postgresql/README.md @@ -0,0 +1,75 @@ +# PostgresDatabase Crossplane Composition + +Provides a `PostgresDatabase` claim interface for application teams to self-service managed RDS PostgreSQL instances. The composition handles the underlying RDS instance, DBSubnetGroup, and connection secret. + +## Claim Example + +```yaml +apiVersion: platform.tcs.io/v1alpha1 +kind: PostgresDatabase +metadata: + name: my-app-db + namespace: my-app +spec: + parameters: + storageGB: 20 + instanceClass: db.t3.medium + multiAZ: false + backupRetentionDays: 7 + engineVersion: "15.4" + deletionPolicy: Orphan + writeConnectionSecretToRef: + name: my-app-db-conn +``` + +## Connection Secret + +The composition writes a connection secret with the following keys: + +| Key | Description | +|-----|-------------| +| `host` | RDS endpoint hostname | +| `port` | RDS port (usually `5432`) | +| `username` | Master username | +| `password` | Master password | +| `dbname` | Initial database name | +| `url` | Full `postgresql://user:pass@host:port/dbname` connection string | + +The secret is written to the same namespace as the claim. Applications can mount it directly as environment variables or a volume. + +## Automatic multiAZ in Production + +The composition includes a namespace-label patch: if the claim's namespace has the label `platform.tcs.io/environment=production`, `multiAZ` is forced to `true` regardless of what the claim specifies. This prevents development teams from accidentally provisioning single-AZ databases in production environments. + +Label your production namespaces: + +```yaml +apiVersion: v1 +kind: Namespace +metadata: + name: my-production-app + labels: + platform.tcs.io/environment: production +``` + +## Deletion Policy + +The `deletionPolicy` parameter defaults to `Orphan`. When set to `Orphan`, deleting the `PostgresDatabase` claim removes the Crossplane-managed objects but leaves the underlying RDS instance running in AWS. This is the safe default for production databases. + +**Do not change the default to `Delete` in production.** If you delete the claim, the RDS instance and all its data are permanently destroyed. + +## Engine Version Upgrades + +If you update `engineVersion` in the claim (e.g. from `14.9` to `15.4`), Crossplane will attempt to update the RDS instance in place. AWS supports in-place minor version upgrades, but major version upgrades require a snapshot, a new instance, and a manual data migration. To upgrade a major version safely: + +1. Create a new `PostgresDatabase` claim with the new engine version. +2. Migrate your data to the new instance (pg_dump / pg_restore or DMS). +3. Cut over your application connection strings. +4. Delete the old claim (with `deletionPolicy: Orphan` to protect the data). +5. After confirming the new instance is healthy, manually delete the orphaned RDS instance. + +Never set `engineVersion` to a major version jump and apply it directly to a production claim. + +## Subnet Group Naming + +DBSubnetGroup names are derived from the claim namespace and name (`tcs---subnet-group`). This prevents name collisions when multiple `PostgresDatabase` claims exist in the same namespace. diff --git a/compositions/postgresql/composition.yaml b/compositions/postgresql/composition.yaml new file mode 100644 index 0000000..7a67f85 --- /dev/null +++ b/compositions/postgresql/composition.yaml @@ -0,0 +1,143 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: Composition +metadata: + name: xpostgresdatabases.platform.tcs.io + labels: + provider: aws + service: rds + engine: postgresql +spec: + compositeTypeRef: + apiVersion: platform.tcs.io/v1alpha1 + kind: XPostgresDatabase + + writeConnectionSecretsToNamespace: crossplane-system + + resources: + # ------------------------------------------------------------------ + # DBSubnetGroup + # Naming includes claim namespace and name to prevent collisions when + # multiple claims exist in the same namespace. + # ------------------------------------------------------------------ + - name: dbsubnetgroup + base: + apiVersion: rds.aws.crossplane.io/v1alpha1 + kind: DBSubnetGroup + spec: + forProvider: + region: us-east-1 + description: "Managed by Crossplane - TCS PostgresDatabase" + subnetIds: [] # Patched in via environment config or EnvironmentConfig + providerConfigRef: + name: aws-provider + patches: + - type: FromCompositeFieldPath + fromFieldPath: metadata.namespace + toFieldPath: spec.forProvider.groupName + transforms: + - type: string + string: + fmt: "%s" + - type: CombineFromComposite + combine: + variables: + - fromFieldPath: metadata.namespace + - fromFieldPath: metadata.name + strategy: string + string: + fmt: "tcs-%s-%s-subnet-group" + toFieldPath: spec.forProvider.groupName + + # ------------------------------------------------------------------ + # RDSInstance + # ------------------------------------------------------------------ + - name: rdsinstance + base: + apiVersion: rds.aws.crossplane.io/v1alpha1 + kind: RDSInstance + spec: + forProvider: + region: us-east-1 + dbInstanceClass: db.t3.medium + engine: postgres + engineVersion: "15.4" + allocatedStorage: 20 + multiAZ: false + backupRetentionPeriod: 7 + storageEncrypted: true + storageType: gp3 + publiclyAccessible: false + deletionProtection: false + autoMinorVersionUpgrade: true + tags: + - key: ManagedBy + value: crossplane + - key: Service + value: postgresql + writeConnectionSecretToRef: + namespace: crossplane-system + providerConfigRef: + name: aws-provider + patches: + # Claim parameters -> RDS fields + - type: FromCompositeFieldPath + fromFieldPath: spec.parameters.instanceClass + toFieldPath: spec.forProvider.dbInstanceClass + - type: FromCompositeFieldPath + fromFieldPath: spec.parameters.storageGB + toFieldPath: spec.forProvider.allocatedStorage + - type: FromCompositeFieldPath + fromFieldPath: spec.parameters.multiAZ + toFieldPath: spec.forProvider.multiAZ + - type: FromCompositeFieldPath + fromFieldPath: spec.parameters.backupRetentionDays + toFieldPath: spec.forProvider.backupRetentionPeriod + - type: FromCompositeFieldPath + fromFieldPath: spec.parameters.engineVersion + toFieldPath: spec.forProvider.engineVersion + - type: FromCompositeFieldPath + fromFieldPath: spec.parameters.deletionPolicy + toFieldPath: spec.forProvider.deletionProtection + transforms: + - type: map + map: + Orphan: "true" + Delete: "false" + + # Connection secret name derived from claim name + - type: CombineFromComposite + combine: + variables: + - fromFieldPath: metadata.namespace + - fromFieldPath: metadata.name + strategy: string + string: + fmt: "tcs-%s-%s-conn" + toFieldPath: spec.writeConnectionSecretToRef.name + + # Production namespace override: force multiAZ=true + # Requires the claim namespace to have label: + # platform.tcs.io/environment=production + - type: FromEnvironmentFieldPath + fromFieldPath: platform.tcs.io/environment + toFieldPath: spec.forProvider.multiAZ + transforms: + - type: map + map: + production: "true" + policy: + fromFieldPath: Optional + + connectionDetails: + - name: host + fromConnectionSecretKey: endpoint + - name: port + fromConnectionSecretKey: port + - name: username + fromConnectionSecretKey: username + - name: password + fromConnectionSecretKey: password + - name: dbname + fromConnectionSecretKey: dbname + - name: url + fromConnectionSecretKey: url diff --git a/compositions/postgresql/xrd.yaml b/compositions/postgresql/xrd.yaml new file mode 100644 index 0000000..f519236 --- /dev/null +++ b/compositions/postgresql/xrd.yaml @@ -0,0 +1,71 @@ +apiVersion: apiextensions.crossplane.io/v1 +kind: CompositeResourceDefinition +metadata: + name: xpostgresdatabases.platform.tcs.io +spec: + group: platform.tcs.io + names: + kind: XPostgresDatabase + plural: xpostgresdatabases + claimNames: + kind: PostgresDatabase + plural: postgresdatabases + connectionSecretKeys: + - host + - port + - username + - password + - dbname + - url + versions: + - name: v1alpha1 + served: true + referenceable: true + schema: + openAPIV3Schema: + type: object + properties: + spec: + type: object + properties: + parameters: + type: object + description: Parameters for the PostgresDatabase claim. + properties: + storageGB: + type: integer + description: Allocated storage in gigabytes. + default: 20 + minimum: 5 + maximum: 65536 + instanceClass: + type: string + description: RDS DB instance class (e.g. db.t3.medium, db.r6g.large). + default: db.t3.medium + multiAZ: + type: boolean + description: | + Enable Multi-AZ deployment for high availability. Automatically + set to true in namespaces labelled platform.tcs.io/environment=production. + default: false + backupRetentionDays: + type: integer + description: Number of days to retain automated backups. 0 disables backups. + default: 7 + minimum: 0 + maximum: 35 + engineVersion: + type: string + description: PostgreSQL engine version (e.g. "15.4", "14.9"). + default: "15.4" + deletionPolicy: + type: string + description: | + What happens to the underlying RDS instance when the claim is deleted. + Orphan (default) leaves the instance running - recommended for production. + Delete removes the instance immediately. + default: Orphan + enum: + - Delete + - Orphan + required: []