diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..8eaa3f7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,85 @@ +name: Release + +on: + workflow_dispatch: + inputs: + increment: + description: "Version increment type" + type: choice + required: true + default: "Patch" + options: + - "Major" + - "Minor" + - "Patch" + - "Prerelease" + +env: + DOCKER_IMAGE: ghcr.io/${{ github.repository }} + +jobs: + test: + uses: ./.github/workflows/test.yml + secrets: inherit + + build-and-release: + needs: test + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v6 + with: + token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + - uses: aboutbits/github-actions-base/git-setup@v2 + - uses: aboutbits/github-actions-java/setup-with-gradle@v4 + with: + java-version: 25 + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Increment version + run: ./gradlew --console=colored createRelease -Prelease.versionIncrementer=increment${{ github.event.inputs.increment }} + shell: bash + - name: Get next package version + id: nextVersion + run: echo "version=$(./gradlew currentVersion -q -Prelease.quiet)" >> $GITHUB_OUTPUT + shell: bash + - name: Build package + run: ./gradlew --console=colored build -x test + - name: Build Docker image + uses: aboutbits/github-actions-docker/build-push@v1 + with: + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + docker-image: ${{ env.DOCKER_IMAGE }} + docker-tag: ${{ steps.nextVersion.outputs.version }} + working-directory: './operator' + dockerfile: './operator/src/main/docker/Dockerfile.jvm' + - name: Push tag to remote + run: ./gradlew --console=colored pushRelease + shell: bash + - uses: aboutbits/github-actions-base/github-create-release@v2 + with: + tag-name: 'v${{ steps.nextVersion.outputs.version }}' + release-description: | + ## Installation + + ### Helm Chart + ```bash + helm install postgresql-operator https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/postgresql-operator-${{ steps.nextVersion.outputs.version }}.tgz + ``` + + ### Manual CRD Installation + ```bash + kubectl apply -f https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/clusterconnections.postgresql.aboutbits.it-v1.yml + kubectl apply -f https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/databases.postgresql.aboutbits.it-v1.yml + kubectl apply -f https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/schemas.postgresql.aboutbits.it-v1.yml + kubectl apply -f https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/roles.postgresql.aboutbits.it-v1.yml + kubectl apply -f https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/grants.postgresql.aboutbits.it-v1.yml + kubectl apply -f https://github.com/${{ github.repository }}/releases/download/v${{ steps.nextVersion.outputs.version }}/defaultprivileges.postgresql.aboutbits.it-v1.yml + ``` + release-notes-generation: 'true' + - name: Upload Helm chart and CRD assets + env: + GH_TOKEN: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} + run: | + gh release upload v${{ steps.nextVersion.outputs.version }} operator/build/helm/kubernetes/*.tgz operator/build/kubernetes/*.postgresql.aboutbits.it-v1.yml + shell: bash diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 69731e3..475903f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -5,11 +5,17 @@ on: jobs: test: - name: Tests + name: Tests (PostgreSQL ${{ matrix.postgres-version }}) runs-on: ubuntu-24.04 timeout-minutes: 5 + strategy: + fail-fast: false + matrix: + postgres-version: [ 15, 16, 17, 18 ] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 + with: + token: ${{ secrets.GH_PERSONAL_ACCESS_TOKEN }} - uses: aboutbits/github-actions-java/setup-with-gradle@v4 with: java-version: 25 @@ -20,6 +26,7 @@ jobs: --console=colored :operator:test --fail-fast + -Dquarkus.test.profile=test-pg${{ matrix.postgres-version }} env: GITHUB_USER_NAME: ${{ github.actor }} GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..21586f5 --- /dev/null +++ b/LICENSE @@ -0,0 +1,7 @@ +Copyright About Bits GmbH + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/Makefile b/Makefile index d54c07c..2c034c7 100644 --- a/Makefile +++ b/Makefile @@ -14,8 +14,21 @@ run: generate-jooq: ./gradlew --console=colored :generated:jooqCodegen +# Latest PostgreSQL Version configured in application.yml test: - ./gradlew --console=colored :operator:clean :operator:test + ./gradlew --console=colored :operator:clean :operator:test --rerun-tasks + +test-pg18: + ./gradlew --console=colored :operator:clean :operator:test --rerun-tasks -Dquarkus.test.profile=test-pg18 + +test-pg17: + ./gradlew --console=colored :operator:clean :operator:test --rerun-tasks -Dquarkus.test.profile=test-pg17 + +test-pg16: + ./gradlew --console=colored :operator:clean :operator:test --rerun-tasks -Dquarkus.test.profile=test-pg16 + +test-pg15: + ./gradlew --console=colored :operator:clean :operator:test --rerun-tasks -Dquarkus.test.profile=test-pg15 # Flag targets as phony, to tell `make` that these are no file targets -.PHONY: init install run generate-jooq test +.PHONY: init install run generate-jooq test test-pg18 test-pg17 test-pg16 test-pg15 diff --git a/build.gradle.kts b/build.gradle.kts index 3259d05..bf2fd10 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,22 +8,36 @@ plugins { java checkstyle id("io.quarkus").apply(false) + alias(libs.plugins.axionReleasePlugin) alias(libs.plugins.errorPronePlugin) alias(libs.plugins.jooqPlugin).apply(false) } description = "AboutBits PostgreSQL Operator" +scmVersion { + checks { + aheadOfRemote = true + snapshotDependencies = false + uncommittedChanges = false + } + releaseBranchNames = setOf("main") + releaseOnlyOnReleaseBranches = true + versionCreator("simple") +} + +version = scmVersion.version + allprojects { group = "it.aboutbits.postgresql" - version = "0.0.1-SNAPSHOT" + version = rootProject.version tasks.withType().configureEach { dependsOn(":checkstyleExtractConfig") reports { - html.required.set(false) - xml.required.set(false) + html.required = false + xml.required = false } } } diff --git a/gradle.properties b/gradle.properties index 47b7308..97efcd9 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,13 +1,15 @@ # Gradle properties org.gradle.caching=true -org.gradle.parallel=true +org.gradle.configuration-cache=true +# TODO: Set to true when https://github.com/quarkusio/quarkus/issues/49115 is fixed +org.gradle.parallel=false org.gradle.logging.level=INFO # Quarkus quarkusPluginId=io.quarkus -quarkusPluginVersion=3.30.7 +quarkusPluginVersion=3.30.8 # https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-bom quarkusPlatformGroupId=io.quarkus.platform quarkusPlatformArtifactId=quarkus-bom -quarkusPlatformVersion=3.30.7 +quarkusPlatformVersion=3.30.8 systemProp.quarkus.analytics.disabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ec170fb..143080a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,9 @@ [versions] ## AboutBits Libraries ## -checkstyleConfig = "2.0.0" +checkstyleConfig = "2.0.0-RC1" + +# Axion Release Plugin # +axionReleasePlugin = "1.21.1" ## Libraries ## jooq = "3.20.10" @@ -11,7 +14,7 @@ quarkiverse-helm = "1.2.7" scram-client = "3.2" ## Testing ## -assertj = "3.27.6" +assertj = "3.27.7" checkstyle = "13.0.0" datafaker = "2.5.3" errorProne = "2.46.0" @@ -19,6 +22,10 @@ errorPronePlugin = "4.4.0" nullAway = "0.13.0" [plugins] +# https://github.com/allegro/axion-release-plugin +# https://axion-release-plugin.readthedocs.io/ +axionReleasePlugin = { id = "pl.allegro.tech.build.axion-release", version.ref = "axionReleasePlugin" } + # https://github.com/tbroyer/gradle-errorprone-plugin # https://mvnrepository.com/artifact/net.ltgt.errorprone/net.ltgt.errorprone.gradle.plugin errorPronePlugin = { id = "net.ltgt.errorprone", version.ref = "errorPronePlugin" } diff --git a/operator/src/main/docker/compose-devservices-test-pg15.yml b/operator/src/main/docker/compose-devservices-test-pg15.yml new file mode 100644 index 0000000..dd15797 --- /dev/null +++ b/operator/src/main/docker/compose-devservices-test-pg15.yml @@ -0,0 +1,19 @@ +services: + db: + image: postgres:15 + command: [ "postgres", "-c", "checkpoint_timeout=10min", "-c", "fsync=off", "-c", "full_page_writes=off", "-c", "max_wal_size=2GB", "-c", "synchronous_commit=off" ] + tmpfs: + - /var/lib/postgresql/data:rw,async,noatime + healthcheck: + test: pg_isready -U root -d dummy + interval: 3s + timeout: 3s + retries: 3 + ports: + - "5432" + labels: + io.quarkus.devservices.compose.config_map.port.5432: quarkus.datasource.jdbc.port + environment: + - POSTGRES_USER=root + - POSTGRES_PASSWORD=password + - POSTGRES_DB=dummy diff --git a/operator/src/main/docker/compose-devservices-test-pg16.yml b/operator/src/main/docker/compose-devservices-test-pg16.yml new file mode 100644 index 0000000..9079a7f --- /dev/null +++ b/operator/src/main/docker/compose-devservices-test-pg16.yml @@ -0,0 +1,19 @@ +services: + db: + image: postgres:16 + command: [ "postgres", "-c", "checkpoint_timeout=10min", "-c", "fsync=off", "-c", "full_page_writes=off", "-c", "max_wal_size=2GB", "-c", "synchronous_commit=off" ] + tmpfs: + - /var/lib/postgresql/data:rw,async,noatime + healthcheck: + test: pg_isready -U root -d dummy + interval: 3s + timeout: 3s + retries: 3 + ports: + - "5432" + labels: + io.quarkus.devservices.compose.config_map.port.5432: quarkus.datasource.jdbc.port + environment: + - POSTGRES_USER=root + - POSTGRES_PASSWORD=password + - POSTGRES_DB=dummy diff --git a/operator/src/main/docker/compose-devservices-test-pg17.yml b/operator/src/main/docker/compose-devservices-test-pg17.yml new file mode 100644 index 0000000..5ab9bfc --- /dev/null +++ b/operator/src/main/docker/compose-devservices-test-pg17.yml @@ -0,0 +1,19 @@ +services: + db: + image: postgres:17 + command: [ "postgres", "-c", "checkpoint_timeout=10min", "-c", "fsync=off", "-c", "full_page_writes=off", "-c", "max_wal_size=2GB", "-c", "synchronous_commit=off" ] + tmpfs: + - /var/lib/postgresql/data:rw,async,noatime + healthcheck: + test: pg_isready -U root -d dummy + interval: 3s + timeout: 3s + retries: 3 + ports: + - "5432" + labels: + io.quarkus.devservices.compose.config_map.port.5432: quarkus.datasource.jdbc.port + environment: + - POSTGRES_USER=root + - POSTGRES_PASSWORD=password + - POSTGRES_DB=dummy diff --git a/operator/src/main/docker/compose-devservices-test-pg18.yml b/operator/src/main/docker/compose-devservices-test-pg18.yml new file mode 100644 index 0000000..be71bf8 --- /dev/null +++ b/operator/src/main/docker/compose-devservices-test-pg18.yml @@ -0,0 +1,19 @@ +services: + db: + image: postgres:18 + command: [ "postgres", "-c", "checkpoint_timeout=10min", "-c", "fsync=off", "-c", "full_page_writes=off", "-c", "max_wal_size=2GB", "-c", "synchronous_commit=off" ] + tmpfs: + - /var/lib/postgresql/18/docker:rw,async,noatime + healthcheck: + test: pg_isready -U root -d dummy + interval: 3s + timeout: 3s + retries: 3 + ports: + - "5432" + labels: + io.quarkus.devservices.compose.config_map.port.5432: quarkus.datasource.jdbc.port + environment: + - POSTGRES_USER=root + - POSTGRES_PASSWORD=password + - POSTGRES_DB=dummy diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/Privilege.java b/operator/src/main/java/it/aboutbits/postgresql/core/Privilege.java index edc4ae2..a0735b0 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/core/Privilege.java +++ b/operator/src/main/java/it/aboutbits/postgresql/core/Privilege.java @@ -1,8 +1,12 @@ package it.aboutbits.postgresql.core; import com.fasterxml.jackson.annotation.JsonValue; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; import org.jooq.impl.DSL; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import java.util.Locale; @@ -12,19 +16,25 @@ * */ @NullMarked +@Getter +@Accessors(fluent = true) +@RequiredArgsConstructor public enum Privilege { - SELECT, - INSERT, - UPDATE, - DELETE, - TRUNCATE, - REFERENCES, - TRIGGER, - CREATE, - CONNECT, - TEMPORARY, - USAGE, - MAINTAIN; + SELECT(null), + INSERT(null), + UPDATE(null), + DELETE(null), + TRUNCATE(null), + REFERENCES(null), + TRIGGER(null), + CREATE(null), + CONNECT(null), + TEMPORARY(null), + USAGE(null), + MAINTAIN(17); + + @Nullable + private final Integer minimumPostgresVersion; @JsonValue public String toValue() { diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java index 83ce268..11a2698 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconciler.java @@ -10,6 +10,7 @@ import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import it.aboutbits.postgresql.core.Privilege; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jooq.DSLContext; @@ -18,6 +19,8 @@ import java.util.HashSet; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; @NullMarked @Slf4j @@ -209,7 +212,36 @@ private UpdateControl reconcileInTransaction( ) { var spec = resource.getSpec(); + var name = resource.getMetadata().getName(); + var namespace = resource.getMetadata().getNamespace(); + var expectedPrivileges = Set.copyOf(spec.getPrivileges()); + + int databaseMajorVersion = tx.connectionResult(connection -> + connection.getMetaData().getDatabaseMajorVersion() + ); + + var unsupportedPrivileges = expectedPrivileges.stream() + .filter(privilege -> privilege.minimumPostgresVersion() != null + && databaseMajorVersion < privilege.minimumPostgresVersion() + ) + .collect(Collectors.toMap( + Function.identity(), + Privilege::minimumPostgresVersion + )); + + if (!unsupportedPrivileges.isEmpty()) { + status.setPhase(CRPhase.ERROR) + .setMessage("The following privileges require a newer PostgreSQL version (current: %d): %s [resource=%s/%s]".formatted( + databaseMajorVersion, + unsupportedPrivileges, + getResourceNamespaceOrOwn(resource, namespace), + name + )); + + return UpdateControl.patchStatus(resource); + } + var currentDefaultPrivileges = defaultPrivilegeService.determineCurrentDefaultPrivileges(tx, spec); // Calculate Revokes: Current - Expected diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java index 56efdd7..d5adb51 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/grant/GrantReconciler.java @@ -10,6 +10,7 @@ import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import it.aboutbits.postgresql.core.Privilege; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jooq.DSLContext; @@ -21,6 +22,8 @@ import java.util.Objects; import java.util.Set; import java.util.concurrent.TimeUnit; +import java.util.function.Function; +import java.util.stream.Collectors; import static org.jooq.impl.DSL.quotedName; @@ -230,9 +233,34 @@ private UpdateControl reconcileInTransaction( Collections.emptySet() ) ); + var isAllMode = expectedObjects.isEmpty(); + var expectedPrivileges = Set.copyOf(spec.getPrivileges()); - var isAllMode = expectedObjects.isEmpty(); + int databaseMajorVersion = tx.connectionResult(connection -> + connection.getMetaData().getDatabaseMajorVersion() + ); + + var unsupportedPrivileges = expectedPrivileges.stream() + .filter(privilege -> privilege.minimumPostgresVersion() != null + && databaseMajorVersion < privilege.minimumPostgresVersion() + ) + .collect(Collectors.toMap( + Function.identity(), + Privilege::minimumPostgresVersion + )); + + if (!unsupportedPrivileges.isEmpty()) { + status.setPhase(CRPhase.ERROR) + .setMessage("The following privileges require a newer PostgreSQL version (current: %d): %s [resource=%s/%s]".formatted( + databaseMajorVersion, + unsupportedPrivileges, + getResourceNamespaceOrOwn(resource, namespace), + name + )); + + return UpdateControl.patchStatus(resource); + } var currentObjectPrivileges = grantService.determineCurrentObjectPrivileges(tx, spec); var ownershipMap = grantService.determineObjectExistenceAndOwnership(tx, spec); diff --git a/operator/src/main/resources/application-test-pg15.yml b/operator/src/main/resources/application-test-pg15.yml new file mode 100644 index 0000000..4ac9513 --- /dev/null +++ b/operator/src/main/resources/application-test-pg15.yml @@ -0,0 +1,7 @@ +quarkus: + config: + profile: + parent: test + compose: + devservices: + files: src/main/docker/compose-devservices-test-pg15.yml diff --git a/operator/src/main/resources/application-test-pg16.yml b/operator/src/main/resources/application-test-pg16.yml new file mode 100644 index 0000000..0d02355 --- /dev/null +++ b/operator/src/main/resources/application-test-pg16.yml @@ -0,0 +1,7 @@ +quarkus: + config: + profile: + parent: test + compose: + devservices: + files: src/main/docker/compose-devservices-test-pg16.yml diff --git a/operator/src/main/resources/application-test-pg17.yml b/operator/src/main/resources/application-test-pg17.yml new file mode 100644 index 0000000..6aec7b5 --- /dev/null +++ b/operator/src/main/resources/application-test-pg17.yml @@ -0,0 +1,7 @@ +quarkus: + config: + profile: + parent: test + compose: + devservices: + files: src/main/docker/compose-devservices-test-pg17.yml diff --git a/operator/src/main/resources/application-test-pg18.yml b/operator/src/main/resources/application-test-pg18.yml new file mode 100644 index 0000000..145b608 --- /dev/null +++ b/operator/src/main/resources/application-test-pg18.yml @@ -0,0 +1,7 @@ +quarkus: + config: + profile: + parent: test + compose: + devservices: + files: src/main/docker/compose-devservices-test-pg18.yml diff --git a/operator/src/main/resources/application.yml b/operator/src/main/resources/application.yml index b3783d2..9a2bf85 100644 --- a/operator/src/main/resources/application.yml +++ b/operator/src/main/resources/application.yml @@ -16,7 +16,7 @@ quarkus: datasource: devservices: enabled: true - image-name: postgres:17.7 + image-name: postgres:18 username: root password: password reuse: false @@ -45,8 +45,8 @@ quarkus: # Container Image config for Kubernetes Helm # container-image: registry: ghcr.io - group: aboutbits/postgresql-operator - name: app + group: aboutbits + name: postgresql-operator tag: ${quarkus.application.version} helm: app-version: ${quarkus.application.version} diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java index b19063a..11185df 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -22,6 +22,7 @@ @Accessors(fluent = true, chain = true) public class ClusterConnectionCreate extends TestDataCreator { private final Given given; + private final KubernetesClient kubernetesClient; private final Given.DBConnectionDetails dbConnectionDetails; diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/GrantCreate.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/GrantCreate.java index cefd0e8..750060e 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/GrantCreate.java +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/GrantCreate.java @@ -124,7 +124,6 @@ protected Grant create(int index) { spec.setRole(getRole()); spec.setObjectType(withObjectType); - spec.setObjects(withObjects); if (withObjectType != GrantObjectType.DATABASE || withSchema != null diff --git a/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java b/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java index d644854..e7079d7 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/crd/defaultprivilege/DefaultPrivilegeReconcilerTest.java @@ -20,7 +20,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import java.time.OffsetDateTime; import java.time.ZoneOffset; @@ -28,8 +30,11 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Stream; import static it.aboutbits.postgresql.core.Privilege.CREATE; +import static it.aboutbits.postgresql.core.Privilege.MAINTAIN; import static it.aboutbits.postgresql.core.Privilege.SELECT; import static it.aboutbits.postgresql.core.Privilege.USAGE; import static it.aboutbits.postgresql.crd.defaultprivilege.DefaultPrivilegeObjectType.SCHEMA; @@ -43,8 +48,10 @@ @RequiredArgsConstructor class DefaultPrivilegeReconcilerTest { private final Given given; + private final DefaultPrivilegeService defaultPrivilegeService; private final PostgreSQLContextFactory postgreSQLContextFactory; + private final KubernetesClient kubernetesClient; @BeforeEach @@ -303,10 +310,7 @@ void failWhenDatabaseHasSchema() { @Test @DisplayName("Should reconcile to ERROR when privileges are invalid for objectType") void errorWhenInvalidPrivileges() { - // given - var now = OffsetDateTime.now(ZoneOffset.UTC); - - // when + // given / when var defaultPrivilege = given.one() .defaultPrivilege() .withObjectType(SCHEMA) @@ -314,18 +318,72 @@ void errorWhenInvalidPrivileges() { .withPrivileges(SELECT) .returnFirst(); - var expectedStatus = new CRStatus() - .setName("") - .setPhase(CRPhase.ERROR) - .setMessage("DefaultPrivilege contains invalid privileges for the specified objectType") - .setObservedGeneration(1L); + // then + assertThat(defaultPrivilege) + .isNotNull() + .extracting(DefaultPrivilege::getStatus) + .satisfies(status -> { + assertThat(status.getPhase()).isEqualTo(CRPhase.ERROR); + assertThat(status.getMessage()).startsWith("DefaultPrivilege contains invalid privileges for the specified objectType"); + }); + } + + @Test + @EnabledIfSystemProperty( + named = "quarkus.test.profile", + matches = "test-pg(15|16)", + disabledReason = "PostgreSQL 15 and 16 do not support the MAINTAIN privilege" + ) + @DisplayName( + "Should reconcile to ERROR when the PostgreSQL version does not support the MAINTAIN table privilege" + ) + void errorWhenUnsupportedMaintainTablePrivilege() { + // given + var clusterConnectionMain = given.one() + .clusterConnection() + .returnFirst(); + + var database = given.one() + .database() + .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) + .returnFirst(); + + var clusterConnectionDb = given.one() + .clusterConnection() + .withDatabase(database.getSpec().getName()) + .returnFirst(); + + var schema = given.one() + .schema() + .withClusterConnectionName(clusterConnectionDb.getMetadata().getName()) + .returnFirst(); + + var role = given.one() + .role() + .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) + .returnFirst(); + + // when + var defaultPrivilege = given.one() + .defaultPrivilege() + .withClusterConnectionName(clusterConnectionDb.getMetadata().getName()) + .withDatabase(database.getSpec().getName()) + .withSchema(schema.getSpec().getName()) + .withRole(role.getSpec().getName()) + .withObjectType(TABLE) + .withPrivileges(MAINTAIN) + .returnFirst(); // then - assertThatDefaultPrivilegeHasStatus( - defaultPrivilege, - expectedStatus, - now - ); + assertThat(defaultPrivilege) + .isNotNull() + .extracting(DefaultPrivilege::getStatus) + .satisfies(status -> { + assertThat(status.getPhase()).isEqualTo(CRPhase.ERROR); + assertThat(status.getMessage()) + .startsWith("The following privileges require a newer PostgreSQL version (current:") + .contains("{MAINTAIN=17}"); + }); } } } @@ -438,15 +496,19 @@ void defaultPrivilegeOnSchema() { @Nested class TableTests { - @Test + @ParameterizedTest + @MethodSource("provideAllSupportedPrivileges") @DisplayName("Should grant and revoke default privileges on table") - void defaultPrivilegeOnTable() { + void defaultPrivilegeOnTable( + List allSupportedPrivileges + ) { // given var now = OffsetDateTime.now(ZoneOffset.UTC); var clusterConnectionMain = given.one() .clusterConnection() .returnFirst(); + var database = given.one() .database() .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) @@ -498,7 +560,7 @@ void defaultPrivilegeOnTable() { // given: grant all default privileges change var spec = defaultPrivilege.getSpec(); - var expectedPrivileges = TABLE.privileges(); + var expectedPrivileges = allSupportedPrivileges; var initialGeneration = defaultPrivilege.getStatus().getObservedGeneration(); spec.setPrivileges(expectedPrivileges); @@ -544,6 +606,19 @@ void defaultPrivilegeOnTable() { defaultPrivilege ); } + + static Stream> provideAllSupportedPrivileges() { + var profile = System.getProperty("quarkus.test.profile", ""); + var matcher = Pattern.compile("test-pg(\\d+)").matcher(profile); + int version = matcher.find() ? Integer.parseInt(matcher.group(1)) : 0; + + return Stream.of(TABLE.privilegesSet().stream() + .filter(privilege -> privilege.minimumPostgresVersion() == null + || privilege.minimumPostgresVersion() <= version + ) + .toList() + ); + } } @Nested @@ -557,6 +632,7 @@ void defaultPrivilegeOnSequence() { var clusterConnectionMain = given.one() .clusterConnection() .returnFirst(); + var database = given.one() .database() .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) diff --git a/operator/src/test/java/it/aboutbits/postgresql/crd/grant/GrantReconcilerTest.java b/operator/src/test/java/it/aboutbits/postgresql/crd/grant/GrantReconcilerTest.java index 744a541..45b9bef 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/crd/grant/GrantReconcilerTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/crd/grant/GrantReconcilerTest.java @@ -21,7 +21,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.condition.EnabledIfSystemProperty; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; import java.time.OffsetDateTime; import java.time.ZoneOffset; @@ -30,9 +32,12 @@ import java.util.Set; import java.util.concurrent.TimeUnit; import java.util.function.Predicate; +import java.util.regex.Pattern; +import java.util.stream.Stream; import static it.aboutbits.postgresql.core.Privilege.CONNECT; import static it.aboutbits.postgresql.core.Privilege.CREATE; +import static it.aboutbits.postgresql.core.Privilege.MAINTAIN; import static it.aboutbits.postgresql.core.Privilege.SELECT; import static it.aboutbits.postgresql.core.Privilege.USAGE; import static it.aboutbits.postgresql.crd.grant.GrantObjectType.DATABASE; @@ -48,8 +53,10 @@ @RequiredArgsConstructor class GrantReconcilerTest { private final Given given; + private final GrantService grantService; private final PostgreSQLContextFactory postgreSQLContextFactory; + private final KubernetesClient kubernetesClient; @BeforeEach @@ -298,10 +305,7 @@ void failWhenSchemaHasObjects() { @Test @DisplayName("Should reconcile to ERROR when privileges are invalid for objectType") void errorWhenInvalidPrivileges() { - // given - var now = OffsetDateTime.now(ZoneOffset.UTC); - - // when + // given / when var grant = given.one() .grant() .withObjectType(SCHEMA) @@ -309,18 +313,81 @@ void errorWhenInvalidPrivileges() { .withPrivileges(SELECT) .returnFirst(); - var expectedStatus = new CRStatus() - .setName("") - .setPhase(CRPhase.ERROR) - .setMessage("Grant contains invalid privileges for the specified objectType") - .setObservedGeneration(1L); - // then - assertThatGrantHasStatus( - grant, - expectedStatus, - now + assertThat(grant) + .isNotNull() + .extracting(Grant::getStatus) + .satisfies(status -> { + assertThat(status.getPhase()).isEqualTo(CRPhase.ERROR); + assertThat(status.getMessage()).startsWith("Grant contains invalid privileges for the specified objectType"); + }); + } + + @Test + @EnabledIfSystemProperty( + named = "quarkus.test.profile", + matches = "test-pg(15|16)", + disabledReason = "PostgreSQL 15 and 16 do not support the MAINTAIN privilege" + ) + @DisplayName( + "Should reconcile to ERROR when the PostgreSQL version does not support the MAINTAIN table privilege" + ) + void errorWhenUnsupportedMaintainTablePrivilege() { + // given + var clusterConnectionMain = given.one() + .clusterConnection() + .returnFirst(); + + var database = given.one() + .database() + .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) + .returnFirst(); + + var clusterConnectionDb = given.one() + .clusterConnection() + .withDatabase(database.getSpec().getName()) + .returnFirst(); + + var schema = given.one() + .schema() + .withClusterConnectionName(clusterConnectionDb.getMetadata().getName()) + .returnFirst(); + + var role = given.one() + .role() + .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) + .returnFirst(); + + var tableName = "test_table"; + createTable( + clusterConnectionDb, + database.getSpec().getName(), + schema.getSpec().getName(), + tableName ); + + // when + var grant = given.one() + .grant() + .withClusterConnectionName(clusterConnectionDb.getMetadata().getName()) + .withDatabase(database.getSpec().getName()) + .withSchema(schema.getSpec().getName()) + .withRole(role.getSpec().getName()) + .withObjectType(TABLE) + .withObjects(tableName) + .withPrivileges(MAINTAIN) + .returnFirst(); + + // then + assertThat(grant) + .isNotNull() + .extracting(Grant::getStatus) + .satisfies(status -> { + assertThat(status.getPhase()).isEqualTo(CRPhase.ERROR); + assertThat(status.getMessage()) + .startsWith("The following privileges require a newer PostgreSQL version (current:") + .contains("{MAINTAIN=17}"); + }); } } } @@ -549,15 +616,19 @@ void grantOnSchema() { @Nested class TableTests { - @Test + @ParameterizedTest + @MethodSource("provideAllSupportedPrivileges") @DisplayName("Should grant and revoke privileges on table") - void grantOnTable() { + void grantOnTable( + List allSupportedPrivileges + ) { // given var now = OffsetDateTime.now(ZoneOffset.UTC); var clusterConnectionMain = given.one() .clusterConnection() .returnFirst(); + var database = given.one() .database() .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) @@ -619,7 +690,7 @@ void grantOnTable() { // given: grant all privileges change var spec = grant.getSpec(); - var expectedPrivileges = TABLE.privileges(); + var expectedPrivileges = allSupportedPrivileges; var initialGeneration = grant.getStatus().getObservedGeneration(); spec.setPrivileges(expectedPrivileges); @@ -669,15 +740,19 @@ void grantOnTable() { ); } - @Test + @ParameterizedTest + @MethodSource("provideAllSupportedPrivileges") @DisplayName("Should grant and revoke privileges on all tables") - void grantOnAllTables() { + void grantOnAllTables( + List allSupportedPrivileges + ) { // given var now = OffsetDateTime.now(ZoneOffset.UTC); var clusterConnectionMain = given.one() .clusterConnection() .returnFirst(); + var database = given.one() .database() .withClusterConnectionName(clusterConnectionMain.getMetadata().getName()) @@ -752,10 +827,10 @@ void grantOnAllTables() { // given: grant all privileges change var spec = grant.getSpec(); - var expectedPrivileges = TABLE.privileges(); + var expectedPrivileges = allSupportedPrivileges; var initialGeneration = grant.getStatus().getObservedGeneration(); - spec.setPrivileges(expectedPrivileges); + spec.setPrivileges(allSupportedPrivileges); // when applyGrant( @@ -818,6 +893,19 @@ void grantOnAllTables() { tableName2 ); } + + static Stream> provideAllSupportedPrivileges() { + var profile = System.getProperty("quarkus.test.profile", ""); + var matcher = Pattern.compile("test-pg(\\d+)").matcher(profile); + int version = matcher.find() ? Integer.parseInt(matcher.group(1)) : 0; + + return Stream.of(TABLE.privilegesSet().stream() + .filter(privilege -> privilege.minimumPostgresVersion() == null + || privilege.minimumPostgresVersion() <= version + ) + .toList() + ); + } } @Nested @@ -831,6 +919,7 @@ void grantOnSequence() { var clusterConnectionMain = given.one() .clusterConnection() .returnFirst(); + var database = given.one() .database() .withClusterConnectionName(clusterConnectionMain.getMetadata().getName())