From d5c888ef9fffac571f7e06a4c46ae05365b21b23 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Wed, 17 Dec 2025 17:22:07 +0100 Subject: [PATCH 01/31] first version --- .dockerignore | 5 + .editorconfig | 23 + .githooks/pre-commit | 6 + .github/workflows/main.yml | 15 + .github/workflows/test.yml | 25 + .gitignore | 62 +++ .idea/checkstyle-idea.xml | 23 + .idea/codeStyles/Project.xml | 32 ++ .idea/codeStyles/codeStyleConfig.xml | 5 + .idea/misc.xml | 10 + .idea/sqldialects.xml | 6 + .idea/vcs.xml | 6 + Makefile | 19 + README.md | 99 ++++ build.gradle.kts | 227 +++++++++ gradle.properties | 13 + gradle/libs.versions.toml | 84 ++++ gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 62076 bytes gradle/wrapper/gradle-wrapper.properties | 8 + gradlew | 245 ++++++++++ gradlew.bat | 92 ++++ lombok.config | 6 + settings.gradle.kts | 60 +++ .../persistence/DefaultCatalog.java | 63 +++ .../infrastructure/persistence/Indexes.java | 35 ++ .../core/infrastructure/persistence/Keys.java | 46 ++ .../infrastructure/persistence/PgCatalog.java | 78 ++++ .../infrastructure/persistence/Routines.java | 71 +++ .../infrastructure/persistence/Tables.java | 41 ++ .../routines/ShobjDescription.java | 93 ++++ .../persistence/tables/PgAuthMembers.java | 271 +++++++++++ .../persistence/tables/PgAuthid.java | 290 ++++++++++++ .../persistence/tables/PgDbRoleSetting.java | 239 ++++++++++ .../tables/records/PgAuthMembersRecord.java | 170 +++++++ .../tables/records/PgAuthidRecord.java | 252 ++++++++++ .../tables/records/PgDbRoleSettingRecord.java | 106 +++++ src/main/docker/Dockerfile.jvm | 98 ++++ src/main/docker/Dockerfile.native | 31 ++ src/main/docker/Dockerfile.native-micro | 34 ++ .../MinioInstanceReadinessCheck.java | 69 +++ .../postgresql/core/BaseReconciler.java | 112 +++++ .../it/aboutbits/postgresql/core/CRPhase.java | 11 + .../aboutbits/postgresql/core/CRStatus.java | 76 +++ .../postgresql/core/ClusterReference.java | 21 + .../postgresql/core/Credentials.java | 11 + .../postgresql/core/KubernetesUtil.java | 91 ++++ .../it/aboutbits/postgresql/core/Named.java | 10 + .../core/PostgreSQLAuthenticationUtil.java | 215 +++++++++ .../core/PostgreSQLContextFactory.java | 68 +++ .../aboutbits/postgresql/core/SecretRef.java | 21 + .../crd/connection/ClusterConnection.java | 46 ++ .../ClusterConnectionReconciler.java | 66 +++ .../crd/connection/ClusterConnectionSpec.java | 31 ++ .../postgresql/crd/database/.gitkeep | 0 .../aboutbits/postgresql/crd/grant/.gitkeep | 0 .../aboutbits/postgresql/crd/role/Role.java | 23 + .../postgresql/crd/role/RoleFlags.java | 43 ++ .../postgresql/crd/role/RoleReconciler.java | 146 ++++++ .../postgresql/crd/role/RoleSpec.java | 71 +++ .../postgresql/crd/role/RoleUtil.java | 438 ++++++++++++++++++ src/main/resources/META-INF/branding/logo.png | Bin 0 -> 3317 bytes src/main/resources/application-dev.yml | 6 + src/main/resources/application-test.yml | 7 + src/main/resources/application.yml | 173 +++++++ src/main/resources/default_banner.txt | 6 + .../testdata/base/TestDataCreator.java | 67 +++ .../_support/testdata/persisted/Given.java | 89 ++++ .../creator/ClusterConnectionCreate.java | 168 +++++++ .../persisted/creator/SecretRefCreate.java | 160 +++++++ .../ClusterConnectionReconcilerTest.java | 85 ++++ .../crd/role/RoleReconcilerTest.java | 229 +++++++++ 71 files changed, 5548 insertions(+) create mode 100644 .dockerignore create mode 100644 .editorconfig create mode 100755 .githooks/pre-commit create mode 100644 .github/workflows/main.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 .idea/checkstyle-idea.xml create mode 100644 .idea/codeStyles/Project.xml create mode 100644 .idea/codeStyles/codeStyleConfig.xml create mode 100644 .idea/misc.xml create mode 100644 .idea/sqldialects.xml create mode 100644 .idea/vcs.xml create mode 100644 Makefile create mode 100644 build.gradle.kts create mode 100644 gradle.properties create mode 100644 gradle/libs.versions.toml create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100755 gradlew.bat create mode 100644 lombok.config create mode 100644 settings.gradle.kts create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/DefaultCatalog.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Indexes.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Keys.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Routines.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/routines/ShobjDescription.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgAuthMembers.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgAuthid.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgDbRoleSetting.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgAuthMembersRecord.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgAuthidRecord.java create mode 100644 src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgDbRoleSettingRecord.java create mode 100644 src/main/docker/Dockerfile.jvm create mode 100644 src/main/docker/Dockerfile.native create mode 100644 src/main/docker/Dockerfile.native-micro create mode 100644 src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java create mode 100644 src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java create mode 100644 src/main/java/it/aboutbits/postgresql/core/CRPhase.java create mode 100644 src/main/java/it/aboutbits/postgresql/core/CRStatus.java create mode 100644 src/main/java/it/aboutbits/postgresql/core/ClusterReference.java create mode 100644 src/main/java/it/aboutbits/postgresql/core/Credentials.java create mode 100644 src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java create mode 100644 src/main/java/it/aboutbits/postgresql/core/Named.java create mode 100644 src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java create mode 100644 src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java create mode 100644 src/main/java/it/aboutbits/postgresql/core/SecretRef.java create mode 100644 src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java create mode 100644 src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java create mode 100644 src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java create mode 100644 src/main/java/it/aboutbits/postgresql/crd/database/.gitkeep create mode 100644 src/main/java/it/aboutbits/postgresql/crd/grant/.gitkeep create mode 100644 src/main/java/it/aboutbits/postgresql/crd/role/Role.java create mode 100644 src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java create mode 100644 src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java create mode 100644 src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java create mode 100644 src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java create mode 100644 src/main/resources/META-INF/branding/logo.png create mode 100644 src/main/resources/application-dev.yml create mode 100644 src/main/resources/application-test.yml create mode 100644 src/main/resources/application.yml create mode 100644 src/main/resources/default_banner.txt create mode 100644 src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java create mode 100644 src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java create mode 100644 src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java create mode 100644 src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java create mode 100644 src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java create mode 100644 src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..4361d2f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +* +!build/*-runner +!build/*-runner.jar +!build/lib/* +!build/quarkus-app/* \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..dd629ea --- /dev/null +++ b/.editorconfig @@ -0,0 +1,23 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 +ij_continuation_indent_size = 8 + +[*.yml] +indent_size = 2 + +[*.md] +max_line_length = off + +[Makefile*] +indent_style = tab + +[src/main/resources/default_banner.txt] +insert_final_newline = false diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..369f90c --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,6 @@ +#!/bin/bash + +set -e +set -o pipefail + +./gradlew --console=colored checkstyleMain checkstyleTest diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml new file mode 100644 index 0000000..34aec92 --- /dev/null +++ b/.github/workflows/main.yml @@ -0,0 +1,15 @@ +name: Test + +on: + pull_request: + types: [ opened, reopened, synchronize ] + +concurrency: + group: ${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + name: Tests + uses: ./.github/workflows/test.yml + secrets: inherit diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..b1f7540 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,25 @@ +name: Tests + +on: + workflow_call: + +jobs: + test: + name: Tests + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + - uses: actions/checkout@v5 + - uses: aboutbits/github-actions-java/setup-with-gradle@v4 + with: + java-version: 25 + cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }} + - name: Build & Test + run: >- + ./gradlew + --console=colored + test + --fail-fast + env: + GITHUB_USER_NAME: ${{ github.actor }} + GITHUB_ACCESS_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d8db0e8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +### MinIO Operator ### +config/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 +.idea/* +!.idea/codeStyles +!.idea/.gitignore +!.idea/checkstyle-idea.xml +!.idea/encodings.xml +!.idea/misc.xml +!.idea/sqldialects.xml +!.idea/vcs.xml + +*.iml +*.ipr +*.iws + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac ### +.DS_Store + +### Gradle ### +# Reference: https://github.com/github/gitignore/blob/main/Gradle.gitignore +.gradle +**/build/ +!**/src/**/build/ +gradle-app.setting +!gradle-wrapper.jar +!gradle-wrapper.properties +.gradletasknamecache + +### Quarkus ### +# Local environment +.env + +# Plugin directory +/.quarkus/cli/plugins/ + +# Quinoa +.quinoa/ diff --git a/.idea/checkstyle-idea.xml b/.idea/checkstyle-idea.xml new file mode 100644 index 0000000..2be65ed --- /dev/null +++ b/.idea/checkstyle-idea.xml @@ -0,0 +1,23 @@ + + + + 12.1.2 + JavaOnly + + + \ No newline at end of file diff --git a/.idea/codeStyles/Project.xml b/.idea/codeStyles/Project.xml new file mode 100644 index 0000000..8824375 --- /dev/null +++ b/.idea/codeStyles/Project.xml @@ -0,0 +1,32 @@ + + + + + + + + diff --git a/.idea/codeStyles/codeStyleConfig.xml b/.idea/codeStyles/codeStyleConfig.xml new file mode 100644 index 0000000..6a7bfad --- /dev/null +++ b/.idea/codeStyles/codeStyleConfig.xml @@ -0,0 +1,5 @@ + + + + diff --git a/.idea/misc.xml b/.idea/misc.xml new file mode 100644 index 0000000..eb37e5a --- /dev/null +++ b/.idea/misc.xml @@ -0,0 +1,10 @@ + + + + + + + + + + \ No newline at end of file diff --git a/.idea/sqldialects.xml b/.idea/sqldialects.xml new file mode 100644 index 0000000..6df4889 --- /dev/null +++ b/.idea/sqldialects.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000..94a25f7 --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..13d2f9c --- /dev/null +++ b/Makefile @@ -0,0 +1,19 @@ +### SETUP + +init: + $(MAKE) install + +install: + ./gradlew --console=colored quarkusBuild + + +### EXECUTION + +run: + ./gradlew --console=colored quarkusDev + +test: + ./gradlew --console=colored clean test + +# Flag targets as phony, to tell `make` that these are no file targets +.PHONY: init install run test diff --git a/README.md b/README.md index d741ecf..8d8c674 100644 --- a/README.md +++ b/README.md @@ -1 +1,100 @@ # AboutBits PostgreSQL Operator + +## Getting started + +These instructions will get you a copy of the project up and running on your local machine for development and testing purposes. + +### Prerequisites + +To build the project, the following prerequisites must be met: + +- Java JDK (e.g. [OpenJDK](https://openjdk.java.net/)) +- [Gradle](https://gradle.org/) (Optional) +- [Docker](https://www.docker.com/) + +### Setup configuration + +To get started, call: + +```bash +make init +``` + +### Running the project in the console + +You can run your application in dev mode that enables live coding and continuous testing using: + +```shell script +make run + +# or + +./gradlew quarkusDev +``` + +The app service will be available at http://localhost:8080, +and you can also use the Dev UI (available in dev mode only) at . + +To execute the test without continuous testing in the dev mode, you can run the following command: + +```bash +make test + +# or + +./gradlew test +``` + +### Run the project as a service in IntelliJ + +1. Open the `Services` tool on the left side of the IDE +2. Click on "+" and select "Quarkus" + +Afterwards, the project can be started in IntelliJ by navigating to `Run` -> `Run '...'`. + +## Packaging and running the application + +The application can be packaged using: + +```shell script +./gradlew build +``` + +It produces the `quarkus-run.jar` file in the `build/quarkus-app/` directory. +Be aware that it’s not an _über-jar_ as the dependencies are copied into the `build/quarkus-app/lib/` directory. + +The application is now runnable using `java -jar build/quarkus-app/quarkus-run.jar`. + +If you want to build an _über-jar_, execute the following command: + +```shell script +./gradlew build -Dquarkus.package.jar.type=uber-jar +``` + +The application, packaged as an _über-jar_, is now runnable using `java -jar build/*-runner.jar`. + +## Creating a native executable + +You can create a native executable using: + +```shell script +./gradlew build -Dquarkus.native.enabled=true +``` + +Or, if you don't have GraalVM installed, you can run the native executable build in a container using: + +```shell script +./gradlew build -Dquarkus.native.enabled=true -Dquarkus.native.container-build=true +``` + +You can then execute your native executable with: `./build/postgresql-operator-1.0.0-SNAPSHOT-runner` + +If you want to learn more about building native executables, please consult . + +## Related Guides + +- Operator SDK ([guide](https://docs.quarkiverse.io/quarkus-operator-sdk/dev/index.html)): Quarkus extension for the Java Operator SDK (https://javaoperatorsdk.io) +- Helm ([guide](https://docs.quarkiverse.io/quarkus-helm/dev/index.html)): Quarkus extension for Kubernetes Helm chars +- SmallRye Health ([guide](https://quarkus.io/guides/smallrye-health)): Monitor service health +- Micrometer metrics ([guide](https://quarkus.io/guides/micrometer)): Instrument the runtime and your application with dimensional metrics using Micrometer. +- YAML Configuration ([guide](https://quarkus.io/guides/config-yaml)): Use YAML to configure your Quarkus application diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..ffe22b7 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,227 @@ +import org.gradle.api.tasks.testing.logging.TestExceptionFormat +import org.gradle.api.tasks.testing.logging.TestLogEvent + +plugins { + idea + java + checkstyle + id("io.quarkus") + alias(libs.plugins.jooqPlugin) +} + +description = "AboutBits PostgreSQL Operator" +group = "com.aboutbits.postgresql" +version = "0.0.1-SNAPSHOT" + +val quarkusPlatformGroupId: String by project +val quarkusPlatformArtifactId: String by project +val quarkusPlatformVersion: String by project + +val checkstyleConfig by configurations.creating { + isCanBeConsumed = false + isCanBeResolved = true +} + +dependencies { + // AboutBits Libraries + checkstyleConfig(libs.checkstyleConfig) + + /** + * Quarkus Extensions + */ + // https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-bom + implementation(enforcedPlatform("${quarkusPlatformGroupId}:${quarkusPlatformArtifactId}:${quarkusPlatformVersion}")) + implementation("io.quarkus:quarkus-arc") + implementation("io.quarkus:quarkus-config-yaml") + implementation("io.quarkus:quarkus-jackson") + implementation("io.quarkus:quarkus-kubernetes-client") + implementation("io.quarkus:quarkus-micrometer") + implementation("io.quarkus:quarkus-micrometer-registry-prometheus") + implementation("io.quarkus:quarkus-jdbc-postgresql") + implementation("io.quarkus:quarkus-smallrye-health") + + /** + * Fabric8 Kubernetes Client + */ + implementation("io.fabric8:generator-annotations") + + /** + * jOOQ + */ + implementation(libs.jooq) + compileOnly(libs.jooqMeta) + // PostgreSQL JDBC Driver for jOOQ generation + jooqCodegen(libs.postgresql) + + /** + * JSpecify + */ + implementation(libs.jspecify) + + /** + * Lombok + */ + compileOnly(libs.lombok) + annotationProcessor(libs.lombok) + testImplementation(libs.lombok) + testAnnotationProcessor(libs.lombok) + + /** + * Quarkiverse Helm + */ + implementation(libs.quarkiverse.helm) + + /** + * Quarkiverse Operator SDK + */ + // https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-operator-sdk-bom + implementation(enforcedPlatform("${quarkusPlatformGroupId}:quarkus-operator-sdk-bom:${quarkusPlatformVersion}")) + implementation("io.quarkiverse.operatorsdk:quarkus-operator-sdk") + + /** + * SCRAM + */ + implementation(libs.scram.client) + + /** + * Testing + */ + testImplementation("io.quarkus:quarkus-junit5") + testImplementation(libs.assertj) + testImplementation(libs.datafaker) +} + +sourceSets { + main { + java { + srcDir("src/generated/jooq/main") + } + } +} + +java { + sourceCompatibility = JavaVersion.VERSION_25 + targetCompatibility = JavaVersion.VERSION_25 + + toolchain { + languageVersion = JavaLanguageVersion.of(JavaVersion.VERSION_25.majorVersion) + vendor = JvmVendorSpec.AMAZON + } +} + +tasks.withType { + options.encoding = "UTF-8" + options.compilerArgs.add("-parameters") +} + +tasks.quarkusDev { + // Java 24+ issue. Remove after this has been fixed. + // https://github.com/quarkusio/quarkus/issues/47769#issuecomment-3148789105 + // https://github.com/quarkusio/quarkus/pull/49920 + jvmArgs = listOf("--add-opens", "java.base/java.lang=ALL-UNNAMED") +} + +tasks.withType { + useJUnitPlatform() + + systemProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager") + jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + + testLogging { + exceptionFormat = TestExceptionFormat.FULL + + info { + showStandardStreams = !providers.environmentVariable("CI").isPresent + events( + *listOfNotNull( + TestLogEvent.PASSED, + TestLogEvent.SKIPPED, + TestLogEvent.FAILED, + TestLogEvent.STANDARD_ERROR, + if (!providers.environmentVariable("CI").isPresent) TestLogEvent.STANDARD_OUT else null + ).toTypedArray() + ) + } + } + + if (!project.hasProperty("createTestReports")) { + reports.html.required = false + reports.junitXml.required = false + } + + filter { + if (project.hasProperty("excludeTests")) { + val excludePatterns = project.property("excludeTests").toString().split(",") + excludePatterns.forEach { pattern -> + excludeTestsMatching(pattern.trim()) + } + } + } +} + +tasks.register("checkstyleExtractConfig") { + description = "Extracts the AboutBits Checkstyle configuration from the classpath." + group = JavaBasePlugin.CHECK_TASK_NAME + + from(zipTree(checkstyleConfig.singleFile)) { + include("checkstyle.xml", "checkstyle-suppressions.xml") + } + into(layout.projectDirectory.dir("config/checkstyle/")) +} + +tasks.withType().configureEach { + dependsOn(tasks.named("checkstyleExtractConfig")) + + reports { + html.required.set(false) + xml.required.set(false) + } +} + +checkstyle { + toolVersion = libs.versions.checkstyle.get() + isShowViolations = true + configFile = rootProject.file("config/checkstyle/checkstyle.xml") + configProperties = mapOf( + "suppressionFile" to rootProject.file("config/checkstyle/checkstyle-suppressions.xml") + ) +} + +jooq { + configuration { + jdbc { + driver = "org.postgresql.Driver" + url = "jdbc:postgresql://localhost:5432/postgres" + user = "root" + password = "password" + } + generator { + database { + name = "org.jooq.meta.postgres.PostgresDatabase" + schemata { + schema { + inputSchema = "pg_catalog" + } + } + includes = """ + pg_auth_members + | pg_authid + | pg_db_role_setting + | shobj_description + """.trimIndent() + excludes = """ + """.trimIndent() + } + generate { + deprecated = false + fluentSetters = true + generatedAnnotation = true + pojos = false + } + target { + packageName = "it.aboutbits.postgresql.core.infrastructure.persistence" + directory = "src/generated/jooq/main" + } + } + } +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..5863ac9 --- /dev/null +++ b/gradle.properties @@ -0,0 +1,13 @@ +# Gradle properties +org.gradle.caching=true +org.gradle.parallel=true +org.gradle.logging.level=INFO + +# Quarkus +quarkusPluginId=io.quarkus +quarkusPluginVersion=3.30.3 +# https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-bom +quarkusPlatformGroupId=io.quarkus.platform +quarkusPlatformArtifactId=quarkus-bom +quarkusPlatformVersion=3.30.3 +systemProp.quarkus.analytics.disabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..ab57db2 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,84 @@ +[versions] +## AboutBits Libraries ## +checkstyleConfig = "2.0.0" + +## Libraries ## +jooq = "3.20.10" +jSpecify = "1.0.0" +lombok = "1.18.42" +postgresql = "42.7.8" +quarkiverse-helm = "1.2.7" +scram-client = "3.2" + +## Testing ## +assertj = "3.27.6" +checkstyle = "12.2.0" +datafaker = "2.5.3" + +[plugins] +# https://www.jooq.org/doc/latest/manual/code-generation/codegen-execution/codegen-gradle/ +# https://github.com/jOOQ/jOOQ/tree/main/jOOQ-codegen-gradle +# https://mvnrepository.com/artifact/org.jooq.jooq-codegen-gradle/org.jooq.jooq-codegen-gradle.gradle.plugin +jooqPlugin = { id = "org.jooq.jooq-codegen-gradle", version.ref = "jooq" } + +[libraries] +## AboutBits Libraries ## + +# https://github.com/aboutbits/java-checkstyle-config +checkstyleConfig = { group = "it.aboutbits", name = "java-checkstyle-config", version.ref = "checkstyleConfig" } + +## Libraries ## + +# jOOQ # +# https://www.jooq.org/ +# https://www.jooq.org/download/versions +# https://github.com/jOOQ/jOOQ +# https://mvnrepository.com/artifact/org.jooq/jooq +jooq = { group = "org.jooq", name = "jooq", version.ref = "jooq" } +jooqMeta = { group = "org.jooq", name = "jooq-meta", version.ref = "jooq" } + +# jSpecify # +# https://jspecify.dev/ +# https://github.com/jspecify/jspecify +# https://mvnrepository.com/artifact/org.jspecify/jspecify +jspecify = { group = "org.jspecify", name = "jspecify", version.ref = "jSpecify" } + +# Lombok # +# https://projectlombok.org/ +# https://github.com/projectlombok/lombok +# https://mvnrepository.com/artifact/org.projectlombok/lombok +lombok = { group = "org.projectlombok", name = "lombok", version.ref = "lombok" } + +# PostgreSQL # +# https://jdbc.postgresql.org/ +# https://github.com/pgjdbc/pgjdbc +# https://mvnrepository.com/artifact/org.postgresql/postgresql +postgresql = { group = "org.postgresql", name = "postgresql", version.ref = "postgresql" } + +# Quarkiverse Helm # +# https://docs.quarkiverse.io/quarkus-helm/dev/ +# https://github.com/quarkiverse/quarkus-helm +# https://mvnrepository.com/artifact/io.quarkiverse.helm/quarkus-helm +quarkiverse-helm = { group = "io.quarkiverse.helm", name = "quarkus-helm", version.ref = "quarkiverse-helm" } + +# SCRAM - Salted Challenge Response Authentication Mechanism # +# https://github.com/ongres/scram +# https://mvnrepository.com/artifact/com.ongres.scram/scram-client +scram-client = { group = "com.ongres.scram", name = "scram-client", version.ref = "scram-client" } + +## Testing ## + +# https://assertj.github.io/ +# https://github.com/assertj/assertj +# https://mvnrepository.com/artifact/org.assertj/assertj-core +assertj = { group = "org.assertj", name = "assertj-core", version.ref = "assertj" } + +# https://checkstyle.org/ +# https://github.com/checkstyle/checkstyle +# https://mvnrepository.com/artifact/com.puppycrawl.tools/checkstyle +checkstyle = { group = "com.puppycrawl.tools", name = "checkstyle", version.ref = "checkstyle" } + +# https://datafaker.net/ +# https://github.com/datafaker-net/datafaker +# https://mvnrepository.com/artifact/net.datafaker/datafaker +datafaker = { group = "net.datafaker", name = "datafaker", version.ref = "datafaker" } diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..c1962a79e29d3e0ab67b14947c167a862655af9b GIT binary patch literal 62076 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..af0e1ab --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,8 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +# https://gradle.org/release-checksums/ +distributionSha256Sum=f86344275d1b194688dd330abf9f6f2344cd02872ffee035f2d1ea2fd60cf7f3 +distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-all.zip +networkTimeout=10000 +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..aeb74cb --- /dev/null +++ b/gradlew @@ -0,0 +1,245 @@ +#!/bin/sh + +# +# Copyright © 2015-2021 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command; +# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of +# shell script including quotes and variable substitutions, so put them in +# double quotes to make sure that they get re-expanded; and +# * put everything else in single quotes, so that it's not re-expanded. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -classpath "$CLASSPATH" \ + org.gradle.wrapper.GradleWrapperMain \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100755 index 0000000..93e3f59 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,92 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/lombok.config b/lombok.config new file mode 100644 index 0000000..dc6470a --- /dev/null +++ b/lombok.config @@ -0,0 +1,6 @@ +config.stopBubbling = true + +# Copy JSpecify annotations to generated code (constructors, getters, setters) +lombok.copyableAnnotations += org.jspecify.annotations.Nullable +lombok.copyableAnnotations += org.jspecify.annotations.NonNull +lombok.addNullAnnotations = jspecify diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..a3edbcf --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,60 @@ +rootProject.name="postgresql-operator" + +pluginManagement { + val quarkusPluginVersion: String by settings + val quarkusPluginId: String by settings + repositories { + mavenCentral() + gradlePluginPortal() + mavenLocal() + } + plugins { + id(quarkusPluginId) version quarkusPluginVersion + } +} + +// https://docs.gradle.org/current/userguide/best_practices_dependencies.html#set_up_repositories_in_settings +@Suppress("UnstableApiUsage") +dependencyResolutionManagement { + // This is a best practice that ensures all projects use the repositories defined here. + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + + repositories { + val githubUser = providers.gradleProperty("gpr.user") + .orElse(providers.environmentVariable("GITHUB_USER_NAME")) + val githubToken = providers.gradleProperty("gpr.key") + .orElse(providers.environmentVariable("GITHUB_ACCESS_TOKEN")) + + fun addGitHubRepo(name: String): MavenArtifactRepository { + return maven { + this.name = name + url = uri("https://maven.pkg.github.com/aboutbits/$name") + credentials { + username = githubUser.orNull + password = githubToken.orNull + } + } + } + + // https://docs.gradle.org/current/userguide/best_practices_dependencies.html#use_content_filtering + exclusiveContent { + forRepositories( + addGitHubRepo("java-checkstyle-config"), + mavenLocal() + ) + filter { + includeGroupAndSubgroups("it.aboutbits") + } + } + + mavenCentral() + mavenLocal() + } +} + +plugins { + // https://docs.gradle.org/current/userguide/toolchains.html#sec:provisioning + // https://plugins.gradle.org/plugin/org.gradle.toolchains.foojay-resolver-convention + // https://github.com/gradle/foojay-toolchains + id("org.gradle.toolchains.foojay-resolver-convention").version("1.0.0") +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/DefaultCatalog.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/DefaultCatalog.java new file mode 100644 index 0000000..a815d82 --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/DefaultCatalog.java @@ -0,0 +1,63 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence; + + +import java.util.Arrays; +import java.util.List; + +import javax.annotation.processing.Generated; + +import org.jooq.Constants; +import org.jooq.Schema; +import org.jooq.impl.CatalogImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class DefaultCatalog extends CatalogImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of DEFAULT_CATALOG + */ + public static final DefaultCatalog DEFAULT_CATALOG = new DefaultCatalog(); + + /** + * system catalog schema + */ + public final PgCatalog PG_CATALOG = PgCatalog.PG_CATALOG; + + /** + * No further instances allowed + */ + private DefaultCatalog() { + super(""); + } + + @Override + public final List getSchemas() { + return Arrays.asList( + PgCatalog.PG_CATALOG + ); + } + + /** + * A reference to the 3.20 minor release of the code generator. If this + * doesn't compile, it's because the runtime library uses an older minor + * release, namely: 3.20. You can turn off the generation of this reference + * by specifying /configuration/generator/generate/jooqVersionReference + */ + private static final String REQUIRE_RUNTIME_JOOQ_VERSION = Constants.VERSION_3_20; +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Indexes.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Indexes.java new file mode 100644 index 0000000..f830603 --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Indexes.java @@ -0,0 +1,35 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgAuthMembers; + +import javax.annotation.processing.Generated; + +import org.jooq.Index; +import org.jooq.OrderField; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; + + +/** + * A class modelling indexes of tables in pg_catalog. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Indexes { + + // ------------------------------------------------------------------------- + // INDEX definitions + // ------------------------------------------------------------------------- + + public static final Index PG_AUTH_MEMBERS_GRANTOR_INDEX = Internal.createIndex(DSL.name("pg_auth_members_grantor_index"), PgAuthMembers.PG_AUTH_MEMBERS, new OrderField[] { PgAuthMembers.PG_AUTH_MEMBERS.GRANTOR }, false); +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Keys.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Keys.java new file mode 100644 index 0000000..a8ae047 --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Keys.java @@ -0,0 +1,46 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgAuthMembers; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgAuthid; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDbRoleSetting; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgAuthMembersRecord; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgAuthidRecord; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgDbRoleSettingRecord; + +import javax.annotation.processing.Generated; + +import org.jooq.TableField; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; + + +/** + * A class modelling foreign key relationships and constraints of tables in + * pg_catalog. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Keys { + + // ------------------------------------------------------------------------- + // UNIQUE and PRIMARY KEY definitions + // ------------------------------------------------------------------------- + + public static final UniqueKey PG_AUTH_MEMBERS_MEMBER_ROLE_INDEX = Internal.createUniqueKey(PgAuthMembers.PG_AUTH_MEMBERS, DSL.name("pg_auth_members_member_role_index"), new TableField[] { PgAuthMembers.PG_AUTH_MEMBERS.MEMBER, PgAuthMembers.PG_AUTH_MEMBERS.ROLEID, PgAuthMembers.PG_AUTH_MEMBERS.GRANTOR }, true); + public static final UniqueKey PG_AUTH_MEMBERS_OID_INDEX = Internal.createUniqueKey(PgAuthMembers.PG_AUTH_MEMBERS, DSL.name("pg_auth_members_oid_index"), new TableField[] { PgAuthMembers.PG_AUTH_MEMBERS.OID }, true); + public static final UniqueKey PG_AUTH_MEMBERS_ROLE_MEMBER_INDEX = Internal.createUniqueKey(PgAuthMembers.PG_AUTH_MEMBERS, DSL.name("pg_auth_members_role_member_index"), new TableField[] { PgAuthMembers.PG_AUTH_MEMBERS.ROLEID, PgAuthMembers.PG_AUTH_MEMBERS.MEMBER, PgAuthMembers.PG_AUTH_MEMBERS.GRANTOR }, true); + public static final UniqueKey PG_AUTHID_OID_INDEX = Internal.createUniqueKey(PgAuthid.PG_AUTHID, DSL.name("pg_authid_oid_index"), new TableField[] { PgAuthid.PG_AUTHID.OID }, true); + public static final UniqueKey PG_AUTHID_ROLNAME_INDEX = Internal.createUniqueKey(PgAuthid.PG_AUTHID, DSL.name("pg_authid_rolname_index"), new TableField[] { PgAuthid.PG_AUTHID.ROLNAME }, true); + public static final UniqueKey PG_DB_ROLE_SETTING_DATABASEID_ROL_INDEX = Internal.createUniqueKey(PgDbRoleSetting.PG_DB_ROLE_SETTING, DSL.name("pg_db_role_setting_databaseid_rol_index"), new TableField[] { PgDbRoleSetting.PG_DB_ROLE_SETTING.SETDATABASE, PgDbRoleSetting.PG_DB_ROLE_SETTING.SETROLE }, true); +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java new file mode 100644 index 0000000..83b23e5 --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/PgCatalog.java @@ -0,0 +1,78 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgAuthMembers; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgAuthid; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDbRoleSetting; + +import java.util.Arrays; +import java.util.List; + +import javax.annotation.processing.Generated; + +import org.jooq.Catalog; +import org.jooq.Table; +import org.jooq.impl.DSL; +import org.jooq.impl.SchemaImpl; + + +/** + * system catalog schema + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgCatalog extends SchemaImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of pg_catalog + */ + public static final PgCatalog PG_CATALOG = new PgCatalog(); + + /** + * The table pg_catalog.pg_auth_members. + */ + public final PgAuthMembers PG_AUTH_MEMBERS = PgAuthMembers.PG_AUTH_MEMBERS; + + /** + * The table pg_catalog.pg_authid. + */ + public final PgAuthid PG_AUTHID = PgAuthid.PG_AUTHID; + + /** + * The table pg_catalog.pg_db_role_setting. + */ + public final PgDbRoleSetting PG_DB_ROLE_SETTING = PgDbRoleSetting.PG_DB_ROLE_SETTING; + + /** + * No further instances allowed + */ + private PgCatalog() { + super(DSL.name("pg_catalog"), null, DSL.comment("system catalog schema")); + } + + + @Override + public Catalog getCatalog() { + return DefaultCatalog.DEFAULT_CATALOG; + } + + @Override + public final List> getTables() { + return Arrays.asList( + PgAuthMembers.PG_AUTH_MEMBERS, + PgAuthid.PG_AUTHID, + PgDbRoleSetting.PG_DB_ROLE_SETTING + ); + } +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Routines.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Routines.java new file mode 100644 index 0000000..8ef8c11 --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Routines.java @@ -0,0 +1,71 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.routines.ShobjDescription; + +import javax.annotation.processing.Generated; + +import org.jooq.Configuration; +import org.jooq.Field; + + +/** + * Convenience access to all stored procedures and functions in pg_catalog. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Routines { + + /** + * Call pg_catalog.shobj_description + */ + public static String shobjDescription( + Configuration configuration + , Long __1 + , String __2 + ) { + ShobjDescription f = new ShobjDescription(); + f.set__1(__1); + f.set__2(__2); + + f.execute(configuration); + return f.getReturnValue(); + } + + /** + * Get pg_catalog.shobj_description as a field. + */ + public static Field shobjDescription( + Long __1 + , String __2 + ) { + ShobjDescription f = new ShobjDescription(); + f.set__1(__1); + f.set__2(__2); + + return f.asField(); + } + + /** + * Get pg_catalog.shobj_description as a field. + */ + public static Field shobjDescription( + Field __1 + , Field __2 + ) { + ShobjDescription f = new ShobjDescription(); + f.set__1(__1); + f.set__2(__2); + + return f.asField(); + } +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java new file mode 100644 index 0000000..157cdb6 --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/Tables.java @@ -0,0 +1,41 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgAuthMembers; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgAuthid; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDbRoleSetting; + +import javax.annotation.processing.Generated; + + +/** + * Convenience access to all tables in pg_catalog. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class Tables { + + /** + * The table pg_catalog.pg_auth_members. + */ + public static final PgAuthMembers PG_AUTH_MEMBERS = PgAuthMembers.PG_AUTH_MEMBERS; + + /** + * The table pg_catalog.pg_authid. + */ + public static final PgAuthid PG_AUTHID = PgAuthid.PG_AUTHID; + + /** + * The table pg_catalog.pg_db_role_setting. + */ + public static final PgDbRoleSetting PG_DB_ROLE_SETTING = PgDbRoleSetting.PG_DB_ROLE_SETTING; +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/routines/ShobjDescription.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/routines/ShobjDescription.java new file mode 100644 index 0000000..c0961d7 --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/routines/ShobjDescription.java @@ -0,0 +1,93 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.routines; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.PgCatalog; + +import javax.annotation.processing.Generated; + +import org.jooq.Field; +import org.jooq.Parameter; +import org.jooq.impl.AbstractRoutine; +import org.jooq.impl.DSL; +import org.jooq.impl.Internal; +import org.jooq.impl.SQLDataType; + + +/** + * get description for object id and shared catalog name + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class ShobjDescription extends AbstractRoutine { + + private static final long serialVersionUID = 1L; + + /** + * The parameter pg_catalog.shobj_description.RETURN_VALUE. + */ + public static final Parameter RETURN_VALUE = Internal.createParameter("RETURN_VALUE", SQLDataType.CLOB, false, false); + + /** + * The parameter pg_catalog.shobj_description._1. + */ + public static final Parameter _1 = Internal.createParameter("_1", SQLDataType.BIGINT, false, true); + + /** + * The parameter pg_catalog.shobj_description._2. + */ + public static final Parameter _2 = Internal.createParameter("_2", SQLDataType.VARCHAR, false, true); + + /** + * Create a new routine call instance + */ + public ShobjDescription() { + super("shobj_description", PgCatalog.PG_CATALOG, DSL.comment("get description for object id and shared catalog name"), SQLDataType.CLOB); + + setReturnParameter(RETURN_VALUE); + addInParameter(_1); + addInParameter(_2); + } + + /** + * Set the _1 parameter IN value to the routine + */ + public ShobjDescription set__1(Long value) { + setValue(_1, value); + return this; + } + + /** + * Set the _1 parameter to the function to be used with a + * {@link org.jooq.Select} statement + */ + public ShobjDescription set__1(Field field) { + setField(_1, field); + return this; + } + + /** + * Set the _2 parameter IN value to the routine + */ + public ShobjDescription set__2(String value) { + setValue(_2, value); + return this; + } + + /** + * Set the _2 parameter to the function to be used with a + * {@link org.jooq.Select} statement + */ + public ShobjDescription set__2(Field field) { + setField(_2, field); + return this; + } +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgAuthMembers.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgAuthMembers.java new file mode 100644 index 0000000..857a6bb --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgAuthMembers.java @@ -0,0 +1,271 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.tables; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.Indexes; +import it.aboutbits.postgresql.core.infrastructure.persistence.Keys; +import it.aboutbits.postgresql.core.infrastructure.persistence.PgCatalog; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgAuthMembersRecord; + +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import javax.annotation.processing.Generated; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.Index; +import org.jooq.Name; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgAuthMembers extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of pg_catalog.pg_auth_members + */ + public static final PgAuthMembers PG_AUTH_MEMBERS = new PgAuthMembers(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return PgAuthMembersRecord.class; + } + + /** + * The column pg_catalog.pg_auth_members.oid. + */ + public final TableField OID = createField(DSL.name("oid"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_auth_members.roleid. + */ + public final TableField ROLEID = createField(DSL.name("roleid"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_auth_members.member. + */ + public final TableField MEMBER = createField(DSL.name("member"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_auth_members.grantor. + */ + public final TableField GRANTOR = createField(DSL.name("grantor"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_auth_members.admin_option. + */ + public final TableField ADMIN_OPTION = createField(DSL.name("admin_option"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_auth_members.inherit_option. + */ + public final TableField INHERIT_OPTION = createField(DSL.name("inherit_option"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_auth_members.set_option. + */ + public final TableField SET_OPTION = createField(DSL.name("set_option"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + private PgAuthMembers(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private PgAuthMembers(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased pg_catalog.pg_auth_members table reference + */ + public PgAuthMembers(String alias) { + this(DSL.name(alias), PG_AUTH_MEMBERS); + } + + /** + * Create an aliased pg_catalog.pg_auth_members table reference + */ + public PgAuthMembers(Name alias) { + this(alias, PG_AUTH_MEMBERS); + } + + /** + * Create a pg_catalog.pg_auth_members table reference + */ + public PgAuthMembers() { + this(DSL.name("pg_auth_members"), null); + } + + @Override + public Schema getSchema() { + return aliased() ? null : PgCatalog.PG_CATALOG; + } + + @Override + public List getIndexes() { + return Arrays.asList(Indexes.PG_AUTH_MEMBERS_GRANTOR_INDEX); + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.PG_AUTH_MEMBERS_OID_INDEX; + } + + @Override + public List> getUniqueKeys() { + return Arrays.asList(Keys.PG_AUTH_MEMBERS_MEMBER_ROLE_INDEX, Keys.PG_AUTH_MEMBERS_ROLE_MEMBER_INDEX); + } + + @Override + public PgAuthMembers as(String alias) { + return new PgAuthMembers(DSL.name(alias), this); + } + + @Override + public PgAuthMembers as(Name alias) { + return new PgAuthMembers(alias, this); + } + + @Override + public PgAuthMembers as(Table alias) { + return new PgAuthMembers(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public PgAuthMembers rename(String name) { + return new PgAuthMembers(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public PgAuthMembers rename(Name name) { + return new PgAuthMembers(name, null); + } + + /** + * Rename this table + */ + @Override + public PgAuthMembers rename(Table name) { + return new PgAuthMembers(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthMembers where(Condition condition) { + return new PgAuthMembers(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthMembers where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthMembers where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthMembers where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgAuthMembers where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgAuthMembers where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgAuthMembers where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgAuthMembers where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthMembers whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthMembers whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgAuthid.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgAuthid.java new file mode 100644 index 0000000..acbad55 --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgAuthid.java @@ -0,0 +1,290 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.tables; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.Keys; +import it.aboutbits.postgresql.core.infrastructure.persistence.PgCatalog; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgAuthidRecord; + +import java.time.OffsetDateTime; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import javax.annotation.processing.Generated; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.Name; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgAuthid extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of pg_catalog.pg_authid + */ + public static final PgAuthid PG_AUTHID = new PgAuthid(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return PgAuthidRecord.class; + } + + /** + * The column pg_catalog.pg_authid.oid. + */ + public final TableField OID = createField(DSL.name("oid"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_authid.rolname. + */ + public final TableField ROLNAME = createField(DSL.name("rolname"), SQLDataType.VARCHAR.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_authid.rolsuper. + */ + public final TableField ROLSUPER = createField(DSL.name("rolsuper"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_authid.rolinherit. + */ + public final TableField ROLINHERIT = createField(DSL.name("rolinherit"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_authid.rolcreaterole. + */ + public final TableField ROLCREATEROLE = createField(DSL.name("rolcreaterole"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_authid.rolcreatedb. + */ + public final TableField ROLCREATEDB = createField(DSL.name("rolcreatedb"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_authid.rolcanlogin. + */ + public final TableField ROLCANLOGIN = createField(DSL.name("rolcanlogin"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_authid.rolreplication. + */ + public final TableField ROLREPLICATION = createField(DSL.name("rolreplication"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_authid.rolbypassrls. + */ + public final TableField ROLBYPASSRLS = createField(DSL.name("rolbypassrls"), SQLDataType.BOOLEAN.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_authid.rolconnlimit. + */ + public final TableField ROLCONNLIMIT = createField(DSL.name("rolconnlimit"), SQLDataType.INTEGER.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_authid.rolpassword. + */ + public final TableField ROLPASSWORD = createField(DSL.name("rolpassword"), SQLDataType.CLOB, this, ""); + + /** + * The column pg_catalog.pg_authid.rolvaliduntil. + */ + public final TableField ROLVALIDUNTIL = createField(DSL.name("rolvaliduntil"), SQLDataType.TIMESTAMPWITHTIMEZONE(6), this, ""); + + private PgAuthid(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private PgAuthid(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased pg_catalog.pg_authid table reference + */ + public PgAuthid(String alias) { + this(DSL.name(alias), PG_AUTHID); + } + + /** + * Create an aliased pg_catalog.pg_authid table reference + */ + public PgAuthid(Name alias) { + this(alias, PG_AUTHID); + } + + /** + * Create a pg_catalog.pg_authid table reference + */ + public PgAuthid() { + this(DSL.name("pg_authid"), null); + } + + @Override + public Schema getSchema() { + return aliased() ? null : PgCatalog.PG_CATALOG; + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.PG_AUTHID_OID_INDEX; + } + + @Override + public List> getUniqueKeys() { + return Arrays.asList(Keys.PG_AUTHID_ROLNAME_INDEX); + } + + @Override + public PgAuthid as(String alias) { + return new PgAuthid(DSL.name(alias), this); + } + + @Override + public PgAuthid as(Name alias) { + return new PgAuthid(alias, this); + } + + @Override + public PgAuthid as(Table alias) { + return new PgAuthid(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public PgAuthid rename(String name) { + return new PgAuthid(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public PgAuthid rename(Name name) { + return new PgAuthid(name, null); + } + + /** + * Rename this table + */ + @Override + public PgAuthid rename(Table name) { + return new PgAuthid(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthid where(Condition condition) { + return new PgAuthid(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthid where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthid where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthid where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgAuthid where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgAuthid where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgAuthid where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgAuthid where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthid whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgAuthid whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgDbRoleSetting.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgDbRoleSetting.java new file mode 100644 index 0000000..ebfcb6c --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/PgDbRoleSetting.java @@ -0,0 +1,239 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.tables; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.Keys; +import it.aboutbits.postgresql.core.infrastructure.persistence.PgCatalog; +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.records.PgDbRoleSettingRecord; + +import java.util.Collection; + +import javax.annotation.processing.Generated; + +import org.jooq.Condition; +import org.jooq.Field; +import org.jooq.Name; +import org.jooq.PlainSQL; +import org.jooq.QueryPart; +import org.jooq.SQL; +import org.jooq.Schema; +import org.jooq.Select; +import org.jooq.Stringly; +import org.jooq.Table; +import org.jooq.TableField; +import org.jooq.TableOptions; +import org.jooq.UniqueKey; +import org.jooq.impl.DSL; +import org.jooq.impl.SQLDataType; +import org.jooq.impl.TableImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgDbRoleSetting extends TableImpl { + + private static final long serialVersionUID = 1L; + + /** + * The reference instance of pg_catalog.pg_db_role_setting + */ + public static final PgDbRoleSetting PG_DB_ROLE_SETTING = new PgDbRoleSetting(); + + /** + * The class holding records for this type + */ + @Override + public Class getRecordType() { + return PgDbRoleSettingRecord.class; + } + + /** + * The column pg_catalog.pg_db_role_setting.setdatabase. + */ + public final TableField SETDATABASE = createField(DSL.name("setdatabase"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_db_role_setting.setrole. + */ + public final TableField SETROLE = createField(DSL.name("setrole"), SQLDataType.BIGINT.nullable(false), this, ""); + + /** + * The column pg_catalog.pg_db_role_setting.setconfig. + */ + public final TableField SETCONFIG = createField(DSL.name("setconfig"), SQLDataType.CLOB.array(), this, ""); + + private PgDbRoleSetting(Name alias, Table aliased) { + this(alias, aliased, (Field[]) null, null); + } + + private PgDbRoleSetting(Name alias, Table aliased, Field[] parameters, Condition where) { + super(alias, null, aliased, parameters, DSL.comment(""), TableOptions.table(), where); + } + + /** + * Create an aliased pg_catalog.pg_db_role_setting table + * reference + */ + public PgDbRoleSetting(String alias) { + this(DSL.name(alias), PG_DB_ROLE_SETTING); + } + + /** + * Create an aliased pg_catalog.pg_db_role_setting table + * reference + */ + public PgDbRoleSetting(Name alias) { + this(alias, PG_DB_ROLE_SETTING); + } + + /** + * Create a pg_catalog.pg_db_role_setting table reference + */ + public PgDbRoleSetting() { + this(DSL.name("pg_db_role_setting"), null); + } + + @Override + public Schema getSchema() { + return aliased() ? null : PgCatalog.PG_CATALOG; + } + + @Override + public UniqueKey getPrimaryKey() { + return Keys.PG_DB_ROLE_SETTING_DATABASEID_ROL_INDEX; + } + + @Override + public PgDbRoleSetting as(String alias) { + return new PgDbRoleSetting(DSL.name(alias), this); + } + + @Override + public PgDbRoleSetting as(Name alias) { + return new PgDbRoleSetting(alias, this); + } + + @Override + public PgDbRoleSetting as(Table alias) { + return new PgDbRoleSetting(alias.getQualifiedName(), this); + } + + /** + * Rename this table + */ + @Override + public PgDbRoleSetting rename(String name) { + return new PgDbRoleSetting(DSL.name(name), null); + } + + /** + * Rename this table + */ + @Override + public PgDbRoleSetting rename(Name name) { + return new PgDbRoleSetting(name, null); + } + + /** + * Rename this table + */ + @Override + public PgDbRoleSetting rename(Table name) { + return new PgDbRoleSetting(name.getQualifiedName(), null); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDbRoleSetting where(Condition condition) { + return new PgDbRoleSetting(getQualifiedName(), aliased() ? this : null, null, condition); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDbRoleSetting where(Collection conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDbRoleSetting where(Condition... conditions) { + return where(DSL.and(conditions)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDbRoleSetting where(Field condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgDbRoleSetting where(SQL condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgDbRoleSetting where(@Stringly.SQL String condition) { + return where(DSL.condition(condition)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgDbRoleSetting where(@Stringly.SQL String condition, Object... binds) { + return where(DSL.condition(condition, binds)); + } + + /** + * Create an inline derived table from this table + */ + @Override + @PlainSQL + public PgDbRoleSetting where(@Stringly.SQL String condition, QueryPart... parts) { + return where(DSL.condition(condition, parts)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDbRoleSetting whereExists(Select select) { + return where(DSL.exists(select)); + } + + /** + * Create an inline derived table from this table + */ + @Override + public PgDbRoleSetting whereNotExists(Select select) { + return where(DSL.notExists(select)); + } +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgAuthMembersRecord.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgAuthMembersRecord.java new file mode 100644 index 0000000..4c81b05 --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgAuthMembersRecord.java @@ -0,0 +1,170 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.tables.records; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgAuthMembers; + +import javax.annotation.processing.Generated; + +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgAuthMembersRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for pg_catalog.pg_auth_members.oid. + */ + public PgAuthMembersRecord setOid(Long value) { + set(0, value); + return this; + } + + /** + * Getter for pg_catalog.pg_auth_members.oid. + */ + public Long getOid() { + return (Long) get(0); + } + + /** + * Setter for pg_catalog.pg_auth_members.roleid. + */ + public PgAuthMembersRecord setRoleid(Long value) { + set(1, value); + return this; + } + + /** + * Getter for pg_catalog.pg_auth_members.roleid. + */ + public Long getRoleid() { + return (Long) get(1); + } + + /** + * Setter for pg_catalog.pg_auth_members.member. + */ + public PgAuthMembersRecord setMember(Long value) { + set(2, value); + return this; + } + + /** + * Getter for pg_catalog.pg_auth_members.member. + */ + public Long getMember() { + return (Long) get(2); + } + + /** + * Setter for pg_catalog.pg_auth_members.grantor. + */ + public PgAuthMembersRecord setGrantor(Long value) { + set(3, value); + return this; + } + + /** + * Getter for pg_catalog.pg_auth_members.grantor. + */ + public Long getGrantor() { + return (Long) get(3); + } + + /** + * Setter for pg_catalog.pg_auth_members.admin_option. + */ + public PgAuthMembersRecord setAdminOption(Boolean value) { + set(4, value); + return this; + } + + /** + * Getter for pg_catalog.pg_auth_members.admin_option. + */ + public Boolean getAdminOption() { + return (Boolean) get(4); + } + + /** + * Setter for pg_catalog.pg_auth_members.inherit_option. + */ + public PgAuthMembersRecord setInheritOption(Boolean value) { + set(5, value); + return this; + } + + /** + * Getter for pg_catalog.pg_auth_members.inherit_option. + */ + public Boolean getInheritOption() { + return (Boolean) get(5); + } + + /** + * Setter for pg_catalog.pg_auth_members.set_option. + */ + public PgAuthMembersRecord setSetOption(Boolean value) { + set(6, value); + return this; + } + + /** + * Getter for pg_catalog.pg_auth_members.set_option. + */ + public Boolean getSetOption() { + return (Boolean) get(6); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached PgAuthMembersRecord + */ + public PgAuthMembersRecord() { + super(PgAuthMembers.PG_AUTH_MEMBERS); + } + + /** + * Create a detached, initialised PgAuthMembersRecord + */ + public PgAuthMembersRecord(Long oid, Long roleid, Long member, Long grantor, Boolean adminOption, Boolean inheritOption, Boolean setOption) { + super(PgAuthMembers.PG_AUTH_MEMBERS); + + setOid(oid); + setRoleid(roleid); + setMember(member); + setGrantor(grantor); + setAdminOption(adminOption); + setInheritOption(inheritOption); + setSetOption(setOption); + resetTouchedOnNotNull(); + } +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgAuthidRecord.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgAuthidRecord.java new file mode 100644 index 0000000..5e07ae1 --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgAuthidRecord.java @@ -0,0 +1,252 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.tables.records; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgAuthid; + +import java.time.OffsetDateTime; + +import javax.annotation.processing.Generated; + +import org.jooq.Record1; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgAuthidRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for pg_catalog.pg_authid.oid. + */ + public PgAuthidRecord setOid(Long value) { + set(0, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.oid. + */ + public Long getOid() { + return (Long) get(0); + } + + /** + * Setter for pg_catalog.pg_authid.rolname. + */ + public PgAuthidRecord setRolname(String value) { + set(1, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolname. + */ + public String getRolname() { + return (String) get(1); + } + + /** + * Setter for pg_catalog.pg_authid.rolsuper. + */ + public PgAuthidRecord setRolsuper(Boolean value) { + set(2, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolsuper. + */ + public Boolean getRolsuper() { + return (Boolean) get(2); + } + + /** + * Setter for pg_catalog.pg_authid.rolinherit. + */ + public PgAuthidRecord setRolinherit(Boolean value) { + set(3, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolinherit. + */ + public Boolean getRolinherit() { + return (Boolean) get(3); + } + + /** + * Setter for pg_catalog.pg_authid.rolcreaterole. + */ + public PgAuthidRecord setRolcreaterole(Boolean value) { + set(4, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolcreaterole. + */ + public Boolean getRolcreaterole() { + return (Boolean) get(4); + } + + /** + * Setter for pg_catalog.pg_authid.rolcreatedb. + */ + public PgAuthidRecord setRolcreatedb(Boolean value) { + set(5, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolcreatedb. + */ + public Boolean getRolcreatedb() { + return (Boolean) get(5); + } + + /** + * Setter for pg_catalog.pg_authid.rolcanlogin. + */ + public PgAuthidRecord setRolcanlogin(Boolean value) { + set(6, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolcanlogin. + */ + public Boolean getRolcanlogin() { + return (Boolean) get(6); + } + + /** + * Setter for pg_catalog.pg_authid.rolreplication. + */ + public PgAuthidRecord setRolreplication(Boolean value) { + set(7, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolreplication. + */ + public Boolean getRolreplication() { + return (Boolean) get(7); + } + + /** + * Setter for pg_catalog.pg_authid.rolbypassrls. + */ + public PgAuthidRecord setRolbypassrls(Boolean value) { + set(8, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolbypassrls. + */ + public Boolean getRolbypassrls() { + return (Boolean) get(8); + } + + /** + * Setter for pg_catalog.pg_authid.rolconnlimit. + */ + public PgAuthidRecord setRolconnlimit(Integer value) { + set(9, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolconnlimit. + */ + public Integer getRolconnlimit() { + return (Integer) get(9); + } + + /** + * Setter for pg_catalog.pg_authid.rolpassword. + */ + public PgAuthidRecord setRolpassword(String value) { + set(10, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolpassword. + */ + public String getRolpassword() { + return (String) get(10); + } + + /** + * Setter for pg_catalog.pg_authid.rolvaliduntil. + */ + public PgAuthidRecord setRolvaliduntil(OffsetDateTime value) { + set(11, value); + return this; + } + + /** + * Getter for pg_catalog.pg_authid.rolvaliduntil. + */ + public OffsetDateTime getRolvaliduntil() { + return (OffsetDateTime) get(11); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record1 key() { + return (Record1) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached PgAuthidRecord + */ + public PgAuthidRecord() { + super(PgAuthid.PG_AUTHID); + } + + /** + * Create a detached, initialised PgAuthidRecord + */ + public PgAuthidRecord(Long oid, String rolname, Boolean rolsuper, Boolean rolinherit, Boolean rolcreaterole, Boolean rolcreatedb, Boolean rolcanlogin, Boolean rolreplication, Boolean rolbypassrls, Integer rolconnlimit, String rolpassword, OffsetDateTime rolvaliduntil) { + super(PgAuthid.PG_AUTHID); + + setOid(oid); + setRolname(rolname); + setRolsuper(rolsuper); + setRolinherit(rolinherit); + setRolcreaterole(rolcreaterole); + setRolcreatedb(rolcreatedb); + setRolcanlogin(rolcanlogin); + setRolreplication(rolreplication); + setRolbypassrls(rolbypassrls); + setRolconnlimit(rolconnlimit); + setRolpassword(rolpassword); + setRolvaliduntil(rolvaliduntil); + resetTouchedOnNotNull(); + } +} diff --git a/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgDbRoleSettingRecord.java b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgDbRoleSettingRecord.java new file mode 100644 index 0000000..1deca3e --- /dev/null +++ b/src/generated/jooq/main/it/aboutbits/postgresql/core/infrastructure/persistence/tables/records/PgDbRoleSettingRecord.java @@ -0,0 +1,106 @@ +/* + * This file is generated by jOOQ. + */ +package it.aboutbits.postgresql.core.infrastructure.persistence.tables.records; + + +import it.aboutbits.postgresql.core.infrastructure.persistence.tables.PgDbRoleSetting; + +import javax.annotation.processing.Generated; + +import org.jooq.Record2; +import org.jooq.impl.UpdatableRecordImpl; + + +/** + * This class is generated by jOOQ. + */ +@Generated( + value = { + "https://www.jooq.org", + "jOOQ version:3.20.10" + }, + comments = "This class is generated by jOOQ" +) +@SuppressWarnings({ "all", "unchecked", "rawtypes", "this-escape" }) +public class PgDbRoleSettingRecord extends UpdatableRecordImpl { + + private static final long serialVersionUID = 1L; + + /** + * Setter for pg_catalog.pg_db_role_setting.setdatabase. + */ + public PgDbRoleSettingRecord setSetdatabase(Long value) { + set(0, value); + return this; + } + + /** + * Getter for pg_catalog.pg_db_role_setting.setdatabase. + */ + public Long getSetdatabase() { + return (Long) get(0); + } + + /** + * Setter for pg_catalog.pg_db_role_setting.setrole. + */ + public PgDbRoleSettingRecord setSetrole(Long value) { + set(1, value); + return this; + } + + /** + * Getter for pg_catalog.pg_db_role_setting.setrole. + */ + public Long getSetrole() { + return (Long) get(1); + } + + /** + * Setter for pg_catalog.pg_db_role_setting.setconfig. + */ + public PgDbRoleSettingRecord setSetconfig(String[] value) { + set(2, value); + return this; + } + + /** + * Getter for pg_catalog.pg_db_role_setting.setconfig. + */ + public String[] getSetconfig() { + return (String[]) get(2); + } + + // ------------------------------------------------------------------------- + // Primary key information + // ------------------------------------------------------------------------- + + @Override + public Record2 key() { + return (Record2) super.key(); + } + + // ------------------------------------------------------------------------- + // Constructors + // ------------------------------------------------------------------------- + + /** + * Create a detached PgDbRoleSettingRecord + */ + public PgDbRoleSettingRecord() { + super(PgDbRoleSetting.PG_DB_ROLE_SETTING); + } + + /** + * Create a detached, initialised PgDbRoleSettingRecord + */ + public PgDbRoleSettingRecord(Long setdatabase, Long setrole, String[] setconfig) { + super(PgDbRoleSetting.PG_DB_ROLE_SETTING); + + setSetdatabase(setdatabase); + setSetrole(setrole); + setSetconfig(setconfig); + resetTouchedOnNotNull(); + } +} diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm new file mode 100644 index 0000000..a1ffebe --- /dev/null +++ b/src/main/docker/Dockerfile.jvm @@ -0,0 +1,98 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in JVM mode +# +# Before building the container image run: +# +# ./gradlew build +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.jvm -t quarkus/postgresql-operator-jvm . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/postgresql-operator-jvm +# +# If you want to include the debug port into your docker image +# you will have to expose the debug port (default 5005 being the default) like this : EXPOSE 8080 5005. +# Additionally you will have to set -e JAVA_DEBUG=true and -e JAVA_DEBUG_PORT=*:5005 +# when running the container +# +# Then run the container using : +# +# docker run -i --rm -p 8080:8080 quarkus/postgresql-operator-jvm +# +# This image uses the `run-java.sh` script to run the application. +# This scripts computes the command line to execute your Java application, and +# includes memory/GC tuning. +# You can configure the behavior using the following environment properties: +# - JAVA_OPTS: JVM options passed to the `java` command (example: "-verbose:class") - Be aware that this will override +# the default JVM options, use `JAVA_OPTS_APPEND` to append options +# - JAVA_OPTS_APPEND: User specified Java options to be appended to generated options +# in JAVA_OPTS (example: "-Dsome.property=foo") +# - JAVA_MAX_MEM_RATIO: Is used when no `-Xmx` option is given in JAVA_OPTS. This is +# used to calculate a default maximal heap memory based on a containers restriction. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xmx` is set to a ratio +# of the container available memory as set here. The default is `50` which means 50% +# of the available memory is used as an upper boundary. You can skip this mechanism by +# setting this value to `0` in which case no `-Xmx` option is added. +# - JAVA_INITIAL_MEM_RATIO: Is used when no `-Xms` option is given in JAVA_OPTS. This +# is used to calculate a default initial heap memory based on the maximum heap memory. +# If used in a container without any memory constraints for the container then this +# option has no effect. If there is a memory constraint then `-Xms` is set to a ratio +# of the `-Xmx` memory as set here. The default is `25` which means 25% of the `-Xmx` +# is used as the initial heap size. You can skip this mechanism by setting this value +# to `0` in which case no `-Xms` option is added (example: "25") +# - JAVA_MAX_INITIAL_MEM: Is used when no `-Xms` option is given in JAVA_OPTS. +# This is used to calculate the maximum value of the initial heap memory. If used in +# a container without any memory constraints for the container then this option has +# no effect. If there is a memory constraint then `-Xms` is limited to the value set +# here. The default is 4096MB which means the calculated value of `-Xms` never will +# be greater than 4096MB. The value of this variable is expressed in MB (example: "4096") +# - JAVA_DIAGNOSTICS: Set this to get some diagnostics information to standard output +# when things are happening. This option, if set to true, will set +# `-XX:+UnlockDiagnosticVMOptions`. Disabled by default (example: "true"). +# - JAVA_DEBUG: If set remote debugging will be switched on. Disabled by default (example: +# true"). +# - JAVA_DEBUG_PORT: Port used for remote debugging. Defaults to 5005 (example: "8787"). +# - CONTAINER_CORE_LIMIT: A calculated core limit as described in +# https://www.kernel.org/doc/Documentation/scheduler/sched-bwc.txt. (example: "2") +# - CONTAINER_MAX_MEMORY: Memory limit given to the container (example: "1024"). +# - GC_MIN_HEAP_FREE_RATIO: Minimum percentage of heap free after GC to avoid expansion. +# (example: "20") +# - GC_MAX_HEAP_FREE_RATIO: Maximum percentage of heap free after GC to avoid shrinking. +# (example: "40") +# - GC_TIME_RATIO: Specifies the ratio of the time spent outside the garbage collection. +# (example: "4") +# - GC_ADAPTIVE_SIZE_POLICY_WEIGHT: The weighting given to the current GC time versus +# previous GC times. (example: "90") +# - GC_METASPACE_SIZE: The initial metaspace size. (example: "20") +# - GC_MAX_METASPACE_SIZE: The maximum metaspace size. (example: "100") +# - GC_CONTAINER_OPTIONS: Specify Java GC to use. The value of this variable should +# contain the necessary JRE command-line options to specify the required GC, which +# will override the default of `-XX:+UseParallelGC` (example: -XX:+UseG1GC). +# - HTTPS_PROXY: The location of the https proxy. (example: "myuser@127.0.0.1:8080") +# - HTTP_PROXY: The location of the http proxy. (example: "myuser@127.0.0.1:8080") +# - NO_PROXY: A comma separated lists of hosts, IP addresses or domains that can be +# accessed directly. (example: "foo.example.com,bar.example.com") +# +### +# https://container-registry.oracle.com/ords/ocr/ba/graalvm/jdk +FROM container-registry.oracle.com/graalvm/jdk:25-ol10 + +ENV LANGUAGE='en_US:en' + +# We make four distinct layers so if there are application changes the library layers can be re-used +COPY --chown=185 build/quarkus-app/lib/ /deployments/lib/ +COPY --chown=185 build/quarkus-app/*.jar /deployments/ +COPY --chown=185 build/quarkus-app/app/ /deployments/app/ +COPY --chown=185 build/quarkus-app/quarkus/ /deployments/quarkus/ + +EXPOSE 8080 +USER 185 +ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=org.jboss.logmanager.LogManager" +ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" + +ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] + diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native new file mode 100644 index 0000000..25de433 --- /dev/null +++ b/src/main/docker/Dockerfile.native @@ -0,0 +1,31 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.native.enabled=true +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native -t quarkus/postgresql-operator . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/postgresql-operator +# +### +# https://catalog.redhat.com/en/software/containers/ubi10/ubi-minimal/66f1504a379b9c2cf23e145c +FROM registry.access.redhat.com/ubi10/ubi-minimal:10 + +WORKDIR /work/ + +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work \ + +COPY --chown=1001:root --chmod=0755 build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/src/main/docker/Dockerfile.native-micro b/src/main/docker/Dockerfile.native-micro new file mode 100644 index 0000000..9070047 --- /dev/null +++ b/src/main/docker/Dockerfile.native-micro @@ -0,0 +1,34 @@ +#### +# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. +# It uses a micro base image, tuned for Quarkus native executables. +# It reduces the size of the resulting container image. +# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image. +# +# Before building the container image run: +# +# ./gradlew build -Dquarkus.native.enabled=true +# +# Then, build the image with: +# +# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/postgresql-operator . +# +# Then run the container using: +# +# docker run -i --rm -p 8080:8080 quarkus/postgresql-operator +# +### +# https://quay.io/repository/quarkus/ubi10-quarkus-micro-image +FROM quay.io/quarkus/ubi10-quarkus-micro-image:2.0 + +WORKDIR /work/ + +RUN chown 1001 /work \ + && chmod "g+rwX" /work \ + && chown 1001:root /work \ + +COPY --chown=1001:root --chmod=0755 build/*-runner /work/application + +EXPOSE 8080 +USER 1001 + +ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java b/src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java new file mode 100644 index 0000000..3e25db6 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java @@ -0,0 +1,69 @@ +package it.aboutbits.postgresql; + +import io.javaoperatorsdk.operator.Operator; +import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import it.aboutbits.postgresql.crd.connection.ClusterConnectionReconciler; +import lombok.RequiredArgsConstructor; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Readiness; +import org.jspecify.annotations.NullMarked; + +/** + * MicroProfile readiness health check that verifies connectivity to all + * configured PostgreSQL instances. Each instance is probed with a lightweight + * operation and the aggregated status is exposed. + */ +@NullMarked +@Readiness +@RequiredArgsConstructor +public class MinioInstanceReadinessCheck implements HealthCheck { + private final PostgreSQLContextFactory postgreSQLContextFactory; + + private final Operator operator; + + @Override + public HealthCheckResponse call() { + var builder = HealthCheckResponse.builder().name("PostgreSQL Instances"); + + var optionalClusterConnectionReconciler = operator.getRegisteredController( + ClusterConnectionReconciler.class.getSimpleName() + ); + + if (optionalClusterConnectionReconciler.isEmpty()) { + return builder + .status(false) + .withData("ClusterConnectionReconciler", "NOT_REGISTERED") + .build(); + } + + var clusterConnectionReconciler = optionalClusterConnectionReconciler.get(); + +// boolean allUp = postgreSQLContextFactory.getClients() +// .entrySet() +// .stream() +// .allMatch(entry -> checkInstance( +// entry.getKey(), +// entry.getValue(), +// builder +// )); + + return builder.status(true /* allUp */).build(); + } + + /*private boolean checkInstance( + String instance, + MinioClient minioClient, + HealthCheckResponseBuilder builder + ) { + try { + minioClient.listBuckets(); + builder.withData(instance, "UP"); + return true; + } catch (Exception _) { + builder.withData(instance, "DOWN"); + return false; + } + }*/ +} diff --git a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java new file mode 100644 index 0000000..7f73f25 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java @@ -0,0 +1,112 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.Duration; +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Objects; +import java.util.Optional; + +@NullMarked +@Slf4j +public abstract class BaseReconciler & Named, S extends CRStatus> { + @NonNull + protected abstract S newStatus(); + + public S initializeStatus(CR resource) { + S status = resource.getStatus(); + + if (status == null) { + status = newStatus(); + + status.setName(resource.getName()); + + resource.setStatus(status); + } + + status.setLastProbeTime(OffsetDateTime.now(ZoneOffset.UTC)); + status.setObservedGeneration(resource.getMetadata().getGeneration()); + + return status; + } + + public String getResourceNamespaceOrOwn( + @Nullable String resourceNamespace + ) { + if (resourceNamespace != null) { + return resourceNamespace; + } + + return ((HasMetadata) this).getMetadata().getNamespace(); + } + + public Optional getReferencedClusterConnection( + KubernetesClient kubernetesClient, + ClusterReference clusterRef + ) { + var connectionName = clusterRef.getName(); + var connectionNamespace = getResourceNamespaceOrOwn(clusterRef.getNamespace()); + + var clusterConnection = kubernetesClient.resources(ClusterConnection.class) + .inNamespace(connectionNamespace) + .withName(connectionName) + .get(); + + if (clusterConnection == null) { + log.error( + "The specified ClusterConnection does not exist [clusterConnection={}/{}]", + connectionNamespace, + connectionName + ); + + return Optional.empty(); + } + + var currentPhase = clusterConnection.getStatus().getPhase(); + var expectedPhase = CRPhase.READY; + + if (!Objects.equals(currentPhase, expectedPhase)) { + log.warn( + "The specified ClusterConnection is not ready yet [clusterConnection={}/{}]", + connectionNamespace, + connectionName + ); + + return Optional.empty(); + } + + return Optional.of(clusterConnection); + } + + public UpdateControl handleError( + CR resource, + S status, + E exception + ) { + return handleError( + resource, + status, + exception.getMessage() + ); + } + + public UpdateControl handleError( + CR resource, + S status, + @Nullable String message + ) { + status.setPhase(CRPhase.ERROR).setMessage(message); + + return UpdateControl.patchStatus(resource) + .rescheduleAfter(Duration.ofSeconds(30)); + } +} diff --git a/src/main/java/it/aboutbits/postgresql/core/CRPhase.java b/src/main/java/it/aboutbits/postgresql/core/CRPhase.java new file mode 100644 index 0000000..4c0e0df --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/CRPhase.java @@ -0,0 +1,11 @@ +package it.aboutbits.postgresql.core; + +import org.jspecify.annotations.NullMarked; + +@NullMarked +public enum CRPhase { + PENDING, + READY, + ERROR, + DELETING +} diff --git a/src/main/java/it/aboutbits/postgresql/core/CRStatus.java b/src/main/java/it/aboutbits/postgresql/core/CRStatus.java new file mode 100644 index 0000000..8983714 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/CRStatus.java @@ -0,0 +1,76 @@ +package it.aboutbits.postgresql.core; + +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; + +/** + * Status Object for the Custom Resources. + *

+ * This object captures the current state of a bucket as observed by the reconciler. + */ +@NullMarked +@Getter +@Setter +@Accessors(chain = true) +public class CRStatus { + /** + * The actual bucket name in MinIO (may differ from metadata.name). + */ + @Nullable + private String name = null; + + /** + * Current lifecycle phase of the Bucket. + */ + @Setter(AccessLevel.NONE) + private CRPhase phase = CRPhase.PENDING; + + /** + * Human-readable message providing details about the current state. + */ + @Nullable + private String message = null; + + /** + * Last time the condition was probed/updated. + */ + @Nullable + private OffsetDateTime lastProbeTime = null; + + /** + * Last time the condition transitioned from one status to another. + */ + @Nullable + @Setter(AccessLevel.NONE) + private OffsetDateTime lastPhaseTransitionTime = null; + + /** + * Observed resource generation that the controller acted upon. + */ + private long observedGeneration = 0; + + /** + * Update the current phase. When the phase changes, the {@link #lastPhaseTransitionTime} + * is updated to the current UTC time and the message is set to {@code null}. + * + * @param newPhase the new phase + * @return this status instance + */ + public CRStatus setPhase(CRPhase newPhase) { + if (this.phase == newPhase) { + return this; + } + + this.phase = newPhase; + this.lastPhaseTransitionTime = OffsetDateTime.now(ZoneOffset.UTC); + + return this; + } +} diff --git a/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java b/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java new file mode 100644 index 0000000..b13591f --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java @@ -0,0 +1,21 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.generator.annotation.Required; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +@Getter +@Setter +public class ClusterReference { + @Required + private String name = ""; + + @Nullable + @io.fabric8.generator.annotation.Nullable + private String namespace; +} diff --git a/src/main/java/it/aboutbits/postgresql/core/Credentials.java b/src/main/java/it/aboutbits/postgresql/core/Credentials.java new file mode 100644 index 0000000..dc376e4 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/Credentials.java @@ -0,0 +1,11 @@ +package it.aboutbits.postgresql.core; + +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +public record Credentials( + @Nullable String username, + String password +) { +} diff --git a/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java b/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java new file mode 100644 index 0000000..c84a67e --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java @@ -0,0 +1,91 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.kubernetes.client.KubernetesClient; +import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import jakarta.enterprise.context.RequestScoped; +import org.jspecify.annotations.NullMarked; + +import java.util.Base64; + +@NullMarked +@RequestScoped +public final class KubernetesUtil { + public static final String SECRET_TYPE_BASIC_AUTH = "kubernetes.io/basic-auth"; + public static final String SECRET_DATA_BASIC_AUTH_USERNAME_KEY = "username"; + public static final String SECRET_DATA_BASIC_AUTH_PASSWORD_KEY = "password"; + + public static Credentials getSecretRefCredentials( + KubernetesClient kubernetesClient, + ClusterConnection clusterConnection + ) { + var spec = clusterConnection.getSpec(); + var metadata = clusterConnection.getMetadata(); + + var passwordSecretRef = spec.getAdminSecretRef(); + + var secretNamespace = passwordSecretRef.getNamespace() != null + ? passwordSecretRef.getNamespace() + : clusterConnection.getMetadata().getNamespace(); + var secretName = passwordSecretRef.getName(); + + var secret = kubernetesClient.secrets() + .inNamespace(secretNamespace) + .withName(secretName) + .get(); + + if (secret == null) { + throw new IllegalStateException("ClusterConnection SecretRef not found [clusterConnection.namespace=%s, clusterConnection.name=%s, secret.namespace=%s, secret.name=%s]".formatted( + metadata.getNamespace(), + metadata.getName(), + secretNamespace, + secretName + )); + } + + if (!secret.getType().equals(SECRET_TYPE_BASIC_AUTH)) { + throw new IllegalArgumentException("The ClusterConnection SecretRef is of the wrong type [clusterConnection.namespace=%s, clusterConnection.name=%s, secret.namespace=%s, secret.name=%s, expected.secret.type=%s, actual.secret.type=%s]".formatted( + metadata.getNamespace(), + metadata.getName(), + secretNamespace, + secretName, + SECRET_TYPE_BASIC_AUTH, + secret.getType() + )); + } + + var data = secret.getData(); + if (data == null || data.isEmpty()) { + throw new IllegalStateException("The ClusterConnection SecretRef has no data set [clusterConnection.namespace=%s, clusterConnection.name=%s, secret.namespace=%s, secret.name=%s]".formatted( + metadata.getNamespace(), + metadata.getName(), + secretNamespace, + secretName + )); + } + + var usernameBase64 = data.get(SECRET_DATA_BASIC_AUTH_USERNAME_KEY); + var username = usernameBase64 == null + ? null + : new String( + Base64.getDecoder().decode(usernameBase64) + ); + + var passwordBase64 = data.get(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY); + if (passwordBase64 == null) { + throw new IllegalStateException("The ClusterConnection SecretRef is missing required data password [clusterConnection.namespace=%s, clusterConnection.name=%s, secret.namespace=%s, secret.name=%s]".formatted( + metadata.getNamespace(), + metadata.getName(), + secretNamespace, + secretName + )); + } + var password = new String( + Base64.getDecoder().decode(passwordBase64) + ); + + return new Credentials( + username, + password + ); + } +} diff --git a/src/main/java/it/aboutbits/postgresql/core/Named.java b/src/main/java/it/aboutbits/postgresql/core/Named.java new file mode 100644 index 0000000..1d90fe2 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/Named.java @@ -0,0 +1,10 @@ +package it.aboutbits.postgresql.core; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import org.jspecify.annotations.NullMarked; + +@NullMarked +public interface Named { + @JsonIgnore + String getName(); +} diff --git a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java new file mode 100644 index 0000000..46685e5 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java @@ -0,0 +1,215 @@ +package it.aboutbits.postgresql.core; + +import com.ongres.scram.common.StringPreparation; +import it.aboutbits.postgresql.crd.role.RoleSpec; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.jooq.DSLContext; +import org.jspecify.annotations.NullMarked; + +import javax.crypto.Mac; +import javax.crypto.SecretKeyFactory; +import javax.crypto.spec.PBEKeySpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.Arrays; +import java.util.Base64; +import java.util.HexFormat; +import java.util.Locale; + +import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; + +@NullMarked +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class PostgreSQLAuthenticationUtil { + private static final String MD5 = "MD5"; + private static final String SHA_256 = "SHA-256"; + private static final String HMAC_SHA_256 = "HmacSHA256"; + private static final String PBKDF2_WITH_HMAC_SHA256 = "PBKDF2WithHmacSHA256"; + + public static boolean passwordMatches( + DSLContext dsl, + RoleSpec spec, + String expectedPassword + ) { + var currentPasswordVerifier = dsl + .select(PG_AUTHID.ROLPASSWORD) + .from(PG_AUTHID) + .where(PG_AUTHID.ROLNAME.eq(spec.getName())) + .fetchSingle(PG_AUTHID.ROLPASSWORD); + + if (currentPasswordVerifier == null || currentPasswordVerifier.isBlank()) { + return false; + } + + // PostgreSQL stores either: + // - SCRAM verifier: SCRAM-SHA-256$:$: + // - or legacy md5: md5 + if (currentPasswordVerifier.startsWith("SCRAM-SHA-256$")) { + return verifyPostgresScramSha256( + currentPasswordVerifier, + expectedPassword + ); + } + + if (currentPasswordVerifier.startsWith(MD5.toLowerCase(Locale.ROOT))) { + return verifyPostgresMd5( + currentPasswordVerifier, + expectedPassword, + spec.getName() + ); + } + + // Unknown format (or plain text, which PG should not store in rolpassword) + return false; + } + + private static boolean verifyPostgresScramSha256(String postgresVerifier, String cleartextPassword) { + // Prepare the cleartext password with SASLprep + var preparedPassword = StringPreparation.POSTGRESQL_PREPARATION.normalize( + cleartextPassword.toCharArray() + ); + + // Format: SCRAM-SHA-256$:$: + var afterPrefix = postgresVerifier.substring("SCRAM-SHA-256$".length()); + var dollar = afterPrefix.indexOf('$'); + if (dollar < 0) { + return false; + } + + // : + var iterationsAndSalt = afterPrefix.substring(0, dollar); + // : + var keys = afterPrefix.substring(dollar + 1); + + var colonIterationsAndSalt = iterationsAndSalt.indexOf(':'); + if (colonIterationsAndSalt < 0) { + return false; + } + + int iterations; + try { + iterations = Integer.parseInt(iterationsAndSalt.substring(0, colonIterationsAndSalt)); + } catch (NumberFormatException ex) { + return false; + } + if (iterations <= 0) { + return false; + } + + var saltB64 = iterationsAndSalt.substring(colonIterationsAndSalt + 1); + + var colonKeys = keys.indexOf(':'); + if (colonKeys < 0) { + return false; + } + + var storedKeyB64 = keys.substring(0, colonKeys); + + byte[] salt; + byte[] currentStoredKey; + try { + salt = Base64.getDecoder().decode(saltB64); + currentStoredKey = Base64.getDecoder().decode(storedKeyB64); + } catch (IllegalArgumentException ex) { + return false; + } + + byte[] saltedPassword = null; + byte[] clientKey = null; + byte[] expectedStoredKey = null; + try { + // RFC 5802/7677: + // saltedPassword := Hi(password, salt, iterations) (PBKDF2-HMAC-SHA-256, 32 bytes) + // clientKey := HMAC(saltedPassword, "Client Key") + // storedKey := H(clientKey) (SHA-256) + saltedPassword = pbkdf2HmacSha256(preparedPassword, salt, iterations, 32); + clientKey = hmacSha256(saltedPassword, "Client Key".getBytes(StandardCharsets.UTF_8)); + expectedStoredKey = sha256(clientKey); + + return MessageDigest.isEqual( + currentStoredKey, + expectedStoredKey + ); + } finally { + if (saltedPassword != null) { + Arrays.fill(saltedPassword, (byte) 0); + } + if (clientKey != null) { + Arrays.fill(clientKey, (byte) 0); + } + if (expectedStoredKey != null) { + Arrays.fill(expectedStoredKey, (byte) 0); + } + } + } + + private static boolean verifyPostgresMd5( + String postgresMd5, + String expectedPassword, + String username + ) { + // PostgreSQL md5 is: "md5" + md5(password + username) + if (postgresMd5.length() != 3 + 32 || !postgresMd5.regionMatches(true, 0, MD5, 0, 3)) { + return false; + } + + byte[] currentDigest; + try { + currentDigest = HexFormat.of().parseHex( + postgresMd5, + 3, + postgresMd5.length() + ); + } catch (IllegalArgumentException ex) { + return false; // not valid hex + } + + MessageDigest md5; + try { + md5 = MessageDigest.getInstance(MD5); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("%s not available".formatted(MD5), e); + } + + md5.update((expectedPassword + username).getBytes(StandardCharsets.UTF_8)); + var expectedDigest = md5.digest(); + + return MessageDigest.isEqual(currentDigest, expectedDigest); + } + + private static byte[] pbkdf2HmacSha256( + char[] password, + byte[] salt, + int iterations, + int keyLenBytes + ) { + try { + var secretKeyFactory = SecretKeyFactory.getInstance(PBKDF2_WITH_HMAC_SHA256); + var spec = new PBEKeySpec(password, salt, iterations, keyLenBytes * 8); + return secretKeyFactory.generateSecret(spec).getEncoded(); + } catch (Exception e) { + throw new IllegalStateException("%s not available".formatted(PBKDF2_WITH_HMAC_SHA256), e); + } + } + + private static byte[] hmacSha256(byte[] key, byte[] data) { + try { + var mac = Mac.getInstance(HMAC_SHA_256); + mac.init(new SecretKeySpec(key, HMAC_SHA_256)); + return mac.doFinal(data); + } catch (Exception e) { + throw new IllegalStateException("%s not available".formatted(HMAC_SHA_256), e); + } + } + + private static byte[] sha256(byte[] data) { + try { + return MessageDigest.getInstance(SHA_256).digest(data); + } catch (Exception e) { + throw new IllegalStateException("%s not available".formatted(SHA_256), e); + } + } +} diff --git a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java new file mode 100644 index 0000000..c2d9669 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java @@ -0,0 +1,68 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.kubernetes.client.KubernetesClient; +import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import jakarta.enterprise.context.ApplicationScoped; +import org.jooq.DSLContext; +import org.jooq.SQLDialect; +import org.jooq.impl.DSL; +import org.jspecify.annotations.NullMarked; + +import java.sql.DriverManager; +import java.sql.SQLException; +import java.util.Properties; + +@NullMarked +@ApplicationScoped +public class PostgreSQLContextFactory { + private static final String POSTGRESQL_AUTHENTICATION_USER_KEY = "user"; + private static final String POSTGRESQL_AUTHENTICATION_PASSWORD_KEY = "password"; + + private final KubernetesClient kubernetesClient; + + public PostgreSQLContextFactory(KubernetesClient kubernetesClient) { + this.kubernetesClient = kubernetesClient; + } + + public DSLContext getDSLContext(ClusterConnection clusterConnection) throws SQLException { + var credentials = KubernetesUtil.getSecretRefCredentials( + kubernetesClient, + clusterConnection + ); + + var spec = clusterConnection.getSpec(); + + var jdbcUrl = "jdbc:postgresql://%s:%d/%s".formatted( + spec.getHost(), + spec.getPort(), + spec.getMaintenanceDatabase() + ); + + var properties = new Properties(2 + spec.getParameters().size()); + + properties.setProperty( + POSTGRESQL_AUTHENTICATION_USER_KEY, + credentials.username() + ); + properties.setProperty( + POSTGRESQL_AUTHENTICATION_PASSWORD_KEY, + credentials.password() + ); + + if (!spec.getParameters().isEmpty()) { + properties.putAll( + spec.getParameters() + ); + } + + var connection = DriverManager.getConnection( + jdbcUrl, + properties + ); + + return DSL.using( + connection, + SQLDialect.POSTGRES + ); + } +} diff --git a/src/main/java/it/aboutbits/postgresql/core/SecretRef.java b/src/main/java/it/aboutbits/postgresql/core/SecretRef.java new file mode 100644 index 0000000..edad032 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/SecretRef.java @@ -0,0 +1,21 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.generator.annotation.Required; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +@Getter +@Setter +public class SecretRef { + @Required + private String name; + + @Nullable + @io.fabric8.generator.annotation.Nullable + private String namespace; +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java new file mode 100644 index 0000000..cdd6d6a --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java @@ -0,0 +1,46 @@ +package it.aboutbits.postgresql.crd.connection; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; +import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.core.Named; +import org.jspecify.annotations.NullMarked; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.StringJoiner; + +@NullMarked +@Version("v1") +@Group("postgresql.aboutbits.it") +public class ClusterConnection + extends CustomResource + implements Namespaced, Named { + @Override + @JsonIgnore + public String getName() { + var spec = getSpec(); + + var jdbcUrl = "jdbc:postgresql://%s:%d/%s".formatted( + spec.getHost(), + spec.getPort(), + spec.getMaintenanceDatabase() + ); + + var stringJoiner = new StringJoiner("&", jdbcUrl + "?", ""); + + spec.getParameters().forEach((key, value) -> + stringJoiner.add("%s=%s".formatted( + URLEncoder.encode(key, StandardCharsets.UTF_8) + .replace("+", "%20"), + URLEncoder.encode(value, StandardCharsets.UTF_8) + .replace("+", "%20") + )) + ); + + return stringJoiner.toString(); + } +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java new file mode 100644 index 0000000..6fd2264 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java @@ -0,0 +1,66 @@ +package it.aboutbits.postgresql.crd.connection; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import it.aboutbits.postgresql.core.BaseReconciler; +import it.aboutbits.postgresql.core.CRPhase; +import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jooq.DSLContext; +import org.jooq.exception.DataAccessException; +import org.jspecify.annotations.NonNull; + +import java.sql.SQLException; + +@Slf4j +@RequiredArgsConstructor +public class ClusterConnectionReconciler + extends BaseReconciler + implements Reconciler { + private final PostgreSQLContextFactory contextFactory; + + @Override + public UpdateControl reconcile( + ClusterConnection resource, + Context context + ) { + var status = initializeStatus(resource); + + DSLContext dsl; + try { + dsl = contextFactory.getDSLContext(resource); + } catch (SQLException e) { + log.error("Failed to create DSL context", e); + + return handleError( + resource, + status, + e + ); + } + + try { + var version = dsl.fetchSingle("select version()").into(String.class); + + status.setPhase(CRPhase.READY).setMessage(version); + + return UpdateControl.patchStatus(resource); + } catch (DataAccessException e) { + log.error("Failed to check database connectivity", e); + + return handleError( + resource, + status, + e + ); + } + } + + @Override + protected @NonNull CRStatus newStatus() { + return new CRStatus(); + } +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java new file mode 100644 index 0000000..5dad726 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java @@ -0,0 +1,31 @@ +package it.aboutbits.postgresql.crd.connection; + +import io.fabric8.generator.annotation.Required; +import it.aboutbits.postgresql.core.SecretRef; +import lombok.Getter; +import lombok.Setter; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.HashMap; +import java.util.Map; + +@NullMarked +@Getter +@Setter +public class ClusterConnectionSpec { + @Required + private String host = ""; + + @Required + private Integer port = -1; + + @Required + private String maintenanceDatabase = "postgres"; + + @Required + private SecretRef adminSecretRef = new SecretRef(); + + @io.fabric8.generator.annotation.Nullable + private Map parameters = new HashMap<>(); +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/database/.gitkeep b/src/main/java/it/aboutbits/postgresql/crd/database/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/it/aboutbits/postgresql/crd/grant/.gitkeep b/src/main/java/it/aboutbits/postgresql/crd/grant/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/Role.java b/src/main/java/it/aboutbits/postgresql/crd/role/Role.java new file mode 100644 index 0000000..a1f137b --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/role/Role.java @@ -0,0 +1,23 @@ +package it.aboutbits.postgresql.crd.role; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import io.fabric8.kubernetes.api.model.Namespaced; +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.model.annotation.Group; +import io.fabric8.kubernetes.model.annotation.Version; +import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.core.Named; +import org.jspecify.annotations.NullMarked; + +@NullMarked +@Version("v1") +@Group("postgresql.aboutbits.it") +public class Role + extends CustomResource + implements Namespaced, Named { + @Override + @JsonIgnore + public String getName() { + return getSpec().getName(); + } +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java new file mode 100644 index 0000000..9b43ab1 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java @@ -0,0 +1,43 @@ +package it.aboutbits.postgresql.crd.role; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@RequiredArgsConstructor +public enum RoleFlags { + SUPERUSER("SUPERUSER"), + NO_SUPERUSER("NOSUPERUSER"), + + CREATEDB("CREATEDB"), + NO_CREATEDB("NOCREATEDB"), + + CREATEROLE("CREATEROLE"), + NO_CREATEROLE("NOCREATEROLE"), + + INHERIT("INHERIT"), + NO_INHERIT("NOINHERIT"), + + LOGIN("LOGIN"), + NO_LOGIN("NOLOGIN"), + + REPLICATION("REPLICATION"), + NO_REPLICATION("NOREPLICATION"), + + BYPASSRLS("BYPASSRLS"), + NO_BYPASSRLS("NOBYPASSRLS"), + + CONNECTION_LIMIT("CONNECTION LIMIT"), + + PASSWORD("PASSWORD"), + + VALID_UNTIL("VALID UNTIL"), + + IN_ROLE("IN ROLE"), + + ROLE("ROLE"); + + private final String flag; +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java new file mode 100644 index 0000000..88f5c0d --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -0,0 +1,146 @@ +package it.aboutbits.postgresql.crd.role; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.Reconciler; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import it.aboutbits.postgresql.core.BaseReconciler; +import it.aboutbits.postgresql.core.CRPhase; +import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.core.KubernetesUtil; +import it.aboutbits.postgresql.core.PostgreSQLAuthenticationUtil; +import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import lombok.RequiredArgsConstructor; +import org.jspecify.annotations.NonNull; + +import java.sql.SQLException; +import java.util.concurrent.TimeUnit; + +@RequiredArgsConstructor +public class RoleReconciler + extends BaseReconciler + implements Reconciler { + private final KubernetesClient kubernetesClient; + private final PostgreSQLContextFactory contextFactory; + + @Override + public UpdateControl reconcile( + Role resource, + Context context + ) { + var spec = resource.getSpec(); + var status = initializeStatus(resource); + + var clusterRef = spec.getClusterRef(); + var expectedFlags = spec.getFlags(); + + var clusterConnectionOptional = getReferencedClusterConnection( + kubernetesClient, + clusterRef + ); + + if (clusterConnectionOptional.isEmpty()) { + status.setPhase(CRPhase.PENDING) + .setMessage("The specified ClusterConnection does not exist or is not ready yet [clusterRef=%s/%s]".formatted( + getResourceNamespaceOrOwn(clusterRef.getNamespace()), + clusterRef.getName() + )); + + return UpdateControl.patchStatus(resource) + .rescheduleAfter(60, TimeUnit.SECONDS); + } + + var clusterConnection = clusterConnectionOptional.get(); + + // We need to case-insensitive sort the roles, as PostgreSQL will lowercase anything without quotes + expectedFlags.getRole().sort(String.CASE_INSENSITIVE_ORDER); + expectedFlags.getInRole().sort(String.CASE_INSENSITIVE_ORDER); + + var loginExpected = spec.getPasswordSecretRef() != null; + + String password; + if (loginExpected) { + password = KubernetesUtil.getSecretRefCredentials( + kubernetesClient, + clusterConnection + ).password(); + } else { + password = null; + } + + UpdateControl updateControl; + try { + // Run everything in a single transaction + updateControl = contextFactory.getDSLContext( + clusterConnection + ).transactionResult(cfg -> { + // Get the transactional DSL context + var tx = cfg.dsl(); + + // Create the role if it doesn't exist yet + if (!RoleUtil.roleExists(tx, spec)) { + RoleUtil.createRole( + tx, + spec, + password + ); + + status.setPhase(CRPhase.READY); + + return UpdateControl.patchStatus(resource); + } + + // When there is NOLOGIN, we set no password + var passwordMatches = true; + var roleLoginMatches = RoleUtil.roleLoginMatches(tx, spec); + var currentFlags = RoleUtil.fetchCurrentFlags(tx, spec); + var flagsMatch = expectedFlags.equals(currentFlags); + + if (loginExpected) { + passwordMatches = PostgreSQLAuthenticationUtil.passwordMatches( + tx, + spec, + password + ); + } + + if (roleLoginMatches && passwordMatches && flagsMatch) { + return UpdateControl.noUpdate(); + } + + var changePassword = loginExpected && !passwordMatches; + + RoleUtil.alterRole( + tx, + spec, + changePassword, + password + ); + + RoleUtil.reconcileRoleMembership( + tx, + spec, + expectedFlags, + currentFlags + ); + + status.setPhase(CRPhase.READY); + + return UpdateControl.patchStatus(resource); + }); + } catch (SQLException e) { + return handleError( + resource, + status, + e + ); + } + + return updateControl; + } + + @Override + protected @NonNull CRStatus newStatus() { + return new CRStatus(); + } +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java new file mode 100644 index 0000000..533db52 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java @@ -0,0 +1,71 @@ +package it.aboutbits.postgresql.crd.role; + +import io.fabric8.generator.annotation.Required; +import it.aboutbits.postgresql.core.ClusterReference; +import it.aboutbits.postgresql.core.SecretRef; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.Setter; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.List; + +@NullMarked +@Getter +@Setter +public class RoleSpec { + @Required + private String name = ""; + + private String comment = ""; + + @Required + private ClusterReference clusterRef = new ClusterReference(); + + @Nullable + @io.fabric8.generator.annotation.Nullable + private SecretRef passwordSecretRef; + + @io.fabric8.generator.annotation.Nullable + private Flags flags = new Flags(); + + @NullMarked + @Getter + @Setter + @EqualsAndHashCode + public static class Flags { + @io.fabric8.generator.annotation.Nullable + private boolean superuser = false; + + @io.fabric8.generator.annotation.Nullable + private boolean createdb = false; + + @io.fabric8.generator.annotation.Nullable + private boolean createrole = false; + + @io.fabric8.generator.annotation.Nullable + private boolean inherit = true; + + @io.fabric8.generator.annotation.Nullable + private boolean replication = false; + + @io.fabric8.generator.annotation.Nullable + private boolean bypassrls = false; + + @io.fabric8.generator.annotation.Nullable + private int connectionLimit = -1; + + @Nullable + @io.fabric8.generator.annotation.Nullable + private OffsetDateTime validUntil = null; + + @io.fabric8.generator.annotation.Nullable + private List inRole = new ArrayList<>(); + + @io.fabric8.generator.annotation.Nullable + private List role = new ArrayList<>(); + } +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java new file mode 100644 index 0000000..4aba15b --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java @@ -0,0 +1,438 @@ +package it.aboutbits.postgresql.crd.role; + +import it.aboutbits.postgresql.core.infrastructure.persistence.Routines; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; +import org.jooq.DSLContext; +import org.jooq.Query; +import org.jooq.QueryPart; +import org.jooq.Record1; +import org.jooq.impl.DSL; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; + +import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; +import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTH_MEMBERS; +import static org.jooq.impl.DSL.keyword; +import static org.jooq.impl.DSL.multiset; +import static org.jooq.impl.DSL.name; +import static org.jooq.impl.DSL.query; +import static org.jooq.impl.DSL.role; +import static org.jooq.impl.DSL.select; +import static org.jooq.impl.DSL.selectOne; +import static org.jooq.impl.DSL.sql; +import static org.jooq.impl.DSL.val; + +@NullMarked +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class RoleUtil { + public static boolean roleExists( + DSLContext tx, + RoleSpec spec + ) { + return tx.fetchExists(selectOne() + .from(PG_AUTHID) + .where(PG_AUTHID.ROLNAME.eq(spec.getName())) + ); + } + + public static void createRole( + DSLContext tx, + RoleSpec spec, + @Nullable String password + ) { + var roleName = spec.getName(); + var flags = spec.getFlags(); + var comment = spec.getComment(); + + tx.execute( + buildCreateRole( + roleName, + flags, + password + ) + ); + + // Optional comment + if (!comment.isBlank()) { + tx.execute( + buildCommentOnRole(roleName, comment) + ); + } + } + + public static void alterRole( + DSLContext tx, + RoleSpec spec, + boolean changePassword, + @Nullable String password + ) { + var roleName = spec.getName(); + var flags = spec.getFlags(); + var expectedComment = normalizeComment(spec.getComment()); + + tx.execute( + buildAlterRole( + roleName, + flags, + changePassword, + password + ) + ); + + var currentComment = normalizeComment( + fetchCurrentRoleComment(tx, roleName) + ); + + if (!Objects.equals(currentComment, expectedComment)) { + tx.execute( + buildCommentOnRole(roleName, expectedComment) + ); + } + } + + public static boolean roleLoginMatches( + DSLContext tx, + RoleSpec spec + ) { + var loginExpected = spec.getPasswordSecretRef() != null; + + var canLogin = tx.fetchExists(selectOne() + .from(PG_AUTHID) + .where(PG_AUTHID.ROLNAME.eq(spec.getName())) + .and(PG_AUTHID.ROLCANLOGIN.isTrue()) + ); + + return loginExpected == canLogin; + } + + public static RoleSpec.Flags fetchCurrentFlags( + DSLContext tx, + RoleSpec spec + ) { + var member = PG_AUTHID.as("member"); + var parent = PG_AUTHID.as("parent"); + + return tx + .select( + PG_AUTHID.ROLSUPER.as("superuser"), + PG_AUTHID.ROLCREATEDB.as("createdb"), + PG_AUTHID.ROLCREATEROLE.as("createrole"), + PG_AUTHID.ROLINHERIT.as("inherit"), + PG_AUTHID.ROLREPLICATION.as("replication"), + PG_AUTHID.ROLBYPASSRLS.as("bypassrls"), + PG_AUTHID.ROLCONNLIMIT.as("connectionLimit"), + PG_AUTHID.ROLVALIDUNTIL.as("validUntil"), + multiset( + select(parent.ROLNAME) + .from(PG_AUTH_MEMBERS) + .join(member).on(member.OID.eq(PG_AUTH_MEMBERS.MEMBER)) + .join(parent).on(parent.OID.eq(PG_AUTH_MEMBERS.ROLEID)) + .where(member.OID.eq(PG_AUTHID.OID)) + .orderBy(parent.ROLNAME) + ).as("inRole").convertFrom(result -> result.map(Record1::value1)), + multiset( + select(member.ROLNAME) + .from(PG_AUTH_MEMBERS) + .join(parent).on(parent.OID.eq(PG_AUTH_MEMBERS.ROLEID)) + .join(member).on(member.OID.eq(PG_AUTH_MEMBERS.MEMBER)) + .where(parent.OID.eq(PG_AUTHID.OID)) + .orderBy(member.ROLNAME) + ).as("role").convertFrom(result -> result.map(Record1::value1)) + ) + .from(PG_AUTHID) + .where(PG_AUTHID.ROLNAME.eq(spec.getName())) + .fetchSingleInto(RoleSpec.Flags.class); + } + + /** + * Build: CREATE ROLE [ [ WITH ] option [ ... ] ] + * See + * PostgreSQL: Documentation: CREATE ROLE + * + */ + private static Query buildCreateRole( + String roleName, + RoleSpec.Flags flags, + @Nullable String password + ) { + var options = new ArrayList(); + + // Only allow the user to log in if a password is specified. + if (password != null) { + options.add(keyword(RoleFlags.LOGIN.flag())); + options.add(keyword(RoleFlags.PASSWORD.flag())); + options.add(val(password)); + } + + if (flags.isSuperuser()) { + options.add(keyword(RoleFlags.SUPERUSER.flag())); + } + if (flags.isCreatedb()) { + options.add(keyword(RoleFlags.CREATEDB.flag())); + } + if (flags.isCreaterole()) { + options.add(keyword(RoleFlags.CREATEROLE.flag())); + } + if (flags.isInherit()) { + options.add(keyword(RoleFlags.INHERIT.flag())); + } + if (flags.isReplication()) { + options.add(keyword(RoleFlags.REPLICATION.flag())); + } + if (flags.isBypassrls()) { + options.add(keyword(RoleFlags.BYPASSRLS.flag())); + } + if (flags.getConnectionLimit() >= 0) { + options.add(keyword(RoleFlags.CONNECTION_LIMIT.flag())); + options.add(val(flags.getConnectionLimit())); + } + if (flags.getValidUntil() != null) { + options.add(keyword(RoleFlags.VALID_UNTIL.flag())); + options.add(val(flags.getValidUntil())); + } + if (!flags.getInRole().isEmpty()) { + options.add(keyword(RoleFlags.IN_ROLE.flag())); + options.add(joinWithComma( + flags.getInRole() + .stream() + .map(DSL::name) + .toList() + )); + } + if (!flags.getRole().isEmpty()) { + options.add(keyword(RoleFlags.ROLE.flag())); + options.add(joinWithComma( + flags.getRole() + .stream() + .map(DSL::name) + .toList() + )); + } + + var optionsSql = options.isEmpty() + ? sql("") // nothing + : sql(" with {0}", joinWithSpaces(options)); + + return query( + "create role {0}{1}", + name(roleName), + optionsSql + ); + } + + private static Query buildAlterRole( + String roleName, + RoleSpec.Flags flags, + boolean changePassword, + @Nullable String password + ) { + var options = new ArrayList(); + var loginExpected = password != null; + + // LOGIN / NOLOGIN + options.add(keyword(loginExpected + ? RoleFlags.LOGIN.flag() + : RoleFlags.NO_LOGIN.flag() + )); + + // Password handling + // - if NOLOGIN, remove the password + // - if LOGIN and passwordChanged, set the new password + if (!loginExpected) { + options.add(keyword(RoleFlags.PASSWORD.flag())); + options.add(keyword("NULL")); + } else if (changePassword) { + options.add(keyword(RoleFlags.PASSWORD.flag())); + options.add(val(password)); + } + + // Explicitly set the expected state to make the statement idempotent + options.add(keyword(flags.isSuperuser() + ? RoleFlags.SUPERUSER.flag() + : RoleFlags.NO_SUPERUSER.flag() + )); + options.add(keyword(flags.isCreatedb() + ? RoleFlags.CREATEDB.flag() + : RoleFlags.NO_CREATEDB.flag() + )); + options.add(keyword(flags.isCreaterole() + ? RoleFlags.CREATEROLE.flag() + : RoleFlags.NO_CREATEROLE.flag() + )); + options.add(keyword(flags.isInherit() + ? RoleFlags.INHERIT.flag() + : RoleFlags.NO_INHERIT.flag() + )); + options.add(keyword(flags.isReplication() + ? RoleFlags.REPLICATION.flag() + : RoleFlags.NO_REPLICATION.flag() + )); + options.add(keyword(flags.isBypassrls() + ? RoleFlags.BYPASSRLS.flag() + : RoleFlags.NO_BYPASSRLS.flag() + )); + if (flags.getConnectionLimit() >= 0) { + options.add(keyword(RoleFlags.CONNECTION_LIMIT.flag())); + options.add(val(flags.getConnectionLimit())); + } + if (flags.getValidUntil() != null) { + options.add(keyword(RoleFlags.VALID_UNTIL.flag())); + options.add(val(flags.getValidUntil())); + } + + return query( + "alter role {0} with {1}", + name(roleName), + joinWithSpaces(options) + ); + } + + public static void reconcileRoleMembership( + DSLContext tx, + RoleSpec spec, + RoleSpec.Flags expectedFlags, + RoleSpec.Flags currentFlags + ) { + var roleName = spec.getName(); + + // ROLE IN + var expectedInRole = new HashSet<>(expectedFlags.getInRole()); + var currentInRole = new HashSet<>(currentFlags.getInRole()); + + var queries = new ArrayList(); + + var inRoleToGrant = new HashSet<>(expectedInRole); + inRoleToGrant.removeAll(currentInRole); + + var inRoleToRevoke = new HashSet<>(currentInRole); + inRoleToRevoke.removeAll(expectedInRole); + + for (var parentRole : inRoleToGrant) { + // GRANT parentRole TO roleName + queries.add(buildGrantRoleToMember(parentRole, roleName)); + } + for (var parentRole : inRoleToRevoke) { + // REVOKE parentRole FROM roleName + queries.add(buildRevokeRoleFromMember(parentRole, roleName)); + } + + // ROLE + var expectedRoleMembers = new HashSet<>(expectedFlags.getRole()); + var currentRoleMembers = new HashSet<>(currentFlags.getRole()); + + var roleMembersToGrant = new HashSet<>(expectedRoleMembers); + roleMembersToGrant.removeAll(currentRoleMembers); + + var roleMembersToRevoke = new HashSet<>(currentRoleMembers); + roleMembersToRevoke.removeAll(expectedRoleMembers); + + for (var member : roleMembersToGrant) { + // GRANT roleName TO member + queries.add(buildGrantRoleToMember(roleName, member)); + } + for (var member : roleMembersToRevoke) { + // REVOKE roleName FROM member + queries.add(buildRevokeRoleFromMember(roleName, member)); + } + + if (!queries.isEmpty()) { + tx.batch(queries).execute(); + } + } + + public static Query buildGrantRoleToMember( + String role, + String member + ) { + return query("grant {0} to {1}", role(role), role(member)); + } + + public static Query buildRevokeRoleFromMember( + String role, + String member + ) { + return query("revoke {0} from {1}", role(role), role(member)); + } + + /** + * Build: COMMENT ON ROLE IS + */ + private static Query buildCommentOnRole( + String roleName, + @Nullable String comment + ) { + return query( + "comment on role {0} is {1}", + name(roleName), + val(comment) + ); + } + + private static @Nullable String fetchCurrentRoleComment( + DSLContext tx, + String roleName + ) { + + return tx + .select(Routines.shobjDescription( + PG_AUTHID.OID, + val(PG_AUTHID.getUnqualifiedName().last()) + )) + .from(PG_AUTHID) + .where(PG_AUTHID.ROLNAME.eq(roleName)) + .fetchOneInto(String.class); + } + + private static @Nullable String normalizeComment(@Nullable String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + + return comment; + } + + /** + * Join QueryParts with the requested separator + */ + private static QueryPart join( + List items, + String separator + ) { + int size = items.size(); + + if (items.isEmpty()) { + return sql(""); + } else if (size == 1) { + return items.getFirst(); + } + + var template = new StringBuilder(); + + // Add the first item without a separator + template.append('{').append(0).append('}'); + + // Add the rest of the items with the leading separator + for (int i = 1; i < size; i++) { + template.append(separator).append('{').append(i).append('}'); + } + + return sql( + template.toString(), + items.toArray(QueryPart[]::new) + ); + } + + private static QueryPart joinWithSpaces(List parts) { + return join(parts, " "); + } + + private static QueryPart joinWithComma(List parts) { + return join(parts, ", "); + } +} diff --git a/src/main/resources/META-INF/branding/logo.png b/src/main/resources/META-INF/branding/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..915e3236dbd987b341852340b40490938c01689a GIT binary patch literal 3317 zcmXX}dq9ipA72;hl%~+dr83U36uB<36lEvEL*>4GOA^w3nf|!i_lu9*S*x# zESHAqP?j<~$y%MVGR|mStQkuDzVGtKw)c6T@AG{=&*$^KJUih}_O&pZW`@CFEY_`E zvju*K(U0kP_&%-OalkOJug45vyyZMAv4ccq7Nvi;(1AQ$>m#XZBv+HJQ~_5t~kc z!DkbN3})+)*cuj2?zEfIT z<#2Z?U_6xBdx11i&va+zxidG0No%|14z(tD-UGg>Z)6fZ9{e$D@V8Wb37TmmczP(~ z_@Zi6-_V}?&LeEu6q^SjzL+=7*v$(iy5C_yriGJflLNK7n+9$X*-mX1;4@s>kzh8M zr?$-6uesQS+-dN=+csHL!uQF&K-$}IP;o|WIc~_x6$aUCS(}Nd4-WGJx$`%F<$I+7 zV{@2k(nF;c9jLJtAqxifhYf(EgZ7U!LB_0y9>7h7D6QJ(8M5HHIChCd=N+JZ7hGij zR~VUQzEf)ZAFS*=WxuNAmWoBR#`CQ3ysRn}3%1j_lW8ol;5*z!r8<=9l1mD0`UW%{ z!u)&M-n*8sVts;B=CNfx0orf&$3vNMJ#yMimZuk$HDu*k%sS$u}jj5s85gvvJv(Ij3Vls(hQwDNH2mqbf4ssi;HG9VVmstJk=IA00r z4Ld5ONgR7+-f;g(WAZb7B=`ktJHK^!=nMz9F-+&zBWZO!p&LMYI&T;}Jptt5c;a3UP^tLd_Z;q&hU7bEUWdrp;2 zCmN1P>Gc7LcwVzdhe>oU>CvQsLP#$%%_Jt5^kq+eoPlQ(wp5o_q4Mok3Qk0gtb#|# z2+3Pz(yjV!S^=7eA2>p6+xeW-6=d3M;)U;{!A69u$cU+J$`m~BsM|SYz-Uhskm%Sw zK7Jl4Su-ugvF)P0cjvVA(v=Yp8~w&Dm+ zHu}Tq*#yqjA*=XZ$AYv_67q!Ep-lVYQj~xiivxIK<}l$2;%Lk{t5;+8(kDo07F+hL z9B8_N`$f~`W4fY&Xhj=jcI~~VUcCzyr8Yl>rT6R(xDlT;9NV6lR@BhW74}=q=;|q1 zwzQXTq>fp8Z6rvMgRa04(=`)rpv)07c2xT~Nlu|BOR>ZAF1ZyT1Lnb~lt1`Gr>FZ# zml>8o^|}6K(1mwtSpkw#(@@i+Owu0n*B;GSqK}42?7m$T#OhZP-YY37@D`$#xKC5I=|&aGF|#IiBEhDUC#H=h4$VNW$39v9?JErNXZGd>^PfBfOnpC zPaW#S<yy4j-&{l1yOgR@-w||+2iG!8TX~sC=vn9!ML>+rF56K%j?nGia$@>1 zTrJ8sM_5L<0LrvC`ML|{@KsS`zAGB%-H%F98^AOvko0Ea-I*VvWFme@MI(y04XdbB zTTVN7b1cltlBViC!1QycVT!)PQt;I|l^^2k#pNqrCQI|jJ?tEk+&saiCLsg*Dq!<9 zj$vxCwS*LE9{l>Pq&zR5{&7PI8X_a6cmE(}9xIoi&K)o`(5gE~)*_KRKGL*buJ^sW zEWIQmyX~o1v`&s=>?6>F7{`Ay6PgO7f7DTqK% zSL+D&&k&LqMt@hJ6p&b{i*D^LT0LGF_r0;)j313wc`)aYa#Ee>F&(SNLydYGcv-K8 zL4H*BDbKjmC?TCt8myzckdkz^Y|f5qqqQwfJJ96Z)UBjsUA(61O@DshY%#{O?nc1S zluqc^J_=ho?(#Dav{6BUkw&7;4Z4VP`%Tg#WBtv@x$7o6+j$aP$De~;;@-bWP2_W4C7xL+4uPaGE4aRg3grJqD))vnzYiszWF=gbgs?cy`*Z8?m8z}>C(FBV( z>XKk_^TX$}#l3UotKr67`LQBG)A6skH!Z*Hn6k9r-`)MysFz literal 0 HcmV?d00001 diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..96e49bc --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,6 @@ +quarkus: + datasource: + devservices: + port: 5432 + test: + continuous-testing: enabled diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 0000000..2b9fd45 --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,7 @@ +quarkus: + datasource: + devservices: + port: 5433 + operator-sdk: + activate-leader-election-for-profiles: + - test diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..7ce0c74 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,173 @@ +quarkus: + kubernetes-client: + devservices: + enabled: true + # To not use our default kubeconfig from the home directory, + # which could lead to catastrophic damage if we had the permissions + override-kubeconfig: true + flavor: k3s + # See https://github.com/dajudge/kindcontainer/blob/master/k8s-versions.json + api-version: 1.34.1 + live-reload: + instrumentation: true + micrometer: + enabled: true + export: + # Additionally to Prometheus, we could also consume the metrics in JSON format + # by calling /q/metrics with the HTTP header "Accept: application/json" + json: + enabled: true + datasource: + devservices: + enabled: true + image-name: postgres:17.7 + username: root + password: password + reuse: false + jdbc: + metrics: + enabled: true + operator-sdk: + crd: + generate: true + # NOTE that this option is only considered when *not* in production mode + # as applying the CRD to a production cluster could be dangerous if done automatically. + apply: true + # Whether controllers should only process events if the associated resource generation + # has increased since the last reconciliation, otherwise will process all events. + generation-aware: true + test: + hang-detection-timeout: PT1M + #log: + # category: + # "io.javaoperatorsdk": + # level: DEBUG + # "io.quarkiverse.operatorsdk": + # level: DEBUG + + # Config for the generated Helm chart # + # Container Image config for Kubernetes Helm # + container-image: + registry: ghcr.io + group: aboutbits/postgresql-operator + name: app + tag: ${quarkus.application.version} + helm: + app-version: ${quarkus.application.version} + type: application + name: ${quarkus.kubernetes.name} + description: AboutBits PostgreSQL Operator Helm Chart + # Keep the map-system-properties flag below on false (else it will map all ${ENV_VARS} we define in the application.yml or application-prod.yml files + # as in Kubernetes the env entries take precedence over envFrom entries as we want to consume all ENV vars from the postgresql-operator-config secret + # This started working with Gradle since Quarkus Helm 1.2.5 + map-system-properties: false + home: http://github.com/aboutbits/postgresql-operator + sources: + - http://github.com/aboutbits/postgresql-operator + annotations: + "catalog.cattle.io/os": linux + keywords: + - aboutbits + - postgresql + - operator + maintainers: + "AboutBits": + name: AboutBits + email: info@aboutbits.it + url: https://aboutbits.it/ + create-tar-file: true + extension: tgz + values: + replicas: + property: replicas + value: 1 + paths: + - (kind == Deployment).spec.replicas + image-pull-policy: + property: imagePullPolicy + value: IfNotPresent + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).imagePullPolicy + resource-requests-cpu: + property: resources.requests.cpu + value: ${quarkus.kubernetes.resources.requests.cpu} + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).resources.requests.cpu + resource-requests-memory: + property: resources.requests.memory + value: ${quarkus.kubernetes.resources.requests.memory} + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).resources.requests.memory + resource-limits-memory: + property: resources.limits.memory + value: ${quarkus.kubernetes.resources.limits.memory} + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).resources.limits.memory + secret-name: + property: secret.name + value: ${quarkus.kubernetes.envFrom.secretRef[0].name} + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).envFrom[0].secretRef.name + image-pull-secret: + property: imagePullSecret + value: ${quarkus.kubernetes.image-pull-secrets[0]} + paths: + - (kind == Deployment).spec.template.spec.imagePullSecrets[0].name + expressions: + release-name-labels: + expression: "{{ .Release.Name }}" + path: metadata.labels.'app.kubernetes.io/name' + release-name-service-selector: + expression: "{{ .Release.Name }}" + path: (kind == Service).spec.selector.'app.kubernetes.io/name' + release-name-deployment-match-labels: + expression: "{{ .Release.Name }}" + path: (kind == Deployment).spec.selector.matchLabels.'app.kubernetes.io/name' + release-name-deployment-labels: + expression: "{{ .Release.Name }}" + path: (kind == Deployment).spec.template.metadata.labels.'app.kubernetes.io/name' + kubernetes: + name: postgresql-operator + version: ${quarkus.application.version} + add-version-to-label-selectors: false + image-pull-policy: IfNotPresent + image-pull-secrets: + - github-container-registry + replicas: 1 + annotations: + "app.kubernetes.io/version": ${quarkus.application.version} + secret-volumes: + postgresql-operator-config: + secret-name: postgresql-operator-config + resources: + requests: + cpu: 50m + memory: 300Mi + limits: + memory: 512Mi + startup-probe: + http-action-port-name: http + initial-delay: PT2S + period: PT10S + timeout: PT3S + success-threshold: 1 + failure-threshold: 3 + readiness-probe: + http-action-port-name: http + initial-delay: PT0S + period: PT10S + timeout: PT3S + success-threshold: 1 + failure-threshold: 3 + liveness-probe: + http-action-port-name: http + initial-delay: PT10S + period: PT30S + timeout: PT10S + success-threshold: 1 + failure-threshold: 3 + env: + fields: + KUBERNETES_NODE_NAME: spec.nodeName + secrets: + - postgresql-operator-config diff --git a/src/main/resources/default_banner.txt b/src/main/resources/default_banner.txt new file mode 100644 index 0000000..a3b0c25 --- /dev/null +++ b/src/main/resources/default_banner.txt @@ -0,0 +1,6 @@ + _ _ _ ____ _ _ + / \ | |__ ___ _ _| |_| __ )(_) |_ ___ + / _ \ | '_ \ / _ \| | | | __| _ \| | __/ __| + / ___ \| |_) | (_) | |_| | |_| |_) | | |_\__ \ + /_/ \_\_.__/ \___/ \__,_|\__|____/|_|\__|___/ + PostgreSQL Operator \ No newline at end of file diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java new file mode 100644 index 0000000..376ee28 --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java @@ -0,0 +1,67 @@ +package it.aboutbits.postgresql._support.testdata.base; + +import net.datafaker.Faker; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +public abstract class TestDataCreator { + protected static final Faker FAKER = new Faker(); + + protected final int numberOfItems; + + protected TestDataCreator(int numberOfItems) { + this.numberOfItems = numberOfItems; + } + + public void apply() { + create(); + } + + public ITEM returnFirst() { + return create().getFirst(); + } + + public List returnAll() { + return create(); + } + + public Set returnSet() { + return new HashSet<>(create()); + } + + protected List create() { + var result = new ArrayList(); + + for (var index = 0; index < numberOfItems; index++) { + result.add( + create(index) + ); + } + + return result; + } + + protected abstract ITEM create(int index); + + public static String randomKubernetesNameSuffix(String name) { + var maxLength = 63; // Kubernetes hard enforces RFC-1123 + + if (name.length() > 60) { + throw new IllegalArgumentException( + "The name is too long (must be <= 60 to allow '-' + at least 2 random chars, max %d total) [name=%s, lenght=%d]".formatted( + maxLength, + name, + name.length() + ) + ); + } + + var separator = "-"; + var suffixLength = maxLength - name.length() - separator.length(); + + return name + separator + FAKER.regexify("[a-z0-9]{%d}".formatted(suffixLength)); + } +} diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java new file mode 100644 index 0000000..bc240dd --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java @@ -0,0 +1,89 @@ +package it.aboutbits.postgresql._support.testdata.persisted; + +import io.fabric8.kubernetes.client.KubernetesClient; +import it.aboutbits.postgresql._support.testdata.persisted.creator.ClusterConnectionCreate; +import it.aboutbits.postgresql._support.testdata.persisted.creator.SecretRefCreate; +import jakarta.enterprise.context.ApplicationScoped; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jspecify.annotations.NullMarked; + +@NullMarked +@ApplicationScoped +@RequiredArgsConstructor +public class Given { + private final KubernetesClient kubernetesClient; + + @ConfigProperty(name = "quarkus.datasource.devservices.username") + String username; + + @ConfigProperty(name = "quarkus.datasource.devservices.password") + String password; + + @ConfigProperty(name = "quarkus.datasource.devservices.port") + Integer port; + + DBConnectionDetails dbConnectionDetails() { + return new DBConnectionDetails( + port, + username, + password + ); + } + + public One one() { + return new One(this); + } + + public Many many(int numberOfItems) { + return new Many(numberOfItems, this); + } + + public class One extends Item { + One(Given given) { + super(1, given); + } + } + + public class Many extends Item { + Many(int numberOfItems, Given given) { + super(numberOfItems, given); + } + } + + @RequiredArgsConstructor(access = AccessLevel.PACKAGE) + public abstract class Item { + private final int numberOfItems; + private final Given given; + + @SuppressWarnings("unused") + public Item describedAs(String description) { + return this; + } + + public SecretRefCreate secretRef() { + return new SecretRefCreate( + numberOfItems, + kubernetesClient, + dbConnectionDetails() + ); + } + + public ClusterConnectionCreate clusterConnection() { + return new ClusterConnectionCreate( + numberOfItems, + given, + kubernetesClient, + dbConnectionDetails() + ); + } + } + + public record DBConnectionDetails( + int port, + String username, + String password + ) { + } +} diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java new file mode 100644 index 0000000..8aa2256 --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -0,0 +1,168 @@ +package it.aboutbits.postgresql._support.testdata.persisted.creator; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import it.aboutbits.postgresql._support.testdata.base.TestDataCreator; +import it.aboutbits.postgresql._support.testdata.persisted.Given; +import it.aboutbits.postgresql.core.SecretRef; +import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import it.aboutbits.postgresql.crd.connection.ClusterConnectionSpec; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +@NullMarked +@Getter +@Setter +@Accessors(fluent = true, chain = true) +public class ClusterConnectionCreate extends TestDataCreator { + private final Given given; + private final KubernetesClient kubernetesClient; + private final Given.DBConnectionDetails dbConnectionDetails; + + @Nullable + private String withNamespace; + @Setter(AccessLevel.NONE) + private boolean withoutNamespace = false; + + @Nullable + private String withName; + + @Nullable + private String withHost; + + @Nullable + private Integer withPort; + + @Nullable + private String withMaintenanceDatabase; + + @Nullable + private SecretRef withAdminSecretRef; + + @Nullable + private String withApplicationName; + + public ClusterConnectionCreate withoutNamespace() { + this.withoutNamespace = true; + return this; + } + + public ClusterConnectionCreate( + int numberOfItems, + Given given, KubernetesClient kubernetesClient, Given.DBConnectionDetails dbConnectionDetails + ) { + super(numberOfItems); + this.given = given; + this.kubernetesClient = kubernetesClient; + this.dbConnectionDetails = dbConnectionDetails; + } + + @Override + protected ClusterConnection create(int index) { + // given + var namespace = getNamespace(); + var name = getName(); + + var item = new ClusterConnection(); + + item.setMetadata(new ObjectMetaBuilder() + .withName(name) + .withNamespace(namespace) + .build() + ); + + var spec = new ClusterConnectionSpec(); + spec.setHost(getHost()); + spec.setPort(getPort()); + spec.setMaintenanceDatabase(getMaintenanceDatabase()); + spec.setAdminSecretRef(getAdminSecretRef()); + spec.setParameters(getParameters()); + + item.setSpec(spec); + + kubernetesClient.resources(ClusterConnection.class) + .inNamespace(namespace) + .resource(item) + .serverSideApply(); + + return kubernetesClient.resources(ClusterConnection.class) + .inNamespace(namespace) + .withName(name) + .waitUntilCondition( + clusterConnection -> clusterConnection != null && clusterConnection.getStatus() != null, + 10, + TimeUnit.SECONDS + ); + } + + @Nullable + private String getNamespace() { + if (withoutNamespace) { + return null; + } + + if (withNamespace != null) { + return withNamespace; + } + + return kubernetesClient.getNamespace(); + } + + private String getName() { + if (withName != null) { + return withName; + } + + return randomKubernetesNameSuffix("test-cluster-connection"); + } + + private String getHost() { + if (withHost != null) { + return withHost; + } + + return "localhost"; + } + + private int getPort() { + if (withPort != null) { + return withPort; + } + + return dbConnectionDetails.port(); + } + + private String getMaintenanceDatabase() { + return Objects.requireNonNullElse( + withMaintenanceDatabase, + "postgres" + ); + } + + private SecretRef getAdminSecretRef() { + if (withAdminSecretRef != null) { + return withAdminSecretRef; + } + + return given.one() + .secretRef() + .returnFirst(); + } + + private Map getParameters() { + if (withApplicationName != null) { + return Map.of("ApplicationName", withApplicationName); + } + + return Map.of(); + } +} diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java new file mode 100644 index 0000000..4c14f6b --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java @@ -0,0 +1,160 @@ +package it.aboutbits.postgresql._support.testdata.persisted.creator; + +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import it.aboutbits.postgresql._support.testdata.base.TestDataCreator; +import it.aboutbits.postgresql._support.testdata.persisted.Given; +import it.aboutbits.postgresql.core.SecretRef; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import static it.aboutbits.postgresql.core.KubernetesUtil.SECRET_DATA_BASIC_AUTH_PASSWORD_KEY; +import static it.aboutbits.postgresql.core.KubernetesUtil.SECRET_DATA_BASIC_AUTH_USERNAME_KEY; +import static it.aboutbits.postgresql.core.KubernetesUtil.SECRET_TYPE_BASIC_AUTH; + +@NullMarked +public class SecretRefCreate extends TestDataCreator { + private final KubernetesClient kubernetesClient; + private final Given.DBConnectionDetails dbConnectionDetails; + + @Nullable + private String withNamespace; + private boolean withoutNamespace = false; + + @Nullable + private String withName; + + @Nullable + private String withUsername; + private boolean withoutUsername = false; + + @Nullable + private String withPassword; + private boolean withoutPassword = false; + + public SecretRefCreate( + int numberOfItems, + KubernetesClient kubernetesClient, + Given.DBConnectionDetails dbConnectionDetails + ) { + super(numberOfItems); + this.kubernetesClient = kubernetesClient; + this.dbConnectionDetails = dbConnectionDetails; + } + + @SuppressWarnings("unused") + public SecretRefCreate withNamespace(String namespace) { + withNamespace = namespace; + return this; + } + + @SuppressWarnings("unused") + public SecretRefCreate withoutNamespace() { + withoutNamespace = true; + return this; + } + + @SuppressWarnings("unused") + public SecretRefCreate withName(String name) { + withName = name; + return this; + } + + @SuppressWarnings("unused") + public SecretRefCreate withUsername(String username) { + withUsername = username; + return this; + } + + @SuppressWarnings("unused") + public SecretRefCreate withoutUsername() { + withoutUsername = true; + return this; + } + + @SuppressWarnings("unused") + public SecretRefCreate withPassword(String password) { + withPassword = password; + return this; + } + + @SuppressWarnings("unused") + public SecretRefCreate withoutPassword() { + withoutPassword = true; + return this; + } + + @Override + protected SecretRef create(int index) { + var namespace = getNamespace(); + var name = getName(); + + var secret = new SecretBuilder() + .withNewMetadata() + .withNamespace(namespace) + .withName(name) + .endMetadata() + .withType(SECRET_TYPE_BASIC_AUTH) + .addToStringData(SECRET_DATA_BASIC_AUTH_USERNAME_KEY, dbConnectionDetails.username()) + .addToStringData(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY, dbConnectionDetails.password()) + .build(); + + kubernetesClient.secrets() + .inNamespace(namespace) + .resource(secret) + .serverSideApply(); + + var secretRef = new SecretRef(); + secretRef.setName(name); + secretRef.setNamespace(namespace); + + return secretRef; + } + + @Nullable + private String getNamespace() { + if (withoutNamespace) { + return null; + } + + if (withNamespace != null) { + return withNamespace; + } + + return kubernetesClient.getNamespace(); + } + + private String getName() { + if (withName != null) { + return withName; + } + + return randomKubernetesNameSuffix("test-secret"); + } + + @Nullable + private String getUsername() { + if (withoutUsername) { + return null; + } + + if (withUsername != null) { + return withUsername; + } + + return dbConnectionDetails.username(); + } + + @Nullable + private String getPassword() { + if (withoutPassword) { + return null; + } + + if (withPassword != null) { + return withPassword; + } + + return dbConnectionDetails.password(); + } +} diff --git a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java new file mode 100644 index 0000000..e51d82d --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java @@ -0,0 +1,85 @@ +package it.aboutbits.postgresql.crd.connection; + +import io.quarkus.test.junit.QuarkusTest; +import it.aboutbits.postgresql._support.testdata.persisted.Given; +import it.aboutbits.postgresql.core.CRPhase; +import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import lombok.RequiredArgsConstructor; +import org.jooq.DSLContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.atomic.AtomicReference; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.within; + +@QuarkusTest +@RequiredArgsConstructor +class ClusterConnectionReconcilerTest { + private final Given given; + + private final PostgreSQLContextFactory postgreSQLContextFactory; + + @Test + @DisplayName("When a ClusterConnection is created, the status should be ready") + void createsCustomResource_andReconcilerStatusIsReady() { + // given / when + var customResource = given.one() + .clusterConnection() + .withName("test-connection") + .returnFirst(); + + // then + AtomicReference dsl = new AtomicReference<>(); + assertThatNoException().isThrownBy( + () -> dsl.set(postgreSQLContextFactory.getDSLContext(customResource)) + ); + + var version = dsl.get().fetchSingle("select version()").into(String.class); + + var expectedStatus = getInitialClusterConnectionStatus(customResource); + expectedStatus.setMessage(version); + + assertThatClusterConnectionHasExpectedStatus( + customResource, + expectedStatus, + OffsetDateTime.now(ZoneOffset.UTC) + ); + } + + private static void assertThatClusterConnectionHasExpectedStatus( + ClusterConnection clusterConnection, + CRStatus expectedStatus, + OffsetDateTime now + ) { + assertThat(clusterConnection) + .isNotNull() + .extracting(ClusterConnection::getStatus) + .satisfies(status -> { + assertThat(status.getLastProbeTime()).isCloseTo( + now, + within(15, ChronoUnit.SECONDS) + ); + assertThat(status.getLastPhaseTransitionTime()).isCloseTo( + now, + within(15, ChronoUnit.SECONDS) + ); + }) + .usingRecursiveComparison() + .ignoringFields("lastProbeTime", "lastPhaseTransitionTime") + .isEqualTo(expectedStatus); + } + + private static CRStatus getInitialClusterConnectionStatus(ClusterConnection clusterConnection) { + return new CRStatus() + .setName(clusterConnection.getName()) + .setPhase(CRPhase.READY) + .setObservedGeneration(1L); + } +} diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java new file mode 100644 index 0000000..0e377b5 --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -0,0 +1,229 @@ +package it.aboutbits.postgresql.crd.role; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.quarkus.test.junit.QuarkusTest; +import it.aboutbits.postgresql._support.testdata.persisted.Given; +import it.aboutbits.postgresql.core.CRPhase; +import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.core.ClusterReference; +import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import it.aboutbits.postgresql.core.SecretRef; +import lombok.RequiredArgsConstructor; +import org.jooq.DSLContext; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.within; + +@QuarkusTest +@RequiredArgsConstructor +class RoleReconcilerTest { + private final Given given; + + private final PostgreSQLContextFactory postgreSQLContextFactory; + private final KubernetesClient kubernetesClient; + + @Test + @DisplayName("When a Role (LOGIN) is created, it should be reconciled to READY and present in pg_authid") + void createRole_withLogin_andStatusReady() { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-connection-role-login") + .returnFirst(); + + var now = OffsetDateTime.now(ZoneOffset.UTC); + var roleName = "test-role-login"; + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ true + ); + + var spec = role.getSpec(); + + spec.getClusterRef().setNamespace(kubernetesClient.getNamespace()); + + // when: create Role referencing the ClusterConnection and expecting LOGIN (passwordSecretRef non-null) + role = applyRole(role); + + // then: wait for status and assert READY + var reconciled = waitForRoleStatus(role.getMetadata().getName()); + + var expectedStatus = new CRStatus() + .setName(roleName) + .setPhase(CRPhase.READY) + .setObservedGeneration(1L); + + assertThatRoleHasExpectedStatus( + reconciled, + expectedStatus, + now + ); + + DSLContext dsl; + try { + dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + } catch (Exception e) { + throw new AssertionError("Failed to obtain DSLContext", e); + } + + assertThat(RoleUtil.roleExists(dsl, reconciled.getSpec())).isTrue(); + assertThat(RoleUtil.roleLoginMatches(dsl, reconciled.getSpec())).isTrue(); + } + + @Test + @DisplayName("When a Role (NOLOGIN) is created, it should be reconciled to READY and present with NOLOGIN") + void createRole_withoutLogin_andStatusReady() { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-connection-role-nologin") + .returnFirst(); + + // when + var now = OffsetDateTime.now(ZoneOffset.UTC); + var roleName = "test-role-nologin"; + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ false + ); + role.getSpec().getClusterRef().setNamespace(kubernetesClient.getNamespace()); + role = applyRole(role); + + var reconciled = waitForRoleStatus(role.getMetadata().getName()); + + var expectedStatus = new CRStatus() + .setName(roleName) + .setPhase(CRPhase.READY) + .setObservedGeneration(1L); + + assertThatRoleHasExpectedStatus( + reconciled, + expectedStatus, + now + ); + + DSLContext dsl; + try { + dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + } catch (Exception e) { + throw new AssertionError("Failed to obtain DSLContext", e); + } + + assertThat(RoleUtil.roleExists(dsl, reconciled.getSpec())).isTrue(); + assertThat(RoleUtil.roleLoginMatches(dsl, reconciled.getSpec())).isTrue(); + } + + @Test + @DisplayName("When a Role references a missing ClusterConnection, status should be PENDING with a helpful message") + void createRole_withMissingClusterConnection_setsPending() { + // given + var roleName = "test-role-missing-cc"; + var missingClusterName = "non-existing-cc"; + + var now = OffsetDateTime.now(ZoneOffset.UTC); + var role = buildRole( + roleName, + missingClusterName, + /*login*/ true + ); + role.getSpec().getClusterRef().setNamespace(kubernetesClient.getNamespace()); + role = applyRole(role); + + // when + var reconciled = waitForRoleStatus(role.getMetadata().getName()); + + // then + assertThat(reconciled).isNotNull(); + assertThat(reconciled.getStatus()).isNotNull(); + + assertThat(reconciled.getStatus().getPhase()).isEqualTo(CRPhase.PENDING); + assertThat(reconciled.getStatus().getMessage()).startsWith( + "The specified ClusterConnection does not exist" + ); + assertThat(reconciled.getStatus().getLastProbeTime()).isCloseTo( + now, + within(15, ChronoUnit.SECONDS) + ); + assertThat(reconciled.getStatus().getLastPhaseTransitionTime()).isNull(); + } + + private Role applyRole(Role role) { + var namespace = kubernetesClient.getNamespace(); + + return kubernetesClient.resources(Role.class) + .inNamespace(namespace) + .resource(role) + .serverSideApply(); + } + + private Role waitForRoleStatus(String roleName) { + var namespace = kubernetesClient.getNamespace(); + return kubernetesClient.resources(Role.class) + .inNamespace(namespace) + .withName(roleName) + .waitUntilCondition( + r -> r != null && r.getStatus() != null, + 30, + TimeUnit.SECONDS + ); + } + + private static void assertThatRoleHasExpectedStatus( + Role role, + CRStatus expectedStatus, + OffsetDateTime now + ) { + assertThat(role) + .isNotNull() + .extracting(Role::getStatus) + .satisfies(status -> { + assertThat(status.getLastProbeTime()).isCloseTo( + now, + within(15, ChronoUnit.SECONDS) + ); + assertThat(status.getLastPhaseTransitionTime()).isCloseTo( + now, + within(15, ChronoUnit.SECONDS) + ); + }) + .usingRecursiveComparison() + .ignoringFields("lastProbeTime", "lastPhaseTransitionTime") + .isEqualTo(expectedStatus); + } + + private static Role buildRole( + String roleName, + String clusterConnectionName, + boolean login + ) { + var role = new Role(); + + var spec = new RoleSpec(); + spec.setName(roleName); + spec.setClusterRef(new ClusterReference()); + spec.getClusterRef().setName(clusterConnectionName); + + if (login) { + // any non-null SecretRef will signal LOGIN expectation; the reconciler will use the ClusterConnection secret + var secretRef = new SecretRef(); + secretRef.setName("dummy"); + spec.setPasswordSecretRef(secretRef); + } + + role.setSpec(spec); + role.setMetadata(new ObjectMeta()); + role.getMetadata().setName(roleName); + + return role; + } +} From e5df81fd63f3c1ddb5e500860ca1624045eef750 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Wed, 7 Jan 2026 13:04:53 +0100 Subject: [PATCH 02/31] trigger the RoleReconciler if the referenced secret changes --- README.md | 2 +- build.gradle.kts | 1 + .../postgresql/core/BaseReconciler.java | 6 +- .../aboutbits/postgresql/core/CRStatus.java | 4 +- .../postgresql/core/ClusterReference.java | 2 - .../postgresql/core/KubernetesUtil.java | 38 +++---- .../aboutbits/postgresql/core/SecretRef.java | 6 +- .../postgresql/crd/role/RoleReconciler.java | 69 +++++++++++- .../testdata/base/TestDataCreator.java | 2 +- .../_support/testdata/persisted/Given.java | 3 +- .../creator/ClusterConnectionCreate.java | 4 +- .../persisted/creator/SecretRefCreate.java | 13 +-- .../ClusterConnectionReconcilerTest.java | 4 +- .../crd/role/RoleReconcilerTest.java | 102 ++++++++++++++++-- 14 files changed, 205 insertions(+), 51 deletions(-) diff --git a/README.md b/README.md index 8d8c674..27a79ef 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ If you want to learn more about building native executables, please consult getReferencedClusterConnection( KubernetesClient kubernetesClient, + CR resource, ClusterReference clusterRef ) { var connectionName = clusterRef.getName(); - var connectionNamespace = getResourceNamespaceOrOwn(clusterRef.getNamespace()); + var connectionNamespace = getResourceNamespaceOrOwn(resource, clusterRef.getNamespace()); var clusterConnection = kubernetesClient.resources(ClusterConnection.class) .inNamespace(connectionNamespace) diff --git a/src/main/java/it/aboutbits/postgresql/core/CRStatus.java b/src/main/java/it/aboutbits/postgresql/core/CRStatus.java index 8983714..db7ac62 100644 --- a/src/main/java/it/aboutbits/postgresql/core/CRStatus.java +++ b/src/main/java/it/aboutbits/postgresql/core/CRStatus.java @@ -13,7 +13,7 @@ /** * Status Object for the Custom Resources. *

- * This object captures the current state of a bucket as observed by the reconciler. + * This object captures the current state of a Custom Resource as observed by the reconciler. */ @NullMarked @Getter @@ -21,7 +21,7 @@ @Accessors(chain = true) public class CRStatus { /** - * The actual bucket name in MinIO (may differ from metadata.name). + * The Custom Resource name (may differ from metadata.name). */ @Nullable private String name = null; diff --git a/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java b/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java index b13591f..262ace8 100644 --- a/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java +++ b/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java @@ -1,9 +1,7 @@ package it.aboutbits.postgresql.core; import io.fabric8.generator.annotation.Required; -import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.NoArgsConstructor; import lombok.Setter; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java b/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java index c84a67e..a2f9f45 100644 --- a/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java +++ b/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java @@ -18,15 +18,23 @@ public static Credentials getSecretRefCredentials( KubernetesClient kubernetesClient, ClusterConnection clusterConnection ) { - var spec = clusterConnection.getSpec(); - var metadata = clusterConnection.getMetadata(); + return getSecretRefCredentials( + kubernetesClient, + clusterConnection.getSpec().getAdminSecretRef(), + clusterConnection.getMetadata().getNamespace() + ); + } - var passwordSecretRef = spec.getAdminSecretRef(); + public static Credentials getSecretRefCredentials( + KubernetesClient kubernetesClient, + SecretRef secretRef, + String defaultNamespace + ) { + var secretNamespace = secretRef.getNamespace() != null + ? secretRef.getNamespace() + : defaultNamespace; - var secretNamespace = passwordSecretRef.getNamespace() != null - ? passwordSecretRef.getNamespace() - : clusterConnection.getMetadata().getNamespace(); - var secretName = passwordSecretRef.getName(); + var secretName = secretRef.getName(); var secret = kubernetesClient.secrets() .inNamespace(secretNamespace) @@ -34,18 +42,14 @@ public static Credentials getSecretRefCredentials( .get(); if (secret == null) { - throw new IllegalStateException("ClusterConnection SecretRef not found [clusterConnection.namespace=%s, clusterConnection.name=%s, secret.namespace=%s, secret.name=%s]".formatted( - metadata.getNamespace(), - metadata.getName(), + throw new IllegalStateException("SecretRef not found [secret.namespace=%s, secret.name=%s]".formatted( secretNamespace, secretName )); } if (!secret.getType().equals(SECRET_TYPE_BASIC_AUTH)) { - throw new IllegalArgumentException("The ClusterConnection SecretRef is of the wrong type [clusterConnection.namespace=%s, clusterConnection.name=%s, secret.namespace=%s, secret.name=%s, expected.secret.type=%s, actual.secret.type=%s]".formatted( - metadata.getNamespace(), - metadata.getName(), + throw new IllegalArgumentException("The SecretRef is of the wrong type [secret.namespace=%s, secret.name=%s, expected.secret.type=%s, actual.secret.type=%s]".formatted( secretNamespace, secretName, SECRET_TYPE_BASIC_AUTH, @@ -55,9 +59,7 @@ public static Credentials getSecretRefCredentials( var data = secret.getData(); if (data == null || data.isEmpty()) { - throw new IllegalStateException("The ClusterConnection SecretRef has no data set [clusterConnection.namespace=%s, clusterConnection.name=%s, secret.namespace=%s, secret.name=%s]".formatted( - metadata.getNamespace(), - metadata.getName(), + throw new IllegalStateException("The SecretRef has no data set [secret.namespace=%s, secret.name=%s]".formatted( secretNamespace, secretName )); @@ -72,9 +74,7 @@ public static Credentials getSecretRefCredentials( var passwordBase64 = data.get(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY); if (passwordBase64 == null) { - throw new IllegalStateException("The ClusterConnection SecretRef is missing required data password [clusterConnection.namespace=%s, clusterConnection.name=%s, secret.namespace=%s, secret.name=%s]".formatted( - metadata.getNamespace(), - metadata.getName(), + throw new IllegalStateException("The SecretRef is missing required data password [secret.namespace=%s, secret.name=%s]".formatted( secretNamespace, secretName )); diff --git a/src/main/java/it/aboutbits/postgresql/core/SecretRef.java b/src/main/java/it/aboutbits/postgresql/core/SecretRef.java index edad032..fb25dfd 100644 --- a/src/main/java/it/aboutbits/postgresql/core/SecretRef.java +++ b/src/main/java/it/aboutbits/postgresql/core/SecretRef.java @@ -1,9 +1,7 @@ package it.aboutbits.postgresql.core; import io.fabric8.generator.annotation.Required; -import lombok.AllArgsConstructor; import lombok.Getter; -import lombok.NoArgsConstructor; import lombok.Setter; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -15,6 +13,10 @@ public class SecretRef { @Required private String name; + /** + * The namespace where the Secret is located. + * If it is null, it means the Secret is in the same namespace as the resource referencing it. + */ @Nullable @io.fabric8.generator.annotation.Nullable private String namespace; diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java index 88f5c0d..085cc01 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -1,9 +1,16 @@ package it.aboutbits.postgresql.crd.role; +import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import io.javaoperatorsdk.operator.processing.event.ResourceID; +import io.javaoperatorsdk.operator.processing.event.source.EventSource; +import io.javaoperatorsdk.operator.processing.event.source.SecondaryToPrimaryMapper; +import io.javaoperatorsdk.operator.processing.event.source.informer.InformerEventSource; import it.aboutbits.postgresql.core.BaseReconciler; import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; @@ -14,7 +21,9 @@ import org.jspecify.annotations.NonNull; import java.sql.SQLException; +import java.util.List; import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; @RequiredArgsConstructor public class RoleReconciler @@ -36,13 +45,14 @@ public UpdateControl reconcile( var clusterConnectionOptional = getReferencedClusterConnection( kubernetesClient, + resource, clusterRef ); if (clusterConnectionOptional.isEmpty()) { status.setPhase(CRPhase.PENDING) .setMessage("The specified ClusterConnection does not exist or is not ready yet [clusterRef=%s/%s]".formatted( - getResourceNamespaceOrOwn(clusterRef.getNamespace()), + getResourceNamespaceOrOwn(resource, clusterRef.getNamespace()), clusterRef.getName() )); @@ -56,13 +66,15 @@ public UpdateControl reconcile( expectedFlags.getRole().sort(String.CASE_INSENSITIVE_ORDER); expectedFlags.getInRole().sort(String.CASE_INSENSITIVE_ORDER); - var loginExpected = spec.getPasswordSecretRef() != null; + var passwordSecretRef = spec.getPasswordSecretRef(); + var loginExpected = passwordSecretRef != null; String password; if (loginExpected) { password = KubernetesUtil.getSecretRefCredentials( kubernetesClient, - clusterConnection + passwordSecretRef, + resource.getMetadata().getNamespace() ).password(); } else { password = null; @@ -139,8 +151,59 @@ public UpdateControl reconcile( return updateControl; } + @Override + public List> prepareEventSources(EventSourceContext context) { + // 1. Define the Mapper + // We define how to find the Primary Resource (Role) when a Secret changes + SecondaryToPrimaryMapper secretToRoleMapper = (Secret secret) -> { + // 2. Filter Roles that reference this specific Secret + return context.getPrimaryCache() + .list() + .filter(role -> isReferencedBy(role, secret)) + .map(ResourceID::fromResource) + .collect(Collectors.toSet()); + }; + + // 2. Build the Event Source Configuration which binds the InformerConfig + Mapper + var eventSourceConfig = InformerEventSourceConfiguration.from(Secret.class, Role.class) + .withSecondaryToPrimaryMapper(secretToRoleMapper) + // or .withWatchAllNamespaces() if we want to have the secret in another namespace than the Role CR instance + .withNamespacesInheritedFromController() + .build(); + + // 3. Create the Event Source + // This will watch for Secret changes and run the mapper + var secretEventSource = new InformerEventSource<>( + eventSourceConfig, + context + ); + + return List.of(secretEventSource); + } + @Override protected @NonNull CRStatus newStatus() { return new CRStatus(); } + + /** + * Checks if the given Role's spec.passwordSecretRef points to the changed Secret. + */ + private boolean isReferencedBy( + Role role, + Secret secret + ) { + var spec = role.getSpec(); + + if (spec.getPasswordSecretRef() == null) { + return false; + } + + var ref = spec.getPasswordSecretRef(); + var refName = ref.getName(); + var refNamespace = getResourceNamespaceOrOwn(role, ref.getNamespace()); + + return refName.equals(secret.getMetadata().getName()) && + refNamespace.equals(secret.getMetadata().getNamespace()); + } } diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java index 376ee28..fc56b8f 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java @@ -51,7 +51,7 @@ public static String randomKubernetesNameSuffix(String name) { if (name.length() > 60) { throw new IllegalArgumentException( - "The name is too long (must be <= 60 to allow '-' + at least 2 random chars, max %d total) [name=%s, lenght=%d]".formatted( + "The name is too long (must be <= 60 to allow '-' + at least 2 random chars, max %d total) [name=%s, length=%d]".formatted( maxLength, name, name.length() diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java index bc240dd..35b3274 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java @@ -65,8 +65,7 @@ public Item describedAs(String description) { public SecretRefCreate secretRef() { return new SecretRefCreate( numberOfItems, - kubernetesClient, - dbConnectionDetails() + kubernetesClient ); } diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java index 8aa2256..9a33a33 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -98,7 +98,7 @@ protected ClusterConnection create(int index) { .inNamespace(namespace) .withName(name) .waitUntilCondition( - clusterConnection -> clusterConnection != null && clusterConnection.getStatus() != null, + clusterConnection -> clusterConnection.getStatus() != null, 10, TimeUnit.SECONDS ); @@ -155,6 +155,8 @@ private SecretRef getAdminSecretRef() { return given.one() .secretRef() + .withUsername(dbConnectionDetails.username()) + .withPassword(dbConnectionDetails.password()) .returnFirst(); } diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java index 4c14f6b..0b474c8 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java @@ -15,7 +15,6 @@ @NullMarked public class SecretRefCreate extends TestDataCreator { private final KubernetesClient kubernetesClient; - private final Given.DBConnectionDetails dbConnectionDetails; @Nullable private String withNamespace; @@ -34,12 +33,10 @@ public class SecretRefCreate extends TestDataCreator { public SecretRefCreate( int numberOfItems, - KubernetesClient kubernetesClient, - Given.DBConnectionDetails dbConnectionDetails + KubernetesClient kubernetesClient ) { super(numberOfItems); this.kubernetesClient = kubernetesClient; - this.dbConnectionDetails = dbConnectionDetails; } @SuppressWarnings("unused") @@ -95,8 +92,8 @@ protected SecretRef create(int index) { .withName(name) .endMetadata() .withType(SECRET_TYPE_BASIC_AUTH) - .addToStringData(SECRET_DATA_BASIC_AUTH_USERNAME_KEY, dbConnectionDetails.username()) - .addToStringData(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY, dbConnectionDetails.password()) + .addToStringData(SECRET_DATA_BASIC_AUTH_USERNAME_KEY, getUsername()) + .addToStringData(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY, getPassword()) .build(); kubernetesClient.secrets() @@ -142,7 +139,7 @@ private String getUsername() { return withUsername; } - return dbConnectionDetails.username(); + return FAKER.credentials().username(); } @Nullable @@ -155,6 +152,6 @@ private String getPassword() { return withPassword; } - return dbConnectionDetails.password(); + return FAKER.credentials().username(); } } diff --git a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java index e51d82d..cf1cc40 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java @@ -64,11 +64,11 @@ private static void assertThatClusterConnectionHasExpectedStatus( .satisfies(status -> { assertThat(status.getLastProbeTime()).isCloseTo( now, - within(15, ChronoUnit.SECONDS) + within(10, ChronoUnit.SECONDS) ); assertThat(status.getLastPhaseTransitionTime()).isCloseTo( now, - within(15, ChronoUnit.SECONDS) + within(10, ChronoUnit.SECONDS) ); }) .usingRecursiveComparison() diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 0e377b5..3bb74cd 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -1,12 +1,14 @@ package it.aboutbits.postgresql.crd.role; import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.SecretBuilder; import io.fabric8.kubernetes.client.KubernetesClient; import io.quarkus.test.junit.QuarkusTest; import it.aboutbits.postgresql._support.testdata.persisted.Given; import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; import it.aboutbits.postgresql.core.ClusterReference; +import it.aboutbits.postgresql.core.PostgreSQLAuthenticationUtil; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import it.aboutbits.postgresql.core.SecretRef; import lombok.RequiredArgsConstructor; @@ -19,8 +21,10 @@ import java.time.temporal.ChronoUnit; import java.util.concurrent.TimeUnit; +import static it.aboutbits.postgresql.core.KubernetesUtil.SECRET_DATA_BASIC_AUTH_PASSWORD_KEY; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.within; +import static org.awaitility.Awaitility.await; @QuarkusTest @RequiredArgsConstructor @@ -52,6 +56,7 @@ void createRole_withLogin_andStatusReady() { spec.getClusterRef().setNamespace(kubernetesClient.getNamespace()); // when: create Role referencing the ClusterConnection and expecting LOGIN (passwordSecretRef non-null) + role.getSpec().setPasswordSecretRef(clusterConnection.getSpec().getAdminSecretRef()); role = applyRole(role); // then: wait for status and assert READY @@ -152,11 +157,96 @@ void createRole_withMissingClusterConnection_setsPending() { ); assertThat(reconciled.getStatus().getLastProbeTime()).isCloseTo( now, - within(15, ChronoUnit.SECONDS) + within(10, ChronoUnit.SECONDS) ); assertThat(reconciled.getStatus().getLastPhaseTransitionTime()).isNull(); } + @Test + @DisplayName( + "When a Role (LOGIN) references a secret and that secret changes, it should trigger a re-reconciliation" + ) + void secretChange_triggersReconciliation() throws Exception { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-connection-role-secret-change") + .returnFirst(); + + var roleName = "test-role-secret-change"; + + var initialPassword = "initial-password"; + var newPassword = "new-password"; + + var secretRef = given.one() + .secretRef() + .withPassword(initialPassword) + .returnFirst(); + + var secret = kubernetesClient.secrets() + .inNamespace(kubernetesClient.getNamespace()) + .withName(secretRef.getName()) + .require(); + + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ true + ); + + role.getSpec().setPasswordSecretRef(secretRef); + + // when: create Role + role = applyRole(role); + waitForRoleStatus(role.getMetadata().getName()); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + // then: password should match the initial one + // Wait for password to match because reconciliation might take a bit + var initialRole = role; + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> { + try { + return PostgreSQLAuthenticationUtil.passwordMatches( + dsl, + initialRole.getSpec(), + initialPassword + ); + } catch (Exception e) { + return false; + } + }); + + // when: update secret + secret.getMetadata().setManagedFields(null); + secret = new SecretBuilder(secret) + .addToStringData(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY, newPassword) + .build(); + + kubernetesClient.secrets() + .inNamespace(kubernetesClient.getNamespace()) + .resource(secret) + .serverSideApply(); + + // then: password should eventually match the new one + var updatedRole = role; + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> { + try { + return PostgreSQLAuthenticationUtil.passwordMatches( + dsl, + updatedRole.getSpec(), + newPassword + ); + } catch (Exception e) { + return false; + } + }); + } + private Role applyRole(Role role) { var namespace = kubernetesClient.getNamespace(); @@ -172,8 +262,8 @@ private Role waitForRoleStatus(String roleName) { .inNamespace(namespace) .withName(roleName) .waitUntilCondition( - r -> r != null && r.getStatus() != null, - 30, + r -> r.getStatus() != null, + 10, TimeUnit.SECONDS ); } @@ -189,11 +279,11 @@ private static void assertThatRoleHasExpectedStatus( .satisfies(status -> { assertThat(status.getLastProbeTime()).isCloseTo( now, - within(15, ChronoUnit.SECONDS) + within(10, ChronoUnit.SECONDS) ); assertThat(status.getLastPhaseTransitionTime()).isCloseTo( now, - within(15, ChronoUnit.SECONDS) + within(10, ChronoUnit.SECONDS) ); }) .usingRecursiveComparison() @@ -214,7 +304,7 @@ private static Role buildRole( spec.getClusterRef().setName(clusterConnectionName); if (login) { - // any non-null SecretRef will signal LOGIN expectation; the reconciler will use the ClusterConnection secret + // any non-null SecretRef will signal LOGIN expectation var secretRef = new SecretRef(); secretRef.setName("dummy"); spec.setPasswordSecretRef(secretRef); From 035d5bf14bef0eda247c880501c4dea4b35063ff Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Wed, 7 Jan 2026 13:23:08 +0100 Subject: [PATCH 03/31] add a test to check if the password changes if the Role secretRef changes --- .../MinioInstanceReadinessCheck.java | 1 - .../postgresql/core/BaseReconciler.java | 1 - .../crd/connection/ClusterConnectionSpec.java | 1 - .../persisted/creator/SecretRefCreate.java | 1 - .../crd/role/RoleReconcilerTest.java | 108 ++++++++++++++---- 5 files changed, 87 insertions(+), 25 deletions(-) diff --git a/src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java b/src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java index 3e25db6..575e53e 100644 --- a/src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java +++ b/src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java @@ -6,7 +6,6 @@ import lombok.RequiredArgsConstructor; import org.eclipse.microprofile.health.HealthCheck; import org.eclipse.microprofile.health.HealthCheckResponse; -import org.eclipse.microprofile.health.HealthCheckResponseBuilder; import org.eclipse.microprofile.health.Readiness; import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java index 7a8f485..09a6876 100644 --- a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java @@ -1,6 +1,5 @@ package it.aboutbits.postgresql.core; -import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java index 5dad726..ef8f4f8 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java +++ b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java @@ -5,7 +5,6 @@ import lombok.Getter; import lombok.Setter; import org.jspecify.annotations.NullMarked; -import org.jspecify.annotations.Nullable; import java.util.HashMap; import java.util.Map; diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java index 0b474c8..dbe6ce5 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java @@ -3,7 +3,6 @@ import io.fabric8.kubernetes.api.model.SecretBuilder; import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql._support.testdata.base.TestDataCreator; -import it.aboutbits.postgresql._support.testdata.persisted.Given; import it.aboutbits.postgresql.core.SecretRef; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 3bb74cd..9908621 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -53,14 +53,11 @@ void createRole_withLogin_andStatusReady() { var spec = role.getSpec(); - spec.getClusterRef().setNamespace(kubernetesClient.getNamespace()); - // when: create Role referencing the ClusterConnection and expecting LOGIN (passwordSecretRef non-null) - role.getSpec().setPasswordSecretRef(clusterConnection.getSpec().getAdminSecretRef()); - role = applyRole(role); + spec.setPasswordSecretRef(clusterConnection.getSpec().getAdminSecretRef()); // then: wait for status and assert READY - var reconciled = waitForRoleStatus(role.getMetadata().getName()); + var reconciled = applyRole(role); var expectedStatus = new CRStatus() .setName(roleName) @@ -93,18 +90,17 @@ void createRole_withoutLogin_andStatusReady() { .withName("test-connection-role-nologin") .returnFirst(); - // when var now = OffsetDateTime.now(ZoneOffset.UTC); var roleName = "test-role-nologin"; + var role = buildRole( roleName, clusterConnection.getMetadata().getName(), /*login*/ false ); - role.getSpec().getClusterRef().setNamespace(kubernetesClient.getNamespace()); - role = applyRole(role); - var reconciled = waitForRoleStatus(role.getMetadata().getName()); + // when + var reconciled = applyRole(role); var expectedStatus = new CRStatus() .setName(roleName) @@ -142,10 +138,9 @@ void createRole_withMissingClusterConnection_setsPending() { /*login*/ true ); role.getSpec().getClusterRef().setNamespace(kubernetesClient.getNamespace()); - role = applyRole(role); // when - var reconciled = waitForRoleStatus(role.getMetadata().getName()); + var reconciled = applyRole(role); // then assertThat(reconciled).isNotNull(); @@ -197,21 +192,19 @@ void secretChange_triggersReconciliation() throws Exception { role.getSpec().setPasswordSecretRef(secretRef); // when: create Role - role = applyRole(role); - waitForRoleStatus(role.getMetadata().getName()); + var reconciled = applyRole(role); var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); // then: password should match the initial one // Wait for password to match because reconciliation might take a bit - var initialRole = role; await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) .until(() -> { try { return PostgreSQLAuthenticationUtil.passwordMatches( dsl, - initialRole.getSpec(), + reconciled.getSpec(), initialPassword ); } catch (Exception e) { @@ -231,7 +224,83 @@ void secretChange_triggersReconciliation() throws Exception { .serverSideApply(); // then: password should eventually match the new one - var updatedRole = role; + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> { + try { + return PostgreSQLAuthenticationUtil.passwordMatches( + dsl, + reconciled.getSpec(), + newPassword + ); + } catch (Exception e) { + return false; + } + }); + } + + @Test + @DisplayName( + "When a Role (LOGIN) changes its secret reference, it should trigger a re-reconciliation" + ) + void secretRefChange_triggersReconciliation() throws Exception { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-connection-role-secret-ref-change") + .returnFirst(); + + var roleName = "test-role-secret-ref-change"; + + var initialPassword = "initial-password"; + var newPassword = "new-password"; + + var initialSecretRef = given.one() + .secretRef() + .withPassword(initialPassword) + .returnFirst(); + + var newSecretRef = given.one() + .secretRef() + .withPassword(newPassword) + .returnFirst(); + + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ true + ); + + role.getSpec().setPasswordSecretRef(initialSecretRef); + + // when: create Role + var reconciled = applyRole(role); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + // then: password should match the initial one + var finalRole = reconciled; + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> { + try { + return PostgreSQLAuthenticationUtil.passwordMatches( + dsl, + finalRole.getSpec(), + initialPassword + ); + } catch (Exception e) { + return false; + } + }); + + // when: update secret reference in the Role + role.getSpec().setPasswordSecretRef(newSecretRef); + + reconciled = applyRole(role); + + // then: password should eventually match the new one + var updatedRole = reconciled; await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) .until(() -> { @@ -250,17 +319,14 @@ void secretChange_triggersReconciliation() throws Exception { private Role applyRole(Role role) { var namespace = kubernetesClient.getNamespace(); - return kubernetesClient.resources(Role.class) + kubernetesClient.resources(Role.class) .inNamespace(namespace) .resource(role) .serverSideApply(); - } - private Role waitForRoleStatus(String roleName) { - var namespace = kubernetesClient.getNamespace(); return kubernetesClient.resources(Role.class) .inNamespace(namespace) - .withName(roleName) + .withName(role.getName()) .waitUntilCondition( r -> r.getStatus() != null, 10, From b35354f4e5dd44765bab44d09126bf301e330017 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Wed, 7 Jan 2026 13:34:05 +0100 Subject: [PATCH 04/31] use a random PostgreSQL Dev Service port --- src/main/resources/application-test.yml | 3 --- .../_support/testdata/persisted/Given.java | 16 +++++++++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 2b9fd45..c16d3b8 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -1,7 +1,4 @@ quarkus: - datasource: - devservices: - port: 5433 operator-sdk: activate-leader-election-for-profiles: - test diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java index 35b3274..e48e0ef 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java @@ -9,6 +9,8 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jspecify.annotations.NullMarked; +import java.net.URI; + @NullMarked @ApplicationScoped @RequiredArgsConstructor @@ -21,12 +23,12 @@ public class Given { @ConfigProperty(name = "quarkus.datasource.devservices.password") String password; - @ConfigProperty(name = "quarkus.datasource.devservices.port") - Integer port; + @ConfigProperty(name = "quarkus.datasource.jdbc.url") + String jdbcUrl; DBConnectionDetails dbConnectionDetails() { return new DBConnectionDetails( - port, + parsePortFromJdbcUrl(jdbcUrl), username, password ); @@ -85,4 +87,12 @@ public record DBConnectionDetails( String password ) { } + + private int parsePortFromJdbcUrl(String url) { + // Typical format: jdbc:postgresql://localhost:5432/db + // We strip "jdbc:" so URI.create can handle the "postgresql://..." part + return URI.create( + url.replace("jdbc:", "") + ).getPort(); + } } From 69fcdfa9eff27a4610fd8a13b464593bfe68c25d Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Wed, 7 Jan 2026 15:39:32 +0100 Subject: [PATCH 05/31] test if the boolean role flags work --- .../postgresql/crd/role/RoleReconciler.java | 36 +++- .../postgresql/crd/role/RoleUtil.java | 4 +- src/main/resources/application-test.yml | 4 + .../crd/role/RoleReconcilerTest.java | 175 +++++++++++++----- 4 files changed, 168 insertions(+), 51 deletions(-) diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java index 085cc01..c8573f5 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -18,6 +18,7 @@ import it.aboutbits.postgresql.core.PostgreSQLAuthenticationUtil; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NonNull; import java.sql.SQLException; @@ -25,6 +26,7 @@ import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +@Slf4j @RequiredArgsConstructor public class RoleReconciler extends BaseReconciler @@ -40,6 +42,15 @@ public UpdateControl reconcile( var spec = resource.getSpec(); var status = initializeStatus(resource); + var name = resource.getMetadata().getName(); + var namespace = resource.getMetadata().getNamespace(); + + log.info("Reconciling Role [resource={}/{}, status.phase={}]", + namespace, + name, + status.getPhase() + ); + var clusterRef = spec.getClusterRef(); var expectedFlags = spec.getFlags(); @@ -74,7 +85,7 @@ public UpdateControl reconcile( password = KubernetesUtil.getSecretRefCredentials( kubernetesClient, passwordSecretRef, - resource.getMetadata().getNamespace() + namespace ).password(); } else { password = null; @@ -91,6 +102,11 @@ public UpdateControl reconcile( // Create the role if it doesn't exist yet if (!RoleUtil.roleExists(tx, spec)) { + log.info("Creating Role [resource={}/{}]", + namespace, + name + ); + RoleUtil.createRole( tx, spec, @@ -117,11 +133,21 @@ public UpdateControl reconcile( } if (roleLoginMatches && passwordMatches && flagsMatch) { + log.info("Role up-to-date [resource={}/{}]", + namespace, + name + ); + return UpdateControl.noUpdate(); } var changePassword = loginExpected && !passwordMatches; + log.info("Updating Role flags [resource={}/{}]", + namespace, + name + ); + RoleUtil.alterRole( tx, spec, @@ -129,6 +155,11 @@ public UpdateControl reconcile( password ); + log.info("Updating Role membership [resource={}/{}]", + namespace, + name + ); + RoleUtil.reconcileRoleMembership( tx, spec, @@ -136,7 +167,8 @@ public UpdateControl reconcile( currentFlags ); - status.setPhase(CRPhase.READY); + status.setPhase(CRPhase.READY) + .setMessage(null); return UpdateControl.patchStatus(resource); }); diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java index 4aba15b..f9e3b36 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java @@ -221,7 +221,7 @@ private static Query buildCreateRole( return query( "create role {0}{1}", - name(roleName), + role(roleName), optionsSql ); } @@ -288,7 +288,7 @@ private static Query buildAlterRole( return query( "alter role {0} with {1}", - name(roleName), + role(roleName), joinWithSpaces(options) ); } diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index c16d3b8..d37b73f 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -2,3 +2,7 @@ quarkus: operator-sdk: activate-leader-election-for-profiles: - test + log: + category: + "org.jooq": + level: DEBUG diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 9908621..e5d881e 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -13,15 +13,24 @@ import it.aboutbits.postgresql.core.SecretRef; import lombok.RequiredArgsConstructor; import org.jooq.DSLContext; +import org.jooq.Field; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import java.sql.SQLException; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.util.concurrent.TimeUnit; +import java.util.function.BiConsumer; +import java.util.function.Predicate; +import java.util.stream.Stream; import static it.aboutbits.postgresql.core.KubernetesUtil.SECRET_DATA_BASIC_AUTH_PASSWORD_KEY; +import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.within; import static org.awaitility.Awaitility.await; @@ -161,7 +170,7 @@ void createRole_withMissingClusterConnection_setsPending() { @DisplayName( "When a Role (LOGIN) references a secret and that secret changes, it should trigger a re-reconciliation" ) - void secretChange_triggersReconciliation() throws Exception { + void secretChange_triggersReconciliation() throws SQLException { // given var clusterConnection = given.one() .clusterConnection() @@ -200,17 +209,11 @@ void secretChange_triggersReconciliation() throws Exception { // Wait for password to match because reconciliation might take a bit await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) - .until(() -> { - try { - return PostgreSQLAuthenticationUtil.passwordMatches( - dsl, - reconciled.getSpec(), - initialPassword - ); - } catch (Exception e) { - return false; - } - }); + .until(() -> PostgreSQLAuthenticationUtil.passwordMatches( + dsl, + reconciled.getSpec(), + initialPassword + )); // when: update secret secret.getMetadata().setManagedFields(null); @@ -226,24 +229,18 @@ void secretChange_triggersReconciliation() throws Exception { // then: password should eventually match the new one await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) - .until(() -> { - try { - return PostgreSQLAuthenticationUtil.passwordMatches( - dsl, - reconciled.getSpec(), - newPassword - ); - } catch (Exception e) { - return false; - } - }); + .until(() -> PostgreSQLAuthenticationUtil.passwordMatches( + dsl, + reconciled.getSpec(), + newPassword + )); } @Test @DisplayName( "When a Role (LOGIN) changes its secret reference, it should trigger a re-reconciliation" ) - void secretRefChange_triggersReconciliation() throws Exception { + void secretRefChange_triggersReconciliation() throws SQLException { // given var clusterConnection = given.one() .clusterConnection() @@ -282,17 +279,11 @@ void secretRefChange_triggersReconciliation() throws Exception { var finalRole = reconciled; await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) - .until(() -> { - try { - return PostgreSQLAuthenticationUtil.passwordMatches( - dsl, - finalRole.getSpec(), - initialPassword - ); - } catch (Exception e) { - return false; - } - }); + .until(() -> PostgreSQLAuthenticationUtil.passwordMatches( + dsl, + finalRole.getSpec(), + initialPassword + )); // when: update secret reference in the Role role.getSpec().setPasswordSecretRef(newSecretRef); @@ -303,20 +294,110 @@ void secretRefChange_triggersReconciliation() throws Exception { var updatedRole = reconciled; await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) - .until(() -> { - try { - return PostgreSQLAuthenticationUtil.passwordMatches( - dsl, - updatedRole.getSpec(), - newPassword - ); - } catch (Exception e) { - return false; - } - }); + .until(() -> PostgreSQLAuthenticationUtil.passwordMatches( + dsl, + updatedRole.getSpec(), + newPassword + )); + } + + @ParameterizedTest + @MethodSource("provideBooleanFlags") + @DisplayName("When a boolean Role flag is toggled, it should be updated in the database") + void roleFlag_togglesCorrectly( + Field field, + BiConsumer setter + ) throws SQLException { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-role-flags") + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + var roleName = "test-role-" + field.getName(); + + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ false + ); + + // 1. Enable flag (true) + setter.accept( + role.getSpec().getFlags(), + true + ); + + // when + var reconciled = applyRole(role); + var initialGeneration = reconciled.getStatus().getObservedGeneration(); + + // then + assertThat( + getRoleFlagValue( + dsl, + roleName, + field + ) + ).isTrue(); + + // 2. Disable flag (false) + setter.accept( + role.getSpec().getFlags(), + false + ); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 1 + ); + + // then + assertThat( + getRoleFlagValue( + dsl, + roleName, + field + ) + ).isFalse(); + } + + private Boolean getRoleFlagValue( + DSLContext dsl, + String roleName, + Field field + ) { + return dsl.select(field) + .from(PG_AUTHID) + .where(PG_AUTHID.ROLNAME.eq(roleName)) + .fetchSingleInto(field.getType()); + } + + private static Stream provideBooleanFlags() { + return Stream.of( + Arguments.of(PG_AUTHID.ROLSUPER, (BiConsumer) RoleSpec.Flags::setSuperuser), + Arguments.of(PG_AUTHID.ROLCREATEDB, (BiConsumer) RoleSpec.Flags::setCreatedb), + Arguments.of(PG_AUTHID.ROLCREATEROLE, (BiConsumer) RoleSpec.Flags::setCreaterole), + Arguments.of(PG_AUTHID.ROLINHERIT, (BiConsumer) RoleSpec.Flags::setInherit), + Arguments.of(PG_AUTHID.ROLREPLICATION, (BiConsumer) RoleSpec.Flags::setReplication), + Arguments.of(PG_AUTHID.ROLBYPASSRLS, (BiConsumer) RoleSpec.Flags::setBypassrls) + ); } private Role applyRole(Role role) { + return applyRole( + role, + r -> r.getStatus() != null + ); + } + + private Role applyRole( + Role role, + Predicate condition + ) { var namespace = kubernetesClient.getNamespace(); kubernetesClient.resources(Role.class) @@ -328,7 +409,7 @@ private Role applyRole(Role role) { .inNamespace(namespace) .withName(role.getName()) .waitUntilCondition( - r -> r.getStatus() != null, + condition, 10, TimeUnit.SECONDS ); From 7e11c983811a19836909618dc813d610a58f77b1 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Wed, 7 Jan 2026 16:16:48 +0100 Subject: [PATCH 06/31] test the connection limit --- .../postgresql/crd/role/RoleUtil.java | 12 ++-- .../crd/role/RoleReconcilerTest.java | 64 ++++++++++++++++++- 2 files changed, 68 insertions(+), 8 deletions(-) diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java index f9e3b36..b1cbccc 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java @@ -201,7 +201,7 @@ private static Query buildCreateRole( options.add(joinWithComma( flags.getInRole() .stream() - .map(DSL::name) + .map(DSL::role) .toList() )); } @@ -210,7 +210,7 @@ private static Query buildCreateRole( options.add(joinWithComma( flags.getRole() .stream() - .map(DSL::name) + .map(DSL::role) .toList() )); } @@ -277,10 +277,10 @@ private static Query buildAlterRole( ? RoleFlags.BYPASSRLS.flag() : RoleFlags.NO_BYPASSRLS.flag() )); - if (flags.getConnectionLimit() >= 0) { - options.add(keyword(RoleFlags.CONNECTION_LIMIT.flag())); - options.add(val(flags.getConnectionLimit())); - } + + options.add(keyword(RoleFlags.CONNECTION_LIMIT.flag())); + options.add(val(flags.getConnectionLimit())); + if (flags.getValidUntil() != null) { options.add(keyword(RoleFlags.VALID_UNTIL.flag())); options.add(val(flags.getValidUntil())); diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index e5d881e..915a519 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -365,10 +365,70 @@ void roleFlag_togglesCorrectly( ).isFalse(); } - private Boolean getRoleFlagValue( + @Test + @DisplayName("When the CONNECTION LIMIT is changed, it should be updated in the database") + void connectionLimit_updatesCorrectly() throws SQLException { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-role-conn-limit") + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + var roleName = "test-role-conn-limit"; + + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ false + ); + + // 1. Set a connection limit + role.getSpec().getFlags().setConnectionLimit(10); + + // when + var reconciled = applyRole(role); + var initialGeneration = reconciled.getStatus().getObservedGeneration(); + + // then + assertThat( + getRoleFlagValue(dsl, roleName, PG_AUTHID.ROLCONNLIMIT) + ).isEqualTo(10); + + // 2. Change connection limit + role.getSpec().getFlags().setConnectionLimit(20); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 1 + ); + + // then + assertThat( + getRoleFlagValue(dsl, roleName, PG_AUTHID.ROLCONNLIMIT) + ).isEqualTo(20); + + // 3. Reset connection limit to -1 + role.getSpec().getFlags().setConnectionLimit(-1); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 2 + ); + + // then + assertThat( + getRoleFlagValue(dsl, roleName, PG_AUTHID.ROLCONNLIMIT) + ).isEqualTo(-1); + } + + private T getRoleFlagValue( DSLContext dsl, String roleName, - Field field + Field field ) { return dsl.select(field) .from(PG_AUTHID) From 2020658babca377a9f407e762cea9cc7fe014408 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Wed, 7 Jan 2026 16:42:39 +0100 Subject: [PATCH 07/31] test the VALID UNTIL flag --- .../postgresql/crd/role/RoleReconciler.java | 15 ++-- .../postgresql/crd/role/RoleUtil.java | 13 ++-- .../crd/role/RoleReconcilerTest.java | 73 ++++++++++++++++++- 3 files changed, 90 insertions(+), 11 deletions(-) diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java index c8573f5..7a1ce84 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -45,7 +45,8 @@ public UpdateControl reconcile( var name = resource.getMetadata().getName(); var namespace = resource.getMetadata().getNamespace(); - log.info("Reconciling Role [resource={}/{}, status.phase={}]", + log.info( + "Reconciling Role [resource={}/{}, status.phase={}]", namespace, name, status.getPhase() @@ -102,7 +103,8 @@ public UpdateControl reconcile( // Create the role if it doesn't exist yet if (!RoleUtil.roleExists(tx, spec)) { - log.info("Creating Role [resource={}/{}]", + log.info( + "Creating Role [resource={}/{}]", namespace, name ); @@ -133,7 +135,8 @@ public UpdateControl reconcile( } if (roleLoginMatches && passwordMatches && flagsMatch) { - log.info("Role up-to-date [resource={}/{}]", + log.info( + "Role up-to-date [resource={}/{}]", namespace, name ); @@ -143,7 +146,8 @@ public UpdateControl reconcile( var changePassword = loginExpected && !passwordMatches; - log.info("Updating Role flags [resource={}/{}]", + log.info( + "Updating Role flags [resource={}/{}]", namespace, name ); @@ -155,7 +159,8 @@ public UpdateControl reconcile( password ); - log.info("Updating Role membership [resource={}/{}]", + log.info( + "Updating Role membership [resource={}/{}]", namespace, name ); diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java index b1cbccc..60500f2 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java @@ -18,6 +18,7 @@ import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTH_MEMBERS; +import static org.jooq.impl.DSL.field; import static org.jooq.impl.DSL.keyword; import static org.jooq.impl.DSL.multiset; import static org.jooq.impl.DSL.name; @@ -127,7 +128,7 @@ public static RoleSpec.Flags fetchCurrentFlags( PG_AUTHID.ROLREPLICATION.as("replication"), PG_AUTHID.ROLBYPASSRLS.as("bypassrls"), PG_AUTHID.ROLCONNLIMIT.as("connectionLimit"), - PG_AUTHID.ROLVALIDUNTIL.as("validUntil"), + field("nullif({0}, 'infinity')", PG_AUTHID.ROLVALIDUNTIL.getDataType(), PG_AUTHID.ROLVALIDUNTIL).as("validUntil"), multiset( select(parent.ROLNAME) .from(PG_AUTH_MEMBERS) @@ -194,7 +195,7 @@ private static Query buildCreateRole( } if (flags.getValidUntil() != null) { options.add(keyword(RoleFlags.VALID_UNTIL.flag())); - options.add(val(flags.getValidUntil())); + options.add(val(flags.getValidUntil().toString())); } if (!flags.getInRole().isEmpty()) { options.add(keyword(RoleFlags.IN_ROLE.flag())); @@ -281,9 +282,11 @@ private static Query buildAlterRole( options.add(keyword(RoleFlags.CONNECTION_LIMIT.flag())); options.add(val(flags.getConnectionLimit())); + options.add(keyword(RoleFlags.VALID_UNTIL.flag())); if (flags.getValidUntil() != null) { - options.add(keyword(RoleFlags.VALID_UNTIL.flag())); - options.add(val(flags.getValidUntil())); + options.add(val(flags.getValidUntil().toString())); + } else { + options.add(val("infinity")); } return query( @@ -386,7 +389,7 @@ private static Query buildCommentOnRole( )) .from(PG_AUTHID) .where(PG_AUTHID.ROLNAME.eq(roleName)) - .fetchOneInto(String.class); + .fetchSingleInto(String.class); } private static @Nullable String normalizeComment(@Nullable String comment) { diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 915a519..9661676 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -425,6 +425,77 @@ void connectionLimit_updatesCorrectly() throws SQLException { ).isEqualTo(-1); } + @Test + @DisplayName("When the VALID UNTIL is changed, it should be updated in the database") + void validUntil_updatesCorrectly() throws SQLException { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-role-valid-until") + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + var roleName = "test-role-valid-until"; + + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ false + ); + + var expiry = OffsetDateTime.now(ZoneOffset.UTC) + .plusDays(1) + .truncatedTo(ChronoUnit.SECONDS); + + // 1. Set a valid until date + role.getSpec().getFlags().setValidUntil(expiry); + + // when + var reconciled = applyRole(role); + var initialGeneration = reconciled.getStatus().getObservedGeneration(); + + var currentFlags = RoleUtil.fetchCurrentFlags(dsl, role.getSpec()); + + // then + assertThat( + currentFlags.getValidUntil() + ).isEqualTo(expiry); + + // 2. Change valid until date + var newExpiry = expiry.plusDays(1); + role.getSpec().getFlags().setValidUntil(newExpiry); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 1 + ); + + currentFlags = RoleUtil.fetchCurrentFlags(dsl, role.getSpec()); + + // then + assertThat( + currentFlags.getValidUntil() + ).isEqualTo(newExpiry); + + // 3. Reset valid until to null (infinity) + role.getSpec().getFlags().setValidUntil(null); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 2 + ); + + currentFlags = RoleUtil.fetchCurrentFlags(dsl, role.getSpec()); + + // then + assertThat( + currentFlags.getValidUntil() + ).isNull(); + } + private T getRoleFlagValue( DSLContext dsl, String roleName, @@ -433,7 +504,7 @@ private T getRoleFlagValue( return dsl.select(field) .from(PG_AUTHID) .where(PG_AUTHID.ROLNAME.eq(roleName)) - .fetchSingleInto(field.getType()); + .fetchSingle(field); } private static Stream provideBooleanFlags() { From 92ab045edbdff67e576de4f4a041efd1b3969b41 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 09:43:42 +0100 Subject: [PATCH 08/31] test the ClusterConnectionReconciler error cases --- build.gradle.kts | 8 ++ .../postgresql/core/BaseReconciler.java | 15 +-- .../postgresql/crd/role/RoleUtil.java | 4 +- .../ClusterConnectionReconcilerErrorTest.java | 107 ++++++++++++++++++ 4 files changed, 119 insertions(+), 15 deletions(-) create mode 100644 src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java diff --git a/build.gradle.kts b/build.gradle.kts index c22a6d3..e69cf33 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -87,6 +87,7 @@ dependencies { * Testing */ testImplementation("io.quarkus:quarkus-junit5") + testImplementation("io.quarkus:quarkus-junit5-mockito") testImplementation("org.awaitility:awaitility") testImplementation(libs.assertj) testImplementation(libs.datafaker) @@ -128,6 +129,13 @@ tasks.withType { systemProperty("java.util.logging.manager", "org.jboss.logmanager.LogManager") jvmArgs("--add-opens", "java.base/java.lang=ALL-UNNAMED") + val mockitoAgent = configurations.testRuntimeClasspath.get().find { + it.name.contains("mockito-core") + } + if (mockitoAgent != null) { + jvmArgs("-javaagent:${mockitoAgent.absolutePath}") + } + testLogging { exceptionFormat = TestExceptionFormat.FULL diff --git a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java index 09a6876..c759ca8 100644 --- a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java @@ -93,19 +93,8 @@ public UpdateControl handleError( S status, E exception ) { - return handleError( - resource, - status, - exception.getMessage() - ); - } - - public UpdateControl handleError( - CR resource, - S status, - @Nullable String message - ) { - status.setPhase(CRPhase.ERROR).setMessage(message); + status.setPhase(CRPhase.ERROR) + .setMessage(exception.getMessage()); return UpdateControl.patchStatus(resource) .rescheduleAfter(Duration.ofSeconds(30)); diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java index 60500f2..5c12ec6 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java @@ -349,14 +349,14 @@ public static void reconcileRoleMembership( } } - public static Query buildGrantRoleToMember( + private static Query buildGrantRoleToMember( String role, String member ) { return query("grant {0} to {1}", role(role), role(member)); } - public static Query buildRevokeRoleFromMember( + private static Query buildRevokeRoleFromMember( String role, String member ) { diff --git a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java new file mode 100644 index 0000000..c88f65e --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java @@ -0,0 +1,107 @@ +package it.aboutbits.postgresql.crd.connection; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.quarkus.test.InjectMock; +import io.quarkus.test.junit.QuarkusTest; +import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import jakarta.inject.Inject; +import org.jooq.DSLContext; +import org.jooq.exception.DataAccessException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.sql.SQLException; +import java.util.Collections; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@QuarkusTest +class ClusterConnectionReconcilerErrorTest { + @InjectMock + PostgreSQLContextFactory contextFactory; + + @Inject + ClusterConnectionReconciler reconciler; + + private ClusterConnection resource; + private Context context; + + @BeforeEach + void setUp() { + resource = new ClusterConnection(); + + var metadata = new ObjectMeta(); + metadata.setGeneration(1L); + + // We mock the spec to ensure getName() works safely without throwing NPE, + // as the custom getName() implementation in ClusterConnection relies on spec fields. + var spec = mock(ClusterConnectionSpec.class); + + when(spec.getHost()).thenReturn("localhost"); + when(spec.getPort()).thenReturn(5432); + when(spec.getMaintenanceDatabase()).thenReturn("postgres"); + when(spec.getParameters()).thenReturn(Collections.emptyMap()); + + resource.setSpec(spec); + resource.setMetadata(metadata); + + //noinspection unchecked + context = mock(Context.class); + } + + @Test + @DisplayName("Should handle SQLException during DSL context creation") + void reconcile_whenDslCreationFails_shouldReturnErrorStatus() throws SQLException { + // given + var errorMessage = "Connection refused to database"; + + when(contextFactory.getDSLContext(resource)).thenThrow( + new SQLException(errorMessage) + ); + + // when + var updateControl = reconciler.reconcile(resource, context); + + // then + assertThat(updateControl.getResource()) + .isPresent() + .get() + .extracting(ClusterConnection::getStatus) + .satisfies(status -> { + assertThat(status.getMessage()).contains(errorMessage); + }); + } + + @Test + @DisplayName("Should handle DataAccessException during version check") + void reconcile_whenVersionQueryFails_shouldReturnErrorStatus() throws SQLException { + // given + var errorMessage = "Query execution failed"; + var dslContext = mock(DSLContext.class); + + when(contextFactory.getDSLContext(resource)).thenReturn( + dslContext + ); + + when(dslContext.fetchSingle(anyString())).thenThrow( + new DataAccessException(errorMessage) + ); + + // when + var updateControl = reconciler.reconcile(resource, context); + + // then + assertThat(updateControl.getResource()) + .isPresent() + .get() + .extracting(ClusterConnection::getStatus) + .satisfies(status -> { + assertThat(status.getMessage()).contains(errorMessage); + }); + } +} From cde4784a076178f2190b71e367a32b15da9ded5d Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 10:54:29 +0100 Subject: [PATCH 09/31] add a test for the login passwordSecretRef updates --- .../crd/role/RoleReconcilerTest.java | 85 ++++++++++++++++--- 1 file changed, 71 insertions(+), 14 deletions(-) diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 9661676..5c5f3ae 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -45,7 +45,7 @@ class RoleReconcilerTest { @Test @DisplayName("When a Role (LOGIN) is created, it should be reconciled to READY and present in pg_authid") - void createRole_withLogin_andStatusReady() { + void createRole_withLogin_andStatusReady() throws SQLException { // given var clusterConnection = given.one() .clusterConnection() @@ -79,12 +79,7 @@ void createRole_withLogin_andStatusReady() { now ); - DSLContext dsl; - try { - dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); - } catch (Exception e) { - throw new AssertionError("Failed to obtain DSLContext", e); - } + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); assertThat(RoleUtil.roleExists(dsl, reconciled.getSpec())).isTrue(); assertThat(RoleUtil.roleLoginMatches(dsl, reconciled.getSpec())).isTrue(); @@ -92,7 +87,7 @@ void createRole_withLogin_andStatusReady() { @Test @DisplayName("When a Role (NOLOGIN) is created, it should be reconciled to READY and present with NOLOGIN") - void createRole_withoutLogin_andStatusReady() { + void createRole_withoutLogin_andStatusReady() throws SQLException { // given var clusterConnection = given.one() .clusterConnection() @@ -122,17 +117,79 @@ void createRole_withoutLogin_andStatusReady() { now ); - DSLContext dsl; - try { - dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); - } catch (Exception e) { - throw new AssertionError("Failed to obtain DSLContext", e); - } + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); assertThat(RoleUtil.roleExists(dsl, reconciled.getSpec())).isTrue(); assertThat(RoleUtil.roleLoginMatches(dsl, reconciled.getSpec())).isTrue(); } + @Test + @DisplayName("When a Role login state is changed, it should be updated correctly in pg_authid") + void toggleRoleLogin_updatesCorrectly() throws SQLException { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-connection-role-toggle-login") + .returnFirst(); + + var now = OffsetDateTime.now(ZoneOffset.UTC); + var roleName = "test-role-toggle-login"; + + // 1. Create a Role without a login (no passwordSecretRef) + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ false + ); + + // when + var reconciled = applyRole(role); + + // then + assertThatRoleHasExpectedStatus( + reconciled, + new CRStatus() + .setName(roleName) + .setPhase(CRPhase.READY) + .setObservedGeneration(1L), + now + ); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + assertThat( + RoleUtil.roleExists(dsl, reconciled.getSpec()) + ).isTrue(); + + assertThat( + getRoleFlagValue(dsl, roleName, PG_AUTHID.ROLCANLOGIN) + ).isFalse(); + + // 2. Add a passwordSecretRef to make it a login role + role.getSpec().setPasswordSecretRef(clusterConnection.getSpec().getAdminSecretRef()); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == 2L + ); + + // then + assertThat(getRoleFlagValue(dsl, roleName, PG_AUTHID.ROLCANLOGIN)).isTrue(); + + // 3. Remove passwordSecretRef again + role.getSpec().setPasswordSecretRef(null); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == 3L + ); + + // then + assertThat(getRoleFlagValue(dsl, roleName, PG_AUTHID.ROLCANLOGIN)).isFalse(); + } + @Test @DisplayName("When a Role references a missing ClusterConnection, status should be PENDING with a helpful message") void createRole_withMissingClusterConnection_setsPending() { From c55c6b54ac79130011a46c15616d95d0d4bd35d2 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 10:59:08 +0100 Subject: [PATCH 10/31] add .gitattributes file to not render generated files in PR reviews by default (will also be excluded from stats and diffs) --- .gitattributes | 1 + 1 file changed, 1 insertion(+) create mode 100644 .gitattributes diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ec83a67 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +src/generated/** linguist-generated From 8cbbe0b9a6fed79b18aab291f996abcb4edfe95b Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 11:18:53 +0100 Subject: [PATCH 11/31] add tests for flags ROLE and IN_ROLE --- .../crd/role/RoleReconcilerTest.java | 190 ++++++++++++++++++ 1 file changed, 190 insertions(+) diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 5c5f3ae..958ade3 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -24,6 +24,7 @@ import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; +import java.util.List; import java.util.concurrent.TimeUnit; import java.util.function.BiConsumer; import java.util.function.Predicate; @@ -34,6 +35,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.within; import static org.awaitility.Awaitility.await; +import static org.jooq.impl.DSL.role; @QuarkusTest @RequiredArgsConstructor @@ -553,6 +555,194 @@ void validUntil_updatesCorrectly() throws SQLException { ).isNull(); } + @Test + @DisplayName("When IN ROLE membership is changed, it should be updated in the database") + void inRole_updatesCorrectly() throws SQLException { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-role-in-role") + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + var parentRole1 = "parent_role_1"; + var parentRole2 = "parent_role_2"; + + dsl.execute("create role {0}", role(parentRole1)); + dsl.execute("create role {0}", role(parentRole2)); + + var roleName = "test-role-in-role"; + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ false + ); + + var spec = role.getSpec(); + + // 1. Add a parent role + spec.getFlags().setInRole( + List.of(parentRole1) + ); + + // when + var reconciled = applyRole(role); + var initialGeneration = reconciled.getStatus().getObservedGeneration(); + + // then + assertThat( + RoleUtil.fetchCurrentFlags(dsl, spec).getInRole() + ).containsExactly(parentRole1); + + // 2. Add another parent role and remove the first one + spec.getFlags().setInRole( + List.of(parentRole2) + ); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 1 + ); + + // then + assertThat( + RoleUtil.fetchCurrentFlags(dsl, spec).getInRole() + ).containsExactly(parentRole2); + + // 3. Remove all parent roles + spec.getFlags().setInRole( + List.of() + ); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 2 + ); + + // then + assertThat( + RoleUtil.fetchCurrentFlags(dsl, spec).getInRole() + ).isEmpty(); + } + + @Test + @DisplayName("When ROLE membership is changed, it should be updated in the database") + void role_updatesCorrectly() throws SQLException { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-role-role") + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + var memberRole1 = "member_role_1"; + var memberRole2 = "member_role_2"; + + dsl.execute("create role {0}", role(memberRole1)); + dsl.execute("create role {0}", role(memberRole2)); + + var roleName = "test-role-role"; + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ false + ); + + var spec = role.getSpec(); + + // 1. Add a member role + spec.getFlags().setRole( + List.of(memberRole1) + ); + + // when + var reconciled = applyRole(role); + var initialGeneration = reconciled.getStatus().getObservedGeneration(); + + // then + assertThat( + RoleUtil.fetchCurrentFlags(dsl, spec).getRole() + ).containsExactly(memberRole1); + + // 2. Add another member role and remove the first one + spec.getFlags().setRole( + List.of(memberRole2) + ); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 1 + ); + + // then + assertThat( + RoleUtil.fetchCurrentFlags(dsl, spec).getRole() + ).containsExactly(memberRole2); + + // 3. Remove all member roles + spec.getFlags().setRole( + List.of() + ); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 2 + ); + + // then + assertThat( + RoleUtil.fetchCurrentFlags(dsl, spec).getRole() + ).isEmpty(); + } + + @Test + @DisplayName("When multiple ROLE memberships are added, they should be sorted and updated correctly") + void role_multipleMemberships_updatesCorrectly() throws SQLException { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-role-multiple") + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + var roleA = "role_a"; + var roleB = "role_b"; + var roleC = "role_c"; + + dsl.execute("create role {0}", role(roleA)); + dsl.execute("create role {0}", role(roleB)); + dsl.execute("create role {0}", role(roleC)); + + var roleName = "test-role-multiple"; + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ false + ); + + var spec = role.getSpec(); + + // Add multiple roles out of order + spec.getFlags().setInRole( + List.of(roleC, roleA, roleB) + ); + + // when + applyRole(role); + + // then + assertThat( + RoleUtil.fetchCurrentFlags(dsl, spec).getInRole() + ).containsExactly(roleA, roleB, roleC); + } + private T getRoleFlagValue( DSLContext dsl, String roleName, From fe5cd98ff30d6840bea8503dc6140460a89b3984 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 11:52:21 +0100 Subject: [PATCH 12/31] add tests for the Role comment --- .../postgresql/crd/role/RoleReconciler.java | 59 ++++++---- .../postgresql/crd/role/RoleSpec.java | 4 +- .../postgresql/crd/role/RoleUtil.java | 54 ++++++--- .../creator/ClusterConnectionCreate.java | 1 - .../crd/role/RoleReconcilerTest.java | 107 +++++++++++++++--- 5 files changed, 169 insertions(+), 56 deletions(-) diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java index 7a1ce84..1334f3e 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -93,6 +93,7 @@ public UpdateControl reconcile( } UpdateControl updateControl; + try { // Run everything in a single transaction updateControl = contextFactory.getDSLContext( @@ -101,7 +102,7 @@ public UpdateControl reconcile( // Get the transactional DSL context var tx = cfg.dsl(); - // Create the role if it doesn't exist yet + // Create and return the role if it doesn't exist yet if (!RoleUtil.roleExists(tx, spec)) { log.info( "Creating Role [resource={}/{}]", @@ -115,7 +116,8 @@ public UpdateControl reconcile( password ); - status.setPhase(CRPhase.READY); + status.setPhase(CRPhase.READY) + .setMessage(null); return UpdateControl.patchStatus(resource); } @@ -125,6 +127,7 @@ public UpdateControl reconcile( var roleLoginMatches = RoleUtil.roleLoginMatches(tx, spec); var currentFlags = RoleUtil.fetchCurrentFlags(tx, spec); var flagsMatch = expectedFlags.equals(currentFlags); + var commentMatches = RoleUtil.roleCommentMatches(tx, spec); if (loginExpected) { passwordMatches = PostgreSQLAuthenticationUtil.passwordMatches( @@ -134,7 +137,7 @@ public UpdateControl reconcile( ); } - if (roleLoginMatches && passwordMatches && flagsMatch) { + if (roleLoginMatches && passwordMatches && flagsMatch && commentMatches) { log.info( "Role up-to-date [resource={}/{}]", namespace, @@ -147,30 +150,41 @@ public UpdateControl reconcile( var changePassword = loginExpected && !passwordMatches; log.info( - "Updating Role flags [resource={}/{}]", + "Updating Role [resource={}/{}]", namespace, name ); - RoleUtil.alterRole( - tx, - spec, - changePassword, - password - ); + if (!roleLoginMatches || !passwordMatches || !flagsMatch) { + RoleUtil.alterRole( + tx, + spec, + changePassword, + password + ); + } - log.info( - "Updating Role membership [resource={}/{}]", - namespace, - name - ); + if (!flagsMatch) { + log.info( + "Updating Role membership [resource={}/{}]", + namespace, + name + ); - RoleUtil.reconcileRoleMembership( - tx, - spec, - expectedFlags, - currentFlags - ); + RoleUtil.reconcileRoleMembership( + tx, + spec, + expectedFlags, + currentFlags + ); + } + + if (!commentMatches) { + RoleUtil.updateComment( + tx, + spec + ); + } status.setPhase(CRPhase.READY) .setMessage(null); @@ -188,6 +202,9 @@ public UpdateControl reconcile( return updateControl; } + /** + * Watches for {@code Secret} changes to trigger reconciliation for dependent {@code Role} resources. + */ @Override public List> prepareEventSources(EventSourceContext context) { // 1. Define the Mapper diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java index 533db52..1693b96 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java @@ -20,7 +20,9 @@ public class RoleSpec { @Required private String name = ""; - private String comment = ""; + @Nullable + @io.fabric8.generator.annotation.Nullable + private String comment; @Required private ClusterReference clusterRef = new ClusterReference(); diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java index 5c12ec6..298016c 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java @@ -60,7 +60,7 @@ public static void createRole( ); // Optional comment - if (!comment.isBlank()) { + if (comment != null && !comment.isBlank()) { tx.execute( buildCommentOnRole(roleName, comment) ); @@ -75,7 +75,6 @@ public static void alterRole( ) { var roleName = spec.getName(); var flags = spec.getFlags(); - var expectedComment = normalizeComment(spec.getComment()); tx.execute( buildAlterRole( @@ -85,6 +84,14 @@ public static void alterRole( password ) ); + } + + public static void updateComment( + DSLContext tx, + RoleSpec spec + ) { + var roleName = spec.getName(); + var expectedComment = normalizeComment(spec.getComment()); var currentComment = normalizeComment( fetchCurrentRoleComment(tx, roleName) @@ -97,6 +104,34 @@ public static void alterRole( } } + public static boolean roleCommentMatches( + DSLContext tx, + RoleSpec spec + ) { + var expectedComment = spec.getComment(); + + var currentComment = normalizeComment( + fetchCurrentRoleComment(tx, spec.getName()) + ); + + return Objects.equals(currentComment, expectedComment); + } + + public static @Nullable String fetchCurrentRoleComment( + DSLContext tx, + String roleName + ) { + + return tx + .select(Routines.shobjDescription( + PG_AUTHID.OID, + val(PG_AUTHID.getUnqualifiedName().last()) + )) + .from(PG_AUTHID) + .where(PG_AUTHID.ROLNAME.eq(roleName)) + .fetchOneInto(String.class); + } + public static boolean roleLoginMatches( DSLContext tx, RoleSpec spec @@ -377,21 +412,6 @@ private static Query buildCommentOnRole( ); } - private static @Nullable String fetchCurrentRoleComment( - DSLContext tx, - String roleName - ) { - - return tx - .select(Routines.shobjDescription( - PG_AUTHID.OID, - val(PG_AUTHID.getUnqualifiedName().last()) - )) - .from(PG_AUTHID) - .where(PG_AUTHID.ROLNAME.eq(roleName)) - .fetchSingleInto(String.class); - } - private static @Nullable String normalizeComment(@Nullable String comment) { if (comment == null || comment.isBlank()) { return null; diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java index 9a33a33..dcbfe31 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -8,7 +8,6 @@ import it.aboutbits.postgresql.crd.connection.ClusterConnection; import it.aboutbits.postgresql.crd.connection.ClusterConnectionSpec; import lombok.AccessLevel; -import lombok.Builder; import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 958ade3..9f1a233 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -144,6 +144,8 @@ void toggleRoleLogin_updatesCorrectly() throws SQLException { /*login*/ false ); + var spec = role.getSpec(); + // when var reconciled = applyRole(role); @@ -168,7 +170,7 @@ void toggleRoleLogin_updatesCorrectly() throws SQLException { ).isFalse(); // 2. Add a passwordSecretRef to make it a login role - role.getSpec().setPasswordSecretRef(clusterConnection.getSpec().getAdminSecretRef()); + spec.setPasswordSecretRef(clusterConnection.getSpec().getAdminSecretRef()); // when applyRole( @@ -180,7 +182,7 @@ void toggleRoleLogin_updatesCorrectly() throws SQLException { assertThat(getRoleFlagValue(dsl, roleName, PG_AUTHID.ROLCANLOGIN)).isTrue(); // 3. Remove passwordSecretRef again - role.getSpec().setPasswordSecretRef(null); + spec.setPasswordSecretRef(null); // when applyRole( @@ -205,6 +207,7 @@ void createRole_withMissingClusterConnection_setsPending() { missingClusterName, /*login*/ true ); + role.getSpec().getClusterRef().setNamespace(kubernetesClient.getNamespace()); // when @@ -327,7 +330,9 @@ void secretRefChange_triggersReconciliation() throws SQLException { /*login*/ true ); - role.getSpec().setPasswordSecretRef(initialSecretRef); + var spec = role.getSpec(); + + spec.setPasswordSecretRef(initialSecretRef); // when: create Role var reconciled = applyRole(role); @@ -345,7 +350,7 @@ void secretRefChange_triggersReconciliation() throws SQLException { )); // when: update secret reference in the Role - role.getSpec().setPasswordSecretRef(newSecretRef); + spec.setPasswordSecretRef(newSecretRef); reconciled = applyRole(role); @@ -360,6 +365,70 @@ void secretRefChange_triggersReconciliation() throws SQLException { )); } + @Test + @DisplayName("When the comment is changed, it should be updated in the database") + void comment_updatesCorrectly() throws SQLException { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-role-comment") + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + var roleName = "test-role-comment"; + + var role = buildRole( + roleName, + clusterConnection.getMetadata().getName(), + /*login*/ false + ); + + var spec = role.getSpec(); + + // 1. Set a comment + var comment = "This is a test comment"; + spec.setComment(comment); + + // when + var reconciled = applyRole(role); + var initialGeneration = reconciled.getStatus().getObservedGeneration(); + + // then + assertThat( + RoleUtil.fetchCurrentRoleComment(dsl, roleName) + ).isEqualTo(comment); + + // 2. Change comment + var newComment = "Updated comment"; + spec.setComment(newComment); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 1 + ); + + // then + assertThat( + RoleUtil.fetchCurrentRoleComment(dsl, roleName) + ).isEqualTo(newComment); + + // 3. Remove comment + spec.setComment(null); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 2 + ); + + // then + assertThat( + RoleUtil.fetchCurrentRoleComment(dsl, roleName) + ).isNull(); + } + @ParameterizedTest @MethodSource("provideBooleanFlags") @DisplayName("When a boolean Role flag is toggled, it should be updated in the database") @@ -383,9 +452,11 @@ void roleFlag_togglesCorrectly( /*login*/ false ); + var spec = role.getSpec(); + // 1. Enable flag (true) setter.accept( - role.getSpec().getFlags(), + spec.getFlags(), true ); @@ -404,7 +475,7 @@ void roleFlag_togglesCorrectly( // 2. Disable flag (false) setter.accept( - role.getSpec().getFlags(), + spec.getFlags(), false ); @@ -443,8 +514,10 @@ void connectionLimit_updatesCorrectly() throws SQLException { /*login*/ false ); + var spec = role.getSpec(); + // 1. Set a connection limit - role.getSpec().getFlags().setConnectionLimit(10); + spec.getFlags().setConnectionLimit(10); // when var reconciled = applyRole(role); @@ -456,7 +529,7 @@ void connectionLimit_updatesCorrectly() throws SQLException { ).isEqualTo(10); // 2. Change connection limit - role.getSpec().getFlags().setConnectionLimit(20); + spec.getFlags().setConnectionLimit(20); // when applyRole( @@ -470,7 +543,7 @@ void connectionLimit_updatesCorrectly() throws SQLException { ).isEqualTo(20); // 3. Reset connection limit to -1 - role.getSpec().getFlags().setConnectionLimit(-1); + spec.getFlags().setConnectionLimit(-1); // when applyRole( @@ -503,18 +576,20 @@ void validUntil_updatesCorrectly() throws SQLException { /*login*/ false ); + var spec = role.getSpec(); + var expiry = OffsetDateTime.now(ZoneOffset.UTC) .plusDays(1) .truncatedTo(ChronoUnit.SECONDS); // 1. Set a valid until date - role.getSpec().getFlags().setValidUntil(expiry); + spec.getFlags().setValidUntil(expiry); // when var reconciled = applyRole(role); var initialGeneration = reconciled.getStatus().getObservedGeneration(); - var currentFlags = RoleUtil.fetchCurrentFlags(dsl, role.getSpec()); + var currentFlags = RoleUtil.fetchCurrentFlags(dsl, spec); // then assertThat( @@ -523,7 +598,7 @@ void validUntil_updatesCorrectly() throws SQLException { // 2. Change valid until date var newExpiry = expiry.plusDays(1); - role.getSpec().getFlags().setValidUntil(newExpiry); + spec.getFlags().setValidUntil(newExpiry); // when applyRole( @@ -531,7 +606,7 @@ void validUntil_updatesCorrectly() throws SQLException { r -> r.getStatus().getObservedGeneration() == initialGeneration + 1 ); - currentFlags = RoleUtil.fetchCurrentFlags(dsl, role.getSpec()); + currentFlags = RoleUtil.fetchCurrentFlags(dsl, spec); // then assertThat( @@ -539,7 +614,7 @@ void validUntil_updatesCorrectly() throws SQLException { ).isEqualTo(newExpiry); // 3. Reset valid until to null (infinity) - role.getSpec().getFlags().setValidUntil(null); + spec.getFlags().setValidUntil(null); // when applyRole( @@ -547,7 +622,7 @@ void validUntil_updatesCorrectly() throws SQLException { r -> r.getStatus().getObservedGeneration() == initialGeneration + 2 ); - currentFlags = RoleUtil.fetchCurrentFlags(dsl, role.getSpec()); + currentFlags = RoleUtil.fetchCurrentFlags(dsl, spec); // then assertThat( @@ -700,7 +775,7 @@ void role_updatesCorrectly() throws SQLException { RoleUtil.fetchCurrentFlags(dsl, spec).getRole() ).isEmpty(); } - + @Test @DisplayName("When multiple ROLE memberships are added, they should be sorted and updated correctly") void role_multipleMemberships_updatesCorrectly() throws SQLException { From 3db49001818d03f0a1c9f849990855309ab3a3bc Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 12:24:04 +0100 Subject: [PATCH 13/31] cleanup and use a CloseableDSLContext --- .../postgresql/core/KubernetesUtil.java | 5 +- .../core/PostgreSQLAuthenticationUtil.java | 6 +- .../core/PostgreSQLContextFactory.java | 14 +- .../ClusterConnectionReconciler.java | 21 +- .../postgresql/crd/role/RoleReconciler.java | 216 ++++++++++-------- .../postgresql/crd/role/RoleUtil.java | 6 +- .../ClusterConnectionReconcilerErrorTest.java | 11 +- .../crd/role/RoleReconcilerTest.java | 24 +- 8 files changed, 148 insertions(+), 155 deletions(-) diff --git a/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java b/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java index a2f9f45..be1bc12 100644 --- a/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java +++ b/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java @@ -2,13 +2,11 @@ import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql.crd.connection.ClusterConnection; -import jakarta.enterprise.context.RequestScoped; import org.jspecify.annotations.NullMarked; import java.util.Base64; @NullMarked -@RequestScoped public final class KubernetesUtil { public static final String SECRET_TYPE_BASIC_AUTH = "kubernetes.io/basic-auth"; public static final String SECRET_DATA_BASIC_AUTH_USERNAME_KEY = "username"; @@ -88,4 +86,7 @@ public static Credentials getSecretRefCredentials( password ); } + + private KubernetesUtil() { + } } diff --git a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java index 46685e5..f14c8af 100644 --- a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java @@ -2,8 +2,6 @@ import com.ongres.scram.common.StringPreparation; import it.aboutbits.postgresql.crd.role.RoleSpec; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; import org.jooq.DSLContext; import org.jspecify.annotations.NullMarked; @@ -22,7 +20,6 @@ import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; @NullMarked -@NoArgsConstructor(access = AccessLevel.PRIVATE) public final class PostgreSQLAuthenticationUtil { private static final String MD5 = "MD5"; private static final String SHA_256 = "SHA-256"; @@ -212,4 +209,7 @@ private static byte[] sha256(byte[] data) { throw new IllegalStateException("%s not available".formatted(SHA_256), e); } } + + private PostgreSQLAuthenticationUtil() { + } } diff --git a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java index c2d9669..e059cb5 100644 --- a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java @@ -3,13 +3,10 @@ import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql.crd.connection.ClusterConnection; import jakarta.enterprise.context.ApplicationScoped; -import org.jooq.DSLContext; -import org.jooq.SQLDialect; +import org.jooq.CloseableDSLContext; import org.jooq.impl.DSL; import org.jspecify.annotations.NullMarked; -import java.sql.DriverManager; -import java.sql.SQLException; import java.util.Properties; @NullMarked @@ -24,7 +21,7 @@ public PostgreSQLContextFactory(KubernetesClient kubernetesClient) { this.kubernetesClient = kubernetesClient; } - public DSLContext getDSLContext(ClusterConnection clusterConnection) throws SQLException { + public CloseableDSLContext getDSLContext(ClusterConnection clusterConnection) { var credentials = KubernetesUtil.getSecretRefCredentials( kubernetesClient, clusterConnection @@ -55,14 +52,9 @@ public DSLContext getDSLContext(ClusterConnection clusterConnection) throws SQLE ); } - var connection = DriverManager.getConnection( + return DSL.using( jdbcUrl, properties ); - - return DSL.using( - connection, - SQLDialect.POSTGRES - ); } } diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java index 6fd2264..c993dd8 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java @@ -9,12 +9,8 @@ import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.jooq.DSLContext; -import org.jooq.exception.DataAccessException; import org.jspecify.annotations.NonNull; -import java.sql.SQLException; - @Slf4j @RequiredArgsConstructor public class ClusterConnectionReconciler @@ -29,26 +25,13 @@ public UpdateControl reconcile( ) { var status = initializeStatus(resource); - DSLContext dsl; - try { - dsl = contextFactory.getDSLContext(resource); - } catch (SQLException e) { - log.error("Failed to create DSL context", e); - - return handleError( - resource, - status, - e - ); - } - - try { + try (var dsl = contextFactory.getDSLContext(resource)) { var version = dsl.fetchSingle("select version()").into(String.class); status.setPhase(CRPhase.READY).setMessage(version); return UpdateControl.patchStatus(resource); - } catch (DataAccessException e) { + } catch (Exception e) { log.error("Failed to check database connectivity", e); return handleError( diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java index 1334f3e..1108915 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -19,9 +19,9 @@ import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.jooq.DSLContext; import org.jspecify.annotations.NonNull; -import java.sql.SQLException; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @@ -94,104 +94,17 @@ public UpdateControl reconcile( UpdateControl updateControl; - try { + try (var dsl = contextFactory.getDSLContext(clusterConnection)) { // Run everything in a single transaction - updateControl = contextFactory.getDSLContext( - clusterConnection - ).transactionResult(cfg -> { - // Get the transactional DSL context - var tx = cfg.dsl(); - - // Create and return the role if it doesn't exist yet - if (!RoleUtil.roleExists(tx, spec)) { - log.info( - "Creating Role [resource={}/{}]", - namespace, - name - ); - - RoleUtil.createRole( - tx, - spec, + updateControl = dsl.transactionResult( + cfg -> reconcileInTransaction( + cfg.dsl(), + resource, + status, password - ); - - status.setPhase(CRPhase.READY) - .setMessage(null); - - return UpdateControl.patchStatus(resource); - } - - // When there is NOLOGIN, we set no password - var passwordMatches = true; - var roleLoginMatches = RoleUtil.roleLoginMatches(tx, spec); - var currentFlags = RoleUtil.fetchCurrentFlags(tx, spec); - var flagsMatch = expectedFlags.equals(currentFlags); - var commentMatches = RoleUtil.roleCommentMatches(tx, spec); - - if (loginExpected) { - passwordMatches = PostgreSQLAuthenticationUtil.passwordMatches( - tx, - spec, - password - ); - } - - if (roleLoginMatches && passwordMatches && flagsMatch && commentMatches) { - log.info( - "Role up-to-date [resource={}/{}]", - namespace, - name - ); - - return UpdateControl.noUpdate(); - } - - var changePassword = loginExpected && !passwordMatches; - - log.info( - "Updating Role [resource={}/{}]", - namespace, - name - ); - - if (!roleLoginMatches || !passwordMatches || !flagsMatch) { - RoleUtil.alterRole( - tx, - spec, - changePassword, - password - ); - } - - if (!flagsMatch) { - log.info( - "Updating Role membership [resource={}/{}]", - namespace, - name - ); - - RoleUtil.reconcileRoleMembership( - tx, - spec, - expectedFlags, - currentFlags - ); - } - - if (!commentMatches) { - RoleUtil.updateComment( - tx, - spec - ); - } - - status.setPhase(CRPhase.READY) - .setMessage(null); - - return UpdateControl.patchStatus(resource); - }); - } catch (SQLException e) { + ) + ); + } catch (Exception e) { return handleError( resource, status, @@ -235,6 +148,111 @@ public List> prepareEventSources(EventSourceContext c return List.of(secretEventSource); } + private UpdateControl reconcileInTransaction( + DSLContext tx, + Role resource, + CRStatus status, + String password + ) { + var name = resource.getMetadata().getName(); + var namespace = resource.getMetadata().getNamespace(); + + var spec = resource.getSpec(); + var expectedFlags = spec.getFlags(); + + // Create and return the role if it doesn't exist yet + if (!RoleUtil.roleExists(tx, spec)) { + log.info( + "Creating Role [resource={}/{}]", + namespace, + name + ); + + RoleUtil.createRole( + tx, + spec, + password + ); + + status.setPhase(CRPhase.READY) + .setMessage(null); + + return UpdateControl.patchStatus(resource); + } + + // When there is NOLOGIN, we set no password + var passwordMatches = true; + var roleLoginMatches = RoleUtil.roleLoginMatches(tx, spec); + var currentFlags = RoleUtil.fetchCurrentFlags(tx, spec); + var flagsMatch = expectedFlags.equals(currentFlags); + var commentMatches = RoleUtil.roleCommentMatches(tx, spec); + + var passwordSecretRef = spec.getPasswordSecretRef(); + var loginExpected = passwordSecretRef != null; + + if (loginExpected) { + passwordMatches = PostgreSQLAuthenticationUtil.passwordMatches( + tx, + spec, + password + ); + } + + if (roleLoginMatches && passwordMatches && flagsMatch && commentMatches) { + log.info( + "Role up-to-date [resource={}/{}]", + namespace, + name + ); + + return UpdateControl.noUpdate(); + } + + var changePassword = loginExpected && !passwordMatches; + + log.info( + "Updating Role [resource={}/{}]", + namespace, + name + ); + + if (!roleLoginMatches || !passwordMatches || !flagsMatch) { + RoleUtil.alterRole( + tx, + spec, + changePassword, + password + ); + } + + if (!flagsMatch) { + log.info( + "Updating Role membership [resource={}/{}]", + namespace, + name + ); + + RoleUtil.reconcileRoleMembership( + tx, + spec, + expectedFlags, + currentFlags + ); + } + + if (!commentMatches) { + RoleUtil.updateComment( + tx, + spec + ); + } + + status.setPhase(CRPhase.READY) + .setMessage(null); + + return UpdateControl.patchStatus(resource); + } + @Override protected @NonNull CRStatus newStatus() { return new CRStatus(); @@ -257,7 +275,7 @@ private boolean isReferencedBy( var refName = ref.getName(); var refNamespace = getResourceNamespaceOrOwn(role, ref.getNamespace()); - return refName.equals(secret.getMetadata().getName()) && - refNamespace.equals(secret.getMetadata().getNamespace()); + return refName.equals(secret.getMetadata().getName()) + && refNamespace.equals(secret.getMetadata().getNamespace()); } } diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java index 298016c..d9397bd 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java @@ -1,8 +1,6 @@ package it.aboutbits.postgresql.crd.role; import it.aboutbits.postgresql.core.infrastructure.persistence.Routines; -import lombok.AccessLevel; -import lombok.NoArgsConstructor; import org.jooq.DSLContext; import org.jooq.Query; import org.jooq.QueryPart; @@ -30,7 +28,6 @@ import static org.jooq.impl.DSL.val; @NullMarked -@NoArgsConstructor(access = AccessLevel.PRIVATE) public final class RoleUtil { public static boolean roleExists( DSLContext tx, @@ -458,4 +455,7 @@ private static QueryPart joinWithSpaces(List parts) { private static QueryPart joinWithComma(List parts) { return join(parts, ", "); } + + private RoleUtil() { + } } diff --git a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java index c88f65e..cfb1cf6 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java @@ -6,13 +6,12 @@ import io.quarkus.test.junit.QuarkusTest; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import jakarta.inject.Inject; -import org.jooq.DSLContext; +import org.jooq.CloseableDSLContext; import org.jooq.exception.DataAccessException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; -import java.sql.SQLException; import java.util.Collections; import static org.assertj.core.api.Assertions.assertThat; @@ -56,12 +55,12 @@ void setUp() { @Test @DisplayName("Should handle SQLException during DSL context creation") - void reconcile_whenDslCreationFails_shouldReturnErrorStatus() throws SQLException { + void reconcile_whenDslCreationFails_shouldReturnErrorStatus() { // given var errorMessage = "Connection refused to database"; when(contextFactory.getDSLContext(resource)).thenThrow( - new SQLException(errorMessage) + new RuntimeException(errorMessage) ); // when @@ -79,10 +78,10 @@ void reconcile_whenDslCreationFails_shouldReturnErrorStatus() throws SQLExceptio @Test @DisplayName("Should handle DataAccessException during version check") - void reconcile_whenVersionQueryFails_shouldReturnErrorStatus() throws SQLException { + void reconcile_whenVersionQueryFails_shouldReturnErrorStatus() { // given var errorMessage = "Query execution failed"; - var dslContext = mock(DSLContext.class); + var dslContext = mock(CloseableDSLContext.class); when(contextFactory.getDSLContext(resource)).thenReturn( dslContext diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 9f1a233..75c5b11 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -47,7 +47,7 @@ class RoleReconcilerTest { @Test @DisplayName("When a Role (LOGIN) is created, it should be reconciled to READY and present in pg_authid") - void createRole_withLogin_andStatusReady() throws SQLException { + void createRole_withLogin_andStatusReady() { // given var clusterConnection = given.one() .clusterConnection() @@ -89,7 +89,7 @@ void createRole_withLogin_andStatusReady() throws SQLException { @Test @DisplayName("When a Role (NOLOGIN) is created, it should be reconciled to READY and present with NOLOGIN") - void createRole_withoutLogin_andStatusReady() throws SQLException { + void createRole_withoutLogin_andStatusReady() { // given var clusterConnection = given.one() .clusterConnection() @@ -127,7 +127,7 @@ void createRole_withoutLogin_andStatusReady() throws SQLException { @Test @DisplayName("When a Role login state is changed, it should be updated correctly in pg_authid") - void toggleRoleLogin_updatesCorrectly() throws SQLException { + void toggleRoleLogin_updatesCorrectly() { // given var clusterConnection = given.one() .clusterConnection() @@ -232,7 +232,7 @@ void createRole_withMissingClusterConnection_setsPending() { @DisplayName( "When a Role (LOGIN) references a secret and that secret changes, it should trigger a re-reconciliation" ) - void secretChange_triggersReconciliation() throws SQLException { + void secretChange_triggersReconciliation() { // given var clusterConnection = given.one() .clusterConnection() @@ -302,7 +302,7 @@ void secretChange_triggersReconciliation() throws SQLException { @DisplayName( "When a Role (LOGIN) changes its secret reference, it should trigger a re-reconciliation" ) - void secretRefChange_triggersReconciliation() throws SQLException { + void secretRefChange_triggersReconciliation() { // given var clusterConnection = given.one() .clusterConnection() @@ -367,7 +367,7 @@ void secretRefChange_triggersReconciliation() throws SQLException { @Test @DisplayName("When the comment is changed, it should be updated in the database") - void comment_updatesCorrectly() throws SQLException { + void comment_updatesCorrectly() { // given var clusterConnection = given.one() .clusterConnection() @@ -435,7 +435,7 @@ void comment_updatesCorrectly() throws SQLException { void roleFlag_togglesCorrectly( Field field, BiConsumer setter - ) throws SQLException { + ) { // given var clusterConnection = given.one() .clusterConnection() @@ -497,7 +497,7 @@ void roleFlag_togglesCorrectly( @Test @DisplayName("When the CONNECTION LIMIT is changed, it should be updated in the database") - void connectionLimit_updatesCorrectly() throws SQLException { + void connectionLimit_updatesCorrectly() { // given var clusterConnection = given.one() .clusterConnection() @@ -559,7 +559,7 @@ void connectionLimit_updatesCorrectly() throws SQLException { @Test @DisplayName("When the VALID UNTIL is changed, it should be updated in the database") - void validUntil_updatesCorrectly() throws SQLException { + void validUntil_updatesCorrectly() { // given var clusterConnection = given.one() .clusterConnection() @@ -632,7 +632,7 @@ void validUntil_updatesCorrectly() throws SQLException { @Test @DisplayName("When IN ROLE membership is changed, it should be updated in the database") - void inRole_updatesCorrectly() throws SQLException { + void inRole_updatesCorrectly() { // given var clusterConnection = given.one() .clusterConnection() @@ -705,7 +705,7 @@ void inRole_updatesCorrectly() throws SQLException { @Test @DisplayName("When ROLE membership is changed, it should be updated in the database") - void role_updatesCorrectly() throws SQLException { + void role_updatesCorrectly() { // given var clusterConnection = given.one() .clusterConnection() @@ -778,7 +778,7 @@ void role_updatesCorrectly() throws SQLException { @Test @DisplayName("When multiple ROLE memberships are added, they should be sorted and updated correctly") - void role_multipleMemberships_updatesCorrectly() throws SQLException { + void role_multipleMemberships_updatesCorrectly() { // given var clusterConnection = given.one() .clusterConnection() From ee9f1ec7db58c4e62e15872dda050eda137ee650 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 12:25:12 +0100 Subject: [PATCH 14/31] implement the PostgreSQLInstanceReadinessCheck --- .../MinioInstanceReadinessCheck.java | 68 --------------- .../PostgreSQLInstanceReadinessCheck.java | 65 +++++++++++++++ .../PostgreSQLInstanceReadinessCheckTest.java | 82 +++++++++++++++++++ .../crd/role/RoleReconcilerTest.java | 1 - 4 files changed, 147 insertions(+), 69 deletions(-) delete mode 100644 src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java create mode 100644 src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java create mode 100644 src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java diff --git a/src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java b/src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java deleted file mode 100644 index 575e53e..0000000 --- a/src/main/java/it/aboutbits/postgresql/MinioInstanceReadinessCheck.java +++ /dev/null @@ -1,68 +0,0 @@ -package it.aboutbits.postgresql; - -import io.javaoperatorsdk.operator.Operator; -import it.aboutbits.postgresql.core.PostgreSQLContextFactory; -import it.aboutbits.postgresql.crd.connection.ClusterConnectionReconciler; -import lombok.RequiredArgsConstructor; -import org.eclipse.microprofile.health.HealthCheck; -import org.eclipse.microprofile.health.HealthCheckResponse; -import org.eclipse.microprofile.health.Readiness; -import org.jspecify.annotations.NullMarked; - -/** - * MicroProfile readiness health check that verifies connectivity to all - * configured PostgreSQL instances. Each instance is probed with a lightweight - * operation and the aggregated status is exposed. - */ -@NullMarked -@Readiness -@RequiredArgsConstructor -public class MinioInstanceReadinessCheck implements HealthCheck { - private final PostgreSQLContextFactory postgreSQLContextFactory; - - private final Operator operator; - - @Override - public HealthCheckResponse call() { - var builder = HealthCheckResponse.builder().name("PostgreSQL Instances"); - - var optionalClusterConnectionReconciler = operator.getRegisteredController( - ClusterConnectionReconciler.class.getSimpleName() - ); - - if (optionalClusterConnectionReconciler.isEmpty()) { - return builder - .status(false) - .withData("ClusterConnectionReconciler", "NOT_REGISTERED") - .build(); - } - - var clusterConnectionReconciler = optionalClusterConnectionReconciler.get(); - -// boolean allUp = postgreSQLContextFactory.getClients() -// .entrySet() -// .stream() -// .allMatch(entry -> checkInstance( -// entry.getKey(), -// entry.getValue(), -// builder -// )); - - return builder.status(true /* allUp */).build(); - } - - /*private boolean checkInstance( - String instance, - MinioClient minioClient, - HealthCheckResponseBuilder builder - ) { - try { - minioClient.listBuckets(); - builder.withData(instance, "UP"); - return true; - } catch (Exception _) { - builder.withData(instance, "DOWN"); - return false; - } - }*/ -} diff --git a/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java new file mode 100644 index 0000000..46a209e --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java @@ -0,0 +1,65 @@ +package it.aboutbits.postgresql; + +import io.fabric8.kubernetes.client.KubernetesClient; +import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import lombok.RequiredArgsConstructor; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Readiness; +import org.jspecify.annotations.NullMarked; + +/** + * MicroProfile readiness health check that verifies connectivity to all + * configured PostgreSQL instances. Each instance is probed with a lightweight + * operation, and the aggregated status is exposed. + */ +@NullMarked +@Readiness +@RequiredArgsConstructor +public class PostgreSQLInstanceReadinessCheck implements HealthCheck { + private final PostgreSQLContextFactory postgreSQLContextFactory; + + private final KubernetesClient kubernetesClient; + + @Override + public HealthCheckResponse call() { + var builder = HealthCheckResponse.builder().name("PostgreSQL Instances"); + + var connections = kubernetesClient.resources(ClusterConnection.class).list().getItems(); + + boolean allUp = connections.stream() + .allMatch(connection -> checkInstance( + connection, + builder + )); + + return builder.status(allUp).build(); + } + + private boolean checkInstance( + ClusterConnection clusterConnection, + HealthCheckResponseBuilder builder + ) { + var name = clusterConnection.getMetadata().getName(); + + try (var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection)) { + var version = dsl.fetchSingle("select version()").into(String.class); + + builder.withData( + name, + "UP (%s)".formatted(version) + ); + + return true; + } catch (Exception _) { + builder.withData( + name, + "DOWN" + ); + + return false; + } + } +} diff --git a/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java new file mode 100644 index 0000000..55c11d2 --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java @@ -0,0 +1,82 @@ +package it.aboutbits.postgresql; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.quarkus.test.junit.QuarkusTest; +import it.aboutbits.postgresql._support.testdata.persisted.Given; +import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import jakarta.inject.Inject; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.Readiness; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +@QuarkusTest +class PostgreSQLInstanceReadinessCheckTest { + @Inject + Given given; + + @Inject + @Readiness + PostgreSQLInstanceReadinessCheck readinessCheck; + + @Inject + KubernetesClient kubernetesClient; + + @BeforeEach + void cleanUp() { + kubernetesClient.resources(ClusterConnection.class).delete(); + } + + @Test + void call_whenAllConnectionsUp_shouldReturnUp() { + given.one() + .clusterConnection() + .withName("test-db") + .returnFirst(); + + var response = readinessCheck.call(); + + assertThat(response.getStatus()).isEqualTo( + HealthCheckResponse.Status.UP + ); + + assertThat(response.getData()) + .isPresent() + .get() + .satisfies(data -> { + assertThat(data).containsKey("test-db"); + assertThat(data.get("test-db").toString()).startsWith("UP (PostgreSQL"); + }); + } + + @Test + void call_whenSomeConnectionsDown_shouldReturnDown() { + given.one() + .clusterConnection() + .withName("db-1") + .returnFirst(); + + given.one() + .clusterConnection() + .withName("db-2") + .withHost("non-existent-host") + .returnFirst(); + + var response = readinessCheck.call(); + + assertThat(response.getStatus()).isEqualTo( + HealthCheckResponse.Status.DOWN + ); + + assertThat(response.getData()) + .isPresent() + .get() + .satisfies(data -> { + assertThat(data).containsKey("db-1"); + assertThat(data.get("db-1").toString()).startsWith("UP (PostgreSQL"); + assertThat(data).containsEntry("db-2", "DOWN"); + }); + } +} diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 75c5b11..4cde65b 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -20,7 +20,6 @@ import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import java.sql.SQLException; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; From 5a4669a39f504bad4779e725a4157653b9dd4181 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 14:46:43 +0100 Subject: [PATCH 15/31] add NullAway and fix issues --- README.md | 2 +- build.gradle.kts | 16 ++++++++++- gradle/libs.versions.toml | 16 +++++++++++ lombok.config | 3 +++ .../postgresql/core/BaseReconciler.java | 4 +-- .../postgresql/core/KubernetesUtil.java | 7 +++-- .../core/PostgreSQLAuthenticationUtil.java | 13 ++++++--- .../aboutbits/postgresql/core/SecretRef.java | 2 +- .../ClusterConnectionReconciler.java | 5 ++-- .../postgresql/crd/role/RoleFlags.java | 2 ++ .../postgresql/crd/role/RoleReconciler.java | 27 +++++++++---------- .../postgresql/crd/role/RoleSpec.java | 3 ++- .../postgresql/crd/role/RoleUtil.java | 12 ++++++--- src/main/resources/application.yml | 4 +-- .../PostgreSQLInstanceReadinessCheckTest.java | 21 +++++++++++++-- .../testdata/base/TestDataCreator.java | 16 ++++++----- .../_support/testdata/persisted/Given.java | 3 +++ .../creator/ClusterConnectionCreate.java | 1 + .../ClusterConnectionReconcilerErrorTest.java | 15 ++++++----- .../ClusterConnectionReconcilerTest.java | 12 ++++++--- .../crd/role/RoleReconcilerTest.java | 6 ++++- 21 files changed, 137 insertions(+), 53 deletions(-) diff --git a/README.md b/README.md index 27a79ef..c469440 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,7 @@ make test 1. Open the `Services` tool on the left side of the IDE 2. Click on "+" and select "Quarkus" -Afterwards, the project can be started in IntelliJ by navigating to `Run` -> `Run '...'`. +Afterward, the project can be started in IntelliJ by navigating to `Run` -> `Run '...'`. ## Packaging and running the application diff --git a/build.gradle.kts b/build.gradle.kts index e69cf33..f85e180 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,5 @@ +import net.ltgt.gradle.errorprone.CheckSeverity +import net.ltgt.gradle.errorprone.errorprone import org.gradle.api.tasks.testing.logging.TestExceptionFormat import org.gradle.api.tasks.testing.logging.TestLogEvent @@ -6,11 +8,12 @@ plugins { java checkstyle id("io.quarkus") + alias(libs.plugins.errorPronePlugin) alias(libs.plugins.jooqPlugin) } description = "AboutBits PostgreSQL Operator" -group = "com.aboutbits.postgresql" +group = "it.aboutbits.postgresql" version = "0.0.1-SNAPSHOT" val quarkusPlatformGroupId: String by project @@ -91,6 +94,12 @@ dependencies { testImplementation("org.awaitility:awaitility") testImplementation(libs.assertj) testImplementation(libs.datafaker) + + /** + * NullAway + */ + errorprone(libs.errorProne) + errorprone(libs.nullAway) } sourceSets { @@ -114,6 +123,11 @@ java { tasks.withType { options.encoding = "UTF-8" options.compilerArgs.add("-parameters") + + options.errorprone { + check("NullAway", CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "it.aboutbits.postgresql") + } } tasks.quarkusDev { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index ab57db2..3548bde 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,8 +14,15 @@ scram-client = "3.2" assertj = "3.27.6" checkstyle = "12.2.0" datafaker = "2.5.3" +errorProne = "2.45.0" +errorPronePlugin = "4.2.0" +nullAway = "0.12.15" [plugins] +# 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" } + # https://www.jooq.org/doc/latest/manual/code-generation/codegen-execution/codegen-gradle/ # https://github.com/jOOQ/jOOQ/tree/main/jOOQ-codegen-gradle # https://mvnrepository.com/artifact/org.jooq.jooq-codegen-gradle/org.jooq.jooq-codegen-gradle.gradle.plugin @@ -82,3 +89,12 @@ checkstyle = { group = "com.puppycrawl.tools", name = "checkstyle", version.ref # https://github.com/datafaker-net/datafaker # https://mvnrepository.com/artifact/net.datafaker/datafaker datafaker = { group = "net.datafaker", name = "datafaker", version.ref = "datafaker" } + +# https://errorprone.info/ +# https://github.com/google/error-prone +# https://mvnrepository.com/artifact/com.google.errorprone/error_prone_core +errorProne = { group = "com.google.errorprone", name = "error_prone_core", version.ref = "errorProne" } + +# https://github.com/uber/NullAway +# https://mvnrepository.com/artifact/com.uber.nullaway/nullaway +nullAway = { group = "com.uber.nullaway", name = "nullaway", version.ref = "nullAway" } diff --git a/lombok.config b/lombok.config index dc6470a..a8fed54 100644 --- a/lombok.config +++ b/lombok.config @@ -4,3 +4,6 @@ config.stopBubbling = true lombok.copyableAnnotations += org.jspecify.annotations.Nullable lombok.copyableAnnotations += org.jspecify.annotations.NonNull lombok.addNullAnnotations = jspecify + +# Required for NullAway +lombok.addLombokGeneratedAnnotation = true diff --git a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java index c759ca8..07265a4 100644 --- a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java @@ -5,7 +5,6 @@ import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; import it.aboutbits.postgresql.crd.connection.ClusterConnection; import lombok.extern.slf4j.Slf4j; -import org.jspecify.annotations.NonNull; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -18,12 +17,12 @@ @NullMarked @Slf4j public abstract class BaseReconciler & Named, S extends CRStatus> { - @NonNull protected abstract S newStatus(); public S initializeStatus(CR resource) { S status = resource.getStatus(); + //noinspection ConstantConditions if (status == null) { status = newStatus(); @@ -62,6 +61,7 @@ public Optional getReferencedClusterConnection( .withName(connectionName) .get(); + //noinspection ConstantConditions if (clusterConnection == null) { log.error( "The specified ClusterConnection does not exist [clusterConnection={}/{}]", diff --git a/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java b/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java index be1bc12..2fb35dd 100644 --- a/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java +++ b/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java @@ -4,6 +4,7 @@ import it.aboutbits.postgresql.crd.connection.ClusterConnection; import org.jspecify.annotations.NullMarked; +import java.nio.charset.Charset; import java.util.Base64; @NullMarked @@ -67,7 +68,8 @@ public static Credentials getSecretRefCredentials( var username = usernameBase64 == null ? null : new String( - Base64.getDecoder().decode(usernameBase64) + Base64.getDecoder().decode(usernameBase64), + Charset.defaultCharset() ); var passwordBase64 = data.get(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY); @@ -78,7 +80,8 @@ public static Credentials getSecretRefCredentials( )); } var password = new String( - Base64.getDecoder().decode(passwordBase64) + Base64.getDecoder().decode(passwordBase64), + Charset.defaultCharset() ); return new Credentials( diff --git a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java index f14c8af..43a515d 100644 --- a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java @@ -2,6 +2,7 @@ import com.ongres.scram.common.StringPreparation; import it.aboutbits.postgresql.crd.role.RoleSpec; +import lombok.extern.slf4j.Slf4j; import org.jooq.DSLContext; import org.jspecify.annotations.NullMarked; @@ -20,6 +21,7 @@ import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; @NullMarked +@Slf4j public final class PostgreSQLAuthenticationUtil { private static final String MD5 = "MD5"; private static final String SHA_256 = "SHA-256"; @@ -89,7 +91,8 @@ private static boolean verifyPostgresScramSha256(String postgresVerifier, String int iterations; try { iterations = Integer.parseInt(iterationsAndSalt.substring(0, colonIterationsAndSalt)); - } catch (NumberFormatException ex) { + } catch (NumberFormatException e) { + log.error("Invalid iterations format in PostgreSQL verifier: %s".formatted(postgresVerifier), e); return false; } if (iterations <= 0) { @@ -110,7 +113,8 @@ private static boolean verifyPostgresScramSha256(String postgresVerifier, String try { salt = Base64.getDecoder().decode(saltB64); currentStoredKey = Base64.getDecoder().decode(storedKeyB64); - } catch (IllegalArgumentException ex) { + } catch (IllegalArgumentException e) { + log.error("Invalid salt or stored key format in PostgreSQL verifier: %s".formatted(postgresVerifier), e); return false; } @@ -119,7 +123,7 @@ private static boolean verifyPostgresScramSha256(String postgresVerifier, String byte[] expectedStoredKey = null; try { // RFC 5802/7677: - // saltedPassword := Hi(password, salt, iterations) (PBKDF2-HMAC-SHA-256, 32 bytes) + // saltedPassword := Hi(password, salt, iterations) (PBKDF2-HMAC-SHA-256, 32 bytes) // clientKey := HMAC(saltedPassword, "Client Key") // storedKey := H(clientKey) (SHA-256) saltedPassword = pbkdf2HmacSha256(preparedPassword, salt, iterations, 32); @@ -160,7 +164,8 @@ private static boolean verifyPostgresMd5( 3, postgresMd5.length() ); - } catch (IllegalArgumentException ex) { + } catch (IllegalArgumentException e) { + log.error("Invalid MD5 format in PostgreSQL verifier: %s".formatted(postgresMd5), e); return false; // not valid hex } diff --git a/src/main/java/it/aboutbits/postgresql/core/SecretRef.java b/src/main/java/it/aboutbits/postgresql/core/SecretRef.java index fb25dfd..ed8a784 100644 --- a/src/main/java/it/aboutbits/postgresql/core/SecretRef.java +++ b/src/main/java/it/aboutbits/postgresql/core/SecretRef.java @@ -11,7 +11,7 @@ @Setter public class SecretRef { @Required - private String name; + private String name = ""; /** * The namespace where the Secret is located. diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java index c993dd8..d3d20bd 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java @@ -9,8 +9,9 @@ import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; +@NullMarked @Slf4j @RequiredArgsConstructor public class ClusterConnectionReconciler @@ -43,7 +44,7 @@ public UpdateControl reconcile( } @Override - protected @NonNull CRStatus newStatus() { + protected CRStatus newStatus() { return new CRStatus(); } } diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java index 9b43ab1..4b06381 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java @@ -3,7 +3,9 @@ import lombok.Getter; import lombok.RequiredArgsConstructor; import lombok.experimental.Accessors; +import org.jspecify.annotations.NullMarked; +@NullMarked @Getter @Accessors(fluent = true) @RequiredArgsConstructor diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java index 1108915..1daf74b 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -20,12 +20,14 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.jooq.DSLContext; -import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import java.util.List; import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; +@NullMarked @Slf4j @RequiredArgsConstructor public class RoleReconciler @@ -79,10 +81,9 @@ public UpdateControl reconcile( expectedFlags.getInRole().sort(String.CASE_INSENSITIVE_ORDER); var passwordSecretRef = spec.getPasswordSecretRef(); - var loginExpected = passwordSecretRef != null; String password; - if (loginExpected) { + if (passwordSecretRef != null) { password = KubernetesUtil.getSecretRefCredentials( kubernetesClient, passwordSecretRef, @@ -122,14 +123,12 @@ public UpdateControl reconcile( public List> prepareEventSources(EventSourceContext context) { // 1. Define the Mapper // We define how to find the Primary Resource (Role) when a Secret changes - SecondaryToPrimaryMapper secretToRoleMapper = (Secret secret) -> { - // 2. Filter Roles that reference this specific Secret - return context.getPrimaryCache() - .list() - .filter(role -> isReferencedBy(role, secret)) - .map(ResourceID::fromResource) - .collect(Collectors.toSet()); - }; + // Filter Roles that reference this specific Secret + SecondaryToPrimaryMapper secretToRoleMapper = (Secret secret) -> context.getPrimaryCache() + .list() + .filter(role -> isReferencedBy(role, secret)) + .map(ResourceID::fromResource) + .collect(Collectors.toSet()); // 2. Build the Event Source Configuration which binds the InformerConfig + Mapper var eventSourceConfig = InformerEventSourceConfiguration.from(Secret.class, Role.class) @@ -152,7 +151,7 @@ private UpdateControl reconcileInTransaction( DSLContext tx, Role resource, CRStatus status, - String password + @Nullable String password ) { var name = resource.getMetadata().getName(); var namespace = resource.getMetadata().getNamespace(); @@ -190,7 +189,7 @@ private UpdateControl reconcileInTransaction( var passwordSecretRef = spec.getPasswordSecretRef(); var loginExpected = passwordSecretRef != null; - if (loginExpected) { + if (loginExpected && password != null) { passwordMatches = PostgreSQLAuthenticationUtil.passwordMatches( tx, spec, @@ -254,7 +253,7 @@ private UpdateControl reconcileInTransaction( } @Override - protected @NonNull CRStatus newStatus() { + protected CRStatus newStatus() { return new CRStatus(); } diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java index 1693b96..39cd0f1 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java @@ -34,10 +34,11 @@ public class RoleSpec { @io.fabric8.generator.annotation.Nullable private Flags flags = new Flags(); - @NullMarked @Getter @Setter @EqualsAndHashCode + // The Fabric8 @Nullable annotation is relevant for generating nullable annotations in the resulting CRD YAML JSON Schema + @SuppressWarnings({"NullablePrimitive"}) public static class Flags { @io.fabric8.generator.annotation.Nullable private boolean superuser = false; diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java index d9397bd..5c14728 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java @@ -225,10 +225,13 @@ private static Query buildCreateRole( options.add(keyword(RoleFlags.CONNECTION_LIMIT.flag())); options.add(val(flags.getConnectionLimit())); } - if (flags.getValidUntil() != null) { + + var validUntil = flags.getValidUntil(); + if (validUntil != null) { options.add(keyword(RoleFlags.VALID_UNTIL.flag())); - options.add(val(flags.getValidUntil().toString())); + options.add(val(validUntil.toString())); } + if (!flags.getInRole().isEmpty()) { options.add(keyword(RoleFlags.IN_ROLE.flag())); options.add(joinWithComma( @@ -314,9 +317,10 @@ private static Query buildAlterRole( options.add(keyword(RoleFlags.CONNECTION_LIMIT.flag())); options.add(val(flags.getConnectionLimit())); + var validUntil = flags.getValidUntil(); options.add(keyword(RoleFlags.VALID_UNTIL.flag())); - if (flags.getValidUntil() != null) { - options.add(val(flags.getValidUntil().toString())); + if (validUntil != null) { + options.add(val(validUntil.toString())); } else { options.add(val("infinity")); } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 7ce0c74..b2fccbd 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -61,9 +61,9 @@ quarkus: # as in Kubernetes the env entries take precedence over envFrom entries as we want to consume all ENV vars from the postgresql-operator-config secret # This started working with Gradle since Quarkus Helm 1.2.5 map-system-properties: false - home: http://github.com/aboutbits/postgresql-operator + home: https://github.com/aboutbits/postgresql-operator sources: - - http://github.com/aboutbits/postgresql-operator + - https://github.com/aboutbits/postgresql-operator annotations: "catalog.cattle.io/os": linux keywords: diff --git a/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java index 55c11d2..a8098dd 100644 --- a/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java +++ b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java @@ -7,11 +7,15 @@ import jakarta.inject.Inject; import org.eclipse.microprofile.health.HealthCheckResponse; import org.eclipse.microprofile.health.Readiness; +import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import java.util.Objects; + import static org.assertj.core.api.Assertions.assertThat; +@NullMarked @QuarkusTest class PostgreSQLInstanceReadinessCheckTest { @Inject @@ -47,7 +51,14 @@ void call_whenAllConnectionsUp_shouldReturnUp() { .get() .satisfies(data -> { assertThat(data).containsKey("test-db"); - assertThat(data.get("test-db").toString()).startsWith("UP (PostgreSQL"); + + var dbStatus = Objects.requireNonNull( + data.get("test-db") + ); + + assertThat( + dbStatus.toString() + ).startsWith("UP (PostgreSQL"); }); } @@ -75,7 +86,13 @@ void call_whenSomeConnectionsDown_shouldReturnDown() { .get() .satisfies(data -> { assertThat(data).containsKey("db-1"); - assertThat(data.get("db-1").toString()).startsWith("UP (PostgreSQL"); + + var dbStatus = Objects.requireNonNull( + data.get("db-1") + ); + + assertThat(dbStatus.toString()).startsWith("UP (PostgreSQL"); + assertThat(data).containsEntry("db-2", "DOWN"); }); } diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java index fc56b8f..e7e871b 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java @@ -1,13 +1,15 @@ package it.aboutbits.postgresql._support.testdata.base; import net.datafaker.Faker; +import org.jspecify.annotations.NullMarked; import java.util.ArrayList; import java.util.HashSet; import java.util.List; import java.util.Set; -public abstract class TestDataCreator { +@NullMarked +public abstract class TestDataCreator { protected static final Faker FAKER = new Faker(); protected final int numberOfItems; @@ -20,20 +22,20 @@ public void apply() { create(); } - public ITEM returnFirst() { + public T returnFirst() { return create().getFirst(); } - public List returnAll() { + public List returnAll() { return create(); } - public Set returnSet() { + public Set returnSet() { return new HashSet<>(create()); } - protected List create() { - var result = new ArrayList(); + protected List create() { + var result = new ArrayList(); for (var index = 0; index < numberOfItems; index++) { result.add( @@ -44,7 +46,7 @@ protected List create() { return result; } - protected abstract ITEM create(int index); + protected abstract T create(int index); public static String randomKubernetesNameSuffix(String name) { var maxLength = 63; // Kubernetes hard enforces RFC-1123 diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java index e48e0ef..459bdf9 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java @@ -17,12 +17,15 @@ public class Given { private final KubernetesClient kubernetesClient; + @SuppressWarnings("NullAway.Init") @ConfigProperty(name = "quarkus.datasource.devservices.username") String username; + @SuppressWarnings("NullAway.Init") @ConfigProperty(name = "quarkus.datasource.devservices.password") String password; + @SuppressWarnings("NullAway.Init") @ConfigProperty(name = "quarkus.datasource.jdbc.url") String jdbcUrl; diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java index dcbfe31..26e68c2 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -93,6 +93,7 @@ protected ClusterConnection create(int index) { .resource(item) .serverSideApply(); + //noinspection ConstantConditions return kubernetesClient.resources(ClusterConnection.class) .inNamespace(namespace) .withName(name) diff --git a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java index cfb1cf6..88550d6 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java @@ -8,6 +8,7 @@ import jakarta.inject.Inject; import org.jooq.CloseableDSLContext; import org.jooq.exception.DataAccessException; +import org.jspecify.annotations.NullMarked; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -19,8 +20,10 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; +@NullMarked @QuarkusTest class ClusterConnectionReconcilerErrorTest { + @SuppressWarnings("NullAway.Init") @InjectMock PostgreSQLContextFactory contextFactory; @@ -71,9 +74,9 @@ void reconcile_whenDslCreationFails_shouldReturnErrorStatus() { .isPresent() .get() .extracting(ClusterConnection::getStatus) - .satisfies(status -> { - assertThat(status.getMessage()).contains(errorMessage); - }); + .satisfies(status -> + assertThat(status.getMessage()).contains(errorMessage) + ); } @Test @@ -99,8 +102,8 @@ void reconcile_whenVersionQueryFails_shouldReturnErrorStatus() { .isPresent() .get() .extracting(ClusterConnection::getStatus) - .satisfies(status -> { - assertThat(status.getMessage()).contains(errorMessage); - }); + .satisfies(status -> + assertThat(status.getMessage()).contains(errorMessage) + ); } } diff --git a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java index cf1cc40..3e9ca0b 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java @@ -7,18 +7,22 @@ import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import lombok.RequiredArgsConstructor; import org.jooq.DSLContext; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; +import java.util.Objects; import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.within; +@NullMarked @QuarkusTest @RequiredArgsConstructor class ClusterConnectionReconcilerTest { @@ -36,12 +40,14 @@ void createsCustomResource_andReconcilerStatusIsReady() { .returnFirst(); // then - AtomicReference dsl = new AtomicReference<>(); + AtomicReference<@Nullable DSLContext> dslAtomic = new AtomicReference<>(); assertThatNoException().isThrownBy( - () -> dsl.set(postgreSQLContextFactory.getDSLContext(customResource)) + () -> dslAtomic.set(postgreSQLContextFactory.getDSLContext(customResource)) ); - var version = dsl.get().fetchSingle("select version()").into(String.class); + var dsl = Objects.requireNonNull(dslAtomic.get()); + + var version = dsl.fetchSingle("select version()").into(String.class); var expectedStatus = getInitialClusterConnectionStatus(customResource); expectedStatus.setMessage(version); diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 4cde65b..329a34c 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -14,6 +14,8 @@ import lombok.RequiredArgsConstructor; import org.jooq.DSLContext; import org.jooq.Field; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -36,6 +38,7 @@ import static org.awaitility.Awaitility.await; import static org.jooq.impl.DSL.role; +@NullMarked @QuarkusTest @RequiredArgsConstructor class RoleReconcilerTest { @@ -817,7 +820,7 @@ void role_multipleMemberships_updatesCorrectly() { ).containsExactly(roleA, roleB, roleC); } - private T getRoleFlagValue( + private @Nullable T getRoleFlagValue( DSLContext dsl, String roleName, Field field @@ -840,6 +843,7 @@ private static Stream provideBooleanFlags() { } private Role applyRole(Role role) { + //noinspection ConstantConditions return applyRole( role, r -> r.getStatus() != null From 5a03d6c24c092eea7f08601817f4123e5aa64e01 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 14:52:27 +0100 Subject: [PATCH 16/31] update to Quarkus 3.30.6, Checkstyle 13.0.0 and ErrorProne Gradle Plugin 4.3.0 --- gradle.properties | 4 ++-- gradle/libs.versions.toml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/gradle.properties b/gradle.properties index 5863ac9..ac266ce 100644 --- a/gradle.properties +++ b/gradle.properties @@ -5,9 +5,9 @@ org.gradle.logging.level=INFO # Quarkus quarkusPluginId=io.quarkus -quarkusPluginVersion=3.30.3 +quarkusPluginVersion=3.30.6 # https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-bom quarkusPlatformGroupId=io.quarkus.platform quarkusPlatformArtifactId=quarkus-bom -quarkusPlatformVersion=3.30.3 +quarkusPlatformVersion=3.30.6 systemProp.quarkus.analytics.disabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 3548bde..681f6c7 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -12,10 +12,10 @@ scram-client = "3.2" ## Testing ## assertj = "3.27.6" -checkstyle = "12.2.0" +checkstyle = "13.0.0" datafaker = "2.5.3" errorProne = "2.45.0" -errorPronePlugin = "4.2.0" +errorPronePlugin = "4.3.0" nullAway = "0.12.15" [plugins] From 288656a5664424f3b935e15e87fa825bc547c005 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 15:06:10 +0100 Subject: [PATCH 17/31] fix small issues --- .gitignore | 2 +- build.gradle.kts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitignore b/.gitignore index d8db0e8..f917d07 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ -### MinIO Operator ### +### PostgreSQL Operator ### config/ ### STS ### diff --git a/build.gradle.kts b/build.gradle.kts index f85e180..3772b98 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -20,7 +20,7 @@ val quarkusPlatformGroupId: String by project val quarkusPlatformArtifactId: String by project val quarkusPlatformVersion: String by project -val checkstyleConfig by configurations.creating { +val checkstyleConfig: Configuration by configurations.creating { isCanBeConsumed = false isCanBeResolved = true } From 6fb668ffd2533a51307047d9de7c3dc368756960 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 15:15:09 +0100 Subject: [PATCH 18/31] remove outdated comment --- .../postgresql/PostgreSQLInstanceReadinessCheck.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java index 46a209e..b707404 100644 --- a/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java +++ b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java @@ -10,11 +10,6 @@ import org.eclipse.microprofile.health.Readiness; import org.jspecify.annotations.NullMarked; -/** - * MicroProfile readiness health check that verifies connectivity to all - * configured PostgreSQL instances. Each instance is probed with a lightweight - * operation, and the aggregated status is exposed. - */ @NullMarked @Readiness @RequiredArgsConstructor From 6f989105c3046bc3dbd9ac0afc7338d91406dc31 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 15:46:27 +0100 Subject: [PATCH 19/31] Revert "remove outdated comment" This reverts commit 6fb668ffd2533a51307047d9de7c3dc368756960. --- .../postgresql/PostgreSQLInstanceReadinessCheck.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java index b707404..46a209e 100644 --- a/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java +++ b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java @@ -10,6 +10,11 @@ import org.eclipse.microprofile.health.Readiness; import org.jspecify.annotations.NullMarked; +/** + * MicroProfile readiness health check that verifies connectivity to all + * configured PostgreSQL instances. Each instance is probed with a lightweight + * operation, and the aggregated status is exposed. + */ @NullMarked @Readiness @RequiredArgsConstructor From 8616b9924e2ec2e1379acb39c69b6d3effee8745 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Thu, 8 Jan 2026 16:15:18 +0100 Subject: [PATCH 20/31] use UBI9 OpenJDK 25 runtime --- src/main/docker/Dockerfile.jvm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm index a1ffebe..a81e136 100644 --- a/src/main/docker/Dockerfile.jvm +++ b/src/main/docker/Dockerfile.jvm @@ -78,8 +78,8 @@ # accessed directly. (example: "foo.example.com,bar.example.com") # ### -# https://container-registry.oracle.com/ords/ocr/ba/graalvm/jdk -FROM container-registry.oracle.com/graalvm/jdk:25-ol10 +# https://catalog.redhat.com/en/software/containers/ubi9/openjdk-25-runtime/69204990c46419100ce30a5b +FROM registry.redhat.io/ubi9/openjdk-25-runtime:1.24 ENV LANGUAGE='en_US:en' From a131ea951ae472803c37e3de9b10ffbbdc013145 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Fri, 9 Jan 2026 11:58:07 +0100 Subject: [PATCH 21/31] implement the review feedback and implement the role cleanup when a Role CR instance is deleted (also in the tests) --- ...rnetesUtil.java => KubernetesService.java} | 11 +- ...a => PostgreSQLAuthenticationService.java} | 9 +- .../core/PostgreSQLContextFactory.java | 9 +- .../it/aboutbits/postgresql/core/SQLUtil.java | 53 ++++ .../role/{RoleFlags.java => RoleFlag.java} | 2 +- .../postgresql/crd/role/RoleReconciler.java | 96 ++++++- .../role/{RoleUtil.java => RoleService.java} | 247 ++++++++---------- src/main/resources/application.yml | 25 +- .../PostgreSQLInstanceReadinessCheckTest.java | 5 +- .../persisted/creator/SecretRefCreate.java | 6 +- .../postgresql/core/SQLUtilTest.java | 99 +++++++ .../ClusterConnectionReconcilerTest.java | 11 + .../crd/role/RoleReconcilerTest.java | 93 ++++--- 13 files changed, 439 insertions(+), 227 deletions(-) rename src/main/java/it/aboutbits/postgresql/core/{KubernetesUtil.java => KubernetesService.java} (94%) rename src/main/java/it/aboutbits/postgresql/core/{PostgreSQLAuthenticationUtil.java => PostgreSQLAuthenticationService.java} (98%) create mode 100644 src/main/java/it/aboutbits/postgresql/core/SQLUtil.java rename src/main/java/it/aboutbits/postgresql/crd/role/{RoleFlags.java => RoleFlag.java} (97%) rename src/main/java/it/aboutbits/postgresql/crd/role/{RoleUtil.java => RoleService.java} (78%) create mode 100644 src/test/java/it/aboutbits/postgresql/core/SQLUtilTest.java diff --git a/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java b/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java similarity index 94% rename from src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java rename to src/main/java/it/aboutbits/postgresql/core/KubernetesService.java index 2fb35dd..5b4e93a 100644 --- a/src/main/java/it/aboutbits/postgresql/core/KubernetesUtil.java +++ b/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java @@ -2,18 +2,20 @@ import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import jakarta.inject.Singleton; import org.jspecify.annotations.NullMarked; import java.nio.charset.Charset; import java.util.Base64; @NullMarked -public final class KubernetesUtil { +@Singleton +public final class KubernetesService { public static final String SECRET_TYPE_BASIC_AUTH = "kubernetes.io/basic-auth"; public static final String SECRET_DATA_BASIC_AUTH_USERNAME_KEY = "username"; public static final String SECRET_DATA_BASIC_AUTH_PASSWORD_KEY = "password"; - public static Credentials getSecretRefCredentials( + public Credentials getSecretRefCredentials( KubernetesClient kubernetesClient, ClusterConnection clusterConnection ) { @@ -24,7 +26,7 @@ public static Credentials getSecretRefCredentials( ); } - public static Credentials getSecretRefCredentials( + public Credentials getSecretRefCredentials( KubernetesClient kubernetesClient, SecretRef secretRef, String defaultNamespace @@ -89,7 +91,4 @@ public static Credentials getSecretRefCredentials( password ); } - - private KubernetesUtil() { - } } diff --git a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationService.java similarity index 98% rename from src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java rename to src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationService.java index 43a515d..4f6c849 100644 --- a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationUtil.java +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationService.java @@ -2,6 +2,7 @@ import com.ongres.scram.common.StringPreparation; import it.aboutbits.postgresql.crd.role.RoleSpec; +import jakarta.inject.Singleton; import lombok.extern.slf4j.Slf4j; import org.jooq.DSLContext; import org.jspecify.annotations.NullMarked; @@ -22,13 +23,14 @@ @NullMarked @Slf4j -public final class PostgreSQLAuthenticationUtil { +@Singleton +public final class PostgreSQLAuthenticationService { private static final String MD5 = "MD5"; private static final String SHA_256 = "SHA-256"; private static final String HMAC_SHA_256 = "HmacSHA256"; private static final String PBKDF2_WITH_HMAC_SHA256 = "PBKDF2WithHmacSHA256"; - public static boolean passwordMatches( + public boolean passwordMatches( DSLContext dsl, RoleSpec spec, String expectedPassword @@ -214,7 +216,4 @@ private static byte[] sha256(byte[] data) { throw new IllegalStateException("%s not available".formatted(SHA_256), e); } } - - private PostgreSQLAuthenticationUtil() { - } } diff --git a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java index e059cb5..aa00111 100644 --- a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java @@ -3,6 +3,7 @@ import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql.crd.connection.ClusterConnection; import jakarta.enterprise.context.ApplicationScoped; +import lombok.RequiredArgsConstructor; import org.jooq.CloseableDSLContext; import org.jooq.impl.DSL; import org.jspecify.annotations.NullMarked; @@ -11,18 +12,16 @@ @NullMarked @ApplicationScoped +@RequiredArgsConstructor public class PostgreSQLContextFactory { private static final String POSTGRESQL_AUTHENTICATION_USER_KEY = "user"; private static final String POSTGRESQL_AUTHENTICATION_PASSWORD_KEY = "password"; + private final KubernetesService kubernetesService; private final KubernetesClient kubernetesClient; - public PostgreSQLContextFactory(KubernetesClient kubernetesClient) { - this.kubernetesClient = kubernetesClient; - } - public CloseableDSLContext getDSLContext(ClusterConnection clusterConnection) { - var credentials = KubernetesUtil.getSecretRefCredentials( + var credentials = kubernetesService.getSecretRefCredentials( kubernetesClient, clusterConnection ); diff --git a/src/main/java/it/aboutbits/postgresql/core/SQLUtil.java b/src/main/java/it/aboutbits/postgresql/core/SQLUtil.java new file mode 100644 index 0000000..b07f347 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/SQLUtil.java @@ -0,0 +1,53 @@ +package it.aboutbits.postgresql.core; + +import org.jooq.QueryPart; +import org.jspecify.annotations.NullMarked; + +import java.util.List; + +import static org.jooq.impl.DSL.sql; + +@NullMarked +public final class SQLUtil { + public static QueryPart concatenateQueryPartsWithSpaces(List parts) { + return concatenateQueryParts(parts, " "); + } + + public static QueryPart concatenateQueryPartsWithComma(List parts) { + return concatenateQueryParts(parts, ", "); + } + + /** + * Concatenate QueryParts with the requested separator + */ + private static QueryPart concatenateQueryParts( + List items, + String separator + ) { + int size = items.size(); + + if (items.isEmpty()) { + return sql(""); + } else if (size == 1) { + return items.getFirst(); + } + + var template = new StringBuilder(); + + // Add the first item without a separator + template.append('{').append(0).append('}'); + + // Add the rest of the items with the leading separator + for (int i = 1; i < size; i++) { + template.append(separator).append('{').append(i).append('}'); + } + + return sql( + template.toString(), + items.toArray(QueryPart[]::new) + ); + } + + private SQLUtil() { + } +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlag.java similarity index 97% rename from src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java rename to src/main/java/it/aboutbits/postgresql/crd/role/RoleFlag.java index 4b06381..1731ed2 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlags.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlag.java @@ -9,7 +9,7 @@ @Getter @Accessors(fluent = true) @RequiredArgsConstructor -public enum RoleFlags { +public enum RoleFlag { SUPERUSER("SUPERUSER"), NO_SUPERUSER("NOSUPERUSER"), diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java index 1daf74b..76395a8 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -3,7 +3,9 @@ import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.api.config.informer.InformerEventSourceConfiguration; +import io.javaoperatorsdk.operator.api.reconciler.Cleaner; import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.DeleteControl; import io.javaoperatorsdk.operator.api.reconciler.EventSourceContext; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; @@ -14,8 +16,8 @@ import it.aboutbits.postgresql.core.BaseReconciler; import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; -import it.aboutbits.postgresql.core.KubernetesUtil; -import it.aboutbits.postgresql.core.PostgreSQLAuthenticationUtil; +import it.aboutbits.postgresql.core.KubernetesService; +import it.aboutbits.postgresql.core.PostgreSQLAuthenticationService; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -32,7 +34,11 @@ @RequiredArgsConstructor public class RoleReconciler extends BaseReconciler - implements Reconciler { + implements Reconciler, Cleaner { + private final RoleService roleService; + private final KubernetesService kubernetesService; + private final PostgreSQLAuthenticationService postgreSQLAuthenticationService; + private final KubernetesClient kubernetesClient; private final PostgreSQLContextFactory contextFactory; @@ -84,7 +90,7 @@ public UpdateControl reconcile( String password; if (passwordSecretRef != null) { - password = KubernetesUtil.getSecretRefCredentials( + password = kubernetesService.getSecretRefCredentials( kubernetesClient, passwordSecretRef, namespace @@ -116,6 +122,70 @@ public UpdateControl reconcile( return updateControl; } + @Override + public DeleteControl cleanup( + Role resource, + Context context + ) { + var spec = resource.getSpec(); + var status = initializeStatus(resource); + + var name = resource.getMetadata().getName(); + var namespace = resource.getMetadata().getNamespace(); + + log.info( + "Deleting Role [resource={}/{}, spec.name={}, status.phase={}]", + namespace, + name, + spec.getName(), + status.getPhase() + ); + + if (status.getPhase() != CRPhase.DELETING) { + status.setPhase(CRPhase.DELETING) + .setMessage("Role deletion in progress"); + } + + var clusterRef = spec.getClusterRef(); + + var clusterConnectionOptional = getReferencedClusterConnection( + kubernetesClient, + resource, + clusterRef + ); + + if (clusterConnectionOptional.isEmpty()) { + status.setMessage("The specified ClusterConnection no longer exists or is not ready yet [clusterRef=%s/%s]".formatted( + getResourceNamespaceOrOwn(resource, clusterRef.getNamespace()), + clusterRef.getName() + )); + + return DeleteControl.noFinalizerRemoval() + .rescheduleAfter(60, TimeUnit.SECONDS); + } + + var clusterConnection = clusterConnectionOptional.get(); + + try (var dsl = contextFactory.getDSLContext(clusterConnection)) { + roleService.dropRole(dsl, spec); + + return DeleteControl.defaultDelete(); + } catch (Exception e) { + log.error( + "Failed to delete Role [resource={}/{}, spec.name={}, status.phase={}]", + namespace, + name, + spec.getName(), + status.getPhase() + ); + + status.setMessage("Deletion failed: " + e.getMessage()); + + return DeleteControl.noFinalizerRemoval() + .rescheduleAfter(60, TimeUnit.SECONDS); + } + } + /** * Watches for {@code Secret} changes to trigger reconciliation for dependent {@code Role} resources. */ @@ -160,14 +230,14 @@ private UpdateControl reconcileInTransaction( var expectedFlags = spec.getFlags(); // Create and return the role if it doesn't exist yet - if (!RoleUtil.roleExists(tx, spec)) { + if (!roleService.roleExists(tx, spec)) { log.info( "Creating Role [resource={}/{}]", namespace, name ); - RoleUtil.createRole( + roleService.createRole( tx, spec, password @@ -181,16 +251,16 @@ private UpdateControl reconcileInTransaction( // When there is NOLOGIN, we set no password var passwordMatches = true; - var roleLoginMatches = RoleUtil.roleLoginMatches(tx, spec); - var currentFlags = RoleUtil.fetchCurrentFlags(tx, spec); + var roleLoginMatches = roleService.roleLoginMatches(tx, spec); + var currentFlags = roleService.fetchCurrentFlags(tx, spec); var flagsMatch = expectedFlags.equals(currentFlags); - var commentMatches = RoleUtil.roleCommentMatches(tx, spec); + var commentMatches = roleService.roleCommentMatches(tx, spec); var passwordSecretRef = spec.getPasswordSecretRef(); var loginExpected = passwordSecretRef != null; if (loginExpected && password != null) { - passwordMatches = PostgreSQLAuthenticationUtil.passwordMatches( + passwordMatches = postgreSQLAuthenticationService.passwordMatches( tx, spec, password @@ -216,7 +286,7 @@ private UpdateControl reconcileInTransaction( ); if (!roleLoginMatches || !passwordMatches || !flagsMatch) { - RoleUtil.alterRole( + roleService.alterRole( tx, spec, changePassword, @@ -231,7 +301,7 @@ private UpdateControl reconcileInTransaction( name ); - RoleUtil.reconcileRoleMembership( + roleService.reconcileRoleMembership( tx, spec, expectedFlags, @@ -240,7 +310,7 @@ private UpdateControl reconcileInTransaction( } if (!commentMatches) { - RoleUtil.updateComment( + roleService.updateComment( tx, spec ); diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleService.java similarity index 78% rename from src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java rename to src/main/java/it/aboutbits/postgresql/crd/role/RoleService.java index 5c14728..740b387 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleUtil.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleService.java @@ -1,6 +1,8 @@ package it.aboutbits.postgresql.crd.role; +import it.aboutbits.postgresql.core.SQLUtil; import it.aboutbits.postgresql.core.infrastructure.persistence.Routines; +import jakarta.inject.Singleton; import org.jooq.DSLContext; import org.jooq.Query; import org.jooq.QueryPart; @@ -11,7 +13,6 @@ import java.util.ArrayList; import java.util.HashSet; -import java.util.List; import java.util.Objects; import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; @@ -28,8 +29,9 @@ import static org.jooq.impl.DSL.val; @NullMarked -public final class RoleUtil { - public static boolean roleExists( +@Singleton +public final class RoleService { + public boolean roleExists( DSLContext tx, RoleSpec spec ) { @@ -39,7 +41,7 @@ public static boolean roleExists( ); } - public static void createRole( + public void createRole( DSLContext tx, RoleSpec spec, @Nullable String password @@ -64,7 +66,7 @@ public static void createRole( } } - public static void alterRole( + public void alterRole( DSLContext tx, RoleSpec spec, boolean changePassword, @@ -83,7 +85,7 @@ public static void alterRole( ); } - public static void updateComment( + public void updateComment( DSLContext tx, RoleSpec spec ) { @@ -101,7 +103,7 @@ public static void updateComment( } } - public static boolean roleCommentMatches( + public boolean roleCommentMatches( DSLContext tx, RoleSpec spec ) { @@ -114,7 +116,7 @@ public static boolean roleCommentMatches( return Objects.equals(currentComment, expectedComment); } - public static @Nullable String fetchCurrentRoleComment( + public @Nullable String fetchCurrentRoleComment( DSLContext tx, String roleName ) { @@ -129,7 +131,7 @@ public static boolean roleCommentMatches( .fetchOneInto(String.class); } - public static boolean roleLoginMatches( + public boolean roleLoginMatches( DSLContext tx, RoleSpec spec ) { @@ -144,7 +146,7 @@ public static boolean roleLoginMatches( return loginExpected == canLogin; } - public static RoleSpec.Flags fetchCurrentFlags( + public RoleSpec.Flags fetchCurrentFlags( DSLContext tx, RoleSpec spec ) { @@ -183,6 +185,68 @@ public static RoleSpec.Flags fetchCurrentFlags( .fetchSingleInto(RoleSpec.Flags.class); } + public void reconcileRoleMembership( + DSLContext tx, + RoleSpec spec, + RoleSpec.Flags expectedFlags, + RoleSpec.Flags currentFlags + ) { + var roleName = spec.getName(); + + // ROLE IN + var expectedInRole = new HashSet<>(expectedFlags.getInRole()); + var currentInRole = new HashSet<>(currentFlags.getInRole()); + + var queries = new ArrayList(); + + var inRoleToGrant = new HashSet<>(expectedInRole); + inRoleToGrant.removeAll(currentInRole); + + var inRoleToRevoke = new HashSet<>(currentInRole); + inRoleToRevoke.removeAll(expectedInRole); + + for (var parentRole : inRoleToGrant) { + // GRANT parentRole TO roleName + queries.add(buildGrantRoleToMember(parentRole, roleName)); + } + for (var parentRole : inRoleToRevoke) { + // REVOKE parentRole FROM roleName + queries.add(buildRevokeRoleFromMember(parentRole, roleName)); + } + + // ROLE + var expectedRoleMembers = new HashSet<>(expectedFlags.getRole()); + var currentRoleMembers = new HashSet<>(currentFlags.getRole()); + + var roleMembersToGrant = new HashSet<>(expectedRoleMembers); + roleMembersToGrant.removeAll(currentRoleMembers); + + var roleMembersToRevoke = new HashSet<>(currentRoleMembers); + roleMembersToRevoke.removeAll(expectedRoleMembers); + + for (var member : roleMembersToGrant) { + // GRANT roleName TO member + queries.add(buildGrantRoleToMember(roleName, member)); + } + for (var member : roleMembersToRevoke) { + // REVOKE roleName FROM member + queries.add(buildRevokeRoleFromMember(roleName, member)); + } + + if (!queries.isEmpty()) { + tx.batch(queries).execute(); + } + } + + public void dropRole( + DSLContext dsl, + RoleSpec spec + ) { + dsl.execute( + query("drop role if exists {0}", role(spec.getName())) + ); + } + /** * Build: CREATE ROLE [ [ WITH ] option [ ... ] ] * See @@ -198,43 +262,43 @@ private static Query buildCreateRole( // Only allow the user to log in if a password is specified. if (password != null) { - options.add(keyword(RoleFlags.LOGIN.flag())); - options.add(keyword(RoleFlags.PASSWORD.flag())); + options.add(keyword(RoleFlag.LOGIN.flag())); + options.add(keyword(RoleFlag.PASSWORD.flag())); options.add(val(password)); } if (flags.isSuperuser()) { - options.add(keyword(RoleFlags.SUPERUSER.flag())); + options.add(keyword(RoleFlag.SUPERUSER.flag())); } if (flags.isCreatedb()) { - options.add(keyword(RoleFlags.CREATEDB.flag())); + options.add(keyword(RoleFlag.CREATEDB.flag())); } if (flags.isCreaterole()) { - options.add(keyword(RoleFlags.CREATEROLE.flag())); + options.add(keyword(RoleFlag.CREATEROLE.flag())); } if (flags.isInherit()) { - options.add(keyword(RoleFlags.INHERIT.flag())); + options.add(keyword(RoleFlag.INHERIT.flag())); } if (flags.isReplication()) { - options.add(keyword(RoleFlags.REPLICATION.flag())); + options.add(keyword(RoleFlag.REPLICATION.flag())); } if (flags.isBypassrls()) { - options.add(keyword(RoleFlags.BYPASSRLS.flag())); + options.add(keyword(RoleFlag.BYPASSRLS.flag())); } if (flags.getConnectionLimit() >= 0) { - options.add(keyword(RoleFlags.CONNECTION_LIMIT.flag())); + options.add(keyword(RoleFlag.CONNECTION_LIMIT.flag())); options.add(val(flags.getConnectionLimit())); } var validUntil = flags.getValidUntil(); if (validUntil != null) { - options.add(keyword(RoleFlags.VALID_UNTIL.flag())); + options.add(keyword(RoleFlag.VALID_UNTIL.flag())); options.add(val(validUntil.toString())); } if (!flags.getInRole().isEmpty()) { - options.add(keyword(RoleFlags.IN_ROLE.flag())); - options.add(joinWithComma( + options.add(keyword(RoleFlag.IN_ROLE.flag())); + options.add(SQLUtil.concatenateQueryPartsWithComma( flags.getInRole() .stream() .map(DSL::role) @@ -242,8 +306,8 @@ private static Query buildCreateRole( )); } if (!flags.getRole().isEmpty()) { - options.add(keyword(RoleFlags.ROLE.flag())); - options.add(joinWithComma( + options.add(keyword(RoleFlag.ROLE.flag())); + options.add(SQLUtil.concatenateQueryPartsWithComma( flags.getRole() .stream() .map(DSL::role) @@ -253,7 +317,7 @@ private static Query buildCreateRole( var optionsSql = options.isEmpty() ? sql("") // nothing - : sql(" with {0}", joinWithSpaces(options)); + : sql(" with {0}", SQLUtil.concatenateQueryPartsWithSpaces(options)); return query( "create role {0}{1}", @@ -273,52 +337,52 @@ private static Query buildAlterRole( // LOGIN / NOLOGIN options.add(keyword(loginExpected - ? RoleFlags.LOGIN.flag() - : RoleFlags.NO_LOGIN.flag() + ? RoleFlag.LOGIN.flag() + : RoleFlag.NO_LOGIN.flag() )); // Password handling // - if NOLOGIN, remove the password // - if LOGIN and passwordChanged, set the new password if (!loginExpected) { - options.add(keyword(RoleFlags.PASSWORD.flag())); + options.add(keyword(RoleFlag.PASSWORD.flag())); options.add(keyword("NULL")); } else if (changePassword) { - options.add(keyword(RoleFlags.PASSWORD.flag())); + options.add(keyword(RoleFlag.PASSWORD.flag())); options.add(val(password)); } // Explicitly set the expected state to make the statement idempotent options.add(keyword(flags.isSuperuser() - ? RoleFlags.SUPERUSER.flag() - : RoleFlags.NO_SUPERUSER.flag() + ? RoleFlag.SUPERUSER.flag() + : RoleFlag.NO_SUPERUSER.flag() )); options.add(keyword(flags.isCreatedb() - ? RoleFlags.CREATEDB.flag() - : RoleFlags.NO_CREATEDB.flag() + ? RoleFlag.CREATEDB.flag() + : RoleFlag.NO_CREATEDB.flag() )); options.add(keyword(flags.isCreaterole() - ? RoleFlags.CREATEROLE.flag() - : RoleFlags.NO_CREATEROLE.flag() + ? RoleFlag.CREATEROLE.flag() + : RoleFlag.NO_CREATEROLE.flag() )); options.add(keyword(flags.isInherit() - ? RoleFlags.INHERIT.flag() - : RoleFlags.NO_INHERIT.flag() + ? RoleFlag.INHERIT.flag() + : RoleFlag.NO_INHERIT.flag() )); options.add(keyword(flags.isReplication() - ? RoleFlags.REPLICATION.flag() - : RoleFlags.NO_REPLICATION.flag() + ? RoleFlag.REPLICATION.flag() + : RoleFlag.NO_REPLICATION.flag() )); options.add(keyword(flags.isBypassrls() - ? RoleFlags.BYPASSRLS.flag() - : RoleFlags.NO_BYPASSRLS.flag() + ? RoleFlag.BYPASSRLS.flag() + : RoleFlag.NO_BYPASSRLS.flag() )); - options.add(keyword(RoleFlags.CONNECTION_LIMIT.flag())); + options.add(keyword(RoleFlag.CONNECTION_LIMIT.flag())); options.add(val(flags.getConnectionLimit())); var validUntil = flags.getValidUntil(); - options.add(keyword(RoleFlags.VALID_UNTIL.flag())); + options.add(keyword(RoleFlag.VALID_UNTIL.flag())); if (validUntil != null) { options.add(val(validUntil.toString())); } else { @@ -328,63 +392,10 @@ private static Query buildAlterRole( return query( "alter role {0} with {1}", role(roleName), - joinWithSpaces(options) + SQLUtil.concatenateQueryPartsWithSpaces(options) ); } - public static void reconcileRoleMembership( - DSLContext tx, - RoleSpec spec, - RoleSpec.Flags expectedFlags, - RoleSpec.Flags currentFlags - ) { - var roleName = spec.getName(); - - // ROLE IN - var expectedInRole = new HashSet<>(expectedFlags.getInRole()); - var currentInRole = new HashSet<>(currentFlags.getInRole()); - - var queries = new ArrayList(); - - var inRoleToGrant = new HashSet<>(expectedInRole); - inRoleToGrant.removeAll(currentInRole); - - var inRoleToRevoke = new HashSet<>(currentInRole); - inRoleToRevoke.removeAll(expectedInRole); - - for (var parentRole : inRoleToGrant) { - // GRANT parentRole TO roleName - queries.add(buildGrantRoleToMember(parentRole, roleName)); - } - for (var parentRole : inRoleToRevoke) { - // REVOKE parentRole FROM roleName - queries.add(buildRevokeRoleFromMember(parentRole, roleName)); - } - - // ROLE - var expectedRoleMembers = new HashSet<>(expectedFlags.getRole()); - var currentRoleMembers = new HashSet<>(currentFlags.getRole()); - - var roleMembersToGrant = new HashSet<>(expectedRoleMembers); - roleMembersToGrant.removeAll(currentRoleMembers); - - var roleMembersToRevoke = new HashSet<>(currentRoleMembers); - roleMembersToRevoke.removeAll(expectedRoleMembers); - - for (var member : roleMembersToGrant) { - // GRANT roleName TO member - queries.add(buildGrantRoleToMember(roleName, member)); - } - for (var member : roleMembersToRevoke) { - // REVOKE roleName FROM member - queries.add(buildRevokeRoleFromMember(roleName, member)); - } - - if (!queries.isEmpty()) { - tx.batch(queries).execute(); - } - } - private static Query buildGrantRoleToMember( String role, String member @@ -420,46 +431,4 @@ private static Query buildCommentOnRole( return comment; } - - /** - * Join QueryParts with the requested separator - */ - private static QueryPart join( - List items, - String separator - ) { - int size = items.size(); - - if (items.isEmpty()) { - return sql(""); - } else if (size == 1) { - return items.getFirst(); - } - - var template = new StringBuilder(); - - // Add the first item without a separator - template.append('{').append(0).append('}'); - - // Add the rest of the items with the leading separator - for (int i = 1; i < size; i++) { - template.append(separator).append('{').append(i).append('}'); - } - - return sql( - template.toString(), - items.toArray(QueryPart[]::new) - ); - } - - private static QueryPart joinWithSpaces(List parts) { - return join(parts, " "); - } - - private static QueryPart joinWithComma(List parts) { - return join(parts, ", "); - } - - private RoleUtil() { - } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index b2fccbd..b3783d2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -2,8 +2,9 @@ quarkus: kubernetes-client: devservices: enabled: true - # To not use our default kubeconfig from the home directory, - # which could lead to catastrophic damage if we had the permissions + # To not use our default kubeconfig from the home directory, which could lead to + # potential damage if we had the permissions to install the CRD in an existing configured cluster context. + # By setting this to true, Quarkus will use a temporary kubeconfig that will be removed after the test. override-kubeconfig: true flavor: k3s # See https://github.com/dajudge/kindcontainer/blob/master/k8s-versions.json @@ -12,11 +13,6 @@ quarkus: instrumentation: true micrometer: enabled: true - export: - # Additionally to Prometheus, we could also consume the metrics in JSON format - # by calling /q/metrics with the HTTP header "Accept: application/json" - json: - enabled: true datasource: devservices: enabled: true @@ -57,9 +53,8 @@ quarkus: type: application name: ${quarkus.kubernetes.name} description: AboutBits PostgreSQL Operator Helm Chart - # Keep the map-system-properties flag below on false (else it will map all ${ENV_VARS} we define in the application.yml or application-prod.yml files - # as in Kubernetes the env entries take precedence over envFrom entries as we want to consume all ENV vars from the postgresql-operator-config secret - # This started working with Gradle since Quarkus Helm 1.2.5 + # Keep the map-system-properties flag below on false, else it will map all ${ENV_VARS} we define in the application.yml or application-prod.yml files. + # In Kubernetes the env entries take precedence over envFrom entries map-system-properties: false home: https://github.com/aboutbits/postgresql-operator sources: @@ -103,11 +98,6 @@ quarkus: value: ${quarkus.kubernetes.resources.limits.memory} paths: - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).resources.limits.memory - secret-name: - property: secret.name - value: ${quarkus.kubernetes.envFrom.secretRef[0].name} - paths: - - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).envFrom[0].secretRef.name image-pull-secret: property: imagePullSecret value: ${quarkus.kubernetes.image-pull-secrets[0]} @@ -136,9 +126,6 @@ quarkus: replicas: 1 annotations: "app.kubernetes.io/version": ${quarkus.application.version} - secret-volumes: - postgresql-operator-config: - secret-name: postgresql-operator-config resources: requests: cpu: 50m @@ -169,5 +156,3 @@ quarkus: env: fields: KUBERNETES_NODE_NAME: spec.nodeName - secrets: - - postgresql-operator-config diff --git a/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java index a8098dd..04e2d08 100644 --- a/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java +++ b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; import java.util.Objects; +import java.util.concurrent.TimeUnit; import static org.assertj.core.api.Assertions.assertThat; @@ -30,7 +31,9 @@ class PostgreSQLInstanceReadinessCheckTest { @BeforeEach void cleanUp() { - kubernetesClient.resources(ClusterConnection.class).delete(); + kubernetesClient.resources(ClusterConnection.class) + .withTimeout(5, TimeUnit.SECONDS) + .delete(); } @Test diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java index dbe6ce5..b178097 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java @@ -7,9 +7,9 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -import static it.aboutbits.postgresql.core.KubernetesUtil.SECRET_DATA_BASIC_AUTH_PASSWORD_KEY; -import static it.aboutbits.postgresql.core.KubernetesUtil.SECRET_DATA_BASIC_AUTH_USERNAME_KEY; -import static it.aboutbits.postgresql.core.KubernetesUtil.SECRET_TYPE_BASIC_AUTH; +import static it.aboutbits.postgresql.core.KubernetesService.SECRET_DATA_BASIC_AUTH_PASSWORD_KEY; +import static it.aboutbits.postgresql.core.KubernetesService.SECRET_DATA_BASIC_AUTH_USERNAME_KEY; +import static it.aboutbits.postgresql.core.KubernetesService.SECRET_TYPE_BASIC_AUTH; @NullMarked public class SecretRefCreate extends TestDataCreator { diff --git a/src/test/java/it/aboutbits/postgresql/core/SQLUtilTest.java b/src/test/java/it/aboutbits/postgresql/core/SQLUtilTest.java new file mode 100644 index 0000000..917bdc1 --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/core/SQLUtilTest.java @@ -0,0 +1,99 @@ +package it.aboutbits.postgresql.core; + +import org.jooq.QueryPart; +import org.jooq.SQLDialect; +import org.jooq.impl.DSL; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.jooq.impl.DSL.sql; + +@NullMarked +class SQLUtilTest { + @Nested + class ConcatenateQueryPartsWithSpaces { + @Test + @DisplayName("when empty, should return empty string") + void whenEmpty_shouldReturnEmptyString() { + // given / when + var result = SQLUtil.concatenateQueryPartsWithSpaces(List.of()); + + // then + assertThat(render(result)).isEmpty(); + } + + @Test + @DisplayName("when single item, should return item") + void whenSingleItem_shouldReturnItem() { + // given + var part = sql("item1"); + + // when + var result = SQLUtil.concatenateQueryPartsWithSpaces(List.of(part)); + + // then + assertThat(render(result)).isEqualTo("item1"); + } + + @Test + @DisplayName("when multiple items, should join with spaces") + void whenMultipleItems_shouldJoinWithSpaces() { + // given + var parts = List.of(sql("item1"), sql("item2"), sql("item3")); + + // when + var result = SQLUtil.concatenateQueryPartsWithSpaces(parts); + + // then + assertThat(render(result)).isEqualTo("item1 item2 item3"); + } + } + + @Nested + class ConcatenateQueryPartsWithComma { + @Test + @DisplayName("when empty, should return empty string") + void whenEmpty_shouldReturnEmptyString() { + // given / when + var result = SQLUtil.concatenateQueryPartsWithComma(List.of()); + + // then + assertThat(render(result)).isEmpty(); + } + + @Test + @DisplayName("when single item, should return item") + void whenSingleItem_shouldReturnItem() { + // given + var part = sql("item1"); + + // when + var result = SQLUtil.concatenateQueryPartsWithComma(List.of(part)); + + // then + assertThat(render(result)).isEqualTo("item1"); + } + + @Test + @DisplayName("when multiple items, should join with comma") + void whenMultipleItems_shouldJoinWithComma() { + // given + var parts = List.of(sql("item1"), sql("item2"), sql("item3")); + + // when + var result = SQLUtil.concatenateQueryPartsWithComma(parts); + + // then + assertThat(render(result)).isEqualTo("item1, item2, item3"); + } + } + + private String render(QueryPart queryPart) { + return DSL.using(SQLDialect.POSTGRES).render(queryPart); + } +} diff --git a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java index 3e9ca0b..d65d6fb 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java @@ -1,5 +1,6 @@ package it.aboutbits.postgresql.crd.connection; +import io.fabric8.kubernetes.client.KubernetesClient; import io.quarkus.test.junit.QuarkusTest; import it.aboutbits.postgresql._support.testdata.persisted.Given; import it.aboutbits.postgresql.core.CRPhase; @@ -9,6 +10,7 @@ import org.jooq.DSLContext; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -16,6 +18,7 @@ import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; import java.util.Objects; +import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicReference; import static org.assertj.core.api.Assertions.assertThat; @@ -29,6 +32,14 @@ class ClusterConnectionReconcilerTest { private final Given given; private final PostgreSQLContextFactory postgreSQLContextFactory; + private final KubernetesClient kubernetesClient; + + @BeforeEach + void cleanUp() { + kubernetesClient.resources(ClusterConnection.class) + .withTimeout(5, TimeUnit.SECONDS) + .delete(); + } @Test @DisplayName("When a ClusterConnection is created, the status should be ready") diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 329a34c..895ba7e 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -8,14 +8,16 @@ import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; import it.aboutbits.postgresql.core.ClusterReference; -import it.aboutbits.postgresql.core.PostgreSQLAuthenticationUtil; +import it.aboutbits.postgresql.core.PostgreSQLAuthenticationService; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import it.aboutbits.postgresql.core.SecretRef; +import it.aboutbits.postgresql.crd.connection.ClusterConnection; import lombok.RequiredArgsConstructor; import org.jooq.DSLContext; import org.jooq.Field; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; @@ -31,10 +33,9 @@ import java.util.function.Predicate; import java.util.stream.Stream; -import static it.aboutbits.postgresql.core.KubernetesUtil.SECRET_DATA_BASIC_AUTH_PASSWORD_KEY; +import static it.aboutbits.postgresql.core.KubernetesService.SECRET_DATA_BASIC_AUTH_PASSWORD_KEY; import static it.aboutbits.postgresql.core.infrastructure.persistence.Tables.PG_AUTHID; import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.within; import static org.awaitility.Awaitility.await; import static org.jooq.impl.DSL.role; @@ -44,9 +45,23 @@ class RoleReconcilerTest { private final Given given; + private final RoleService roleService; private final PostgreSQLContextFactory postgreSQLContextFactory; + private final PostgreSQLAuthenticationService postgreSQLAuthenticationService; + private final KubernetesClient kubernetesClient; + @BeforeEach + void cleanUp() { + kubernetesClient.resources(Role.class) + .withTimeout(5, TimeUnit.SECONDS) + .delete(); + + kubernetesClient.resources(ClusterConnection.class) + .withTimeout(5, TimeUnit.SECONDS) + .delete(); + } + @Test @DisplayName("When a Role (LOGIN) is created, it should be reconciled to READY and present in pg_authid") void createRole_withLogin_andStatusReady() { @@ -85,8 +100,8 @@ void createRole_withLogin_andStatusReady() { var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); - assertThat(RoleUtil.roleExists(dsl, reconciled.getSpec())).isTrue(); - assertThat(RoleUtil.roleLoginMatches(dsl, reconciled.getSpec())).isTrue(); + assertThat(roleService.roleExists(dsl, reconciled.getSpec())).isTrue(); + assertThat(roleService.roleLoginMatches(dsl, reconciled.getSpec())).isTrue(); } @Test @@ -123,8 +138,8 @@ void createRole_withoutLogin_andStatusReady() { var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); - assertThat(RoleUtil.roleExists(dsl, reconciled.getSpec())).isTrue(); - assertThat(RoleUtil.roleLoginMatches(dsl, reconciled.getSpec())).isTrue(); + assertThat(roleService.roleExists(dsl, reconciled.getSpec())).isTrue(); + assertThat(roleService.roleLoginMatches(dsl, reconciled.getSpec())).isTrue(); } @Test @@ -164,7 +179,7 @@ void toggleRoleLogin_updatesCorrectly() { var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); assertThat( - RoleUtil.roleExists(dsl, reconciled.getSpec()) + roleService.roleExists(dsl, reconciled.getSpec()) ).isTrue(); assertThat( @@ -223,9 +238,8 @@ void createRole_withMissingClusterConnection_setsPending() { assertThat(reconciled.getStatus().getMessage()).startsWith( "The specified ClusterConnection does not exist" ); - assertThat(reconciled.getStatus().getLastProbeTime()).isCloseTo( - now, - within(10, ChronoUnit.SECONDS) + assertThat(reconciled.getStatus().getLastProbeTime()).isAfter( + now ); assertThat(reconciled.getStatus().getLastPhaseTransitionTime()).isNull(); } @@ -273,7 +287,7 @@ void secretChange_triggersReconciliation() { // Wait for password to match because reconciliation might take a bit await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) - .until(() -> PostgreSQLAuthenticationUtil.passwordMatches( + .until(() -> postgreSQLAuthenticationService.passwordMatches( dsl, reconciled.getSpec(), initialPassword @@ -293,7 +307,7 @@ void secretChange_triggersReconciliation() { // then: password should eventually match the new one await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) - .until(() -> PostgreSQLAuthenticationUtil.passwordMatches( + .until(() -> postgreSQLAuthenticationService.passwordMatches( dsl, reconciled.getSpec(), newPassword @@ -345,7 +359,7 @@ void secretRefChange_triggersReconciliation() { var finalRole = reconciled; await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) - .until(() -> PostgreSQLAuthenticationUtil.passwordMatches( + .until(() -> postgreSQLAuthenticationService.passwordMatches( dsl, finalRole.getSpec(), initialPassword @@ -360,7 +374,7 @@ void secretRefChange_triggersReconciliation() { var updatedRole = reconciled; await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) - .until(() -> PostgreSQLAuthenticationUtil.passwordMatches( + .until(() -> postgreSQLAuthenticationService.passwordMatches( dsl, updatedRole.getSpec(), newPassword @@ -398,7 +412,7 @@ void comment_updatesCorrectly() { // then assertThat( - RoleUtil.fetchCurrentRoleComment(dsl, roleName) + roleService.fetchCurrentRoleComment(dsl, roleName) ).isEqualTo(comment); // 2. Change comment @@ -413,7 +427,7 @@ void comment_updatesCorrectly() { // then assertThat( - RoleUtil.fetchCurrentRoleComment(dsl, roleName) + roleService.fetchCurrentRoleComment(dsl, roleName) ).isEqualTo(newComment); // 3. Remove comment @@ -427,7 +441,7 @@ void comment_updatesCorrectly() { // then assertThat( - RoleUtil.fetchCurrentRoleComment(dsl, roleName) + roleService.fetchCurrentRoleComment(dsl, roleName) ).isNull(); } @@ -591,7 +605,7 @@ void validUntil_updatesCorrectly() { var reconciled = applyRole(role); var initialGeneration = reconciled.getStatus().getObservedGeneration(); - var currentFlags = RoleUtil.fetchCurrentFlags(dsl, spec); + var currentFlags = roleService.fetchCurrentFlags(dsl, spec); // then assertThat( @@ -608,7 +622,7 @@ void validUntil_updatesCorrectly() { r -> r.getStatus().getObservedGeneration() == initialGeneration + 1 ); - currentFlags = RoleUtil.fetchCurrentFlags(dsl, spec); + currentFlags = roleService.fetchCurrentFlags(dsl, spec); // then assertThat( @@ -624,7 +638,7 @@ void validUntil_updatesCorrectly() { r -> r.getStatus().getObservedGeneration() == initialGeneration + 2 ); - currentFlags = RoleUtil.fetchCurrentFlags(dsl, spec); + currentFlags = roleService.fetchCurrentFlags(dsl, spec); // then assertThat( @@ -669,7 +683,7 @@ void inRole_updatesCorrectly() { // then assertThat( - RoleUtil.fetchCurrentFlags(dsl, spec).getInRole() + roleService.fetchCurrentFlags(dsl, spec).getInRole() ).containsExactly(parentRole1); // 2. Add another parent role and remove the first one @@ -685,7 +699,7 @@ void inRole_updatesCorrectly() { // then assertThat( - RoleUtil.fetchCurrentFlags(dsl, spec).getInRole() + roleService.fetchCurrentFlags(dsl, spec).getInRole() ).containsExactly(parentRole2); // 3. Remove all parent roles @@ -701,8 +715,12 @@ void inRole_updatesCorrectly() { // then assertThat( - RoleUtil.fetchCurrentFlags(dsl, spec).getInRole() + roleService.fetchCurrentFlags(dsl, spec).getInRole() ).isEmpty(); + + // cleanup + dsl.execute("drop role if exists {0}", role(parentRole1)); + dsl.execute("drop role if exists {0}", role(parentRole2)); } @Test @@ -742,7 +760,7 @@ void role_updatesCorrectly() { // then assertThat( - RoleUtil.fetchCurrentFlags(dsl, spec).getRole() + roleService.fetchCurrentFlags(dsl, spec).getRole() ).containsExactly(memberRole1); // 2. Add another member role and remove the first one @@ -758,7 +776,7 @@ void role_updatesCorrectly() { // then assertThat( - RoleUtil.fetchCurrentFlags(dsl, spec).getRole() + roleService.fetchCurrentFlags(dsl, spec).getRole() ).containsExactly(memberRole2); // 3. Remove all member roles @@ -774,8 +792,12 @@ void role_updatesCorrectly() { // then assertThat( - RoleUtil.fetchCurrentFlags(dsl, spec).getRole() + roleService.fetchCurrentFlags(dsl, spec).getRole() ).isEmpty(); + + // cleanup + dsl.execute("drop role if exists {0}", role(memberRole1)); + dsl.execute("drop role if exists {0}", role(memberRole2)); } @Test @@ -816,8 +838,13 @@ void role_multipleMemberships_updatesCorrectly() { // then assertThat( - RoleUtil.fetchCurrentFlags(dsl, spec).getInRole() + roleService.fetchCurrentFlags(dsl, spec).getInRole() ).containsExactly(roleA, roleB, roleC); + + // cleanup + dsl.execute("drop role if exists {0}", role(roleA)); + dsl.execute("drop role if exists {0}", role(roleB)); + dsl.execute("drop role if exists {0}", role(roleC)); } private @Nullable T getRoleFlagValue( @@ -880,13 +907,11 @@ private static void assertThatRoleHasExpectedStatus( .isNotNull() .extracting(Role::getStatus) .satisfies(status -> { - assertThat(status.getLastProbeTime()).isCloseTo( - now, - within(10, ChronoUnit.SECONDS) + assertThat(status.getLastProbeTime()).isAfter( + now ); - assertThat(status.getLastPhaseTransitionTime()).isCloseTo( - now, - within(10, ChronoUnit.SECONDS) + assertThat(status.getLastPhaseTransitionTime()).isAfter( + now ); }) .usingRecursiveComparison() From a5d308167119e2e3d68dc69f20a55d912ac2c243 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Fri, 9 Jan 2026 12:05:39 +0100 Subject: [PATCH 22/31] add some additional CRStatus printer columns and Role#name validation rule so it is immutable --- .../postgresql/core/BaseReconciler.java | 4 +-- .../crd/connection/ClusterConnection.java | 26 +++++++++++++++++++ .../aboutbits/postgresql/crd/role/Role.java | 26 +++++++++++++++++++ .../postgresql/crd/role/RoleSpec.java | 5 ++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java index 07265a4..4b7ec77 100644 --- a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java @@ -8,11 +8,11 @@ import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; -import java.time.Duration; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.TimeUnit; @NullMarked @Slf4j @@ -97,6 +97,6 @@ public UpdateControl handleError( .setMessage(exception.getMessage()); return UpdateControl.patchStatus(resource) - .rescheduleAfter(Duration.ofSeconds(30)); + .rescheduleAfter(60, TimeUnit.SECONDS); } } diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java index cdd6d6a..7f11bf8 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java +++ b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java @@ -1,6 +1,7 @@ package it.aboutbits.postgresql.crd.connection; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.fabric8.crd.generator.annotation.AdditionalPrinterColumn; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.model.annotation.Group; @@ -16,6 +17,31 @@ @NullMarked @Version("v1") @Group("postgresql.aboutbits.it") +@AdditionalPrinterColumn( + name = "Name", + jsonPath = ".status.name", + type = AdditionalPrinterColumn.Type.STRING +) +@AdditionalPrinterColumn( + name = "Phase", + jsonPath = ".status.phase", + type = AdditionalPrinterColumn.Type.STRING +) +@AdditionalPrinterColumn( + name = "Message", + jsonPath = ".status.message", + type = AdditionalPrinterColumn.Type.STRING +) +@AdditionalPrinterColumn( + name = "Since", + jsonPath = ".status.lastPhaseTransitionTime", + type = AdditionalPrinterColumn.Type.DATE +) +@AdditionalPrinterColumn( + name = "Age", + jsonPath = ".metadata.creationTimestamp", + type = AdditionalPrinterColumn.Type.DATE +) public class ClusterConnection extends CustomResource implements Namespaced, Named { diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/Role.java b/src/main/java/it/aboutbits/postgresql/crd/role/Role.java index a1f137b..c56df40 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/Role.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/Role.java @@ -1,6 +1,7 @@ package it.aboutbits.postgresql.crd.role; import com.fasterxml.jackson.annotation.JsonIgnore; +import io.fabric8.crd.generator.annotation.AdditionalPrinterColumn; import io.fabric8.kubernetes.api.model.Namespaced; import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.model.annotation.Group; @@ -12,6 +13,31 @@ @NullMarked @Version("v1") @Group("postgresql.aboutbits.it") +@AdditionalPrinterColumn( + name = "Name", + jsonPath = ".status.name", + type = AdditionalPrinterColumn.Type.STRING +) +@AdditionalPrinterColumn( + name = "Phase", + jsonPath = ".status.phase", + type = AdditionalPrinterColumn.Type.STRING +) +@AdditionalPrinterColumn( + name = "Message", + jsonPath = ".status.message", + type = AdditionalPrinterColumn.Type.STRING +) +@AdditionalPrinterColumn( + name = "Since", + jsonPath = ".status.lastPhaseTransitionTime", + type = AdditionalPrinterColumn.Type.DATE +) +@AdditionalPrinterColumn( + name = "Age", + jsonPath = ".metadata.creationTimestamp", + type = AdditionalPrinterColumn.Type.DATE +) public class Role extends CustomResource implements Namespaced, Named { diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java index 39cd0f1..86c4bb6 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java @@ -1,6 +1,7 @@ package it.aboutbits.postgresql.crd.role; import io.fabric8.generator.annotation.Required; +import io.fabric8.generator.annotation.ValidationRule; import it.aboutbits.postgresql.core.ClusterReference; import it.aboutbits.postgresql.core.SecretRef; import lombok.EqualsAndHashCode; @@ -18,6 +19,10 @@ @Setter public class RoleSpec { @Required + @ValidationRule( + value = "self == oldSelf", + message = "The Role name must not be changed once it is created" + ) private String name = ""; @Nullable From 358aee1a3f548efdc8781d31d9bcdff3688e4366 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Fri, 9 Jan 2026 14:12:44 +0100 Subject: [PATCH 23/31] implement RoleCreate and update the related tests --- .../_support/testdata/persisted/Given.java | 9 + .../creator/ClusterConnectionCreate.java | 1 - .../persisted/creator/RoleCreate.java | 156 +++++++++++ .../persisted/creator/SecretRefCreate.java | 32 +-- .../crd/role/RoleReconcilerTest.java | 265 ++++++++---------- 5 files changed, 295 insertions(+), 168 deletions(-) create mode 100644 src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java index 459bdf9..112bb84 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java @@ -2,6 +2,7 @@ import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql._support.testdata.persisted.creator.ClusterConnectionCreate; +import it.aboutbits.postgresql._support.testdata.persisted.creator.RoleCreate; import it.aboutbits.postgresql._support.testdata.persisted.creator.SecretRefCreate; import jakarta.enterprise.context.ApplicationScoped; import lombok.AccessLevel; @@ -82,6 +83,14 @@ public ClusterConnectionCreate clusterConnection() { dbConnectionDetails() ); } + + public RoleCreate role() { + return new RoleCreate( + numberOfItems, + given, + kubernetesClient + ); + } } public record DBConnectionDetails( diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java index 26e68c2..33e9f59 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -19,7 +19,6 @@ import java.util.concurrent.TimeUnit; @NullMarked -@Getter @Setter @Accessors(fluent = true, chain = true) public class ClusterConnectionCreate extends TestDataCreator { diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java new file mode 100644 index 0000000..0a5d8a3 --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java @@ -0,0 +1,156 @@ +package it.aboutbits.postgresql._support.testdata.persisted.creator; + +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import it.aboutbits.postgresql._support.testdata.base.TestDataCreator; +import it.aboutbits.postgresql._support.testdata.persisted.Given; +import it.aboutbits.postgresql.core.ClusterReference; +import it.aboutbits.postgresql.core.SecretRef; +import it.aboutbits.postgresql.crd.role.Role; +import it.aboutbits.postgresql.crd.role.RoleSpec; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +@NullMarked +@Setter +@Accessors(fluent = true, chain = true) +public class RoleCreate extends TestDataCreator { + private final Given given; + + private final KubernetesClient kubernetesClient; + + @Nullable + private String withNamespace; + @Setter(AccessLevel.NONE) + private boolean withoutNamespace = false; + + @Nullable + private String withName; + + @Nullable + private String withComment; + + @Nullable + private String withClusterConnectionName; + + @Nullable + private String withClusterConnectionNamespace; + + @Nullable + private SecretRef withPasswordSecretRef; + + private RoleSpec.@Nullable Flags withFlags; + + public RoleCreate withLogin(boolean login) { + if (!login) { + withPasswordSecretRef = null; + return this; + } + + if (withPasswordSecretRef != null) { + return this; + } + + withPasswordSecretRef = given.one() + .secretRef() + .returnFirst(); + + return this; + } + + public RoleCreate withoutNamespace() { + withoutNamespace = true; + return this; + } + + public RoleCreate( + int numberOfItems, + Given given, + KubernetesClient kubernetesClient + ) { + super(numberOfItems); + this.given = given; + this.kubernetesClient = kubernetesClient; + } + + @Override + protected Role create(int index) { + var namespace = getNamespace(); + var name = getName(); + + var item = new Role(); + + item.setMetadata(new ObjectMetaBuilder() + .withName(name) + .withNamespace(namespace) + .build() + ); + + var spec = new RoleSpec(); + spec.setName(name); + spec.setComment(withComment); + + var clusterRef = new ClusterReference(); + clusterRef.setName(getClusterConnectionName()); + clusterRef.setNamespace(withClusterConnectionNamespace); + spec.setClusterRef(clusterRef); + + spec.setPasswordSecretRef(withPasswordSecretRef); + + if (withFlags != null) { + spec.setFlags(withFlags); + } + + item.setSpec(spec); + + kubernetesClient.resources(Role.class) + .inNamespace(namespace) + .resource(item) + .serverSideApply(); + + //noinspection ConstantConditions + return kubernetesClient.resources(Role.class) + .inNamespace(namespace) + .withName(name) + .waitUntilCondition( + role -> role.getStatus() != null, + 10, + TimeUnit.SECONDS + ); + } + + @Nullable + private String getNamespace() { + if (withoutNamespace) { + return null; + } + + if (withNamespace != null) { + return withNamespace; + } + + return kubernetesClient.getNamespace(); + } + + private String getName() { + if (withName != null) { + return withName; + } + + return randomKubernetesNameSuffix("test-role"); + } + + private String getClusterConnectionName() { + return Objects.requireNonNullElse( + withClusterConnectionName, + "test-cluster-connection" + ); + } +} diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java index b178097..7033537 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java @@ -4,6 +4,9 @@ import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql._support.testdata.base.TestDataCreator; import it.aboutbits.postgresql.core.SecretRef; +import lombok.AccessLevel; +import lombok.Setter; +import lombok.experimental.Accessors; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -12,11 +15,14 @@ import static it.aboutbits.postgresql.core.KubernetesService.SECRET_TYPE_BASIC_AUTH; @NullMarked +@Setter +@Accessors(fluent = true, chain = true) public class SecretRefCreate extends TestDataCreator { private final KubernetesClient kubernetesClient; @Nullable private String withNamespace; + @Setter(AccessLevel.NONE) private boolean withoutNamespace = false; @Nullable @@ -24,10 +30,12 @@ public class SecretRefCreate extends TestDataCreator { @Nullable private String withUsername; + @Setter(AccessLevel.NONE) private boolean withoutUsername = false; @Nullable private String withPassword; + @Setter(AccessLevel.NONE) private boolean withoutPassword = false; public SecretRefCreate( @@ -38,42 +46,18 @@ public SecretRefCreate( this.kubernetesClient = kubernetesClient; } - @SuppressWarnings("unused") - public SecretRefCreate withNamespace(String namespace) { - withNamespace = namespace; - return this; - } - @SuppressWarnings("unused") public SecretRefCreate withoutNamespace() { withoutNamespace = true; return this; } - @SuppressWarnings("unused") - public SecretRefCreate withName(String name) { - withName = name; - return this; - } - - @SuppressWarnings("unused") - public SecretRefCreate withUsername(String username) { - withUsername = username; - return this; - } - @SuppressWarnings("unused") public SecretRefCreate withoutUsername() { withoutUsername = true; return this; } - @SuppressWarnings("unused") - public SecretRefCreate withPassword(String password) { - withPassword = password; - return this; - } - @SuppressWarnings("unused") public SecretRefCreate withoutPassword() { withoutPassword = true; diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 895ba7e..e6c5bac 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -1,13 +1,11 @@ package it.aboutbits.postgresql.crd.role; -import io.fabric8.kubernetes.api.model.ObjectMeta; import io.fabric8.kubernetes.api.model.SecretBuilder; import io.fabric8.kubernetes.client.KubernetesClient; import io.quarkus.test.junit.QuarkusTest; import it.aboutbits.postgresql._support.testdata.persisted.Given; import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; -import it.aboutbits.postgresql.core.ClusterReference; import it.aboutbits.postgresql.core.PostgreSQLAuthenticationService; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import it.aboutbits.postgresql.core.SecretRef; @@ -73,35 +71,31 @@ void createRole_withLogin_andStatusReady() { var now = OffsetDateTime.now(ZoneOffset.UTC); var roleName = "test-role-login"; - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ true - ); - - var spec = role.getSpec(); - - // when: create Role referencing the ClusterConnection and expecting LOGIN (passwordSecretRef non-null) - spec.setPasswordSecretRef(clusterConnection.getSpec().getAdminSecretRef()); - // then: wait for status and assert READY - var reconciled = applyRole(role); + // when + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .withPasswordSecretRef(clusterConnection.getSpec().getAdminSecretRef()) + .returnFirst(); + // then: assert READY var expectedStatus = new CRStatus() .setName(roleName) .setPhase(CRPhase.READY) .setObservedGeneration(1L); assertThatRoleHasExpectedStatus( - reconciled, + role, expectedStatus, now ); var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); - assertThat(roleService.roleExists(dsl, reconciled.getSpec())).isTrue(); - assertThat(roleService.roleLoginMatches(dsl, reconciled.getSpec())).isTrue(); + assertThat(roleService.roleExists(dsl, role.getSpec())).isTrue(); + assertThat(roleService.roleLoginMatches(dsl, role.getSpec())).isTrue(); } @Test @@ -116,14 +110,12 @@ void createRole_withoutLogin_andStatusReady() { var now = OffsetDateTime.now(ZoneOffset.UTC); var roleName = "test-role-nologin"; - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ false - ); - // when - var reconciled = applyRole(role); + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); var expectedStatus = new CRStatus() .setName(roleName) @@ -131,15 +123,15 @@ void createRole_withoutLogin_andStatusReady() { .setObservedGeneration(1L); assertThatRoleHasExpectedStatus( - reconciled, + role, expectedStatus, now ); var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); - assertThat(roleService.roleExists(dsl, reconciled.getSpec())).isTrue(); - assertThat(roleService.roleLoginMatches(dsl, reconciled.getSpec())).isTrue(); + assertThat(roleService.roleExists(dsl, role.getSpec())).isTrue(); + assertThat(roleService.roleLoginMatches(dsl, role.getSpec())).isTrue(); } @Test @@ -154,21 +146,18 @@ void toggleRoleLogin_updatesCorrectly() { var now = OffsetDateTime.now(ZoneOffset.UTC); var roleName = "test-role-toggle-login"; - // 1. Create a Role without a login (no passwordSecretRef) - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ false - ); + // when + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); var spec = role.getSpec(); - // when - var reconciled = applyRole(role); - // then assertThatRoleHasExpectedStatus( - reconciled, + role, new CRStatus() .setName(roleName) .setPhase(CRPhase.READY) @@ -179,7 +168,7 @@ void toggleRoleLogin_updatesCorrectly() { var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); assertThat( - roleService.roleExists(dsl, reconciled.getSpec()) + roleService.roleExists(dsl, role.getSpec()) ).isTrue(); assertThat( @@ -219,29 +208,31 @@ void createRole_withMissingClusterConnection_setsPending() { var missingClusterName = "non-existing-cc"; var now = OffsetDateTime.now(ZoneOffset.UTC); - var role = buildRole( - roleName, - missingClusterName, - /*login*/ true - ); - role.getSpec().getClusterRef().setNamespace(kubernetesClient.getNamespace()); + var dummySecretRef = new SecretRef(); + dummySecretRef.setName("dummy"); // when - var reconciled = applyRole(role); + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(missingClusterName) + .withClusterConnectionNamespace(kubernetesClient.getNamespace()) + .withPasswordSecretRef(dummySecretRef) + .returnFirst(); // then - assertThat(reconciled).isNotNull(); - assertThat(reconciled.getStatus()).isNotNull(); + assertThat(role).isNotNull(); + assertThat(role.getStatus()).isNotNull(); - assertThat(reconciled.getStatus().getPhase()).isEqualTo(CRPhase.PENDING); - assertThat(reconciled.getStatus().getMessage()).startsWith( + assertThat(role.getStatus().getPhase()).isEqualTo(CRPhase.PENDING); + assertThat(role.getStatus().getMessage()).startsWith( "The specified ClusterConnection does not exist" ); - assertThat(reconciled.getStatus().getLastProbeTime()).isAfter( + assertThat(role.getStatus().getLastProbeTime()).isAfter( now ); - assertThat(reconciled.getStatus().getLastPhaseTransitionTime()).isNull(); + assertThat(role.getStatus().getLastPhaseTransitionTime()).isNull(); } @Test @@ -270,16 +261,13 @@ void secretChange_triggersReconciliation() { .withName(secretRef.getName()) .require(); - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ true - ); - - role.getSpec().setPasswordSecretRef(secretRef); - // when: create Role - var reconciled = applyRole(role); + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .withPasswordSecretRef(secretRef) + .returnFirst(); var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); @@ -289,7 +277,7 @@ void secretChange_triggersReconciliation() { .pollInterval(500, TimeUnit.MILLISECONDS) .until(() -> postgreSQLAuthenticationService.passwordMatches( dsl, - reconciled.getSpec(), + role.getSpec(), initialPassword )); @@ -309,7 +297,7 @@ void secretChange_triggersReconciliation() { .pollInterval(500, TimeUnit.MILLISECONDS) .until(() -> postgreSQLAuthenticationService.passwordMatches( dsl, - reconciled.getSpec(), + role.getSpec(), newPassword )); } @@ -340,38 +328,33 @@ void secretRefChange_triggersReconciliation() { .withPassword(newPassword) .returnFirst(); - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ true - ); + // when: create Role + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .withPasswordSecretRef(initialSecretRef) + .returnFirst(); var spec = role.getSpec(); - spec.setPasswordSecretRef(initialSecretRef); - - // when: create Role - var reconciled = applyRole(role); - var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); // then: password should match the initial one - var finalRole = reconciled; await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) .until(() -> postgreSQLAuthenticationService.passwordMatches( dsl, - finalRole.getSpec(), + role.getSpec(), initialPassword )); // when: update secret reference in the Role spec.setPasswordSecretRef(newSecretRef); - reconciled = applyRole(role); + var updatedRole = applyRole(role); // then: password should eventually match the new one - var updatedRole = reconciled; await().atMost(10, TimeUnit.SECONDS) .pollInterval(500, TimeUnit.MILLISECONDS) .until(() -> postgreSQLAuthenticationService.passwordMatches( @@ -394,11 +377,11 @@ void comment_updatesCorrectly() { var roleName = "test-role-comment"; - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ false - ); + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); var spec = role.getSpec(); @@ -462,11 +445,11 @@ void roleFlag_togglesCorrectly( var roleName = "test-role-" + field.getName(); - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ false - ); + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); var spec = role.getSpec(); @@ -524,11 +507,11 @@ void connectionLimit_updatesCorrectly() { var roleName = "test-role-conn-limit"; - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ false - ); + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); var spec = role.getSpec(); @@ -586,11 +569,11 @@ void validUntil_updatesCorrectly() { var roleName = "test-role-valid-until"; - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ false - ); + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); var spec = role.getSpec(); @@ -664,11 +647,12 @@ void inRole_updatesCorrectly() { dsl.execute("create role {0}", role(parentRole2)); var roleName = "test-role-in-role"; - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ false - ); + + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); var spec = role.getSpec(); @@ -741,11 +725,12 @@ void role_updatesCorrectly() { dsl.execute("create role {0}", role(memberRole2)); var roleName = "test-role-role"; - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ false - ); + + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); var spec = role.getSpec(); @@ -820,11 +805,12 @@ void role_multipleMemberships_updatesCorrectly() { dsl.execute("create role {0}", role(roleC)); var roleName = "test-role-multiple"; - var role = buildRole( - roleName, - clusterConnection.getMetadata().getName(), - /*login*/ false - ); + + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); var spec = role.getSpec(); @@ -870,11 +856,27 @@ private static Stream provideBooleanFlags() { } private Role applyRole(Role role) { + var namespace = kubernetesClient.getNamespace(); + + role.getMetadata().setManagedFields(null); + role.getMetadata().setResourceVersion(null); + + var applied = kubernetesClient.resources(Role.class) + .inNamespace(namespace) + .resource(role) + .serverSideApply(); + + var generation = applied.getMetadata().getGeneration(); + //noinspection ConstantConditions - return applyRole( - role, - r -> r.getStatus() != null - ); + return kubernetesClient.resources(Role.class) + .inNamespace(namespace) + .withName(applied.getMetadata().getName()) + .waitUntilCondition( + r -> r.getStatus() != null && r.getStatus().getObservedGeneration() >= generation, + 10, + TimeUnit.SECONDS + ); } private Role applyRole( @@ -883,14 +885,17 @@ private Role applyRole( ) { var namespace = kubernetesClient.getNamespace(); - kubernetesClient.resources(Role.class) + role.getMetadata().setManagedFields(null); + role.getMetadata().setResourceVersion(null); + + var applied = kubernetesClient.resources(Role.class) .inNamespace(namespace) .resource(role) .serverSideApply(); return kubernetesClient.resources(Role.class) .inNamespace(namespace) - .withName(role.getName()) + .withName(applied.getMetadata().getName()) .waitUntilCondition( condition, 10, @@ -918,30 +923,4 @@ private static void assertThatRoleHasExpectedStatus( .ignoringFields("lastProbeTime", "lastPhaseTransitionTime") .isEqualTo(expectedStatus); } - - private static Role buildRole( - String roleName, - String clusterConnectionName, - boolean login - ) { - var role = new Role(); - - var spec = new RoleSpec(); - spec.setName(roleName); - spec.setClusterRef(new ClusterReference()); - spec.getClusterRef().setName(clusterConnectionName); - - if (login) { - // any non-null SecretRef will signal LOGIN expectation - var secretRef = new SecretRef(); - secretRef.setName("dummy"); - spec.setPasswordSecretRef(secretRef); - } - - role.setSpec(spec); - role.setMetadata(new ObjectMeta()); - role.getMetadata().setName(roleName); - - return role; - } } From f8e539d2ebe018ebda8bc781ffbbe36740287278 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Fri, 9 Jan 2026 14:15:39 +0100 Subject: [PATCH 24/31] add a test to check if the Role CR instance deletion works and deletes the role from the database --- .../crd/role/RoleReconcilerTest.java | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index e6c5bac..ac0014c 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -833,6 +833,41 @@ void role_multipleMemberships_updatesCorrectly() { dsl.execute("drop role if exists {0}", role(roleC)); } + @Test + @DisplayName("When a Role is deleted, it should be dropped from the database") + void deleteRole_removesFromDatabase() { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-connection-role-delete") + .returnFirst(); + + var roleName = "test-role-delete"; + + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + // Verify it exists initially + assertThat(roleService.roleExists(dsl, role.getSpec())).isTrue(); + + // when + kubernetesClient.resources(Role.class) + .inNamespace(role.getMetadata().getNamespace()) + .withName(role.getMetadata().getName()) + .withTimeout(5, TimeUnit.SECONDS) + .delete(); + + // then + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> !roleService.roleExists(dsl, role.getSpec())); + } + private @Nullable T getRoleFlagValue( DSLContext dsl, String roleName, From 020797c9138a87a4d0f1c1f204d7f5b7f2e1ad3d Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Fri, 9 Jan 2026 15:40:40 +0100 Subject: [PATCH 25/31] add example using the existing Dev Services in the README.md --- README.md | 87 ++++++++++++++++++++++++ docs/apply-cluster-connection.png | Bin 0 -> 52455 bytes docs/created-role.png | Bin 0 -> 27777 bytes docs/established-cluster-connection.png | Bin 0 -> 47748 bytes docs/role-in-table-pg-authid.png | Bin 0 -> 85489 bytes 5 files changed, 87 insertions(+) create mode 100644 docs/apply-cluster-connection.png create mode 100644 docs/created-role.png create mode 100644 docs/established-cluster-connection.png create mode 100644 docs/role-in-table-pg-authid.png diff --git a/README.md b/README.md index c469440..d46e6c6 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,93 @@ make test Afterward, the project can be started in IntelliJ by navigating to `Run` -> `Run '...'`. +## Test the CRD on the Dev Services cluster and AI assistance + +This example demonstrates how to set up a local development environment using Quarkus Dev Services and AI assistance to generate Kubernetes resources. + +### 1. Configure Kubeconfig from Dev Services + +When running in dev mode (`make run` or via IntelliJ), Quarkus starts the pre-configured K3s and PostgreSQL Dev Services. + +1. Access the Quarkus Dev UI at [http://localhost:8080/q/dev-ui/dev-services](http://localhost:8080/q/dev-ui/dev-services). +2. Locate the properties for the `kubernetes-client` Dev Service. +3. Ask an AI to convert these properties into a **Kubeconfig YAML** format. +4. Merge this configuration into your local `~/.kube/config`. This allows your local environment to communicate with the ephemeral Kubernetes cluster provided by Dev Services. + +### 2. Create PostgreSQL Connection and Secret + +For the `postgresql` Dev Service, you can generate the necessary Custom Resources to test the operator: + +1. From the Dev UI, get the `postgresql` Dev Service properties (username, password, host, port). +2. Ask the AI to convert the `postgresql` Dev Service properties to a **Basic Auth Secret** and a **ClusterConnection** CR instance. +3. Provide the AI with the `ClusterConnection` CRD definition from `build/kubernetes/clusterconnections.postgresql.aboutbits.it-v1.yml` as a reference. +4. Apply the generated files using IntelliJ or `kubectl`. + ![Apply Cluster Connection](docs/apply-cluster-connection.png) + +**Example Secret (`secret.yml`):** + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: quarkus-db-secret + labels: + app.kubernetes.io/name: quarkus-postgres +type: kubernetes.io/basic-auth +stringData: + # extracted from quarkus.datasource.username + username: root + # extracted from quarkus.datasource.password + password: password +``` + +**Example ClusterConnection (`cluster-connection.yml`):** + +```yaml +apiVersion: postgresql.aboutbits.it/v1 +kind: ClusterConnection +metadata: + name: quarkus-postgres-connection +spec: + adminSecretRef: + name: quarkus-db-secret + host: localhost + port: 5432 + maintenanceDatabase: postgres +``` + +![Established Cluster Connection](docs/established-cluster-connection.png) + +### 3. Create a Role + +Similarly, you can create a `Role` resource: + +1. Ask the AI to convert the desired role properties to a **Role** CR instance. +2. Provide the AI with the `Role` CRD definition from `build/kubernetes/roles.postgresql.aboutbits.it-v1.yml` as a reference. +3. Apply the file using IntelliJ or `kubectl`. + +**Example Role (`role.yml`):** + +```yaml +apiVersion: postgresql.aboutbits.it/v1 +kind: Role +metadata: + name: test-role-from-crd +spec: + # The actual name of the role to be created in the PostgreSQL database + name: test-role-from-crd + comment: It simply works + # Connects this role definition to the specific Postgres ClusterConnection CR instance + clusterRef: + name: quarkus-postgres-connection + flags: + createdb: true + validUntil: "2026-12-31T23:59:59Z" +``` + +![Created Role](docs/created-role.png) +![Role in pg_authid](docs/role-in-table-pg-authid.png) + ## Packaging and running the application The application can be packaged using: diff --git a/docs/apply-cluster-connection.png b/docs/apply-cluster-connection.png new file mode 100644 index 0000000000000000000000000000000000000000..fb6bcfc5e15b929c238af3172b4d35fddd28a28b GIT binary patch literal 52455 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 Ry9ft<$xEw9)j~{y|1ZFgMyCJ( literal 0 HcmV?d00001 diff --git a/docs/created-role.png b/docs/created-role.png new file mode 100644 index 0000000000000000000000000000000000000000..b386e3913b6cb7b9d5d5355a368fe809b0fa0d57 GIT binary patch literal 27777 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 literal 0 HcmV?d00001 diff --git a/docs/established-cluster-connection.png b/docs/established-cluster-connection.png new file mode 100644 index 0000000000000000000000000000000000000000..5a436dbedf0d8c3a0d517ba954a8fab6c14ef7a1 GIT binary patch literal 47748 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 literal 0 HcmV?d00001 diff --git a/docs/role-in-table-pg-authid.png b/docs/role-in-table-pg-authid.png new file mode 100644 index 0000000000000000000000000000000000000000..5ca863585a14be4e023c84d5cb1db578e83fcd95 GIT binary patch literal 85489 zcmce-WmKD6*EQT0id#!@FD)&l6n87dDrj++K%lr3x8feExD;rixVyUqEfNS$ASr|p zAVovq&AH$E8{-*0=RCi@@$%yW#!mLNb*;JPnk!CQQ;m|0ne5uNYm_gZE5E*WjWi$s zcP|MM{u#jV+xptIch_DhE9&`K?6%)bGlOKKkS8nI{^>8^ny&NLW5(_PY-D_PB{dU; z26p>$($Z~ji`0MZAGNZ6#qa7-nvVZrnv-)%)hunvC~3L>%4bgCEOGkn zW1hE2FQ*iqL-8F&<)q~PG81EW=btAm@Yz+dZYa@bH_1Vl1oi4}gaZYoE48DdQf>6F zfBs4qTg%NUD&@wz_rCWMW|=8z9WIHM63*YAkN>$tg?PpLFXLmxk1w#t^j3NvCp7hi zMQM2$|HBeJ8jXM21`-b+cbV7}Ck=3AUD>`m1T{2W8t(AV6R1rxRTPb$eC^3x?L77* zF}G$&(&PNLdRj{pvKCY4p-XQwGD;_T=Y3+^Va|H7z$c1nI{lL@&4njfhT02WIs6Dj zcK6eqf9qXDQNx8TrD8tz-B)*rv79?!?7-LAn)+5Dx9&%gYJFUHA6@4j^s~l-aB%eG zz7cZuwBn79P~5)EujCIfAh&e$#w$8izA|b3;iSRf$C7j`4QzWJR1|sN8AM_2)_^Kk zOqfNl!@~V+qt-VH;RTq0GW4Uy)J3pxutuZ)zby{Wkif$Mq+4=ViI}OG{ZSW+6B_yE z6%9Lk!a$(`?DIDHEp{fi%ZhTRx=WXB@$$S6xd@npyGbP%KW+XaU&8WJfz)k&_bu6b zKiWY^;r2If)82mCfw|+0jg7{71Z_tvB#%w1>H6nZe^2X7yZ3*AWOQc7uxv>WOHno< z_d0EAAo;HP(@hj7+4@Uixws9(v|5J<!L`b8{DLN%M)CNl5KN16v5W*29LBF)@rl^X|)0%a-CR&Ww5s8z6rJn(V2q%?m>#5TY4j zMF=9|@g@HuRmz$1k5_!d8RkRdvhn--{(}Ft4^wdiLNNQ@LCC}f8~rT^fSFCsl?N2{ z?Sv$ML4WAd6W#mQ=+-lkAu*Ph=QbDTJ+G>RP@1&&VA2nIL_8MRLWM9-S~kgKuQp7Z zw&rBYU5?G|lB~rlrTALrn%9atyPVuY6L&w-i=dN3Tc{IK+{Ca}H|y-2eAYk;%T6G~ zS!hSx)#*ef46AU;b$Pz8%fl$)b7*GfcVOptu^qZGQd-$d9s2hA3f7^lKB$%SJ?@|a zTjkd;{(0s)uT;I?&O+)w#?m4-;YkN;vfP>MlhXx(DyxN*kO+rDjWU=1Zw6M_y=%(% zzuYZtq70-~S)Ys}q@D9JUolV={YN9(w=jU>WR8JTkS8me2Y>p|>mD6Fn9*5Uf-DPV zY1QxwvY13pX8F*d;!7*Ux+_)q8aM@onJPPuggP+U=yhd3qC-CDeru-NV0q&eQ;cSx zH6)D2=?uAFUl~jpL-0|(UKpZH9|0_T=z}_vSjTA%aNXqs>v3bDkV^v7Ae@dFxmnOQ zU7CDg6yTQz60>{e`j>7>boh%vno%pQe|X zDNKFU=}pl}t@?GyBo`UrU#YB5zCR$7yK%vB$(R{L1@n6@%O3KZDa&`o->3y@-^Iy1 z2Iz16Ot!nkaATDq4=!8#N*Y@h4V=^Yw`))Iqqw+t!w%_jS8&wZfLfL;|7MJGUP;S} zq(AfqVU%)aCwQb(gK^A11BSW4??I}iKc$7=fs3BFRP^*HKeDefB$%{t%p{sSjQ4c> zB;=aHmEY%C0=lHGlBB?cBex3%Dq@}!X64$(Y@PY|vJI-p2Y0LrCT;~mpyOFl$g%y5 zRPzIt(X`qUzF2s>JKK}?3&+|ZV}kv~OxNB)Zl26w&vYG_{rXREg)o;y$oL~i3AzdT zC#yCkUp^+U=`hQS!@KGy7S`i2m}da;eUw`$1G%BIH{PrQ5FWeIG|`l%RCk4o z`*tDTQucJpwEt-Hvq;Iv;tvL7xT_pd26(G1iBzoP6-jv;RmXS-rk#bRWQmvv25mfG zxS;UZ)(09BO&oJ1^q(nQkW%G5-xZ4mhMTGIGYZX-GnfZ4RkWQ4EbT9@!jP6%MDWMZ zNjl|BDPM0x8kh0>BXdR3kd6c2Mw@wiS~d*78YnXk>Dsh#;bO?sx>-{y=z9=d!I^Oq z?j5UD*-bruRbAXRz@^aa4AhZ%()LSO_lwI{&J6CzIZ&`#zQ>Ss=a0?X&Z}&(zcz)v zE-S!`k79ZL(OXK=zV-3RZ~8`YM441k8{nQ2;n3M_icWs&9|17Y`NEwgZaosTt3V&) z7L1jVWKG-r5j#g%wp_=tdRlq4Y_B3p)HUZS|E@9=*D2_P1QSEdF%YggvCo6s>ccB;c zc$3<%_2g8FzI-0oFy;k%QQmbDy04@wSH8-bL0Vp)L6fn1ASt5$kS|XtvcbjoTK>nE zSk@ff|LvkfPm2?7F#}^piKz1RA?{2>Q?IcU`Q}HN$b()E9HCnHCk<_<<8bfO0f|eA zV^&ZND320iaV4Y6<#JX1e0QE9a@8!bfHa+rR9(DV(6!f?s26|Q9D6LMhPQx{6ZcDl z_T8?I(S?V``XcUwxqJQI5-Z`qKHp^}eL0|#Y6jw~Xqh9Ndg&Oph;AdMmOG~qPGBCh z_YHTTXcl@nc;uH@UHpAc%6AQyJh;(@8Sni$D~}_Z@+=1=Yn1nKT`7C9`us{n+(|bI zFPJ-PsQ~=%R{uO}cD@}USmMO2t-I*(%%00y2#$^)F+r|ISG=kB*1-Gp?ljU`8pd>| zl0a{Ai){Q-crBydr_t!HN`V%NRI`Bl9Y-ki#`Fi>u}h6YZy1`ciuL=suGK*sk2p*f((ypG5`;Jxjf7iG99l6kSp231geIdb9-+@20 zh^82AGllHwMws7y{ozcj3}^x=d9DOt%_XlcIIP!e#_-F;>T|bouNlZVVy-ALEzd;13_7_xcFF>dW}hw`?f-Eg zQtc2$Et~)ih%C~i9P_6Hj+sWlxQ30ILnNrOp9^w{ju#C}9X%ItQ1lDRJ92AhYB{{X zAE!WSH5lFvGALlrDqH+f-kr56{Lf4*031O2OX4WW=$SU4GEd5e_EVe2JfUV;0ykOWOSbHddGTa9p;~%m z!>RRHAr9_F#XsU`A52*;rT4#Cv4jJ7D|dCkf2*P8fMdZm$Vo5c&MksUS=pJqHzHVtY;2NIqroD%w+-2@;m zpDy`e&71IMD`Ui-wZm(%f_C}$GDmz}8<3Ua(#Qf_-F(ST&`m1h7X1a+nl zI)h7URiI^8{R1l&;j70TC>NOth2{U*uysH2Me#?f;zvoV zbwzF9vb4zkOQrb|gzIjCjR$&c%|g~oR+rx4hs&rIp{uT)h)nBB{S@_}7f`Wy{^(zi ziSsmnY=cBT-THPg@swsG9xlrjN`b4GN>lLXlS{s!1zC&qy!vSZaeQS(9$$Lr-~7g3 zk;?_`qPu7Zj+{KdS+{+(BFBd(rtzX?q~Qg>txBDN-mI^*<;#BrCy}Q;zvz|0yr?et zI|JJK_54eX^^f=xfMKoVj)RKBbF_vk$Rw0$cY3f7c;Slb4DQE@+_# zX9x86w(46gp8d&5r4ttFfpf9vr-ZyDh#{(sx_X^h|H;bUGk4fBYz zMemN^W~~}dNaoqX8ZGjK^1yEvCqq{JRnW?cju)s0e_Msax{(kEpnMumJNNhY2h@=B zPs3^rI%Thq3=)-9gtPTWmd)hB4Rwokg$C>L3yuCa?~}cw>NYFI-8ugI^!;1J@`|BE zpZ@IZ(2P3&APowYnS#!(08CyXi{BtV7IDXl&mWD8P0TvIee#17I*Dfh4srQNfj`G6 zi#I)Uo#RaGzPK8~DL?1k1XpKAq1@%+_J^_XP3>@pau+t$%Q12*;y&4BP0Pap7YAq6 z=YA9hWh>#u7UV+KBW9!Non#$>R0UtFW_BmM<+L`dTLRm19!q@YWOm!|X7T0Z=Bq3X z?o2#fdi4^lZMZrQSV^A@VVb8ZL@m#DxJB1c-<*nur%J^R_?kjmYZnei8|!m(?3X*P z&NO}0Dn!yw5&;S>at)Bwh-EFy!(~e&y?8r5iM*z7J>>#dmlwh-YJVQV5fN@IwU!!< z%R^*~Q-ZERkM~M?ReJ&L;Du7PH&#CR{M`b*5^28F`K-*;)O94I)v;U}>i2_2$mG0@ zunb^eV&K~w%&pw_(pKWbFwP7%cd6wE?oWoi4n<=Jwiv%XfBteX<7iRisq@7*DIQLr zwMo*?hI*~-SpUzTo||wux6A{EsHlE(tlTmdek?3pI~uI(6mA<4m`*Jo3(WmgS^@Uo*q$F? zD1V?>7X}^4KC74};#+lDkcyhG8iuqjGFF(kITQ30Y~YEo)s*NS;^-ySqF)vN)CerJ%XOQ6!&PKe87^6Npd` z0Dxzn46qWp>Y_e9=bg<&ZC!1}1Dg8-D>$_&xZ=pq6bUtFGgc6X0%(7KKZKH$w7VKG zsI6}5Xy;wA5a&cZl2JkxZJGe0IQX<aC7S|r&PLVM^ulZZEM*xer z6;k6xem=W0fydn1W+r>bApz4mq~)UlYU6_&-;o*)5~6qKf)tvbAmb>+?QP*1C+MeK zB9}v?l*#>l#h7&%(mwcscq?xA`5#W^tg=LI$HL^l^k?M?bDyx`ekqaXSFie)Ky0SfWM?i%S8PLhur2L~@;*Mdpm<*e z)`Wt=h?viiZLPftg~?mbUcG!Ff%RbH1+(*)N-ThK2&Z-f?@Eb*2~h)D8RY0@zAE=& zY27MYXrfA*sCNgiv%a~m!jC1It>;$V)>HHFT58hb3&t*b@W~g zWj$1u3cE|*G%Eda*tTV={OTzi0Bpa9TwtxZC}#kctN{ADHfhnT=zMLbO$0J=EDG^H z&wv;{INkodcycdxAhk<87dO{iL!xqji-!@|$+}BiR06#Pb+-?!s1eCkniZOCm~H9u z;fzfAV#w9s(!GoFw9vHRQZ<&4B4EprSEmpb(!RqyqOC)bUr>Pkj7esdvWd=k+g?}! zbvdtOREC`a?H0M}USbxGz5_b_qCG>s%nhCbzPAhwJ;bQ=qdZ&M&7+fF>rSidx7O7q zR=~WLQ)HlsTO7GoH{2*(JrsC(-@q9kJt`$?6EHi%n@j{Br{GWkwOp8eZXgx4?7pGw zK_9K(#_$6nyOBEAbzVmWiHb#!6b#d7z(Id%nTxvEgqI~!CiGawkL0`EvSmdU;tdOh z3O;1a8g%N%09p^^E+vq1@_`MFXV-hh$G`%jsI$#aTE?ieZ)d*SHCyl)N`fk#scOHI z_LI>=C8^H2IVvQv?uVm}jJTm+gqZJkoiYo1-{-JNmLG|!i8GwGTRLP8g9bH!s{#!! zhbLWQNShXN*_6Vi!Fr8=^xvPj9z$G?fwf!nakU?;T_8{jBRcn|>d8)u>*J(C3 zuDaLiS;7p9KD&+M#k5n^FBpXa&foVsfPBCrDBEX1U3jm35eIVsX?nfM$@S2g(_Ptj zx8{=Hw_Vsw=Tt2I&D~4eT3VU}@T>%3G-MBlxKomCPWNx&V^`tpcaH}JjB6;86%Gh< zg5rZ4wbCvSrJ}9(@ua%#u!f?q0fQ=b{;P1!wOl#0K zZ>?Nhw=B>%!XayQekL9zLv1zFWW2xZ#n^uzlz5}`(ozPuUI* zzq0B%amEU45ax+9w1q{d`UM<%kl}4|VxnTl?}L`HA@dna@4QV;Unz1@>ZMS#s3=;| zM+5CqNzgGch^}{nz&Nh_RtWE8u|z$vH@vf<)Wdf8)kq~K7ZrgB@Y%ZSnu(m5m3ZqC zGuw~`x9xQw>Rf8OQ9BmaLya^4dI?{^EK}wmu==g5Cg1=Qb z@9OHZu*)ne{C?t>vNW|@^o`7KmkE>r78KhG)H$E&ILsh9(KD51GlxQ!~yx2qR{HSBZATfOwzq zilJ$9oV-e5Oq(9ib>0RgH|#ow_BM0$>ec0m-|4!Vww@W(n<^;OgXOqpNqp5sWv*kd z8Ntpfvln`}1qH77W=#O9D`SuySrfh988R3%Z;9A}w->k*g?CCZ$wq^Qv9Yla+fFwU z{H`LX%V5qbKm#gv|KB>umAI!C79D3oz3!{uUVPFpdVEimRZ9VrSe!Dq>QDKNx3$Gi zVlK2}I>@+r+@58i?>=DU_f4Mi}@KG)DaCz>4lXCzqKs9(VlqM=G z+I*c_tHK+cx|-`ESL^SJ9yFGT93XR`$Xh@R`XI>)qNwE)=jhgI8|-67RZSHf$jXAH zV@Ca0%jTtQ>QX%pep1@MJ+=PB{_}S3*5rP{l|N<`4(TD(J$TF~10kHcyi5-LT3tQ- zTS4J;Eh1Lyxw6L#(z!Bo`*WA{?6Y45$wGhu_32P>h}5yJ0(Q4{zGbY)w3^Nyi|j}LPa zIKQl&=JohjeCN&rn!XQWffJ^!Au@$yX3jNqZO30`z2~h^xwu7(+Jy=a_<5N0lvT zU1KXkQG#_DYry1m-X^9x&$O0mmK(8FlM07*_g?3b>Bi{-A4+<16S$9CD>Q~X$O^4Y zmR|AuI)pCoiSwyG-kcHT)C_Xg+=i{`K*&nXZP&f+C z{<`R?if~Z(kk&#-2|w(5`Rt|&K}=tk97DK6kfN~Q?Bq}=n_4I3TTEDxUon1X|+_tlhM;w#tCl~aK*xrMhN;NAh8c#3GSo?su zP1mo!INBjF-&14%DAGg^Bh8B5v!E0yy$2Ca1m+45-0a*e_uaccDf+h=J8H1iha$Oe z48r&xKiU)O6u^TeohM)~xYyx4AlVpk=jV~|CU%=+F7+)CD9*SRgjn`nht>85(DUtY z7v*x@7b_nOjGQ=#B&8K{X-Pf03}BO&2^tf14c+K_G2d=qSuEXu9EhriXxwbtwy-$G zbL$=oXwjI#h*Ts1HrW1?-jrbL)}P z6;NCwK~!PDCvdISyi_dkd_7QVE^i3u&f)wD_~vdtYw$)6VYTcBtW)(;(*lDaHar_>{iLlYULZs$^Rtf2A_Y;W9%@R-(+NROA9ot=%1i|Y+OS&n(?6!6BF zNfpEzMIA#alGOV&B6n5s^XJd7z0M_<5mI5l>VPqmHtb$2ywGa4zW-H9enkbl(_BSk zio%SOd)Q&#;c_scd`HZvJ=R8wnUjH>!EIOKYyTE8S-uB{X#=YMiLE4@-;A=e_d(TC z3(0PsZ}1PMhKIc6T@>i=ep^Ws`zuu*lI%24#HIO`2j*F{YM-~0y&4VwxZca!n9sEc zF(ZL^-f4!ryx*_7a!T!G@aZ935co+VBP40)yg zQZ=hSCz!3t)lWIH_|8D!098&@R7|G8-EoA!AS>M+R%du~tHKio1_rm1w(1>YZTI)2 zb^7`TuS$DIVUxLKmp+?AI;U7{rf8YWB8NK7SPD`wmY#;jc10&^z~1)f=5XP~!-y)1 zYGLI;>sM-tjB%f5e!D_Gq53A-8$V@zj4oenfF((+)IqD3ofE=p#!MSB1eBw%*n@uy zA%keuw{=rq%H5)IU{gyV9T%SIe+js6z!JLO9m8nHrsQVLx#v|naea5iceIUf*9+nO z>g9M{hp4y-dUWJ^uF%dK8h+{M*_z#kaQ{`;g_f~MMkZx|ooH+DsX#=ZVmH?ugW>2K z()MnOBzmP$nY>P?u&LuYsW-lpV^aECTamJCMv`eWEV1%^9cOi%IyuRpEL1>(0! zsGY`p?5Tc4AShdK5SAz9?{ZUO}Pz)$hCMutatb2WUX@Vr?RQzVUFi!O=O>_mn%iSO8>odPF)+%XthO8 z!{f0<#k!UAZLJtt*Vg3Riq$3pCT`LS+&5u0mhAGrjW zA|!}b#B;;ruDJqEW{DA9qnfu&m&d2D!kToi@60*5ouTiFtp+|lC^f9?evljFcnNQb zd%^2>zFnlj8Gc>FfZ67L9zLDSP}zRjLMq*MbSzUCa4TJIadFq_?2zvHOU>=0_3EuC zV20cF;h4F#sCYntDm&1>^4W8vEaL%hIRu79GIkF1?K4GU_RzZ-MhfiP+w!X$2?GOg zcJQIiArQt`$GYM?(ZLO0U9x!Q)IOYg?G=%#@`JanLVmS$^r`7CW9C)NOzHm1pj1Us zB3W5dj;y~D_>BMufd`F~k>}1D^W6bI7%u=v*}53A51E#`KK&2vjV~v?6@q0>aoX4{ zrk`Zg)N1Y1`=AQ&?97Z*xcRIYuHg$tTBzD%xg^koS;?N2{^uDxfb%i82w2Yo)+{JH zAjp)fRD|DCTwXi&D2CVBr9A-lIB+@ zT^%=edh0W0c3NE@NcPq9xwn2x$8WE>GjqhZ0=rp#C)FTB04Ik9W%|-4f8eT1U*RhU zXzo;NT2hI{9(TuZ*nm?yA{-u*voO9r4|iCC$IqHpIic4rV&I!t&~?#h7w`yiqd<gHQ@3|o)+9-KUe!?w|2k?`*@31s zT+rkQ5xynk%dtzAe!yh?y?(u*UC<7CBS_p)+Gp1@a`LQb5fxRJlw$hz4bH1box*Db zwX;v~6R2jK+3{YKBg?ObGB>i(raCXXxfHtV2H9jZqnkLWmWQ350|Nwlt7)y}_T-ZB zyYj1W%v9Ydf-cHT$$DH-l$|rxvgK`h@k?bt&*C>sK>1Q?q589l(93DPhtTS2h6DzI z?#Q@L`#o0|)WX6wqleD(1R=y@g1JlR2A4%_yYamBxaZqfhR))%Q?Gy4ln+=WP40)4 z_UxzOy`#>a!Yp*p9xrl&!OaVoEG)qoqclM~yY!y?*PnJb$$Pz|{DOVmY}Sw=q04#9H;t=(&3=sn0<`)411{Mc&FEqWVK4BacQsUPs41d-iNp z@as|~z$E2p%WFxG7vL>URDhRP1UP$fHfx!>redoj94;~^yurh%E}@9+oo%Xo50hXe z?wsH~sigp9IlLXvDX}ZyhH#m;e%t|A2}1JgQliFX(0wENrDLs5^J*OFkKRP$HF^_x z2FK@Af*>rid*RJ=VPdN*aaS!aXps(8IUX)4d0aO!(q~4f+J;z1FtKC>_uV+W?FFHcUm}nZB@!oBSm#R z-a}S8cs^sdQWx{@BdccZbJb95j~i$4i8!U7?1^vsQQVGIBHwNq_wzp=c2hM`6tStz zI2hVwR-ZR>lam3xPe=Ng*GD)A4M#n!B!21GVrsPY;_a9JaPn z(KFu5K(|9&ljj)lv1#>n+M*|2r4|Q%evaJi8G4JQ)c7#KtJZOUuW02#M6n#Yvtv^` z3uGH~;xr1_Ist>SLl~ui(WJ!GzuO=E!A^gdy^mFelrO$BwD6z(4T0h=c4OcV%^#EK zzcz6__i_37@%8>>FEzJ3*KiQrJxfagM{$f&u)5r8!5uBvOo+(}ci(Y;sX5lOC#R-C zlBDF*GMzl+Ld_Gax-giQlkTKy8i2qFo$h}kEevO@rJ(3*exjh4PM#+3b-;v&1WW!) zNCkVLlBhz?*yUB^n^yNTyzl$GxY{%f(c^%5UYO~d$KVlKm+uLemwAmB*_dEfOfpfr z)7Y)L+aG^wbhK;@HY^iW+WN3PZNG*GS-(=q zP#Q9c4K}j|>RC0D(*6c_Sek0H26r`S#;TL4?wC?5EPm~@8}1+&)YuXxA`Kuf_H^*9}| zBcDa@+E*))rGi$Meixt}B5lN$B5XmRdGoWL7eA9E;N7p&16XWgyf7rCTWnA}Z_E{4 zB+*>qL^HNblU=GrnJ;(#s{k*U4N`k-Qa4XH!blZ}9BUlK^yWQ3)3i|$2()w-X}GoO z!um1|l2=_ktoIb)kTQYld-3UDxF|Qjx%p<`32KnPaVb2q&$f22caGuGBUEi+8b^Gw zKg6A!!9k3g>lXZcd`Zla_TIq~0gRbCRly@To4&rnv@P>gq?T5M!v~!yxX1*yAX#C6 zZ%(GUx_Dvf)nDx1Pm!8Y`xP~T&I__=Y7Xn*4{td|A1_5>PkUp~j2%~g9L;-RHvg3i z8{2a^Nm1y<(7IgL{(jGWJ^0I@l)03wN>^9T9t4eC`!@koxo z^LXt@m2uu|QE}+!NnWb0Jq1!mu3&9voShXuM@Qb^lx{7^6q34Vq|}AG43^ZJYOxn# zu^VFB{G+4jxn(TKsxOUc4%gx_IZ^f}-+5J1Lk+t5Y+j)~sGn>aF>;r0#S5Ns<8i(B zkx_j>64Q(KH?@p5xgSc-Y`VUlmK0{kel*>OI#sUY9fk<~6Ejh(=xERO*dNO387(AW z17}4QyYj{B_m4?iSd?oc?49P^&`^17r_^0~@-Oasih-HOHBJ(zO~H@Hu`j?wQq6nC zAsW#|Z~>EuwE1rpoFL`u zMJ?OAb3dTSek&tSQ!Od;4b&T-Cj!dsTH5Co;bFqYX`EoPr?_(_Q*A0f+_d@>>aI88 z)EgFYS(vf>laA%Xmu$CVw-Z>cTROLs6ne0nlUb{*tnGHP?D<32jx(Uh$}V@ZY&ag1 z{-hvxaGNBY&?MaWixp)}>@^t8K)L)(552;!c5|(4Y|v`Fizk+_+tHK7jfwyl4o4hy z{q4t~-k=@Bxuk}!0Ef%-wTQwJniKz}t4Py4;R^8Wa8~&EGE2(fnk!9Gjrs1@sl;Np zyj$SI+WFHviL5ZY*WYtiCXbT_3G+I=BYqzpy6fFW3eGk#dXkXD|NaFO#=fROm2Ed! zN~`zR?eCL_inmH+eJ^KI1#~^;m+K`gmK>|-X$ZXvAtbu#Z7xG~D`5X?Zu82;Gq2^G z*2AWWFPzM)FT%pQd141n2x1QGyld0u{gB|Vq!Y53#|_wBWmk*qmxrmHoR1&haCy-A z_B0C`=Oy2GTx(|b8u$!Y<~ZOx*M72+K+F+9!Vw}#L2YDt=mQPfTk>zhXoZGXqMy{6 zOl*96p<8yuE)>R31s-;ECTOqq!$~!Gc{(->qDgn~6GxMDsG*w?7P9BR0Px_8h5hwH z8~VktFzcKLtGyQ=Ff-FyfWP9)8_cT;6jjR}gKULtjF+(F0)Gs=pmrK#|7ozdfrC&ASBAX+bnpS@w? z^KV{-42C?zrwn$T>@|TspZy(tcic8RjEBKOC7A#g3hDlVBAboR%>>8Ats^-1)1f$B zxap!){gdQm744lJExIooA-i_V%hbsd0-Zy8%(cB14Z9Fv?oL8wNaTI6gim(zgN(~S z3B0=;?8#FwzUBlSy+7Cd?d{R5XYAz2j>+`uMB-;^wMJ$cm@UazyH~wkrsofh$ zL1yxVkx|?LppJaM@BX*Dvcls~$aVE2lgN^c7FU-w1DYh^sNwfF__hS?UN8k%M{%|4 z?0!|U2(g9)_{_fx6H6hs{n|}+N6Ph`Ba-WpIC1}k8N~mzhaL&nsz2_vhLI)22fj@_ zdC<7NeTYvSKIG6$sWI(v6uDbKJ4QJM3e^M@c%2Fp@e{G+Zv-67sjvz`!Jq441U4~` zb{n=R^>y?kq%el*FJ(afKH3!s@xV`DRaMn=!B5;(cV=Uz@C3e~VqHfx%iB5E={$U5 zQplcoF&Yscly}Snwk`Z_CmB7zIhn$bYkcE&D7_z`tfIm#C`eLW?eS2cQW*$LIkKa3 zGOsL{6S?F1EV|8SeNHSfS&Oks**?@$4v!z7J2!x&BqdMF@Ixm69yeG|R|k$A z=BYC!^rJ54l`q;f(7^<O;t0>&^OJ2hSI>#+F_&1~4*2HCwY86?XX zL@h4bkj6p4YS28~a-5Vj_QTD&pMchB?0r_UR;kK!PZ4Pu&2n_h*vjVQC@bLNG>)dO zK{#!0$gh+eJS&DvF3WO^aA<8aNm#cN#muaijiOBcB9XpZv)nK;RGog zpC{wFm_t<5q=09RcM*1Jalwb!U?;Ao1WcH+{7&8C#GnHgwo@!0D0$D8Lb#&&DvP;KGJw}CmWilQT^{WdCgQcmY70 z(0+POuJGDuTU4orDz8vfe>W==PoM%GX8vF^a-XYLJY5jGu+yp~pd81XHut|dTWMH; zv=o`{L8dt~aPQCyp);SCVJeKhCGxrm>tgRb5p^;4lkkW5AgdsscJ#EI70Ayew47Ky zLYyse!G%<1kr-wvg}5Tg*afTT={`j|Bct8h+1rOPRKk*pkI2nB_7 zo%#7C4;U-3pGLfcsRz~wuycg^?Z0yWEvY7qTc#dN*A}<}I?An{Pwht;u5l6#ONHn)R1m z7YcRX+bHFULXDxTS?bpzoJ4M5dP!1@$ul*aZF%l2+wARENQ;b>cKcM zK(H{iL&aX|3by`9XgV-m?YdB)!VBJXKWs$x9@>7~Gx-E?0$3DY!}Ak;&LaHWi@*Ev zD(}j{$5U}#`xc9d)A*hiknW+oZlFZX9)sNALI?U7#UmM3D>9%*=W%d%r~mS~bo9#G zj>=Pkn2W4dt1qNKghYpq9}D8fT?!puA%}fDR+7~e>ndBEY4CviIG}BwwWO?SUS~GO z?qIhn8hIG;f%;YiV`jtdKpj&;OLTRnq_;dC4JO165e_DW9^%P^;Lv%$CzAh=TG3mO!dr?jv7=cd)l_2U+vbPH|gaJw@D z(}fpD=L0;l(%czqFUdo3IVM-rje_E&{y+typL zRSODu+K3yb;ehgc7YOidtgG8Nlkzq~ug0HdQOOGC5xu!t^vo-0RqMdS_PBQL{6lwO zcSr{l96*ASLk6V<`67Z&A43t7-txF~w1cU>%BMee{IK)^o+qNr_oypriRPIOm9z?l z4WE7A(g(^xT#kwsv~cOG$+f#qW5fKS`tz@+YtAY2JR*5P4wC%|`tt!#27P{u#=_g4 z5O}lNCH4XjE!h*ii2CG-h-M{&fNq16xjuW5+(AMQht}m4Si~sRl4IWT1=ynP)9X2a z=}jCrr?TplbaRNNvLKBFPlLa#u*5GtabuTXQ|aG=hdnC&Av}%BjK1=uShmhp?^CeE z&FgI*Y({TLH5DKOg5NZ5<5i6j=4i9&dP?HyoQMyL)a2xY-4Vndc!dS4bYHv!2s_|y ztR-iTz?V=?UOXb6`lUUdfXoRHXvJsh+MPTgznGWg^NG^?*_&lTum->W|TWw zZai(NK!e6CCquIAPRnab=I-!$WUDn()9)MAy2@N4wygIv>c2rosTMt|@KV4{p&8S` zs(@wjwk|+gdmRMeuI8F>)5vH)s=w9@;qglsv~t=5=CBzU#^EKP_XPIv%Vqg_S?NX* z=u@uob=0R@y8Guc+>h0Aj=1tb)ysT@~F?$`>lP~O5E*mT$l4$UH)+Tz>HcIsJ zhi8sksK9(~^F-_q!l3tLtfP4%PW<173;deB@X2S;-6FWeF8(NfRd9P zZ!bOBcfD<{B@OOKw+oD&-N}*7waH129{)4KeI3AqmB4F%wT_29-zptT=u#2yv{2Z| z`~t%EeQ67hCBy#lQT zIkOb+bVU5AwEQkBQ=^cJ`<xKv+LLSJ6)B3Jn8&kt@jZ}|UT zl{`X}BEP=Tw@H)!&r-HnFQS`M|0(|(_xOj3g)HR5|63jA0V?>rc7DSQFCG0$Yai>y z`rlsuXC)azn(Y5d@v~t6?d5;!@+BevtufqS{ckV-vk1Gd8T8+pU4-O+d-4u8OHm%XEAp3AXlQ82J^Oy7WDMiO*f}UP8V*kh*$RFB^(yx1{~YvRYvWOfFW-1I zF*H!bU3B-ZFcm)Mj~A<=tnKv0+7Oj}BVwkLaNto%_FzJ_pDE}paB=;vx=~K_@v)}z zJ$=A=##_uu-R!pHZHtxq@%U{2lx8@3u7Rp{2}ujsKa4bLlb!NZ7lpfw3u8nMkq4J` zer+}yp|d}oG@`}I^PEh1rH=f39a$~1?*uyHs;6Q#i?>bS>e`mj*ofRyzyva;8ja$P zHMxau8A=UzQH$TdGOJ+|nafW9I)WEH?H^T!vI(^RzC8lgS|+$xE6mb+40`6HZ_&7I zN%&@qi9{gQCwoZ+7zLa69Pg_3e(Pg15a&OiQ?=-w!Zj=15E}w=d8j8byJlp-xnrs+ zr(~b;`%t}Y3oEqp8%Kghvx9<*W6T+(_}`jbVO}f&jt_P~Cqe=a>Bzg50jHyhQldBQ zj+S%3PXHPLiu;GI(?fYmG45kxkTtwMR5|D(6~lJFuk!{0p3`e}KK-#RcS&$#@fHAR zwUemwqqNl5-1{k`MM7L$H?-cV8aT$Rtg7mB6KbS?&=p3&qut?;0b+a&3=AMGc$LC@ zdthUFCt#3@R?^Mcc{jt-%*?satAoGsQzr71GA$#+vCw6q=BB*7JP9Qwsv#s)R6@d+ zjb&@~?Ci`OlguUqY(P+)Ad$#~=xBjG8SOs~LTn`&o>{&3Q1S-8x=bvuc4Z#zOCbW} z_h_YkpJ<#FUYaWlyzDqZ(FEaA;kj^QmXGSZeEy_}>-Yd;TJXcnOBh zWrd4F@5G)=WGRP2yuGUO`mh%F!DbbSx+#oAm5t;{zkll4T9~iWL7CXq&lqYLWbXL2 z7h86DD=2lX4a|ngWoKWg5An0{hFp#ldaF>*V+O2u{j3#$I2+!%mBwZ74+1yO?rWU( z$qW+`Yf0>01CqFOhu^HIr5F^}T5F^3jPA$T zL6VNb0CAvN%M(wN`P2ZCE1{jBrS?N1+1714b-6th{&401(yyOROev_oeq++Ot>tp@ zAf$Dz!gbntXJ^M9FEv@u3EZ2~&VH5!eEE{^L$>LrG!x#Q4h=nW&t7_fKOj3u7H#uo zi~^^7LqmhBHr>?SyLZ)(`i_tg7-m92B9!>%&2)b;F)>CFAO(CpJ3%8;-1_q3j8V$- zo(%vncG1=*8-!1R8+8OV8;4$;9vIcwl4fLNj0O=|DV+b}E;FeoV-a^2tT1h)T37&~ zzv8ebnMbH?e8|=R^ZTpi07D_dK>$qXpvitbD{iVl&DSMUaY?5@HLs*(BnY_ZzKGB8 z&1tXyX|5^4*%d`=ys;MevXY+^>znF&>NixAGNV3z-237~WP|ItVRrcImT3LkHEIFk z4JQedqNf2{wEu&QHOBQI86#Ep00j!_;5Cxd8L_%`Lu&Sc8aB`lV})Ss@oFPVMX|U* zx3;Y0rb6=~H8(?H+C=L`!U0O=q@d1oO%uHGFpf`zUM2NzfcK5Va58fSMdQ~uZ;OBD z#61-^*=cQLSvZ0!mbG@;)0Fd6Qk`+Bz*i&3c!wn4M;xc@W#@WF9X+4_7(eg*;$fV^ z?D0`QCEQ4u8gchTIc!07X~1@7(}((&XLeeB+|WK))k$drfJ}(bFxVx1SPgBYc#zMh zW4su=oHoPUXFqdSs+FoV2t&h+{xx(I*|-|QBC+I7AT1q$HnSf>DyZUUtj|E$zQTrP zsxT?8hh_W&4TP4?Nhd0HfD@ddm_ERFJH+jv^fHMP{Vczgl#`^5TP|1kOQy-E!bzYzYO0Zm2xxm7&|cFMw@JPK`^9zU8-eBsrjEVxSYF>Ro9?CpC{ZqJz387n)LJ*zUIbF+wzMqi~$2xl122Yg7|5Uptmf83@p2 zuw2<;p6tEyGhKP;*51Si`I>H~&^SQ2d{SPn zBxbT%@&B-r5xHbT_DwuByPA)u;z_l4Bc=nJRtQ|Bq^stH?aK&>c#!#T8x(pZ#8vgG zf7Cd+_QtY0!UlhKv9^}UmcY!)yH7qb$!J@rmgJzsXkU7f)SB^q-5a6S8!Fa@9V$e- zh36UE=Qv)&<&pI^l+xP!!)p~GJ4~HsJDvo~lSh)PUUKI}pF|z6c_({9%v%qGOZnG7 zCO&`1S0b|L;^tv(n7&;x@cNxf_}Kd*Ucyb3z>8yt`@v&jronRd&aqY`O;TnZA z17$}fy_WB=$&z70qxCVlR=0PA055ub+BK<%`$PGujsa^&FNaMBTb@?H&2`wWI>dil zG|6D2G7Y`#w2c^YNC~rAKx+?Sh&)PMia2_V#qU|*Uy#ACg~&9Ld8?fz!EdG?H%1)0 zqUP|I>sJY5m+?;8uh^}#Vgm9Jng70Y|tBX51ofJFB0se88A;|?chx|43Cr8@?pT7NxiG0h1IYfx6cWfj~e4-6i15#nT_D$N{pGUZ$?hKl&IBX$&Pxe z$o!Ts?=7d~50mu@xIJ*sr0=LPcc!p*|4>mpG>;!i**l8l)ho)uN=*;eS$0N?^n9zg z{V5oHMk_w@phDlfo{Rp;#mtg(N)Eeqk5J(^vcar=-b+`8j-FM%8|hxkHhbdWcwSWm zZPz)mxV}H{QEf3T9X473-JQ79&Yz7xo+7U7e;O%e=a%88X3PbMLd&@HD)WYnM;Z-Z zVtcLIFlNRxsHA*U|D)nkZ*@s1l6neRmH?3djF|x}!JU6kM#No{jDpmYCvE1f<;UoM>TT z(L%-k@d|3N|F^I#wpn+;*z1%%ZZ^>=b?g)h70ZY;>)%N-R|XueXYGOBgs?Z@WVp@o z16rc{ie(qS6U&t`QC}({g7_%?DDT-^t%FVfg~sy7i}PX(+rg2Q^`*~{WG7OMR2eR= zz&vXWdq|;N?|69WQc$*0FEfRPnx6^&vn3;|OM1ntQDTbcky}QPo714&n;Twk<-@z} zIf%1T6v*5YG25U+l{fIi)!6k`UGZoHdx?CIH-ZO@Ei7sfkDjfh0b5A5pYZP`np!te zhExl`vTVPbMG0cT$}3HKjtL!md~U6FNvN1!R#Zu%;=b}5RawYrn|xHj2T68CtC(BX z$-gXH2^-xh=sL!4xONJ%r%KR`0}(k>Rm~Gd?(X;$1EyeLF=9}CU9~u(gWERtVq^U`-pCy$Q;CL(ii%D45xNJY}lL8NeTalrur0KWL_*sw;UVFHd8hHkyaWiwOKS~=K=IlN` z>Y`32uxJFPgPc>S`T5@R=EEwoV8A9*2F+@*!A?T%SbI?M|6UK)1U*qE2QHJPUqg0@ z?o5H?LJ2uOd`mQp$j;Q0h^|F=@pq^=K1S;Lxv}Dz<;eO^JdxBk?&p*vG96swWc>&# z@!Z_v4BkL#O2M!oAxs{`YUKUdZNJ=CPK!s<(k&e#yzutODl!VQK>v3^-9xs^@u1UI zp`(89r6xk;0l$unnk%De9nYV)NUu^h3UY#o9dXbsdF}g`rANmv^E@(Eg2Hk50_|wX zzB$aKYc%JW?X6gPbXDMrbTuy;J*!qKVG?Ge18ot z@{8}b31kcVTH*YT8)-#IpjAEVKdS)FF+ju@Ps}bxSjiuKz0FePz|6lXPfVs#cm3^T zDWqxm%}x?FEn{nSc6Cs4vFBy^ypx*G+kU(g*eGb{-!JniMH^1mkv$yx2p5z&c;Ctc zk^cR68C_HhJb>jH%}^X&TT{O}&P4G^nDmWuzMIKSzSgiqfrxUvKJRZ~2JMemuN0|= z181@jH4m9(B>)F(mIAi?xRjAHL|hj zL)p)!zN$8d4`@uZ+)1Z#Ok~Qjoea?5>i&9p5c%d;grhA_+b^3?oSEG#G+sx}G^7M2&%5KCHWe{Ff-Yncu`CJL;c4hwba*G;#z075dgunh?s8j|MtvqD*kifr*n@Xa?t zS}XtbX>E@)QStX1{9rb}mV~e`ytFn^<5Ue!M+-_#j(apg*h-6Au`L1@BOFuZI_>^B z0(k!^v9~e5f@j6Ktp=IzA?XucG0Bb6QqslrnbZvO5GTmc!(R2t03@|M*6zY#vy35e z2ntbsZ&63kXhBs}OIcTan1Ko@elU|;`aDV~I10}MaHCQi>poAvT0gCDHkC&=A ztk2iNr?Y_Kx)BSZrA&KI>&qF4bZIHsO<0UDWpou~*myJPbv>?1N)3bo!4O^9fM76y z5tNFhmwo6)HyTA;9V-T|$%83AJN=Z)SYZj3Cu-wuwVQ7|OQj!3Koio|XBOyw>$|{?Ml5B%F0_$Y#wpSnFkz$F%kHQr3sLKyZf8+<` z0|i-!uX`3kv>KL2h*(xmkiRrn&XnQbV~e*`Y#wKhYI3Vgq3q%!F#Gpn->@)1)3Phv z8pg(BvBa>Qq4lNV;e9ea@fxT}?fd&tK05a+?N0-q)|@gT&2>vqqrZ1^Wqgch!KU?B z4?dbO#KHMQh0gH!SYqhXqLf_U0Cpq#d7z`}RJZ4P%+hq1f}98w@-G8iz>eRTBLgz4 zdVGwWu5y5#gB!Fql#g@{z#sjB6O!u{`B{%YL`J$zeNJp)#AV^>NQ3Y!&08598;gr* zR10M@KURp~PIkFk2;|4pY8Npt$liN?GNK`Y^fn(i9T@&C_<{HUB$|>){W!x-7BDEZ zT&YyS-obAgcRXH>B_U?P#br^NZ*mGs<4sP}i5to1Qz=!aYiw+caI7=^(a3TYVu~ih zIba;@m2$B=xjP*&8*=8|eS1 z#5BE=6|q<;WXEN}ha*-JhQ-@KS__!-)J)uO2l`Jkjt9)#9U_P4t9Qt7OOGbb$)OZ3 zU?Vyj_4c!Qqn++&xJTX2vayG02FDIDI-Zu90`^`!TrX%A7}|J*?ayO0Y_ED5vH}9M z`l;NF*cg!`vlaLKeCuke^a!&&O!Vo7X=P|Rn1J?TY4lXX>yf_ns@Vxxj6+y;cw zzGnrS>iK->%jZ%ODFsfk++6-D#C9Lv19M_l(^HA} z^?k@xM*v^&tVvbsqq=f5Cg1u z`;8NJf`i1YChSvQcFy7=if@vn-r}+_%Wl`$!4>9{nneZA`*s$0KE_gKDbc~OP57DF zV2W+cR7dQ3(qWglq9Pd57p_O-X9@xAr|_AdA*RX6Uj+pbXRlto0mf7c1}IZDqf(~M zlIcbdn}RVaL@_a^hC{t6#I#VN-g?Qf%l|xGFJB&V3?=}%Ut1F6S}irrxd>P<@|rC* zIn85RN{MW+bsdKuD%3gdDcop;n7$s(dO<@1N~R9Np#=L^(n%+{sECHXnd|};$fj@( z<`OQ$O9~1Gj<)OU3=TPF>Hk}1V*N#GN@31TD+@LQVb(_;4`(lvOkB@ zX|E>*U3PvBE)de@Vx~2SFMA)nRZAqMUHl?)A88{WkA8tO^c!pIhVU(e_-m3Im>K(d z=lRRIic{AyuX?P+A2{+ci&#NM7qH~x=@eo=CNFODudzl`Yx`|gR#FMHfoJHl@2<_p zzTyX)9#5wgV>hKk?;fD3=i8CX2@fOR2GvrT9+B?;mrX{dVZq#9LCu8r^GV_i!$TCG zXw-McZ(<+FMeX-@KkVI)>ahdRnH;KO3GmoZ0tF9F^vt6^q*~_PJq~%I(E*0x-IVZo zh`aH?L2DiVo3-uqN;mJ-My2yulc5mP)_!AYKgw~rkl?;=*Z3-$)b}?hH}Xm!UITvy zLjf9+&9q?+Z8i)rYqS#|>DINjkR!;{$LP`}?>q|A4_lMct`{6D4z2_*I0KAD* zA~Cns$lEUBQgvBJ5O!T$Ma@%3iV4covTKv!%Wa&p{=oyUhYB7AD={A6SjXUSw$T(s zvR1k21X>C-G=>c%NZcM8b*qg&bj#hQ?W3k@9!)Jp*DGp6mJ|f1E*&B|@Ds$v9^|YK zJ(`*4YX8QE%WKn=kDC+6Z83dKHF{@3ztvP{@@1(9FSA=Nj#NUlPHue_ z96mB*@4^;M)B>vwc~189ilR+bq$`kAaej*3WY*K8hU{Mbr?t2n*g` zNmdzZ`M~rJFmOiVPmrJMBwWw`s=@op38Z$Sn}^DfM%`TJB7X*=4f3~A+tuDtK3@NX zZ+_+=`>>C|jcVJe3cA1y;kQdW zC!!}w^wS9xzdtkk$RG|ZUGGDFLl!WHGwAMf$9aMz`%yg8<&Zlbd`>m%Wd@@2bigbm zFFU3^D&J+Hr3_~AEhDWO#GQ{I>?in%V>J@XszXM_XQ7W#qrqS-naGA26#+S7cz#Sq zXFv&A7p!&VE~p;)09tB&JxTxI6<9)CF^RkA=IInnn?Zf}clO{wICpyIPBMt;ejuqo zZy?%lvN_^*%ItRRPexvo+xqLi5$Z6{h3Ms#DCwU|kUxmvIACT`^4rf{!?NDo< zp-YT;=Jh(AH&-H~x-LKggKDBJG~Wsm%aO`I zWS%<$d*1)NIM7@s8DnPfcG6kaDKq!X)|}r*Y0R2Q%$;um8yS|S#rU1aQb#tEkI(gn zRtcMfNNE_;_?)@lTW~bCY$f_$w!e42ZW}+MNeo+@-x2S;Shx!s&gnmYYR$txU<%WRo`>SLJNHwgdDR}ISJ~dMpBxwE!p_^y z)7yj+rcIdi9pxtl;ot6Uc+WNBzB7ay*T)QNcn;1CW2LvzP1ldx?cDnBCGszCqd6=k z-Z;GD3O3b2Kj&ey$V^nI`I6L9cyUywNr^G^(4*mv{!w#v?CYp__eZ9gm{SkAOOm@SPU>x(d)qe9r19?Nl?jFH_4&}AJhHAB;JgxE5^#Q4>SWk{wj zO#(UOd*@^XU=fSyJlS~$0a=;Y*xr}K*`$-S_W0p&HpUxg0xtawywdcn8w3G$2IfOi>rm6L4q%lvX1tLk06 z>DF=W8F}mKP1~F4Gt$tf#z84365sD62MxD??2zh3AZH0*5E(C*dAQ|Td>bOQAE?#M zdx8O}S=8|8I6B04(tfE=VHT@g1=>HIAJ5KTzj^9!L8TI(Rv_^L4Fi;5Hve#N{qktg zL^u*6*FqY><`MBzNS1_P-{0Ohq{x3fB<98-Umubt+#%6fNh7XI`#5#M`$0har`b+z zk-s~2b!DuUYRn*M>GR}>yLGox=eVfBQIQK5GvG+@lZ z*S2DSz_!H6aN3KuIqTC!%olN|5s{`8AWQQ^T&VkTbuv4sHX47EZ)a?++zLN9NOsp20ea4V6! zG+LA`Cc?p@7;Ks=EcfaWWmq*P2&#uh^)B_sx zubBC-sA}#{4zw2wnwOZhpr2&s`LnyU&`h*eP|xi{(b~eLf+Fy80QZp6rt$Blyu3YS z;KJ;065LUsA^(bj|BCvBxZr>4X{G`V3DwKH`hiuSmxn)d=MS{>-xrk)4>tbS-*?tZ z|9fDb#sLd%X+gpF-@@uYfBww9yMf|gT_j69-X9bxi;952=V@f+f8LOVmLneXK3yGr zh`zf?%_{@^HxF$oKrLCkVx6FnzwPa1=1L-*?J_`drRI9Z#!qB_#rsn&V^;*^1Ss05 zx6jzD{O{U~t*2S>irBw<+RT+g^}Let3ccI+dR5xP-xPX#YbkY?nmNtbAc4v{Dl>OI z%^Hu~GC7DxCnYTCiS-riBcK6PR8~afVmR)f0&Hz}R{tx=0;5Uk-}NH{nyV9@(v|&s zn|}pQXY-{L6NTa8j5EpQnhp)C1#uK0#Sd81e>o{|0l&N|Hl?8E ztWNR%^juC_n%^;n5czb`)oQ1v3C1ITX-+5C+#^7|C}s9nG+bQdIc*U344dYfE(M?c zX4Vd^lm(Lw-xX2f85-yc*FA4fX`!YFTwdNV_Sx~ht0t-^%vS5>t>vsrss>(oJ<+yp zda6L#SuQ4YMr9vchi2(%ZJcfT^D?IVAB0@#4J@vS8wtFBd-t0OH|9|lcJVGLk%*=u zr7LtE=x3cW5l>%)Lnf@J#`w38ua160#QT#rd(C#3m8X)rBBehU)?XoQyw;y~TKKr( zU|TO3+@7xMDpT0F{2(RIS7RZu;TI(E-#pDN0*1`0YJ;o#fY=?FqogEJd&of7s+22N z*n-x^R&ll99mtCv=XCnxzwV;V`ua_yy7ar(Qswt!3uWdTR5z7w1$h5Kcg~O4*>m*` zaD1(#J2A-GJoi%Z2mfIp7OCM(krFzCr{k$1y>o6Z9lPy-#`$Hn?B-P-xo=BddD_hv z9h`HdefR8n7ZVGeL;qOLRNEX0Fv4B;(+m&!S=x_(lGJE~EUvVOv|TLVx)z;%G+T_b z2N(P)KdUzrPv&*7g}?_cad{lbA_f2>O$xb+WLft>w<*QcTKWbhg(f74JP z??sS(cKiDQdB1Z|tncT3(_sAlv5MrbYd&DQ)&;_D}wkBxr#{Kr%gtR z(PxmpyJl*jlK=->j0X)cja2-jk;E;wKc|SaaJd!Zcf;an)2T zQ*$7gx>6V^aR05^pZf-$s38%%ovyV?JG2eu z30KE_4rN+a+ZZD5bLtlRD@>+|>fqOSk(fgc{d?Bia}_|u^xgmru4C40m|$VN+bPb@ zNlr{;vs$=`G10M1nAjFX+F$5;9Pel7P}-6QXjIfY&NG@#mBe0zc399Xa$|2dV-T>q zL)R02ovinU8tsgwo0UI5w3`#V|GlJDXPImXt2^@5jWDUu?Ic5MQ87B$W7ExdYR-5% zd(J9>FfTg0>v?!7{XEUtaf|T)uPklS^I3DdC49do796vON7#MR`dX^7%jjktDpIH- zp)BhM?iP~tw|Gbcliy2tO(1{MOch&@;6NR=S;1bYTW(zFnmksMYRhVk*IBD~`es$G zz?5M(pJ)5^_=CVrh=2 zwSFN%6~yE>Iy6UYs81ibnW}kPct2n_ z$W~MkK*#Wo>0b9^=K(Vaypt0z;&d1{AYI-?-&zA>9_t8d}54y3)9LCGu^EU2@Ph4BF4E#0Bpkm`3kx*22x zgR%5aja5pu8G>+jl#Zu;@G4w~!8}wZ#{*I@ec$VOfUZr9!e#X-9N--}w*29#Rv?x0 z!it@pd#Knawpo9Rq<(G{dX(9reLP<+uU>OZZ?K%9evlJ;sL#l2|?OMk2YU)nZNWn)%Myl36c)s1EYJ9jOsm+!s#TEO5Ie==IRh0(|82 zjGGqme;iAEcIzU!msxQwb=FJjZddzTX}U-c{NS;Y+j5QP$Hw{8g!9Zeth7gfw=Ncy(qlreT&>#J$vShAg<=fC zR&6paw|kQ-`~ei&#nyJ4br2gVa30(O(A3&fo#Ia`vBjD7f-KTo!{^i9Yf>hpH&^9K zUdXY#?jP3FxLd8K@LSB+HIJlK&#m#h%ST*f0*#{}gj%q3H}rm4-jouDe;)z^#G0<> zgABvAOGQddiR~!~pmZti?U7{Ki|x^3+%ojv%&HX~;nernM-F@JftbYXIqKILVAUp% zcv_?Kl?pyqZfC6W}(dj@LzKSZedq>dz zXkjkgd2{$=r=_LV^FCt0F2$?LRhl`W&i3qFz4mHvAIJ13oIDHVpC^aRB&E4Y8$z5bcW~Pn}YI5^*}+b~jT#MA&xvk_X`rOTXYS9*N48 zPRtAAD}%5*MbIX4MD%xaIY@0MgzXdx(&Eb!mSqQ@MG0o(uC<0{PjQ%M=^73vu#9;>0X>ilxY&koTU{y51ah#p!nac;XP!dklQ z&ZRarlU*ii%%+ECQ`X_en--%fT<6es#0)%VuK;6e_2pnPhl$vo(xw#vXkxV~Z$~r@ zliCFUG-dhB#^SA&5s8+qYpIH;v|_ThYzkEyYH{*7{YHZ^&S@*{cGI&156QEXvWZ%S zi6S)&{BMU2ukv#*{dqmJrg4*7^CTk3KR|5#uXZPCr;4m_ZjAApqu}FT_gWk!2#O!a z#2M&L1%OqHnBUQqt{cz2?}zhfp<@1Kf`)apH{t?f7cSphBdzs1B%rKBcdD6eufGz)tU=Pp4C$?az~aW9UP?KL+mS8rli^0!9R z1l!fr<_k-dVA@sI4hd-jipe4`_*f)n(=iYdr~T12T~J$Ai9R#pxX0&z3}#i>##Tl~Q3o{?`@CPwS*)gayl~ z*bRRBHUXu*x#>yP3S{`+DMaI!9xm4qe3ghh;J%V+?oGRpJ+^x_5{?b>`LF z+9Cw`ZX}e#nBq0!o3@W!f+K!l#4XenuK%tK>JVsXIw)(_d=k5A)L_vf}SYQ(?DgF2bDbRm*uUcX7EGWu*=uMl>5epQ`X|pI#>a_nCa10|K z#*w^-$IDvDzevWKb=X4U*kzLWVYu$pCH}CCozr>~NT$yy`3Gk1+!$~i|2;q_-f}go zVKjbTE~%Vt8-6k-R|x_iJ8d`~XFjWUKP=Olx3e2%eH*dZTx`FTwaJHD#&@zo;yXfW z)3QboYu$RK3{2q-U~U8)k6p0!*$pG7=5gFfY>sItl`e^@6H-TVR|_@&hrU{ zW8`ke2Ye>LeB_o;dhGo;Bno$h**af84YCM6c<2*<1(j~_QpMaP3BLc~@7e1l0P92w zmhquJw3Hf7u4q%PRx>!nT>HjPtIU9~Z%btgY`)kHMDwW+~KFQbEb z4nCyPMkDmFhBG|2*Q)1U%staj(0-*kr<^6oeG9oH#!#=b+{<_zwegz1B87YzMCPE= zyItg`92l*3WIlr&MMfjwMR5lME2q&L zix@g1q7Q;TEEcfL!~-6$^c)RI$|{P-E}NB~Tt)I@vIHIlK@d zpGLer#`8*lU-nQl@o_tQEH*=bnXP^S+-o#Pfl0>uJ;NK}TL1B)GSZXz{AKQGb++YK zr6CTY)F4dK4KP#>L20csc19yoT=&`Np=U3CpKfWtFTF#kQ@(Jo+Cx*3b%4BO#PMT& z#~Qx{RPZLXjWodG+|QZ0dW)8A-w^|@U zr@X7szROOckgTN^ZsCSBL6C;xi*%~*Cu=)NMqod1y% z?@0>5?G^%b(lXteH0reGnDz5w%DKaKZ^_1mF14qc*knGy|zJ~NB_g8D2re=r{l%>=*eIUE$1bt_*t4;i2L=)%83UDnl^%H z(59@jK1I;8$zxZT(%zr*%BXuI<1Xq>W4u_0v@OA)1ae06-jGkLf6C&aWsihhN+8rN z+Y)ly!!RUazUHBln-eZHoqPYp)q(e2-ES*-is0&Mu^QV+cD>ON`R*D-0l<$20$lT1 z3P>5z13KIz6>65Ezsy@UFE#n@~q&kvInk+6wR_JP*PPQLGsWvW#i zqO1RqXLvHqA}x+?NGlc|&E^e90bG5o&5ZnqVW!D2mp*p_Z4@`ZtiU zrFOR?F*8_Ob+hq_Pm7dDt%wwfLEmT`cKW^aSu~yzoFQH9A^`~9G@))W${twTUrBzr zxh9`K*I$i$;7f{=Phl?=bHa@bMtS;jC-jMi*Vho*tX|sW-s2CyrA0P)p3)Ot&EYNY z>*MmY{ORM05~HR$?RyuhXI`E3i*~L$)uyd+>KoaO{)y`M)lv7UK>u#LvTkacfife% z#V=H-Z*ZWmC)BD8LmbSh8T2T-jaI$Ksw=*IOuToB>eBwU+xwJ+%RG*W6od|U`W|n8 zRDrvZqnP-6Q*%gOH#GgLBlU1QkCqh0Y`MZm-iqINRB%|#mPj?|gY+aMd4(*Q$iQ9m z$^IsLr3u6}d0cjh_7*&K zUw%X10AZlNrdNG0#og*6^;wrP!}D70hF(^duqO)*vf9f9D5PpLapNLU-6(?>yhy zquf4EJp7nz7I@LepfLBjz+^n5Y$f1A%xi#SNJQh{B_&?x7=XmBEgS1{Dj$DJyP57r z%{I8eI7$mM1+uep4po_T_Uo_hZQ9^g8AV0mRJJ47Z*}RG8t9`so;?wKy1{94@>QhR z9CfgJ=OlzCE82u!FDxX?KsT~d;jHi~h($k|@;-ax{S8x@St{tbA^Q!kY~}O329)L^ zZoDl#vnY`c>=$HU=BLk71c|T>ynu}yzU`<^FG@o<+XO%A$)#{GZj{?@`D#j~uwA&# z)9Xs8!_JtKD`UTSA%^uYvx-Q_GqpE50T#PVa|?s93?QLjKDf>SDj0O|$QLvoJ2IDE zWk27217I2lC=qn7xmulE4&OY{rQ_ir8;lnj8hSm+80G>vE$1d$UuQytrcptQsMn5! z^hY|Yzm~`=dUcjrcMwmt%gEGN83RqC^x-rfWINfiG!BxqyUC)r26f5BQgxJuwHe;|dg>8GJh(}}#W3)O16S$1do z`x&{x5k+C}ajugP?IcS6QCGOKHVqYZPGgD+XO6#*cN;vCK2RK~@vLKR@gpva@^Kn_ zdz@XGfd3I1k|VO)csk!j*+qbwa#6VbNn#Cy(K`In>24iQUSN%&A{n%<_{(TYccinP z#;T9!?n{IGnb0F4Wt#nk%00)PqTGJkoo*Tpat)CoQH!9;!rh#0#OjyKM*fMO%D#Ec zvwWWM9~S^hmPmIA%!Gy6~N8_^@Y1otJXmt22w9-o3YdIg_6`SnCn z_g1h-)QxqGLQ}YoN=LknGL|56YCHgT7(tm!BStjtp|b*?hvc z8g*Z;kZQc*ez;|tqm|Xi*L{POv=dIblsx>jvqvhxVL6;^a(X?{JV!clG_{p5hjiJuA*G(QBejQHK~l>tE$=(17ic+ELzF zAL5CgZ4b+rE@Zf4K%jd`{asgM+}}uPQUEYg(zc_+R1t>Y_bLCn?RRjWE0B2q>lUM7 zwgJDWp>KBGk3H}p?PJ(MKc%-oOt4t`s`NpGcaJQm)@PhR_nYG*eVRFuBT`+0b~4Qi zP22D#=*7qhk@%@ygOC6v)=oG=tQ}(~+~QPn7b6Tcj-1R~#~8B%vJSsJSS8Rw5t6&d zck1G^POzFi(Oxs=!hQY9Qcu3J zSoa4k@qmhLgFzRsGpWyxx9mbCIQI6*E$45Qp&q9+T5c@k!EC|Q_FJ6Q*vc?BJxlcV zlCR$Rf&-*RhMM~Mw}#MliJ(t$;Y@wo*q1Xx>^|BCx#K(t{CHHx5FOCKn@=(K{_KYmXY?@aQR^bkHf4ldzD&A zD|T~YuRcQe>eNKtIgXo7xnmgnG_dC67D|_$$cydjn9+M8MlaRCCVtKolMg&?; z>no_>?b7E=$48~ePaEX38PK-t#kYr9)yKcwXoOsRv=KV>!D$%7kiO2Qd@R7|V1R{m z$5ub}6K_Ns#E#0cHu!fc)2fmVU7)vpOs0QQZ1eZEVKHk3E06<0GyjLWdy|$D9{g}q ze-d@#i9|hzAmNbnVjUzmueZAIJEPlyPv?VwKDT@Jr`u1D>Yy*chpW7%uU{F~bP5Nl z&}7+pEcO(hXX1FOwp}bWZ%y`7Bo4E-YaTyNJ}P{76p?#*V>7g&w&*ymVmO^JBDGZ_HNSK)a5fDrO{wgTnWs!Fa>m!KaCO9URbtyp2TK+E83DAHKo6 ziu8WiIF>a8f9?k3UMhgTk}${Iig0H1{^~3XuYk|Dr3au+QB(bT-cR!qjNOk-D)!`H z8iJ_7c=dWy7j}b!MKS;!)v=RCE;!Tmj29ZilU6+LvCVPJzbrxf=Vi2SOEk{%^^UAO z_>9_{5^)-&^}Wm6fh|M7{Prz=vq+VOq1_MWf{}O z?DUE75lGc#wkh~!;%is~f|x%i+?Dq_#}og#DjRX)dE$<(vYN&zK`G$JB&gEq(1DBH zx_y+FY8?ZZ;uqK|m*{smU%5Yq8_4TSwU{+6hH@YtJG`cYc1?1g0;f`k+ca>?f)8I3 z23=rF2g!LF{oR!;i=0LoPALEFsr;U!c;OaJ$nO_LESn_>o^+j;ah1YSO*R^6k3MS> zF^AtvfJe9B(<`u_cuiL@=n7u!=ALZR+aWlN4wM&BM815e(0x(X$qQ+GmJKxGbAJP& z$F75_KMHD8Mui!(paIlHIwRzxr|(Xz{OJo;YL954XBBBCHyU^7UbMF;Al+z5nz?So zo7XR6+W|jsT5Il70quF;yVw>zhe1-LqEHHt?}Un$Q+zSb)V4Rl8%H}|H@#1^zk|h= zD}M;YlEVfVY?@;0R>aQUl5y-o$;BHY9SK=ncOK;0w*1@n8O_)wjn~T?tbx3V+}`V- zoUfq&+U0eK=uyQiBR=WivPqZ}eD8_hGMRVC_zVTTF33(+&69@2+l-+?IMqRj$Z!M& zDUjMB*xzH0_M6sRk4Y}^#g&u{Cw~+VW zmNyt=2JX0Kjf((ydVROA4m*{}Pb4^QH* zgIUNbEF=zN`vtraUAbcfM+;hVL(N1|UaDz^%Ql1z@uU=yTN$GPzL*C0Ikf)L_349kIV2`gn>h4_w6%Xw1;WL3-V-Ay>b`s zokHP{vXt9ux~Qb%u7E)vr;qAU6}62w$%(@(qhFp}&SAV7sk`x>&;a{pQU#c6l%Pd@ zD4*P)h%b~bTfE~+>;9xjKd-@1)6H;BE}IuJioINHio0dhx45e=*Dbxry_^dpHz!kt z0+25(Pj!1)AT+%zQViDrl&%l!uOV#c+Ie2Tsnu_PN7;~jR4G13{iylT?3V^XDe&PN zf^J`5(^lLIJO_=sK76!%cWcN%r!U65@2BB`i)Orv;vQC->%Eq`t6B!pxCk-beBQ&hD%e(T)HLZ>N#)uO{&yNxz)4XJL$#sG@Ueq;aID|dFy~**>`R0CWMI=A$yZ96_ zkKf#owCY|dlg!sJ&#$05ClC2QJ+RA`0C+3PzrVcDZh7LxRN`saK=t_w8@vWtP6TnC%vonV zJqv%>Kt}Q0;XC1FIDa%=U5R4#j79_q_4wv3JQ&99oW>Ih_7AD(+{&g#kPFE!)SYaB z?=K$592v)H>4$ua)>Gg{f6LYecLS&Z1#4)Vzx$~iVr+2D`H_`fMPf~>lH8&)tRr9lNigtW(iqOiDe~l41H^BSUl{kwH8s)6d*hCPNzQcDbzlr6J)fnEgR{iPZ_Qo6O z9AsoGk05e>kAuLGT2@dC75=bsEi)^>Fg#^cnPhGEqq2p|xT@x)Csa1xDjERtm}x0m z5}u*2bNA?K>|9SV1S*{t*IdgW-oTu*Rew?|8z~vpijO-(^90-rhK;Ui%X#HwIFV2k3YxRB z?ctK*n@&B3y1$fhb*}k@N$)&&L!*LWs=ZSm{dB&r)!~m!ja7W>3e<@H`4*NFBr88* zvKsLAxGq2?pF|ybRH$|Lk6jX?H%jSt#Oq@WyC_B}yO}ifTS8SXi96sCXZ%SV2k*)E zIG#x4_%>JVV(njTtoh0QP`F{;YAL2D>!Bz^$ms(PitA0e-QrH430^y2B#_W?OiZx#Cbp7M)&WR>1pl3lSHe6 zk0B!`ogO|6C{j7)*LS~oEC;ZJN!Rv&)_JO?@!PE*n4$6ZS&WL_dg?C5X`Kv}nX`Sj z9h^H9N1H3tlt>lx3A$ZsefXDO&^rS1dZ;I)7T&J;eilfWoK^DmW0iYOs0E1!(=yu+ zaD);5C_m^M-z&K>O!>%BeTULQAm0liDuqJ9u!sY-_qG3nUIS>m@~>5(^hO0xK7Th* zWkquSAjf;q_!V`!6pf!RZU=V;nZM-1pj`$Rky{_GbFd_)ox$ftV3X@#tkxzI_{=(A z+w1IL`M0ofD3cho?|P<4Z_z2P=#IO++S_WTR%?9=mK85CL{ziHguM~~WpfpBrKicNqO{r4x zDS%2?pMI9CtBd!w0{f$xG1sSjE7sKy?O84*0&okJ;IBwZ{epMF2{Ill@|At^9@Se) z9x~P#Fit?Bm}?paMd`M!3SvIavJpk4E*$p|N-38;$8DnRfnp((%>0sqcA~!RjTpZBG#>B_BfP*U==vFg_B=9@w%-3AHbMk2fgKjv zdOxkA`N}ThFc@`66V(U9QJt?aon2)x_VAWB-9RF3JT2Th&k<~WaYRnTP<&B3wjB+e z9`tVhbJ-=|t}R5`vv6-5B4cpW7v3ab`{h8OZTo9&Xw7;s=Cbr>ov9 zwMR8huzCskZ#|^Vv06)bqhHD#ylCBf=0|ZimW1wy<8OC`L;>6DWr|}uAIv4hG+c{^ z0Z33I5)Jp1v%dLUC8Ew21k4vkv{{ViY+&T4r+6qDEjq^B5Ovl8;Yb;1zsKp?-BYDp>RpJ}K0=^8aRXZn z%%m4%tTKvsVvCZ8%1no#jAryQzokpNkxM{n^*>8_^i%VEHo|)dqfO>(7v>b;zSMXO zs#?wCuIq|E+9zynz#&)7{PC#L%q8FTsBt20=X6R=hbNuR6%{KufbN$5a6qt0z`{ua zUMQrNH1f+o1#(fO4FA0@aM+mJes@%U?!V)@WtdzdcOE)NlPg6BWBc>Zk|u#$0$Fp` zb`F(%=+`Xo4SH**7aahVx?-7OJ!r!d>blG+J+`4?;<}_0n`m>6#{s?Bu*t{9NrCv= z5Sh3#T^FbsTY;UKzCEs>trQ_3)ka;&nEtq^wOgARA#mu@j04CJtu<4C!OL1~Tk17H zET-qZJE)xicwyug-I(&x&P$I2URk~7-VY1A{~AltU%kJ1^8f0GKR z5+Zd@@7DO}*)HF65pU<|;|XI#K2CoKkpOWVFP#Sod77QbQIKadQ1AjS)Ep7wH8Y2e zhMRtNdwG4_B`j$&%y_)JB_=M8rk9GG~j2TKt~2+6EBPNJC;r1^ka1XY;4_aSipX)Sh3W_ zM+mul0ukee!Lov~%OAiqC&(<}OZn~RSFA>UR+SF3(tjH9?ERP8UDdK_9{g3;I6HV# zsCj#0#Le2|X`HbiXq%-fCTB6dQN-h-G_Q=$$g^Z_x&$k`PE9-v%C5q|EUerHBo-VF>pE21PA#UbNDSU(F*5w`1%m! zIG!f%IQ`!b(oThd<4)$>9Q|lDl<~Apt|-Zoo@9E%gZg-TORB_n_0iH%KtK6tfl9=2 z3D{nBHW_y_@|xOhyvg!rN=g-O@v5Z*0H`exvqVuscEmdeGBVvFmlg}pH7i-Rt60zD z;1|#k>C`MZ{Jpyh4z`cf7ob2S%54>cUH_|oOV6KchYK`8-Rn}wB4m`=b+7HePJidN zgssH9QQTY;>S0lVb{cI-`INV%PS@g zOesdHDSZDj1D4Ovct=G#h+Fxew1P?m)1Wy=ql)toiSUE;QTP~~=;rQ~#B zt0|u7eq$QlXdTW5WYrPA}xbM=Tk0jjeRr+Qwk=0Cb`f+v9nvZ(R~b z%$KO$Y|j(Lx~-CMUy5+RAfdsVVpQ)w7b5@tQRE`x7JnZe%&RblT@<<`WG!Th1qD%| zf5DSn7Wx6XH!|!;!@(O}uS30ZO>f7oWz;}Mwno||qD3a}e*@N|Kw|wpGFkkYvoi5B z=g&dZ=mKrG5*du;f_OM8t^YG(oae0=pZ!=fOpwb!sXC$>%qb|y$RAcv%7HLd!qOCW zcn@H^puFgQxqU>$h)DuUtjCGql2X@?)h1%B&%TQlB+6GL)~|%4D$1Ys@akS^>aQ$U zE9AzbouH2BUZE3=t8?YGe;|4(to^Tcjldx?zSmSM`LjFsS zMeaGcq@_rx1qhwCWXG0WU z{ZvA9Hg^(B>n1>; zrttNuD1WogSS!pcY~Qzb{ue*N;KxP{J%y;EDJ{Y>%h9i^Wb<7e?}7Vg)A56i9H$2% zEqvIKHe`JY!t(nzO4PzN=UE+r7SeBly-dV;SLA!VQGp{ zY>%43Re^0Sppq0)0_C$#xp}dSz7S7zc^UzT2LW&uliSQ_)TDTf-B*XmHb$UoR*eMW zamSV}UnW&|>(fveU9J?619OD=qNL0?z|Srt#!aGjzDSo zia%aqb`>O&ZS096u}1kQtr%QGdl{g12c^XAv?+956`Bb^IY7g_3s&eHp<{dDNZk&Y z=B^RZN(M9ok}W9Y5%aU|zue|}Ykch40KoWki$66bQ?5g{Hyt>`f$~1^q4-Lkb`@Z; zy+)s?4v0U9QX(7bo^4n13O_o%R!yzkHl`zU8ZzN=)R@Qx#Hcv#xi43((R_o!J^l{& z^r&#cFP}V;?XZtyZOC4o=u&ks%5_SzI}7Yd|&*^ z?Gr`#lf-wV4Yg?M^F91l|LFnMTL>){KUZu$C-W7(jq;F=!>|{inns|UfhTfpDpRq^ z`0VdN>GBZn@-F$d@}BJOB^tX~`aODXj1lf4khy4pM<~KqY@-ukmePW~5{>2#@r>i# zNbCZ!FyEtp5nb;grZ-IRtH*Zh{1fNm$$V3BLx;yr%gW^po;PM&rrL@4O|4K?rVu8hh|nP+)j_x}JQmmU7y0@>YzGSxdP)!m}t{THXozpYxjK$1lAZQGo3gkVi?)Okpl^_m?y3^rB7;Fkt<_Z#B@B ze49m}d?I#(wPL2zJ5EvX!p9i_aAXyiThFWFm2S7q$kuoMfyPfb6|Xw_D*IDi7U5WN zy18LNAK8-^(Mhp=VbLIhN6k$NhrR0dK-Ey#)+uMxTAx8b{&-paiBkVu^U}XY0L|`s zQC)c!<3LbYA3DlO<)v?6xA4yUbY7ybSK=6>e^&hsK;2_oeYQ2NM&*75PCiJstAER+ zmdS$l1(O0Cspj&qZuc0&{CKB*jfnS&E&JyI(MUDK4NY(dFftCi9P&`B@k05d$Ac$X zuF?1DT2{V5dFEi#;l8BlY~m@FBcN^gaNchrO7~&cSnK{2=&FBMeH@iU)R@ySwA;>|>w z+Bs$9ak8e1ay2;wczkx<<}`^d1)hcrQ=;xwkn~(gVK%(LV zLo*=N?F$%$h|<~<@yF3UPei+465wXo9&AP`A=j-18I`SuJMc6Iy-? zDCtg>$eS*%@}NnraSkk-tCN(H~p>;R`i zJ57L&jDP~$o%Nzmki_WtHHG?SDV6wJaE}47@128t7zS_{^JO6f8KxzF)Ig5e95ucK z6*9j;3NQ6y>V$f8kuPEu8N=(ZoQ+~>nl~c(65)ltH4FIxk&LQDs_H~jI)Td{n7V%p zmDzlEd)~T-C7(SR%1oeX{e8}9fI1p*W$MD|?L?ESa#luX8Gpuj0p{qX&z~|Sn~#)? z*i1aL%-#GDx`Lb3yb8IMv>OJ=xL;P~m?`<;aJeH%=DRQ}E+nML+U{i3@B|c$2uhLtjgM%)qNfM?%n$jx6cUsWCCdP2=C#~FNX3BmnWr5 zqIiXUeK`Y?ZR0jGxhpDJj@bPhi?$>gY7wL1R>-loLJ%{vgEvlL%nXGhe8gSW2}(F> zCJfFLd&B_`fSkv*juIfKD`xNZW17aB!c7l_m+dn-=su(@6x5^k?rkBxKXUtOoLx!E z#-I$vL{Mh1lqphs;IQCRCt4|zv$2A)sZq0)y-++WxE$aJU~^o?yTyL!loEy~C;-|` zMhIXu#ESzWK7c`rfiquMi8R38 zw=MS#ICa}}x!m&OH#i6)e~$X;QCvqHe9Y6LitJc8oQNt}A^mH8k^|9__CC8nY)#uY z)WW~hGB#9R5pL-hxKY}GD@^bzS{|C0ZSi5Q=t^04+5jQp$eFJ}l#n`TFLrHgZNyOo zsk|Cx%6vtab`Yhg9=93GblyfmNL%4O^UlO&+*RfB zsNDq2IOuH3;6%j}9bF>EiHy%;c_-p)8)l2(G15K(n}AdsExV_wJ!l-eRnO&5=hFIz ziEnC?GZlX$NY_5vK(0-tLeDA&uR5+IKQP^W-UwJBcB_%Fasy1ZSECy!Ql3;N@~OHN zbfd*aWM+3}Aue}G02XBcHD8Z~pwf0u&mpokZpSvr_}oENBxV5aMmfZ8U;l;}jO|hA zn95al9GM80^RUlI7CAHHFY9&o8(ez0s5A3=zAg&o0~X_uwuch6@f$chn*15JbN`x~ z5r#z2H3^hbOwHgpR1pF=8O!xYMR6em=V^lWmAcqD*Ow&}(-7B#gED%GY- zO9Hp{Gv^*B^Q_)Fh)+93R7?a95yRH1ztDYfTJi}11>S?E2#*FF;Q3h(SGQwcwU=#U(v#t$8qP|2 z65bIJ9i3O3iH4jSfirj)9i!^8E@WLqC*5eX-BFezu+Inp{6c@%J4Iz1P6W#6J32D) zgt<+7tWXk?7E%PMS4dM#?+*U>)r4Gx=leI%$<6;{u2VP1D(*6bHwmVvs~3^B#F$5% z1`qZrs7jNGB&LAShZ3jVj>b^@lrK=sOC}KJ>=5!b&N!{%U?n`^SRf!BbFC-^4pM zKHCvfhjL!6Vyzerp1@$E%s`M3V!=g@D9nvDGY!jf5Y-i$nsjrah~9jy#w_AMc(4W& z5gfgW+s!yc5UauGV-=A98?g4vX^Hf2ye1n;H!}t4R^>%O)vIABpEDIg+!=a%_scK_ zoY#}!cZRF>py>T|>Hekou&_m+eFH#z7a-d0tOYCL9bu-+6}vj3Z~x|?raPysUE&~B z1%>j74;N=|Q%E>`{(B!&NYklhURMTeC{6x(E)XzwfkD+JWx|Ys&%YJ-k)|O2AMt=d zs!iJ*mLLjOQi|#$XU|pqw0@cY2CB)7R21GXJb#6#=aOQU9Af6}L=worB6^8_b42d( z67yi6<8}}4BJ3Lp(g$l2KLJLO<+WpSc~zG(I)xq+G%5a(oVAxls38$e;0VO92Qubb zh%5$$gcH}375S6b)gd}=bXN658a%F^C1np{PV|jzvD|-h-hs!%FnmGvbR>g1+7uCb zdof9GfF>`s*{bmDnRSXG0%Ief&z+d~zv&5Z8dnF_1ugV?L?5c^CGA7GW8GJN11r-N zC!ApoyNGfq7T0XmO4yYN$DI(!;kf%`9Do8IPR|N~%eCfGt9#$bIoT_E5B8fg0(MwV zzM@gItoI_78DxhHHVTnzsIyyI>7!sHBqSp06}L>0qe~MHIjOMd?qGl<7>`;Q-c@lS z1;dQcAC=|__WX!Fd6|+xW(_#{%K?=L>P^7d48uJMDAl4vzMPcMp;#u3slB>O@_s;D zsQ4~xq<4#AIiXh3i9Va7pN|rCOx_T@dmWCwL7Gnukkf5O_Or!X?y<}=XRnA4@#>_& z=%Pxe#TBVK8n1mVN5Js%5gd2GL~5N>gaUbr`!dTZG@_=-b8Y-yv7_NU2J4%|J^#Dq zCYb{im$SF_D(RBXj$EQlz<4G^mw^t$IQ%!3_+>DMn0{)^ZyC{axt8LusA24lp|8q> z35a8LWCpfB5^^Kon?MC&2q`o~x`K-;W>rM=`c3+t@PMj335Mh!KTAG!4BMG)R_} zs8@qF*U#f7>W2J`<>Xr+;nF|y{vtZvU{ zuITUnRQlddZ_;wkzCrFr-)v{`!hKZQ=M|@0rz-U1-VNzhzl;hxL5E4*>=h=U zc3Udn;olKh%c&YzOXt}S*B{t7yJX+`xLkf*{>bFSG zyYv-^t$8=Ld5OG}UuXI$$OQ z-j|0%e;h(2*Z~(tVFfI0m?#tHm1hP*(Bb_*lmtG2 z8u>Hl|J{Q$%{{I94*u&S}h4a zA9+5M1CkSe(RXkDQI+_sDzvtT+L=phXE|2s(1an*QykRi-#H5PHNup_V|u5rhL<5I zm7;LsMiycCW278^e0BmaBw3BRvS8)u1^yi9uy(z#cBlC^8;&Zxr|w-0c!xC%tR`ql zYJQ^xe~!n>^!bov2cb6~38Rm@cE2gXAFp~1hSNlz?@B1xHm&r$fbwaTg*xpua`p)ssU8IU==E$X|#OX2JP(zv_!>uu7)*JW5xZfR$FQp5E&p0H-J9LaBc|p2M}eA8zrcIKYX# zwy#T3lM?6cW18tSGvK>MFFD^6n?jj>V#^ROg_zAz{B_Nu(fSlGt(08`1e|D|$lxUaN__sqI0M^Fl@FG01iBR72~Cs=zvOSS3X z9~EJZflZg!h_Gi$fJr=;@J%BHUN~Uq{}Yey%9<{`6%aN z8+?{6?QnKg)rj_->N`L$y#~1C9Br zL*vn4Y(+eEGM~rv;(`WgIV2y&BWEeUmzlWHnR=~aCf7+&m;8!>fN9vCf!8`Y8j>LT zP8JH&=m#;YKsDD%+7*ddF(?Qs0D~lGi0i_2_wN4Cr&Uit~cSs$W0yF*VF&pgG+I04rwXScu&Pg?)c4cyWirQ%f!SFJ&gWs#l zu?{IJztcAU2|fb2dK$lUbvlW+xW0t)aTR43zSvBDOqIa<-dm=@(WCEAVFaCz{)X=7 z&V`Z_7o^73TYWWnu5#AK6Ub%FDL`P&gG$f^|1f6$&|!j#M9zhcbIg*`i}$VVVIsL= za5)X-op*=}z%~S`s0fc)v>^Uy@yz^8*fTLA0;{eq0{hSCcVB)=iLLA@FeVDyH+4Z! z=$2ywv8caQ);d5Fr}RMy8j$}CX}?=rPeg-gf78&1n^3>{N+I+5f>{e7cR|R)qdwjU zd;AMaRtLy%>Uve3oZ9LUurwGOz`qOa%;ILcMPNQw+pEQ3OC+FJ8^}E?TKNZU=dZ;# z#Ah2DS&lgg5ZmZC!aaEw4qG;~p+S=NcUnd@1Z9Iv0QTM^V;Ym6<=?t!hgrSCTadDj z?J7e=<2^9COp0imPXR#9(Bm;{eRK;5{Jc?-!ne{_Jh++Z5HF4e(`;4T;A&aWEAA{5 z&4QZFZuaxNsmbdLw)dmlbYuW_ENj_)sbv6EGN&=1oDS|Q!(NbnaWK|UUfl~&&tTnM zmhx=uvy?>~+wY3M{eS|-hJZrh_=pJ?XbZo4c`{H)9?+4orI_&Y-{5191 zP`?SaTUtD^-h+s6ea;3-`+PAx6+0wel;#?3)kn;G>i9X<)nzI$D#d7XYJ;IG3 z7sNbBvumX`6UGrVcFUP9!D5AKC)OK5%b7LD&8f+LZg&(t{#;<}J%rsj?#sz3@aC4w z9g_qbo_gyUb3)TLT?IbTcJvu393NEeX6$dy3RVp`e2aLmrDTu*50>cA-|B9|ZBPfI zUB3%er;@HWHOIe;R~k8tph~FiAMUX|T0u{Ap23LN|Yei8TAeS>~g%=i2vRN52+XJ{E94ofF#@h%;dT=JQu3iCo4zS+JnW5r(HJ6kr zArgVqe}jFPR~^~YnYUX3Yl)OR4aqIt&47vEP{oxB->oQ`?YCdt5==nz7;=B^^->~y z=}N8ZC~+;Ey2JJRsbmk%&Rc|E*U6U88LoY4W6)b~PoAFFCQKR3!KhIpYp69LpC)!XE^HGC!l)3%af6q#8=w%~W zkk#Bv3VcMHd5}@aa2MzFi67;L#73pyzmu*$zGi6(I*#|p%fiJ+jC9Sp(kj7slfI36b%tdFkY|1_c#U^jZ_zw6{_reYG&A%QrZxcVH? zza#iXqLQPB_|KFArq7Gk;M-$C9kk9O7=^ibhpU`!$|V6StOV8~%jRFjmBkZW72GWv zeqoXog{wUDe%H^HaOOx4L0OUs_&*|g*HV)sSp?U=8NiJ#7mw6vD=inNS~tA^I37Rm zW4#O@Z_WIn!I=BYdXk=GCVH^xTA6}MBXu|ZkBwH7FSt9|N44rniSzZCg&$8e&lnL$ zeE4l$z=uuI{VUyhFp1JSq#7Z^{(6F@g+>F1#2td#?zkEAT9xzl^Iy6e^`J+m(yk z8H_z6{}CXmn!QahPV*d_oL|@dlAI+3Q_1vt0NZxvyDH>`X>SYtmm~F9ioVkCdlt3E zgB*3=TdmY=3r1P8go*Qo@7S*oTn>}&a$$*2ta#b+LnYSNf1l&_YiSF{ph`71A5E}o z=pb9NJ7YISCsIKeRhKMi4bf5A;GdS7&ISIu&=?jDXD}N!uO}`46+UoG_PC&af$C>B zF!UF5&H4!9xM^^`(tL@cQ!fpagLQU$2ufZjZ@-RA=cRPp|D)H+xGQLr>=zKyFlu|! z1Ua~iQc35Y`3Gd2*fu;qWY;%+Bo)1^YrPHF8x($*RQGObpJJvgStUb+vrV0PTf(Ad zex+2T@#n{|A6;^fu0M7N#?i~LDDINZ_9gUjKweJm9cDN)dbPGLc5lH7KaJv%&A>j> zX-*t?vA?QMqIiTzKovSoJp$L8u&5>XI&^q*2i1yeU>$s&vsL&F4bprdOx`|f84VM~ zozoc`IRGE3K%I;BhVZfyU4iD!5=on)@ms$j3IC>iW%VRS3pu%9y*37~17@M3Y&Xfu z=olVPG+r&na=b^HKFK6fE{k^ybyil&(*`%anp%LiU3P;_vNF&`!sW7Pa(RJnM|jwW zti7n?LZy9J@?qD$JH5-h<+~m7MU5U1RIBYI{Zsp#yy@l2VU`?TRyS`qnvQQ5iXytG zJvD-%-Xw$JTd1RQ1>w++EhgJq#qv+P#Oidy`l&W{x9;mr_X@Ngn%r2y1WElXUQE!G zYj8Ac7B*?eD0!={XO7CNb#vN4N#dF1?*tL2#CG^B_unl~M|d1g*fu?Jsdz-bcb*|M zZsUDKwE&K}vmJ;EDlR3$IU|MNBgZ2>h;jn`ca;%-Q1d$L`^IN%UHVMvE9PTNY&TWD zq_WUS6l!ZK8|8o1G;;GHmGVb%LC-7Xxrmmqs3QgZn|ksIRCq#7q~KrMi7-RhBtw{g z{FsI85!lhP)V8sB`B}Mf5SV1i<0W;2Zt$eue&kygu~)fn!9ml2xwBc=RSc$TotI~ z;|5n|FLVm`fQXQE*E$7c!U7U{O;F7~8RO*QdSUAgRkxQIMga<2#BI5t4X0+|NG#JZ zq#t0Xp!6GSy)gMK;!yfX{IdJOd0Sjvu7$rH3}#YG-QS2#^?U56GZNM3)UR2X))GBI zz6j(_6}{c8th)+~xDT`HgRJacWe2fubcI>j2R=%Y^S=LS7pBT!Q7Fz}Nnnql>hb)y zA9|bx*wBM-%yw8~wC&5>WP$MCgmHC0eHVE}?>T6OM#7##FZ3|i%fEFmw*#otz*7N$ z+1r^e@mZghli{ix`UlzWpge|(m$`K=#t9DW&A_wszd}J0w5HZQj zuYmd46co2FXS4lf83art_Fd$jSVW)c9UARghx96@ntk!tcQu#rKB3dePYdqLj3o+V z+eH7Pr=j4WenecsIgq`t01R^f}uX-Fw9d%wYwsd7+_#-_a zJ60X(ym|C(f=A0xZKDl6$0*$ORCVZe>v=K3gpCg5C?R^KMW2m|QcL&(o}N5Vom+#9 z#ANO$Q-!LJnZA+AvH^O|RHC?EVkMd(NFvx*thJxO=X$tW*XR>fShKca)-#BkA4Bqs z+P*vh3gUQ`j_6S<|N7C!%H|xy+0f=+nK_qB%Jxl{o|I2-EkG*w;s)wm@bJycq^ATW zN{2fAr_Tq5v+ z+_9B)61SoLJXB1>8*h6NNCF_iMygz)kBIAKstglD#bj@(rFI`C)qT4)y<1dgk_-n4 zXNm>OyCh22*==9PE3TNA3b+fmwR8&{cz=$Xzcv{epw}KJx^fH%?+8&9m%cV@S%X_d z&O`K18(g`Tdw*K#Sz*r1em?5QOj{;H{%~m^qaR_tS$0*war0;2L+<9wa=06q6V%;# z{y4hm)~2Y%;B$9yx0ETonc+A>IwYK7G9qjaB5bEnk7JO^rD2kMM+gQ%F<#&kzI})H z&VMbV{if9p_t)7)OKPUEi@D3E-OEbT&2H~QEf;O=``=`@B*+X)!FHo5TvJxo=J2#F zGTIS|pWQhJ4`MU(2S<_6;59Zto>5nfJls7|OJ#F^s) zU_;t{zT0ut+FhR9Gt!92N|bAHir!;)j*HkR?QxGYa%l?++K4LfyG<{9j{C?c0-nfj zDJV>uv1N*i`qE+lOZlQ(XI_h@HE7Z=I{SF{`-T5hxjJk z0`C{iH+H==o#zkN$!W4L9s_D@9-{`~0UbY&JkDm9Oy%yY%{F|acswRtQ+XZJ0s}k@ z7-n+8?ZrLH(@N3p;dxPV;i%Fb!t&R3oo195S4lcCd^gsCZ^? z=>rj5A4t|t1^qx5R&IAyEA@q*3xk3JETeNZ(K+gG{2hlV9!w-nHoO%fT_5q5#8WI(}5(3#hk{eNNu}TnU`{#r-9zH)u!tqxwp-`48P4nr#j>X^&U!;$Mje`Slo^=PV6`E1q?>j$wr7=prJ8$F8kriK&Edp}7Bd&yWf zE~cWBSElLuxHtPB{t6o|)M#zfutyQWWRn@sKLrW=s~SVDl*&n~nmB>*0ob2xB?}Fcb*H~nK-~1gr{l!AQ z{|j|%74`6QNbv7L=YfvqN(aQEm58)3wVn+OmnYIG76yh=t9ZoEok-dgrz1CvN392tU zTrxQEiPu!wIBy%-bem?Iwc%tjV>J9RehgEVMKj~Gq$l<~&2q#V+x$yuc1fWIR=4Hu znJO+d)IASthEbQdQ5EIUKLdU&wPfPgh9+d5N}Dr_gxX{2D%x*cuc6z(8Vdhl?&D6d z=!vXm35y)Yri}`BEDLtZ)!&933TZP;>7iq6F_8+4&-cgUgXrNHcuUuiDc!RSMP`|h zNR`j#rnz;Vttw}=^%w8=3BGnyLy8|(xy&tXyz?&gnXOM(tto09*_XVvFm?$y?QIqV z42!PPZ9Wvu63xL@9Ctm|8ai)(Y&CwvaqwESay>jsC%mMJ#~2kd8+hkdaWgq`*;^0s z^l4x)&P?HR3nLdiikJ!wC3yVGfWt$`yZu@>v&~NE^AxTk<$crRn8A6FU68k>A*D9+ zR1*4b;2ai3bY#2pFH*db_JhE_t@wq%BTh6eQs^?oW2$9&PI~9DUyL751l{vV6Kf(j zZyZ{%Y&eA_%Cd77j49D&cVBMa)c#sbUs^~%Ifm#qCMeMe-Cb`oG(4^pt#xas4SqjF zk5~^eVC|2p&2oVoL{pER1Vv^T#J7v38o*lXwVuZ{X_jec7~BFkx3BfC{1^3%vr;SM zcY#fzPfP8j2#4lJGwK^ZgW{}Uvq-DQ4uodn@QmydYEFc{X8Ew)630r5HSWCq%ScNa z5!>VP{bY8^oIL`j2ii+<9l@I6V$xp?w11zi&62@3gnW^1235JZHNfvK1gbuOfSkv;X{FTZSvZoMh9 zUup{^zB^>6G~Vl&`028ETc56Qe#(h3^E=OGQe*&RhAAeD2}A1br&o9-%#aAHSMMQu zo~dFIJUpB#|h#q%DToln~obDeTzUTDO znspuJ#{eAYWxu_y8u&g>ykU9})^fk3+)LWb5O8c^(`ll+VHz|sTkLpQRYM}J<)F-7 zMXm8(q&G-eFnF`;{-T`HrOap8bdy;DRy>%jM3WVH_n>|4HG!qu5_bDitmnVI+#MU||)1E{#n;>68UJinNv+?@|I$Ch#vVGCWzl9U{kN~Y7sJ)@sXC<(_-hJH; z^D?A&r}k*UwAE>TZbwU}(6qN`F1gH;_iJD}xN*!eSL+=H24u@11W0;C;r-LAW-l=F z#NW)81$MYqrs0;L~T&q57o1Cj)TRAi~9Qlty+5z*G!ee z%(5|YDScx(J&qmz(Kku`?@J+#SQdv)`zuLpCZQdvyudaEDqcGjJMapLJGeDWKP0YW zUogyaFTL$>ytnBeQPZ|x#kzK{AMDve<2ZTSVMOALD-vlNZ=7tlK*ndgfA;=)tOL&^ z`0;hE!R_tpSLVNmMmr>qTnX#j$^ECGEvuqoGGqZjYm7KJXNK zpUr9OQ{@2+b8$QRHY0G9(&hdrLnW}qT|IxsRC3e|pG+ggDD``_X(NLbtNZW^jSvYT1~8YHX?!}eefz4@d#m0=*~=nF?*;LHR4)7PHkl#f zqD)$UC&42CN|6`NN`?W{jLEN3=Ak2#rdE*Kj$chVpXiZGz^hl#15tMYid(N_-?n&YWWA% zkpXUZwncdix6KS*iA8MTX z3b7CKQO&vN>z`7I{s@$#_XLS(cI-DW0k6>I5L@U4<&vZO^wIR6P|}3%9j$SzbW_7G zikt{^2o~MkFP|AR7!B9Oq*`mrLF!tQK|FqoQ)Au7^qbF<&bHE+`N6rrt=%)N;>Ly- zYJT#>a*_1#@{<14N*{RxKp-0KYv)4E_MHklJYd`WNz$g4GD-RzYI=e*%mu}S)AMdT z$Cnaozh)r9&1R`M=pENT>SA8sTO+Eu!PsOg zi~lvVz(9wHYseSPEe|O0SvfeNEoba6FZ-Cat?+$jc2=dLM?k8aHiz4iCi#4hL2L!G z5nxb6mrGl0lN6RSQ&TAuj1?;tfSPC}{6*?S*6yh!m6D7Gcs!;eTbXgCf$5sj6X8i( zl%7z!24FpDxEMxo^vfF6sN^c9+dad`Gx9-W4%fsxi2Ek`Wxqs%3KtLml77v|JUI__el^KVkZ9`U?^8^$bBGn)&o8yvxgM=m z@@6eNzi37_nwz`$y9NcAT^qDyv%vG|Nk%a-_2&!ySC>e!FwPOmml&awluBWx@fshf zwOPq#&eZT45MqfDa|{FoYO8RI*M-_lW-JbYlrJa6;`LunTE%-NyldwIzQbM@0#Yxi zdnZs)JZ$@yVvAx6-uV9&@Z$Nu%18;dYi!`*d=ur@z<`QnN>5+?bN_uQ>M8-nT!52L zEycr7I#s^BRRMnO7va+w@t%ZF!w>)!WuB@ly?g&(9VJD}aG+Ms02F{%#6_Dx`Qodd z3g{#xlNk3hPgJvtMiA+px^myYXsyr+5bbMu`D;`Q+-^$6lSOq@FY zZ^Wg*%KYtL|J8RgBb-4idxK{R)R5I6<8ge?f7^n5QrVU> z&gnxla1liRsv}7t~^=AlUns$dJ%GRc*BtSon zrrH71;P5{v)o=LG_cVB-mQT7t`dW5L*n=R1tiJq%88ACYm7LupN7ZOoKFN1@*%Il%^s9Iep< zOQBB|c&tXRu;_D1Pr&~>$wh({Jn#ZIg%vyz474#r{@>*U6rjy3PGHT%*a4l>QX2!E za{!&A$`}J3#YF$_WI($S*Sq&meRCxUJhdVC-{l;(ryFu>M~4J^1^}I3qXV6Pc^aGK zMJI5Vo`L^6`QKEY`VPpcc^W6R?SGfekbpJ=m~yk+YvfOpkbmkt92mP@o#cO$`0u0< zFqOoYPkmz*U_Z41{dc)a73#5>>0-Jymb3CnCW|&5%163-pch!iv(O5tAN2*{a=v7wu*GW&nby;V?L!S^kU zyAy)L;E>?%gS!MGgy1kh2=4A4+}%A1PH+jqU4z4*L4xZb1K;8Im#VMoKHaCw3k)^q zobEo|dw1`>*4iV*zW3L>ZifpFY2ERHz|HjlbS@sud&6*eANORjR@n?v++W~(M1%T7jLkdiRQF$JMg!lBH zN;Q-i3g7Fq8??@%LK6Y#R+x`mW8`PxmR;?z&8YwBOwDetw+c}1vu7+!CBlZt?H6a; zAw9>Z1`HueoRY9}P@!$ps#|P>8Fwl~c#hm*c2GD&$-#eE@ml^dE|EI_QbpJn7t5BGpB1upi(aA?1^(^}w>7=B1i=0`w zzd$jL#_6n&G(0N`w0PT>!jW^6dbq?S?`S@PVnTkKGhUy9veh4xl;gIf4Af62I(FI` z|JirGGm^uIJn{b13CG(PI)a^6%y8xbW--Yb%k8W(QI?rHb0i{8eLy*F<8L(Kfumr+ z3D^5&qq!^AtmoHn%xVh>jS#edq$lUKiN1XLQaqZ|#=VmBLP72|Eq4?QkT_4OpO9Q0 zV?E<>{;TG>R}j8kUKE>1tRXB-A!J9je49o}c(m#cO#uqkg=gW0X-fl|5IFA!DFAUO zhDFB#>Yd-%T*($=nJul?Kv{%lvr>(m1Kt`ONb>s$j)xyw3nv}2)?hnvHQ?L<|qOO@qYF8H-xw?M1=2`4f-9y?6J2j(#U_Z3@4;FpuNj-9gs+O$&jtfj+yDk(t>vCM*E zKloi8AXGlGlV)`6a`H#VD84P&6-%AGoLh_Iq|az%cWhf$qrRR`VkJ>ipw$V zx?Zy8H6Kn6GQ&;5UZgMN_*f%l=nr@G+%HLk)Q<@!QQ9?k?+f_0HV&Z>t@&K2-`@SJ z-ic(%jR&OIUhFa3@?Mwbxn6y*?o7m&gysLr7~`S!gB8sD11gda8TrHCJN%&QiU(L_ zC=IC`daY*!#aViGEq(j!t@nViW3!pxZc=vx#`Xo!JfN(?i28%a-waDglDc35P>*ro52g*o)_KZ3C=eLd66?6d z3C{pWhHLCAg|kJ=JP|DioZYu9psk4vzB1#y0;lJtsgK;qdi^iMGI!sH$Wci_E{n%3 zOdPfz%*pK3Pi`p;4z0e2*;Kb4yb`&bl-;?U(d2@t8tY{P)f()?cGm#?xo3s^Ab3FC zRFzEmG!GLfX%-M@;sT2ARMvZ+)~>iM+m1ISR-FX9G(r9JYO3v_o#V%r z>R4d;D6v!2^^_zbP%5l7R?^s3?!m^S(8S-f(Kl$L756{!0%NN{=@V_ClXF|7O{{`Jw)Dh1~;_ z!d>)ikRh&kHO-7GpKFizYVWze`c#o(csL0Hl-KXG|BB*DkiCY%2HfuneuH{X7A1MJ z;*8oQD+TL52H))!85nGT%tHw|Uh&+`ae{4xk0`U`yxYzyMY@(ep)e@+0c>EEw>>07 z9ld=Q=%{wA$X_y%>jkqM|0G+iVE}5_B<5TB>F2kZRR|1NPOEJNv6E>8_cg&i(hu3Cpjt_tl*j>Xu%FBbGJdsSOEP9|v-_HDL+Wd7t zunEo9jM+y$=Xk|0x9whtGXV6yig#zD1-a6 zq$MzWn|?)D)^rEFMMQ+;`ChYeCu1fP3tJNOtOw!fR-na%)ybF2@uL~WQ-Gw4Gfj4o z!->YJEF@n3d`ULXqF9>wHQuzT^V%4_OzrVB7dO@>5naSiKSYpLq7k z*A_xN4l=tky!8d9bZ>v-p`cKq2>;SHm%j?^*?zc~aBppzQlK0Hp!WwZ_~C>hAAhX_ zg_q#f|9Yk*s3L^HF6%=9G(_z|e!1`i8yQj&^j+lG24pb}J;f`MbyEl%m1v zWUIWG=7(279U>nksk2euOpx@IO(5?*r<+>RpAGR{EL@-4b zx4tsaoo%-sypCl?yY#7FG1_`k?+}q$9YIOp36K@rb;0ZCd&xX09e}(DycmN3Y3YE7 zRs>dbL5wYq%hWsR9B!u?y0<@Y32}dm2Kw84b(shTNwulve6;Vl(r;6%NwvMiMes+k z6={vGn)%Cz80$tQW*$!M=76Y<+U@T10zohGm+%00V9FWDl^`gr;QT-452=%b*?hCf zbQ;z6BNcEI+7sQT?Dr*DKa1G_2lt|W7CWn3`O>KK>kGDxi~T7vXY#GTj3UNW9RvIHZi3esjX#4TRra_Xv0d=*slW|OgakZX z&FeuyTe1$CKHM-%)ww zwNtLK^~xQvno$O5XbbkgYM^spaSZp46?S4U7Q3fwo*vjdksJ8Q4znbWB6ddd0e6a+ z@2muT{5O+)Kun%C;+Nfp*6~=M7O$V%)dDLgt$WQm;aKN5w*Bv0$YH zTc2e=Gn5ni@cI9?dd00AvOqhpkaqR>T!a4J${R%dYM}|(Ka?Fc>FYsMhUXz@fFfwwLbOlLv0<$^^BNCh|qwT$IQf>3f)gtN#Pu!Hf7l=wt%kB07~;rkZ;e zQ(>|{%u9Yb+AIjfXWY*mOTn9gpcDo4-oE<`XWF5G2P4ep1!tSmuVCrsf=znBw2gyY z@C!xx;-^tt6_q9Zo%9Cgfns!=WbIlQWP0v`{DBamyW=h57F*ON0;76pQv^kK4d#M|vQ8oYv zf@2{!j7FqJ)}7w9OUs%h0iRx`teYd4ju@B~ z3?7=ssiM;PQxh+>sV4n-@n-&-T3#%7aXs=LWZaEl>`gYxj_-Cx|HHU@eM(G(fC{_9 z_)VyU2D>~ZE*(BF88ph*dp-nWopMQS2bANrywsL`woWi=5Yw$KLoYvEJ&b3N;EVNS zs!a2nL<~0p9&DxrWBaXv)B4XQ*6VyGMCB(lef@!#BPfsvER5dQRlAneYG_N=cZB`| zD9i|7KJY6!HQZ+sViTbpwcAHYj~XXpnKJ}`Y@IYG&wzb1&PFmpo-E@&&WShuO%j6A zkAn|>aA+RsD>;?mjor2HUte&L6v?a=Fh6CPLmEcI?45uq&xtbAAgu8{zRmH8s{ERl zt89aHiA3vtb?^tf@ispuL|mckpQRQO?9;7ayIz&@;vYXIW`#wcClu)Dye{)aK3MJZ zmJyiY1zfaMZt<*7%kJ`hx2wxED(D{(RA-2~VW084u_^J+<~>^mHaxJo-!EXzfeS;7 zF5*IZbE=jhKrNVNzdn(iKsz~6G=m@CB#J}`d582spok&Z6H~I;Ee?>~^XoZqY1+wmYK|lRdbV zGyV!=^o!Pv_KMI9#2isc-oC%>4~oA0~(>660nD0B~nQL(=`ezAdLUF1D@GvN3e z9QSk56L5Zwf5sn(d?k)d}Fr4rt#Q#j1M?~RB_JAA03!WjrC!__IpzjcK*V}m? zyC5WqeB4?!D>T_YxeVf|zhg#wK{D(Ui}0=L=!g&91>|yj?c~J(Q8(IHRL4Ioy!1D& z*eGz4j`qSq%ZbF1&CYKu#k6oh6U#-!3?oD4HIJXMl9qaVEk154`bG4$s3aOD&M7M& zvTXJO4(CkHnK5^9Fkq!rmZToY`}PrX2AYdwL-P#_|A{UmGhnlPrDz3$^<=#`$Br-f zqy`9>%VUD|f;qwt8Q#)oCNdK+F_dECcv7)B;vq%5U1C|{7)uSV8=>0+7onW)(K^uZ z1jtsCrB5mF@eRIlOaMF=Yb~PIZ`0PvXa?bR8L8RssR~_32mV5_0~BQixt{A^%3o&! z$-S3KH;UE%DsgWM@d6T4vV}(9<~Ov_`vPr}gnv3F17ZoIjq^;b7_*x;uVW+791Eo5 zalNPJdVroGh7+dt{3H-bV}0UR%uxsuZvUOMJZh4RuMCQxlnc$13=q+cAj;eb7M0G@ zT-NxDE4%X?ylFuR+EID)dp-q|Fh-C(5!1E+Tw&;%hakL71%CYk(VdA32wU3_`N_W1 zTEF-u*&RO8k^BOM99a*QGPCw|FUEK<8EsnVD-=14L8%4NN8Z3sZcK$%x-Aw;atx}3 zf?!6h!=ElX@OAQt)z4wP{m|Zo+1Mb2VA{kRSulE}C<;89D8FZifo{h5X$pPSV=D%A z87_#~7R#fUK9rWVkenNgVinO$Ur`A4C7;6_M7d%nqtwKP825OAO}nChbkWFVKOJMCqtPMH0r)D!5>%gwgOW^6`x=w3|0|n3H9~!eSZqyczLR^uR48Hgo7sQ z_U5k~f8UBHc1%R7>||g7z>zG!3%xy)5MrDHbS8h11!O|s5*!fsHM&l zCS9PLK^E_5AaKm%#B=J>FHS9?Awpd<JzNSQv)v8?Bxr#QM%nQzJss9 zoLMQ+5n+a*i>xH0x#z)l@na6G4(e3zh3Uzn@4Z50NB+xNaHkg;_3tO`vola}RYZIi zCV$Y4@S}~DSfAi7IT^T>6N}`F2^7a8GP>EcP$EC4%^;dWz+=HM6b^QAl55S!)c_bt zQ#)e1@2j^gx`-y6U)V^)gI2e6~KK?RvXa;Mk?{EQ5!(g-du>69|q92boTlOxbTzC8PhCEkhVXi8{f z)A7*}3dZ8G@d41F{7i6cu!po1QT$b)sC3JNXhDV=P6{@#>v+dcpb0vyl+h!zl(kfm zLxwDNr+GPd4!m|~EP7jO6(C8l%>Q9ITxi+iuX%YLdj9FI7#rPWEbb>(?x!kuS zVKMmR$TI17Mh_&Pn z|9*YO>MM90ZbVc_>d17b-X*cw<^ZOimtv)pL_J-+7y~tLw6QFcNNQrZ2b|zaf3-H1 zb>bLS%){{*@Ij89syp-b^(|u8Pdh50{(6#o2JZA44{S!JLpweCX#?}G6{Dlzsm0Cr z_R++U3lJt(|clF?inZ%^|=4jbdqJc#fxWQqa?opjlJRQWHOgWmudwRY|_7; z2$MHUj_eRkyfwlR6!NM%!EU1h3wnid^0NJ5aoD7^43an-jvpoQ71YmwOdS@%j)$lr{A?~V*xg!QbDJT$97Yv*b>Sm`ERn5ET>o9csWG| zrRpkZg8>bBKHf`&;6Ut1lvDE}zIN|nrA-WjW15bQjtje6zjmQZU1eLUvf7tx=vN55 zCY}USFpWL*E#xJ>{y{%CzF{>Bg+KNO$hIX!YMyS_*4O+?61hfQ4EaaW=$rkV0e70q zsWwh{^V@zt#LTnBva>+aX)zG+eYHS5v*@B{q3>f~{Afso0t+yFIc)tHXr%oZIRs%r zd4?$(ACXCM99ZMJ`UMqC(#er}{#aH3k)%jqm%@bT`c=9iZ(3IikiN=L|1yh|T0S%S zo@^ZlMZlIDl)hKSAp{`>Y=8=qOMqLtZZsWPdkG-z6L3!LTHG-ARnqbLb! zx!^l0h{^&Rrb9dT>4&nMI3PKTpDpf}sz^m4d`MQjDfO%NFPB7k&j>960XN9JlYb4Ut+hMXK!+YtE?>tkkO4{H^%{%OoqGb%%I6P8 zb)|%$Nwryojxm?Sgzo>k=Q(OCWtssuphRX|hslT@t3_bUOn^tmM^t`MKc|)}-U8an z|E~b@SF}TfP3(BAz57Vr-k+M*vU11^^}Vt+o^-T(+P-~+Q#Id*JWxGD8b$Pp5(QIg z8Aifi6`*dS{5lSJKwAu6P^q^VTM;JA(#JGk*b}do6fzrgI0uji%X2|aiYSAL_jt8QW1@-PH!K=l}k$$nP`R3$uu^IQW4}H!G&3f zeu8L}^vnvCuG$1FPT}Fh7#yLOtII|Wq0q*UorC-{U!yx1)PB{k)OZm zRHVj*t|KSlFUu6DI=}J|yu@BDve_q?nxq}pG?2=>)TN6oC{qdRP8(|$8m*C|-WJIq zh|t)ADriPlO0Co9MoJBD7@3yrWf4u0;mfiIiGRwJ<3GUNdVyg;!0VfjzLJM20ehIz zd@>9W?e|=0P%^$@RczbOp|J3zATmj~e{UC28Z z!>bHoES?rjV&-P2jqjy2)}ygrDvRXqAHIJW+n4Z@<^V5)%$@v~EQnC@gLkCmifr>^ z%yj=WOQy8S(c@I=&C+35AKY(9{MJGx%TCbFMx^AUbX1u#>IJSNK?m}& z#w_`?!qt;>F8G$zzZSpR@d(EuBYW8(SQIA`VxeO$79sEz){vx&e7#V#Oy0;Acqa*D zEW;R*{W9HB{4x=OMMrC_9MgeCgD}pRZHt0CX?qYL`p)qefw)wYqiMm!Y+&=Kb=ZR< zkWSsB#nU`|b4dZ8#l#j~+lOvNd`3X$4#YgvJjAicHUlGMUo6lrVDAaUs$5EPUS!hU z02xzYU@<~W%m`4<(@15wfJ>5-0s`*nml|#V1-;0O%yGt`AWmkSl$LtY@e?N{Obxh% zqMPC})0oCJ=vogmc~2B8 z+y-Bi_V=K$zQswvLY|h~*)qF@Cu$*dMmSh$*nAno3ByN8B-=HSl`1}Eu{0J-%7|=6 zgCGy`wYt!6sV>=oDPNyayF)1sQ+40#Ww{slC)pi3-;34hq$EAX#nbZyhO4hR~k z@Z*Vd6*Jnn(b6G)^`M_9lxujW>MZl7n^wtJZUS_9`s17If((Gds;vaD?kt^>g_N`J zBT^a=VU}b<@f~=Hv=h->N_;k215tXJNp);4RvMRxiHUgyd7>JNzZdZ5Sz?QzpQKv` z!(O6Soy?1M;ydctNHivye7$+eIi?IpCinyLmZ^CweZz0fAZg&DdsD#mKuHw#T!eVZ zzz40t=z#by_1w4jBOTs81fp~p#y!(Co3E{>iz68_Wni=RXbH)+lx9*cAM0^m!2}yr zXZ;T_x5fP~Iz71_YXhS7^uq;H;Cm8`+@aT~D(^13$ae`?=G_1llbsTr2HZVrh&Z5+ zi^ceXrZEj`t&8@)zgpW+)p>fH_{2nCDlfm55%q}8^*t1Q%VDiWL%$%#g1z9Hp4cM7 z=q{3{ygECHT`*tw&%;iwZ@y-rAuEJhss?L- zsWrTuA^J!-$;HA7<1)fcWtrU~;Xzn=@c*&|2w6#sU3&r=Z*h}1PW#BS ze)tcpFGH=(_P&C|=<|176x z+RRW6%Ir(`))u^~<;DYaih2!ezBkwqw2|E~GiZk7M^oX8Fz9*Y{lxDcz!*{< znt;{QIkVR6?4|K%5pGi|S*yJ?wi)ZSW&Um4#$y;y4rvk~iu?nR8qWSn zIU`27u`q;UP9-q}z`{mP3fUlF=q`Pb<=t#T3c@!NiVfWkl8AYd?ry{7D>QLO?xfz5 z&udnoigVAg*$mTYh#L8o6CKfiUot^QvuBxDMDt~lpVB71QXM!uReEX?6tW7*)jnrH zL2JK5baDpq%aIADTyCXOaZ9S=LvdH-t1DDzIXd}VQ{5{-!L-n1_iw7D_pfKyxq`%p z+`DhKh^al%1|$6XS*66JK5$j-GLR7B*5QZ9!z}^?F->2Js^^5S?98La4%}cxP)egbzqjABfMi2b$%6PHVB#zlDF^G66? zx1=7=-^kso!sGPtQg6c6?jA*40-~+_a+tPWx+3}_!)o9#zFOH1iO;0T z`PrO+dKgKNyUOhg0tc(83UmV6+ps-qN#!+CTVdB%i|IlJy@XRQqO<5UWXZO2zAUl@ zYx0|oN*hcH5_g1CmFACyt^bRo1yxG(M{VIDjD35_30LzTL@~k`*wa)>1Q1m_al9;r z>RFO{{cu5DKbT{X%l+qoXbOpOcrLih!|YGQ=S6fh^c=D8sx8P zDr8M$?xR?EKsKhxxep`upW2=j55Hr4f>pQ~&qr4dXEZ~XL$XvCCztk`RXEO|-HaCK%3%J%?iqEa3JSnGLkS#} z`rvAczH#mfgbJ6$1~Ca{=%l0+0)CWyuQO&KxCl6v4^zV6AkYUXBPi)Z?xu&RfI8vU zQV3J*Mvv$AYHI;+5W8cnle$Oa0{7~C{m>dA#z-I@n!y(+Pi%a0cw?+`IH7e`?PyLI zHx_l@EN;*FkfeuAx`_U02U_!Pyf+h%XY?TTvaga}RB}OB48^Q&*BL`W^F$27ISI27 zkMDh8dPCPN^*M-Y;3i~JX602jzbs|wDLnbJ+!GpMgtG@1MU*yg8WJ4Dke!G{;)6fj z-&SD5_-rV_gUee<7gU=-r^@$TmUHtK(L8t^1v9KpdZo}N-VLR z!5aU|M~0-AVsKlOX6x=y#~>Ub=<#fnAQ$9y8$z187*gEpxk60O=qQ3fh`Wg&B02c6 z_re2@$r_Q_*5RpH`J`QF7|9Cj9Ag3s6eJHCx{jBFM}JJ#QqGpDUpvcTrM!~*hgbYE z;(Cut6s-bdGlPgSl1rvsX27E|wZuT5EVY#&TK7QA1qbw1*QS zbu8VnC6T||F4P%JWLEq7)^ATbyArD+>ZZ6@bP3nbIxFBe7%%36}VRByp92*rKayWGdB! zkn&%lK=yCN0#YwG;n&L(aPyqwNtpujfaqQl1m)WV?zo^F>Q31b_b!uTBflpR5y{X(e1<}E+SXiQ2qzAWsF0|fdOg_WH;QEq4{TcC z+m?k^Kj9R&#y(jRAsIJhf3O7{MjY)3ofgp7j{E`-*)Z0QGJxFt?_YB-|MTe;{cuu*1pr)pc7se(YxJpzbOFss zGKcG_dUk9s`an7$9*$-jScQb(*vldi&q2idqL}e$ZS6!fy> zt{RyVcC2^CPk|uPc65B%nw&u{(p2gr3fEdO!P4wOJ_--f@XT7Bk--}UofI_@Px&6m5kf0V~}f7&OHDT!K!a4{^1qJv&Mg z)DHf`GORSmin`e7p?JqWOWlb%p}t7xe;XpS8Q!jQU0`j(Y-So0{{hsiRYq5LM(IGM zEFv6t;TbO39V^VRV~)yptlV)$>~d;1=%ly^z*QbEmoa!l&Q@Clp;Z7A0F@ZEnWlBL zZQWn)qSo3Lk%jj$;9AQSkKtar_!Bb7B&7lkiz`KI~1Orb&yh0||LmXIv7ZYQt!DPATd zb8Pl%C5zXol;+Bcxs}4EYUVxb-NEJnvdvAmLWP)pe(=03>}$+VI>6AO$-a7S4D_SEV(?u<=)2IQxAXK4?n^ zaGKB1Y&%_9S@Dm4FQ|bS2ypZNClUBShgh+ndv3`6JC+XmzZ?bqXY_yA@BbZ+XW{}t zWiT%P@!#<7|I5(=coDMZp}@V-WUYz-2Fq)ms^sYx(T_~3X(azm0Aas#`~IPlpJ#|N zgLwaHx@073uPstp88T-x^E&CA(8YeSj^vpavi-^0m|mp&M|K?(%-$~ z=@8&yW1mGmKyhVQu{2Hm{vY1U?~g6HHi9D4bG7DGK!E{bYkrn!UIM_8 zl(^J0c$}+7v){Y4DfgviOf}t`E%{`?(jt`~2OG$pqxM>XR`gxD@&Kff;ZOg6%8UA> z&NHP(;6Ica`76;CJJZAw;K2Zm##CDuRWj@<5a68vEEQ;+4vG>Km?r<|3Z? zNXen9A4EG08)gBTk$*@nM5a3GnD3dH!!u;5TNCjkAN#9~5b#M?FQdz$Wt5{b`twGg z>MI4g5d(m`q}=&TLK;>9=8U%CGjqx31;AX|&LQ*cW<>8(68c{f74WTdE#O=K)%(7P z&(D450E{Xd@&ALjQT8O7ddWqsTc^L$wbA+J@?(lamvY}TYb!Y|nUv7>?BIH6>Az`O z+d0X^p56BTe74nAyFGB`6xHD<7c(zwZLJde*+}_}xk&z4p^BCN9h~Rs_J81g*=y6t zHI%%TD|{++a|j*PYi8&8jS+j~snt`JXsg(y{5KI`etc6ZORa3k4^qAcBB85ayZXrU z8Z`G;mVvgYF)hpSTfbEw)*lXRuVL3d+|lx`w5|R*&zzy+8E=*Dk<)i=PG^P7> z!_8>(Iy^&u(?aB&5=pIDqFtd5u4GPM*dftc6LPQmw$3&0Jn`#;%_4K57P>!`m|<1R zwU@}KTEYcf%vD!FR#OCywa(>4;`I=_|yog5w0WN-s#7ruMCTf zg@j6aunpW!S5QZaO^Ql+VOfN8rJ2b&kc{>KPJaoPV*Q6M)g9KQe+;q7 zO>6t94IQPN>`{kvmbtzKztAM$(e;qaxUNqf<-Liv)3~jI4yR2{8}d)iUf$aYipptr zv?a58J`}D8k_P!rmN~Am4*=Rs2oF0BSd10PiaD_MlQgGePts;l$5|~!)#^2P&ICyO zrPkA$EWQp`Iq`0;duMc>@H4ki`+4eDseEfz0QT4XZmB=?`;MO18Zh(~&qoA%P^|m< ziRPT9qv1FBF$LS*?k3}(7J#mk;o#%wgC)Uf=PjD}!)AX?ETh=lukNT%xpiLQ_tAbt zXBxmu_qKnPt+Vn7ndh|gcQk)pQQYb-djD2g8M?fr1i+qH+3^UF1nU2C4(*|m6LJ`0 z{5m(>l|SLnAhx!;jNKhII^boyT^6X<4s5pmfjPI4{E3KdadzK%)g3G69mIEi&3`O? z_nHYCjfJ7G10Jk2pEnX~Gi&~1unqij)iCw^Z+XhJRj(6^eXPC~#NTSjd#b*JxC7jw zvwQN$>7-VrTDAx85c%>x;fO>UhyVg;a+izBx>dL35!I^$Rd`r6^PueaoL{pYT{=RY zv#Lj+PAb?>{?D^=U*iN7QG~nwY2R@f)AA)+4C3VFC(+ zF4=YpK%H(@-O{|cgxFz^6d&JoNYO6D!feGwH=h1}>a=sj|3lfK^ARuUD-$s13M|tM z-C7Ro{@I;7!Nat&=H$@uZUxP=!hF_FRIL`8BWKIs>&@xeOWgjLAeFZvt}*Y|6VeB1YV@8WVha@N+Ip~!U`0J^@3 z!Jl<{tuWlHYQGZTrwaZ<5fuS=)gqe_MbGT21cd~SnB1q+nPw!o+6R_ax08+7j-5ua zXKqaj4>*QPn+2BhO#^1zaZPMOu6{22Y$vQ`yyddw)fl5Q%9GIvZB5>VpN^G>_@Z{L zUNhc5vnQW<#fgMddt&>tWolVlJ_XUVK809j*mq?^{$p;w$tPJlPt3cHo!5OE5(~$5 zyov*%_u82AyM8bfPW*ilNQurefybF>WO2)$~6uUcKuYE8ASuEm-Y z5v#VZzwqA1zVXw<9wN0(XL9AE1{V z1@;f)a!}aV<9_{1xd};V&cC#nE)l<_Xir|V>)$eFJ2f`q?jIWyG3HCe`%-ZIH1rq0 zCb_IpLfMZyQsjn{BO22w#zZ>dF&8u+%;9Q_BUKO>+2v-cm)u*^Bi)`Bq5=@F4DNFJ?f ze)yq+((EPgpXYW*UU$-Y-*y3p|FHkP-@v|Mb1-{rAkv~jvS{B0-w7(mK}N@S9cc{N zi3V04pF;8NZqm-u!PU-Bu2K>0_e$4n3DGZGwlW&E%WVDKtLg7mbwM+(G(WY!>AQ3s z);?|2-OvrFq+nO42?p<`-qoLp|H<+Jg;V*59lD-QO?AX3pwY&RiSAdihu(M&_X!Oh zY5q**1e2^9ttF}hF%Ggfr1qUV*Fg%Yc-|Y+a+X4-vt$)lFk+{XN_V9#<}rg^1jH0C ztbX-$#%0^C4rj)Cr;5x0e^6@1a$>^|WQKS&jCCwoOo` ziH02JXaCdV;7ETM;|ewV{BaC?BJnqk?dJ|^4sTI76`50J3g1SD)) zBm+)X^0j8lS5H`OR-fX}2Td*ix4b2Ea(kYhcX zT|c=0%63VWODNv4OPzOh)NCj@BGoGEs|F9~2kE|cOlI5&z`Q!U8StjaKc}jyS8_U zy*p^z$*?qX={B2LR(Yk%$Tj(akIkwJjvsHC>+R@j-4VtZ`G`jbbDd3{k64-&D{)=M znEc$@vo@Qi!shlAy9s!2My%uHpIl>lm!dm*IT(Qv-jACy zUQvejEq^{>o@bUhXN&o5LOdV)(sI^5#Mz_(f}|wV%M~-b&4=lG97m6|NtWqrnJ*G- z{$MhVY)P#-mgN*1glnL>$}B@J8+Gy?ot}{K70sI3cE+04ToF-}`W6<~w>0ku0jtaa zj)hk$<&fR~lS@M-uKP6hNwYb+&o2k&Jd9Iuqk^o%k|}7v^MK!pyvpd`3+z8z7#rTg z#Av@8=m6|9VL&m}tJd}{GvGE>T>9fHLY#0Zc;5jTVisyKx5V)rs8eJ3$qcL=N;+L- z;NA3;2SA7mr}9eQ zdQ0X1JJQL=g?iKQ&IUFt`Ouv9uEOc7zRTGY))ll5)7<-h)h>27fpwYud~w&P+4|yv z!^nP@_r3FpS)7ESM#QoIn818Xh-Tl_z5Z6hfq?!8n{h>t*tw%MhxxD>>6Md2RW9ep zE`XV5_!xExT9Q0I7b~pZ1T7){mPc$G4I4MB(Cxr#(CQ!T11M z)A!dR=YBh-3!*>07ZQG`r7@B)RQD!QjV!fURV%evePesb~&lv~70_!K8W zSHHWt{OYlTGsbW-%icY~EKaq(31Ixby}N!I-Vb{1eQBB{XieVH#87n8*cIi{%d8*Z&DchbuPwtZ{3QQ5B}Yg@4~Wts}8}W ze$`%8otp5h6OvSh3Ug`~A39XR**>c_iYl%(nkGZc*3#g`6M~gOlQ%4?MiybEu zYl<(rii02ZU6>n`_m!S)0xSUvC;QoR1ME#HwvwiUGi4juVWwDWW!RP@X(zW&`{3^zv_A$Zb;g~5A z9M*aGN$dF|dY*6x;8pPr#F5fPnBu!I1q;Ka!WaP{W(Nqa!@@*HI zGqO59tiVD9U8iSt#|`_3WYBVauUCbpJ7%)$QNuAks{f&%SGzuv;I_uaf6>ZnHJD}L zi_hk@O(pmtwjO}253RC2K*pEYM5~GRO_d*3{lFH{)3oXr{;tC7tFwv^*y6`kUy4?p zl%doK9hfLS7}R*wxGaC?^Q~T*wlV{>#!d53Gf>7U#&k`WlsM=UW@fjKmCYHM;T=0k z48Y!nEFVoR-KC_hy&^Q5=|3(VJX^yFU-#)4c zhy3QvdS^3)wvX2FyBpIg5%UGW^>l?mbN4SB6)FeJ2Zb22*XrbUf&BKrOZ+UvjvbGs zPk)vr;hpRMUrSh9+sghS zTu?TqGz!8UvC;Nq(sK>Un9`Kr;%BzBp^3Sj=vtT2Vfixm?WjLt7IQEipq=^a*K8fu zPo!MU85!mJ{S6Hgq-@)ULaEi zxNO7Ma#pIhT@)vo_$AGN4p@sFQJTEhzre#D4;u#pJ2`s~X0al3F)h6fNB_Vg}naY$(|6unFT^<~ipc)@;S{ zUZ+LY3I=dpP7xfYKRkZ=E_h7w1RDVB1I6r#9Op<4z2pTKJ!P9qX+10PSW1kw5MKvY2Hr)vFBV5aIe|1fnz0A-byE^md z=Cqz`F99}ZE#9%xM)>Y$iIM(lj^9Dq`a3T??^mnr>e$TpVSmL!i4aclZu=kkv;>(S z>}z*Yfg@$V9WNWPva+dMn9lfsX0v%imt&H}^ubkQtYVPi=N~LSw6BKkMNWSOXLCIS zo-DC*)Q_;0LfrBRe`=@luo zPv{TNE4?I(>K0+H)^4FQ=>ON;S9Zk_blsAKKuFL4!F56i?(Q}~aDo#oxCeI&Hpn2s zo!~BmJA?#xcM0xpgUszb?|VPp=LcLCi&;Z=Rh_D?Rp->+eRfgJUJvHB>v(HppSas& zU%EzBa2+>@EPDl3^?P}>y6Sm!+=oWB%Bq%Ha>yMo(gW#^!Pd*|rOrnyg_I;c{sEx8 zk;%$|c+0SSif$)wU?XKDrmf6LD#PgVy@nRpmzRB9i4r*8MA2%8S>__Icos&0GF)~q z6O9F7k4G}&+{?}!L_9_?0Wx zar(HM=U!PqCQFw;kVs-6<~5f=Nvk)tU+((`Cp_26rhk{KpIb~uFSW~^l!30ABk3s> z#I&rq44c0g)KsTC+>tqUL8rI&q5_vZ0APczVaAqj`q)#oe#MTKZ#Vh#<^XCYv_eHQ z)8~N14H2WZ`DWIAeR-W3m*7TZBU~UCCJY+Bm;aQ00Qp>+2fm2jdN`m+lsk48*FR*3 z`P-m~9d(CMyLF7I)GvBqc{~oL1VbzyhU*U~Y}YCjv=`A+b%V;@(PIBAU8bitQtZ?q)_YC{12LNWGDho?CYjsR?IJ1=Jyn0~DDo;5L5Ty8EFDZc`uF}{ z99EqscH<0|y|ywOSM)|*V&2$$s#!HWeX;ZS%XQ;j>Bq&v#GdNqb^?uwurH=D*LO%- z(mY|>BV3NYcYF*bagmEZ(q_^v{SK?{xaypD)VqQ^!c80Gt_mmyj_=7nB(10vs^X6C zeguLr-yPC|DEjDEt^Z=j0?BsK%{k*$2xgBMJGIU)2f^8C zU4A^# z`J?S-rO?n)QHJmmWA{~!*ee;svVdm>K1}}-?4#fj6%~jNhUjl7z6pwTNMQ0!y+o4T z3I92C_-wa;eT(5<{cv?^AK>`YN z`OJUIpKwV2)~96h4j}oi*>Hm>)Al)g-+lpIplO06UIecD8lXGh%c4SKm&>G77XQ2# zd9Md#9Ibw*Vc7Z|SK4^(oZ&L#5XU@0#+ze}>}7ec)1GN@U#lS1Kq>ri(}8fQneU{{ zx^}qnSBpAkFU3eBA2Sm^w@{G&mc2?-x~HLQuV)zOq#*Na?v|1Tc!Be$$WiwLmU#oYS|3Kf zv5sL_2lNK9%Jydf6-?*xw4+)wu6KDl>UcNF^Jz!K6Yn1{tBZVbW`#AW1|0;)S>II8W79Lj2OMl9yjh8ShVk8 z=!m>uhMu&OJQ~*?5fGg%V@+I4j+)#;PBJP-p#r%Z{U11d|GuBq0Es}!cA~bLA9_~E zgdJ-+?Z>Iv_MGQaVV26(2>;4Fg|zx$_}hVQR~ajlJajbs|Jf4meUg#vehO`^-O=~r zqYy9@=;t_Yz1P3F--1KGum{dxylYxD`DOl#*MXt){nEu~$Jo^$!%j^JQZ@_vOh6S*)L%Na`^)4zsDF2xr4cnMhtMa+spE(oRh&$~Ail0NJVQvfSAnVyD+q zza*+dllxHj6uN}^#0wbK92g&Pf!-@k-a87vXa1mDYL8u&VO%BUI&VslWFvL9vd>06 zTIBnScb$0mC32~~#sSHw_&XL@Nq~8lS3ix_#0|Pbby=3F4ub4XmOZANyKDYRRT%SPzxW{^|M7WvEZT>o-8` zs6~u;Ep_NN;IZFkunJK9wlMkgz0qZ?y0FodW>(K$XntqRcx!D#F>Yg|>;n^A!C}6( zqHRTR0%Zz5^BZpVW<-Mm0D=9vpX#7+;9<)F5Qf6 zYy~+n$3eO0rrql~o2Vjw8% zCCjr;Tb;&=4)g6JEl?CUVCx|y{rv=A z7?M`B7h~%#+sOQ8w#EhY45AfUtU*g-;R zB9iOdKc-Xyhreq~?)7e5PtwdQJjXF$cN&WWG;UMOl!1Q|phpjS4c$od&KFF%ZjRqc z^|!ug+b>^jE*+3ZMrrT0ldLAe$h5D%e~0NgdL2oc)t+b$gze~UK*D-69jd*gO{$w7 zGR^6^a4>#$XDep-_M}90J}W8=Q>2U4$8#KWnO*&uq*1z^e3T}+YQO0gmXFQH9R&$t zZCRV>E9xBUZ1jJo_~A8|I2?=cu;PzT>1+{Q^i0{-+yl?@kB`z?8gW153M6ZtM))fI zA=lu-hDmP6w^WNa8bL&Zt-)=96r&$FVOHxkET77$aUja6^&6K%nRd*I=@mBn-m&{bzffccBi* zcG&q!o9QvbXBYB{!^ZHE@$K43jj7Au*-a~ z@Os4HY2n5%#E6qm-ZJ;pMWcDM`4WFY5gz;kQ&1+;bK9fPenB4;iI55Nn^vD~zD}Jl z+1=hO4%-R@EgGNXekFzS_hf!ietX>S2L>6 z0`&c+S%rb`AN2~ws?Ap;-XClLHqU{X2&d0bly!gKx*~x#%Xj6D*7Bqncc?yeNFL0+ zkF4kcvA!hZW*!px#|lc}F3>$Ms+EPaES`~79lrHt?sSv@a!Qh&Io+OA?_7VoO;q@x zS}v5OTpms@{?R|(bJ;x&;k2B1PyG2h=BC*>!SiaL?<>(w_A*V}e8b9w@=5!wdb6v~ z>f`X0o*~T5z>-2wptYd|@jBM-#4U+guNgnQ*x{*h^(h9^-Z{PpYTz>4=^N(7{f`mG zjZ*^zU3W$Hc6{%B!VeC1btwhe98_orVV^L6b3X82CPYf~VbA1g%b6o;N|1#?aAw~Q zL|3mJx{%Fp3!dX-F7tMh+?s*@QZ#QMz`iQL8?N2)9KkaB`!?+0yz*@Y_S4hjXt7u*^Ge)pg2mmJ^R?T8@u zG#Yx4hy9~f`#eD0IBN8L0*DyEE<=KW{bAV?|(JDN#a|6FZOJW zi0Ua8J;Yw@#|N4C-*I?XB%H1rrtPSv8%_Px1*R>Op1rfH-#+V{ArHLKY4DM7U9yxe zd=VdT^q?S4_!Zs%=)`637*-RD7j8d%o337t$I>yOj2YFl&Ht0}%vQJ29;HF_0B5n7 z!X)&?lKnvKJhSg&y##FoMn$K(roqnBcH86Wq))kv~qI}*^n1$Szz?h;|6EQFN z-S)X+lD?%n7?gvLzXBTI@ZQ9d0(K}78@M!qlqjEJR@0xi?%6~LTJ7;5&fnGKzqby> zzpoRXH8nVDR%!x6LtGvKfM7v|_Lpk~M(Mj5`fy>>eS>5EywgI}kZ>&%%aL&v5d zCwm_d0EjlVo_7&S2ThdeAB8giAah%luJFlg_h#qYkL-ycbvSQQz4}YCysF@ZSbwF& zGi%2zL`yr;a+5!DzO*Q@yn6DGOqqJ*u@=jWyKukpw&T%Mgye>lmc@mP=Bn8T_#~pK zyh_ulve~j#s{et(v!bSd{1m7=bFo4o(2pp;s4PksX^#9Va9rQ;a8lp9%;8Lq&!rKX zMXAo*x`NZy=nLp$FIsaVew%Vw!{<-V_tav+6o`G_SxcVI*t#$C!e|3C2o~u}tIIPM z$019C!DrIxhd{=WcWY8N$PzC@!XrxLfPH?$AC%+B?9icfx2gb}JieFeNTOX?1bpU^ zQbXeLxR(uwdz1}D!Wpb3%Q0mJQ(&Dj38TEf5Z5GB@7noyLrq2N;_@WZc(LgLtl35n z*VIWa0lX!!_uV>|oeZGgb@q>ahPzn?fvwqo;@e<(*CnE)X8Y%{F^|~Gt}9N6-5W>C z3F{miz$Y@tkq*|XYCuAgmak0mX>+Ki<|h?Jy4hj_>F_5`DYx59Us7GufV1xi{W*#zeI77ypaDFCBu*57 zx>m|XvKUsZrn%vG0nGczVO%uz5q&5H-SMi9$KBS7X^$A=uM4)NYS{aT&6k()huo1g z2f|l2VqQm=JXYT0pZi*|G%KkTa<1WkbC3gsl#JsmD|bbni9*DunLB=&?saWHoNnkJ z3)e2z&dxQ|PS?(JSxqOl8=%)-^P|$F<&gmTQy@TEG#j zT*PB7lvopBM!}0hHn$%w7wg+C{&d+*w%edRw{O%+wYDQSLulhk5;MZr4u@~F-md#>5Gg2k#^cn$Pu!b-3o8x;9U61#})A>GX%Wa*CkmQf~uu5=pq)_5_#y}O<_7vZ~& zkyvt1dy4ao6iyV@yLWwM6Xr0O3UkPafrMDBW_|3Y=LyR&r3j;`f<9$1>cuoR3JZVL zFc^P|V>groST3;af|M;y`9`1GEgmA;#qR9A;CJX-q9Zk6{}}@b>7O5 znRI9k@a#&!Id%IVt}eWN%~%F{fYXkr&H`VQ%-zCHU#z#g~n_kBx>8fD^y9lbqawfok+50ClgM4z zLYy$5hlBm4v~r?9tmm6Eraa4{mPnw5+J9p5d4WVb!b}Iat=kfflF(x-XXJj)hu1&7 zy>kKf&`GWkR+739F(vWgUPEY$^G3KN)3M|d;aHUxIaAS{cH9=rl#F)v+o?Lv>*jvK z^ezxdd#MJW)>Kha!hKHLiJ-r9Sm09|itU%gTD5_KuadRyKXHIVMOuyN`!NB;QWfio z%_BVWX^(&pU_kXub?Y6~#kzS#tkSm`wHPU`cem0X8S}v#9Mr^RRWlrlMLm_=E;}l8 zM8C%x<+Hd>5Q_p>w;xODpneVBz9Nr|nr9M~*#yW`oKi7kzrV}i#L(w__s3IVf#hVr za(sppp!QaWGX6a$mFV-IUif03>NxQq6vSR(i?iH3ZhP>U*Q6|);3n2?-64FjjNHu3 zM~2e*oXocQP8cfdt$~wYaFvj)oEajmL|nF87td-*#wn94P|Q{9djXa>}NoGVSK_T_YZtR+=SgozX4mU9?0jGR1JFIzF20CBnL}7WUqtpb5E9c+G z$TkbcmwUFoBN~`_>Nd?(qcx~cKQg{THt1|`&o4VF1~c0!@a)hW79h; z$!=HV8QY3Q`+;lEENt^8oRkXv1G{Fscv`ay)37hTGG80Mj9*Px)t72?bMKi(e6dI< zkNB-)N`iw3Otjq{f?`UnME{yfXBH3r6NiJbx*;C$Cc|j`@7BUyAknL%7u)mI)>+LB zI5Y3oz6PGKQ5$vTqecsGkF`N!tMQP;dpbTiaSV}~3o%k4xc z?+SxV;ssb_t=T!?s-Uxk5~wUP)v;WcP$7^&tFd3qXVy2`WfGM`5L}5gSwJS_3w|4y zSVZ+ITUbP+3AXYLX^AjfA2dNB>bKLI(Q+lmy2y!8Q;f8TsZn@T8d*@A;)>+#RY)N! zH(5}7s3RF4I;p~3^8R}HE(e!{-+4C=>+9(c*Ly4xYai)Hu7_IdO0cGJnkOzj*6^F6 ztQ4a0Qz3Fv7q1H@^EOfeyBvMQ5tvgjNT-~_BlW3h)(2uJ% zs;q+>q!-F^+DFx;IJWvnmfR68Ou z(njj&;tLSQ9YsUnLM_z9-l~3B&j+E%t}Pg}x;vnum#w$17?amJ@Nwm=^SCDxza^$; ztFAr|ozDFE@^^%>d@syM2dl22bo?-NV_tVEC=6zGrIT?JN+vQ`1bOB2Gtx_f5o>(1 z-sgn1)0m8|IH{c5`G@y~Z8P6+9s2I1BpA$}TV~4GklQ9DVsi4-Jp%)Ail_Q4ehkm~ zNYCKGo

r!aqUrtCMrH#>i9XkI;AGap2rDt^;n_ZZLFu3V9=nEoZ$UY9~i?YWI5= z^8t_D^ub0-4Z&7F?LpsXB@vK--#h~x~ZvN1W?vi{V7hfHmd8cT^S9xHKrCiS&=D(`laNHW)`sRyTw){1_`V=h zX}c~9RKIF}asP@OYQY-UHnnZ4aI@bJ$e;Epdvx4{>J$QVHn}ZEVduiATZnjExi_uB zhVBgk(_kWw!oA0gFrYn$jIE94-Gus>^pv?n zM^3CH)3O@1Jg+^KLVVaJh9My?DK~E`dU3MIz_raYPde5|4Ifg`*eEGGhaubozU`TT zgm_;pA}!`D@?;5lj0({2ydz0@y#*I(si78`Tm2A}E+LQ5X{|L=CSrY7D$j7i%P|qo zGRpKK3-=Ly`O3&|^l3}YZDrvQ6!H;m|03*fx7YjAi}Q2Y?1)zLXns=!e72+HV*2w& ztTAX|;)UvEKViW{Q3RgWs0)GDEm+*YVB|?(tKW=Ox66G4vOKklLxV z3UKZ5i={}5R7!{FR}fjH#{ogbSnDq8Z#t%W$@a}oBJu^Jjg(pvYTJ2f--DYEm< z2rbiBvP=2k+Gpbk*VI+#tP-5^I^uD8XKupgsxR^+{KkO=OPpZNhj(7mXg-< zby-0yE3`C&!X@{wLimg&yUz_7FVwh=CB2^Vc``1(l0gj3(oH0g)pjt)NV+< z>|pSteu+0e@%TiL>svBbXH5S)ax$D&q~CWUp7QY_j+NS_^#vtd0BnUNtIB2ak>7yo zr_o!yw-rY{XPX@7GAZn;ZDvd<#(HWo1t=e&nJ4X)2eQIG8X(x(%e%u{kMn;20LKH{ z2CD@k0~X$R^Q|e!cEnn^wAPBh|G5qew$$tKi*Lg+Ixunj_w|oegTMsA-0xdsZCbW+ z3oLKubbUZ>0^U9a9*(cq@n7{}s_3YtA?=zvP*Bt(B=#o}b*0KnIS*UOQq?UqkHXOI zZ-(tHCRa?SNQ5lEvBf(#>@yw`urJwjxKz5@iy-U)n|*lRAI9Ak0p<5ydPa5xb^_Q zqOMU%%*SU*)ne`=TJ=7K9eURg%Z~QdEpr=M!)n!rP89Yv1O=bZtTiY&+EuWR2p&A6 zd)dX-{6Di(TOXuD;;SHRj;DQWP+~|xLS?<5-dh63A4=s< z=$RNjifQ>f$dMQB$x+UKd1+4tU)YLJqo1aqii|_R`0u+8Ur)|QiyUbfu`Vzl@E^m6 zx?3Yh!T{ctMv}BCrvO%M+*{Iha8Kc8>17AUxkZngtKOi4-(i)K8m+IV=A+!$uU6=Q zHga<~ONa_UI#OB|MX5cfM1y>p>GbQ&+nlrPMsO;w7QUR4rWJ$z2&)5@_ACJwF>2}P z8Lz&!4)T#x)zlooCAHFVfQaFDuR;QLuH2*D*U#14j{g#enoAR!*m=LMDCT2Zb|`Nx zz==B}v5LhU6nJLcmVmI1hiGsI@ZkrP znFy@A!#8cAT>^^tPU?BuzXec^1ho8&swT|QH2jc8n^i~jCv1@dL(wN~ow%N|BWH~z zIZR17H=URkr{;)8U`C7IMBIKYB$+Kk41*sVj4p0QiY&0<$k#489cPQLx0Ab;f_B_)F`^c?%NMzj zUT1=i@2?=bd~8|EQ=y04jnPv6&q*~;zg{Gs>$G6P{o+z_C#yPqN2$4JMT6{H-Ol4v zad|&Gk@FXfa*!4CCGizTqrY~M8xAu^6xhfN&p%xq=5H_6oK!uudPSo@dIt$h>$JJX z9nRJ1-q3EQrs8(K51mR!#iE!MrDS#ZOIBfY3t2-i<|FIvtyXe49P+Hr`sO>O2vj)A zieqIp(tljc_e|C4?-Ju_F<*7pw>yQX4B9LoHVsU2zI7Wz*d$H?&ahsQC=!%9@*{8c zv-Y0;C{(Er3do`mk>q|=RhGU1^#jg_ztI9WyPSd_MF~HpHzMVFQj|V465}Hg<9LH# za$!)?v!L77S=j9For&o8SR8O2z2s>GzJQVY$)T&wIo z**h-TF1%o8XmXT{DFsyTecWtTLXZJjNWl*vgm)@$=9QPTEMOv>(s90*+zCKK-~LeJ z%h;Pd$eE+Hz^Mf0+_5i1L?f<))a(6Z8Uj>)R2gs{nGeIu^A~DZ_%ZBnBVL&!Zf;Or zh>}qUgP)b|KvJwsBJ|U!!%)e#UMbGe#m(gN51XYVUguBn-QcOvOsr5+?42ZFfuRJ-XI`sxP3P(TiSX98D zOD7=PyP}j_I5{d`YFrqx^|rR^D@!8CnkLz>MxFc-or+x3|+NZPV@*hX9;(SOgk znap==6&GeY1hv#L+sOI}v2T@mPuctHe;!E0h#@~|-$BPm=fL3)ElQGk47(h8 zWgo#=wKMtaTJ@z8;4i$|Bxs4q@qYYNkXY`LTiIsPu}ahE5Cd`T8Y1K8h@_yF6~#5G z{24U)?8f#S*_K!p6(r6OiA%`$eXO6g!g9GaTqatVEByTer=(mrB&&u%@W!C%S7j>@ zDmHrb1|m(dOvw^X#Ax8Ukn&i)@_>$?Rq*&>FQ`T4NmGy<(e2x2MZ+xxBDis{-eGAQ z+K9G+U%BU131y^`n`JbI#fNUc?0vkaA+v=jA75V+_aH~|O5XZNj`zIi{h(mrT)@@o zZP9RsjR`tpWyjiinWJE&y&Hj|QCV@s27k?q`Xu8jSuENn&EMVZa-coP0#Yy0m{tjw z0fiFyU&B;i>UvlM{HHzdL|SE0XLkmzMai0&;FR(DoCqb ziI7oDARy)$M?I+6L8h*(s6W0RlNQUcn=XN>7mCcdQh-e^%HFxS4@_AS|3uo6G&FSa z*$Z%HVC~6;_8>L9#%Hj?LpJ%;$rrGL?fxy=JcO6_dFkxfC3!^w+JK)Vh**6rh#P&@ zOf$vOf`rUoQ1TxAXu)j^v4|?zNc1n3L06$6VAB3zWVHU4ghyf6$UUs}TsL$aeN>Zl z)D}z5T`T!ewz3x`Ibg*pl|K8Hb0y=H+<={ zWW7(v;2ur2!J?i5Pl4bmpW2(UpNL*Jci(WYxGELnO?$Uhrnqu1690C8YeZ)&F4wO* zd~wNo^K!2b#^0Vp=z}j)+>=6Gj7da){h}cjdVOLW@W@w1`_qO{IU3d#gd#sb*08&D zrQlI>zi4UwE-b4<`9*jmI8cJVIv?4mzCUI zsCH69`Am3C0jCprs-+D)!!q@1(6m32&YxGme58)FI4vTKBQ2nU(xX^{2vKIXn;#ol zz#7cR8tnIP?s4bEBG)n31ma2V$tj5`RxAtCTk^8}#^n`m`oCBs{73XwrjS^G#a~4` zudfYewrnQmsnM$WOF;1jO2UTEc7MDR5F9vg z*~0d~mkg}rG-N2gRP9uW8P3{^e*ZjhDHaFEg62Vbfh=j{sE}5@?-)^N%r>#oE2_OHc+&N3qHxdJ#%S7bp1HD8*|>fbO-{UTRqN*a=Ae7_93ZF^dj@h@F>NYD2m`g z#ENfdD}05ukO;l!V{*C*zl!G0up#7btwJU0&8NRK%aImiLu4Oy zz3AXKp?UW)XcP52%1uZ0hy=U9+^dFWyMvQXy43-u88(j!b#v=Q>b%8{oHAPhP;TOJ z9JLgJ*(J3;uHE?U=KMRd=}4Zh$5f?iz_%p&q0!@xs2)4e%t z@%&Jn5Bul{NU)jo`R)^VS@JA;HZgg4$7NN`3k6)4Xg4T6Utq(_kWa6cAWi@B(`c;2 z#1MTipqxZtsQ^m-y#4cg;mLS=4#a^@#yE%fP(tTQSeJn7Uc-!W5Tt_&4JKM9YlQ!@Jd=`Z-QkS&I0A zTXi^0y0$txLXNg^k?ggW%r5w=Mokuzg404ypfH)(IsJOer}9?L9I=%JwfhHffx;h` zxi1k%SMLU;;wjJ%joRA65&+kbJG5XuB_}W81??y}Ufk)v*_B%uee*dwR&jpHdiE-L zt4lQC$Ytn6U$|tAfklA_tUayk9qoCrV79n3>6xa{>r>uvZTeZwHv=~lj#*T5yRyg^ z(40Qc7tp~=D=5Umy7OuTZT61tKah_R&cb`i%2$}sn3yURD@JLPgX|dA6fSe$5DyNH zoRqC>A<3XVKBt}jC;&?fK5V^1W4DE)+rAmMvsy*^ohRC18$oi7&G2|I>yHgZifpX5I8bMDk4c|aQ=N- zz0IueIf}KOjIPUW!^X(c&*tF@;K z89Y9d$q38lBja~X{2d&@1$(P1e7w+R;DfgDL4IN?@+#AwGS;})Ix&s~9I`7ao$O_3 z)x48Q?WsU6LZt(>WYVcmbRsakc4W!LsQ@GdxvegF(J1hXwbhhIvd6xKWxk~))QW5T z7WM$V6F;N246(vA#+rFtUVv)rvsCL#`gj#9=t5)gx!81a3%Y}X{DtT2mQbnaon^Ed zoe!s^I2wJH{IU~KW0h)jgpZ>Q8}BExp7O>^I1+8+jvH{gjP|=lf7d3@S-!c;$qzlr3+X->XQh2e&$W5h*#ze%Xth}SM zk!Ef5vAmBTjSQA6P9ba~Ui=TfOScwx_N6wDa6hXnKeJC;1JMUac^kB3FBDWlIChw= z3XlnJSYt+?&`n!h9%5hqbl#h=Lh8QWWfSrgr&lY>GlK;M1p}!WKIM1EV-T~`YC~mw zd3(rOz2Q1FMv>}(4$3}V0q6}TEd27ZvE#ZJyA{cdEgr)V``wV=MvqcF-Dl%`t?I86 z<$wz+=(j%Kw?3MVe22@UH#zw0!JIvr^y#tjnkvhqpyK^|=BZ|X7?bXqLj8${L!{w3 z@o8lj{Aqbf`rxU){wlNOB-TL%*K}fvkjvTC?;uN2!hBIvfAW=OwF3v|U&{Iy*kB2L+7Y{W5%y_gM@N0WA(`KH+r4 z_~9-2L9ZZT^$JYg+GCWym!?!S1*&*sA{l6SzR%{}9ABv;J*=XhWmh&{V^~CN|L9ZU zqDlAk&0kQhxwW7=Qr~MK1qC*`8?0KfA7?qqAhY3m^64^la^#hs%qmfOqns7DMJ^q= zT)dAOJWBT5>iXBEK2;5u=WL#fxgg;DN#%__uT$1A+kH?@59?+hKuXXHDh_FJJi!)YU4mJ)6K&aV$w&E5E2jN@m)oCd-CvFdH7+!^$|1J2mo(n#6S=PZ5dMPS%RLD!U#W(kk?c3tkGZ`5`sj-%;@@bj17GVX)zi-^5 zVZPl!bd{7n$+o)3L0~%m*OXn@>brb0A(Me$&8J-KmUhG5yIRj-PiXo(AxoHi7T;hB zAa#_V-onVxu0t1BD4!5E9`|0@H#0A@qhQpTHG6c{7gI_FPpXM%yzF4-M!;8v<@*_E zP3JY@S&qwkD(Y~Q($uRjeehH7$B=!A&N_mgP5=3+bmjnlS=vN zlNR@l!XSZ3UHh9DiH?lEiN1W(nwbc66^Mp|4g z-pwsqTq|*Cm9Hep!7Z3znK{q-@ahTmyfnwegJjU`HZ=b@HW&Iu;kOU27cI)q+VdrC( zOIbtbz^3wv+-R$LY!BbTYqF86QZ&EEcTh&G(J;18L`Hh|m=6^x_Ay3#x5bwhSGP>m zrCe51T7yxZ!4|H!7&B0R21tFVF?8emnmnsEABwKgou=vK+{a=YvNtA_meb8p8-<99 zP+JjScBx0Et%0Oj_)PAOm!&p2>DKqA6X=K@O|}gm*Bk~RZ(r=s_e^k}oJOOJkGcw_ z$5hLuKC2$M_0#$yoL-+C!)Ms0xZ=yrD{r_=3+YJqjcLoHx18@|=$N)_Jg@OcLGHmZ z0e^FSA34fibVR4I>E3rjU?DaHtiSV58W`TPd8UISyWzSWQ!|!!wMim{1L`1R6LQ5l z)2z`r3rQye`UuMK6=qEG-R6?Y5zCH$?&e+QwaD9N zoqq_Bn$g|Eh0rcc@%e`1nOqO>x!zmO+YI*X4AmwL;V>ii*h^5c_)`9Q-5w!+C;h^v=s{fQAh@+JUL?X zO}9YI)9%ykL3i^- z>5{Tt#bTlH-$DBeWzHOxCK_N!AKpSO;zh#2oC7D47thsSNdW_HiZ_ZjKp_Pp-imG} z2kE$O9o)bBPbj7U@MK$(?O%3UJ&4ag-)VE(A6&aM^O#=g%3qkc4^*@!FqK-a6quXO zw<|XIx#h-ZxOk=6Ax4UA9=J>QWl7(dMg9>1dFKsG8 zk@&|{yN~p_|NFo{`l;CwkN52LKjqMFSO{ z3yiml;){V0`&tm*l7S?RHmTsfKn9@pu4L;sRZ4BX0ZI<+a45w zNf)flxvZ=PK1~-=ZSnjwJ`t$=Tn@PMpW8Zchu^4C z;VJOoC8!9}e)Plp-=PKU-#5dDTHr}7Tl^~(ZYZf+brJsC2A3;WMa@X_&=nY%B^)F_$Wj>h5KhV%yra zkou|11i$O*VbjMqzRlFK0(2#()<5$frAPZxrXuXFGIG4f!#43`joNa~#iE+s`j5QA z>4*47JQeQJ%1B(xXJ-r=Eb-JugW&_7aw>a&`NZyW|3S{4P_fNx;DT1Lm#lP~Yt}y_ zRGt`HbNOSvdw@D}iVmjt;@|L$KwJlOu%(Fd^OWQL(4hHknV*3h|LbqDdYV?*AX%y7fEukL1*Lt)WH&eq^N- KB`d@YzyBXj5y(*h literal 0 HcmV?d00001 From 97629ddfeca8018d29a6cb9d3a8251eae7712e45 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Fri, 9 Jan 2026 16:21:59 +0100 Subject: [PATCH 26/31] fix trailing ? bug in the CRStatus .status.name --- .../postgresql/crd/connection/ClusterConnection.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java index 7f11bf8..13b6e8a 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java +++ b/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java @@ -56,6 +56,10 @@ public String getName() { spec.getMaintenanceDatabase() ); + if (spec.getParameters().isEmpty()) { + return jdbcUrl; + } + var stringJoiner = new StringJoiner("&", jdbcUrl + "?", ""); spec.getParameters().forEach((key, value) -> From 9db67e1489542c5b73cce95d60479049874c7173 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Fri, 9 Jan 2026 16:33:38 +0100 Subject: [PATCH 27/31] always set the .status.name --- .../java/it/aboutbits/postgresql/core/BaseReconciler.java | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java index 4b7ec77..3bbe52c 100644 --- a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java @@ -25,12 +25,10 @@ public S initializeStatus(CR resource) { //noinspection ConstantConditions if (status == null) { status = newStatus(); - - status.setName(resource.getName()); - resource.setStatus(status); } + status.setName(resource.getName()); status.setLastProbeTime(OffsetDateTime.now(ZoneOffset.UTC)); status.setObservedGeneration(resource.getMetadata().getGeneration()); From 9b0cccd905c56251b85c0f663fe0816925f66579 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Mon, 12 Jan 2026 08:47:19 +0100 Subject: [PATCH 28/31] rename package it.aboutbits.postgresql.crd.connection to it.aboutbits.postgresql.crd.cluster_connection --- .../postgresql/PostgreSQLInstanceReadinessCheck.java | 2 +- .../java/it/aboutbits/postgresql/core/BaseReconciler.java | 2 +- .../java/it/aboutbits/postgresql/core/KubernetesService.java | 2 +- .../aboutbits/postgresql/core/PostgreSQLContextFactory.java | 2 +- .../{connection => cluster_connection}/ClusterConnection.java | 2 +- .../ClusterConnectionReconciler.java | 2 +- .../ClusterConnectionSpec.java | 2 +- .../postgresql/PostgreSQLInstanceReadinessCheckTest.java | 2 +- .../testdata/persisted/creator/ClusterConnectionCreate.java | 4 ++-- .../ClusterConnectionReconcilerErrorTest.java | 2 +- .../ClusterConnectionReconcilerTest.java | 2 +- .../it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java | 2 +- 12 files changed, 13 insertions(+), 13 deletions(-) rename src/main/java/it/aboutbits/postgresql/crd/{connection => cluster_connection}/ClusterConnection.java (97%) rename src/main/java/it/aboutbits/postgresql/crd/{connection => cluster_connection}/ClusterConnectionReconciler.java (96%) rename src/main/java/it/aboutbits/postgresql/crd/{connection => cluster_connection}/ClusterConnectionSpec.java (91%) rename src/test/java/it/aboutbits/postgresql/crd/{connection => cluster_connection}/ClusterConnectionReconcilerErrorTest.java (98%) rename src/test/java/it/aboutbits/postgresql/crd/{connection => cluster_connection}/ClusterConnectionReconcilerTest.java (98%) diff --git a/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java index 46a209e..02587ef 100644 --- a/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java +++ b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java @@ -2,7 +2,7 @@ import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; -import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; import lombok.RequiredArgsConstructor; import org.eclipse.microprofile.health.HealthCheck; import org.eclipse.microprofile.health.HealthCheckResponse; diff --git a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java index 3bbe52c..4f0fc51 100644 --- a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java @@ -3,7 +3,7 @@ import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; -import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java b/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java index 5b4e93a..1fb4a74 100644 --- a/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java +++ b/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java @@ -1,7 +1,7 @@ package it.aboutbits.postgresql.core; import io.fabric8.kubernetes.client.KubernetesClient; -import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; import jakarta.inject.Singleton; import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java index aa00111..23fc3ea 100644 --- a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java @@ -1,7 +1,7 @@ package it.aboutbits.postgresql.core; import io.fabric8.kubernetes.client.KubernetesClient; -import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import org.jooq.CloseableDSLContext; diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java b/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnection.java similarity index 97% rename from src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java rename to src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnection.java index 13b6e8a..1e6f781 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnection.java +++ b/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnection.java @@ -1,4 +1,4 @@ -package it.aboutbits.postgresql.crd.connection; +package it.aboutbits.postgresql.crd.cluster_connection; import com.fasterxml.jackson.annotation.JsonIgnore; import io.fabric8.crd.generator.annotation.AdditionalPrinterColumn; diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconciler.java similarity index 96% rename from src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java rename to src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconciler.java index d3d20bd..e212983 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconciler.java @@ -1,4 +1,4 @@ -package it.aboutbits.postgresql.crd.connection; +package it.aboutbits.postgresql.crd.cluster_connection; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; diff --git a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java b/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionSpec.java similarity index 91% rename from src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java rename to src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionSpec.java index ef8f4f8..7a22912 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionSpec.java +++ b/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionSpec.java @@ -1,4 +1,4 @@ -package it.aboutbits.postgresql.crd.connection; +package it.aboutbits.postgresql.crd.cluster_connection; import io.fabric8.generator.annotation.Required; import it.aboutbits.postgresql.core.SecretRef; diff --git a/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java index 04e2d08..1f0640e 100644 --- a/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java +++ b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java @@ -3,7 +3,7 @@ import io.fabric8.kubernetes.client.KubernetesClient; import io.quarkus.test.junit.QuarkusTest; import it.aboutbits.postgresql._support.testdata.persisted.Given; -import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; import jakarta.inject.Inject; import org.eclipse.microprofile.health.HealthCheckResponse; import org.eclipse.microprofile.health.Readiness; diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java index 33e9f59..a457eb0 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -5,8 +5,8 @@ import it.aboutbits.postgresql._support.testdata.base.TestDataCreator; import it.aboutbits.postgresql._support.testdata.persisted.Given; import it.aboutbits.postgresql.core.SecretRef; -import it.aboutbits.postgresql.crd.connection.ClusterConnection; -import it.aboutbits.postgresql.crd.connection.ClusterConnectionSpec; +import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; +import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnectionSpec; import lombok.AccessLevel; import lombok.Getter; import lombok.Setter; diff --git a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java b/src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerErrorTest.java similarity index 98% rename from src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java rename to src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerErrorTest.java index 88550d6..5a2c4fa 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerErrorTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerErrorTest.java @@ -1,4 +1,4 @@ -package it.aboutbits.postgresql.crd.connection; +package it.aboutbits.postgresql.crd.cluster_connection; import io.fabric8.kubernetes.api.model.ObjectMeta; import io.javaoperatorsdk.operator.api.reconciler.Context; diff --git a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerTest.java similarity index 98% rename from src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java rename to src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerTest.java index d65d6fb..4144cbe 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/connection/ClusterConnectionReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerTest.java @@ -1,4 +1,4 @@ -package it.aboutbits.postgresql.crd.connection; +package it.aboutbits.postgresql.crd.cluster_connection; import io.fabric8.kubernetes.client.KubernetesClient; import io.quarkus.test.junit.QuarkusTest; diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index ac0014c..989ba83 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -9,7 +9,7 @@ import it.aboutbits.postgresql.core.PostgreSQLAuthenticationService; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import it.aboutbits.postgresql.core.SecretRef; -import it.aboutbits.postgresql.crd.connection.ClusterConnection; +import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; import lombok.RequiredArgsConstructor; import org.jooq.DSLContext; import org.jooq.Field; From 8d64cfe49712768c2fa025090701bd6707dfb97c Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Mon, 12 Jan 2026 08:51:01 +0100 Subject: [PATCH 29/31] remove the native Dockerfiles --- src/main/docker/Dockerfile.jvm | 1 - src/main/docker/Dockerfile.native | 31 ----------------- src/main/docker/Dockerfile.native-micro | 34 ------------------- .../creator/ClusterConnectionCreate.java | 1 - .../persisted/creator/RoleCreate.java | 1 - 5 files changed, 68 deletions(-) delete mode 100644 src/main/docker/Dockerfile.native delete mode 100644 src/main/docker/Dockerfile.native-micro diff --git a/src/main/docker/Dockerfile.jvm b/src/main/docker/Dockerfile.jvm index a81e136..1f1607d 100644 --- a/src/main/docker/Dockerfile.jvm +++ b/src/main/docker/Dockerfile.jvm @@ -95,4 +95,3 @@ ENV JAVA_OPTS_APPEND="-Dquarkus.http.host=0.0.0.0 -Djava.util.logging.manager=or ENV JAVA_APP_JAR="/deployments/quarkus-run.jar" ENTRYPOINT [ "/opt/jboss/container/java/run/run-java.sh" ] - diff --git a/src/main/docker/Dockerfile.native b/src/main/docker/Dockerfile.native deleted file mode 100644 index 25de433..0000000 --- a/src/main/docker/Dockerfile.native +++ /dev/null @@ -1,31 +0,0 @@ -#### -# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. -# -# Before building the container image run: -# -# ./gradlew build -Dquarkus.native.enabled=true -# -# Then, build the image with: -# -# docker build -f src/main/docker/Dockerfile.native -t quarkus/postgresql-operator . -# -# Then run the container using: -# -# docker run -i --rm -p 8080:8080 quarkus/postgresql-operator -# -### -# https://catalog.redhat.com/en/software/containers/ubi10/ubi-minimal/66f1504a379b9c2cf23e145c -FROM registry.access.redhat.com/ubi10/ubi-minimal:10 - -WORKDIR /work/ - -RUN chown 1001 /work \ - && chmod "g+rwX" /work \ - && chown 1001:root /work \ - -COPY --chown=1001:root --chmod=0755 build/*-runner /work/application - -EXPOSE 8080 -USER 1001 - -ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/src/main/docker/Dockerfile.native-micro b/src/main/docker/Dockerfile.native-micro deleted file mode 100644 index 9070047..0000000 --- a/src/main/docker/Dockerfile.native-micro +++ /dev/null @@ -1,34 +0,0 @@ -#### -# This Dockerfile is used in order to build a container that runs the Quarkus application in native (no JVM) mode. -# It uses a micro base image, tuned for Quarkus native executables. -# It reduces the size of the resulting container image. -# Check https://quarkus.io/guides/quarkus-runtime-base-image for further information about this image. -# -# Before building the container image run: -# -# ./gradlew build -Dquarkus.native.enabled=true -# -# Then, build the image with: -# -# docker build -f src/main/docker/Dockerfile.native-micro -t quarkus/postgresql-operator . -# -# Then run the container using: -# -# docker run -i --rm -p 8080:8080 quarkus/postgresql-operator -# -### -# https://quay.io/repository/quarkus/ubi10-quarkus-micro-image -FROM quay.io/quarkus/ubi10-quarkus-micro-image:2.0 - -WORKDIR /work/ - -RUN chown 1001 /work \ - && chmod "g+rwX" /work \ - && chown 1001:root /work \ - -COPY --chown=1001:root --chmod=0755 build/*-runner /work/application - -EXPOSE 8080 -USER 1001 - -ENTRYPOINT ["./application", "-Dquarkus.http.host=0.0.0.0"] diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java index a457eb0..b402ec1 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -8,7 +8,6 @@ import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnectionSpec; import lombok.AccessLevel; -import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; import org.jspecify.annotations.NullMarked; diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java index 0a5d8a3..a1884e2 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java @@ -9,7 +9,6 @@ import it.aboutbits.postgresql.crd.role.Role; import it.aboutbits.postgresql.crd.role.RoleSpec; import lombok.AccessLevel; -import lombok.Getter; import lombok.Setter; import lombok.experimental.Accessors; import org.jspecify.annotations.NullMarked; From 46ef0ae46786a280c0330d7f270ec2e1752578ca Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Mon, 12 Jan 2026 08:58:26 +0100 Subject: [PATCH 30/31] remove the AI slop --- README.md | 44 +++++++++++++++++++++++++++++++++++--------- 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index d46e6c6..991945e 100644 --- a/README.md +++ b/README.md @@ -52,9 +52,10 @@ make test Afterward, the project can be started in IntelliJ by navigating to `Run` -> `Run '...'`. -## Test the CRD on the Dev Services cluster and AI assistance +## Test the CRD on the ephemeral Dev Services cluster -This example demonstrates how to set up a local development environment using Quarkus Dev Services and AI assistance to generate Kubernetes resources. +This example demonstrates how to set up a local development environment using Quarkus Dev Services to test the Operator manually. +As the K3s cluster port and the secrets change on every `./gradlew quarkusDev` run, you will have to manually update the port and secrets in the `~/.kube/config` every time. ### 1. Configure Kubeconfig from Dev Services @@ -62,17 +63,42 @@ When running in dev mode (`make run` or via IntelliJ), Quarkus starts the pre-co 1. Access the Quarkus Dev UI at [http://localhost:8080/q/dev-ui/dev-services](http://localhost:8080/q/dev-ui/dev-services). 2. Locate the properties for the `kubernetes-client` Dev Service. -3. Ask an AI to convert these properties into a **Kubeconfig YAML** format. +3. Convert these properties into a **Kubeconfig YAML** format, see the example below. 4. Merge this configuration into your local `~/.kube/config`. This allows your local environment to communicate with the ephemeral Kubernetes cluster provided by Dev Services. +```yml +apiVersion: v1 +kind: Config +current-context: quarkus-cluster +clusters: +- cluster: + certificate-authority-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJkekNDQVIyZ0F3SUJBZ0lCQURBS0JnZ3Foa2pPUFFRREFqQWpNU0V3SHdZRFZRUUREQmhyTTNNdGMyVnkKZG1WeUxXTmhRREUzTmpjNU56UTFNREF3SGhjTk1qWXdNVEE1TVRZd01UUXdXaGNOTXpZd01UQTNNVFl3TVRRdwpXakFqTVNFd0h3WURWUVFEREJock0zTXRjMlZ5ZG1WeUxXTmhRREUzTmpjNU56UTFNREF3V1RBVEJnY3Foa2pPClBRSUJCZ2dxaGtqT1BRTUJCd05DQUFRYlpRQmgzdlNXMVExd1pST0tBQ1NlY3dreXhQUXVjVm9FN0tVM1MrQnYKZ1hJYzdCREQrb2JqTXFETXZuRkpNUlBCYUw0R2RDVVNsRDM3QzJUV01DNjlvMEl3UURBT0JnTlZIUThCQWY4RQpCQU1DQXFRd0R3WURWUjBUQVFIL0JBVXdBd0VCL3pBZEJnTlZIUTRFRmdRVURPdkI4eWt1VFJBTDRjRjhNOUo4Cit3STh5U2t3Q2dZSUtvWkl6ajBFQXdJRFNBQXdSUUlnQWlZb0RsR2txUXd6WXVzcno5V3RMcUdEMXE2SmR6TVYKdW1nTFFPeFdNTEFDSVFDenQyMmxVTXJMNm1zMnBSRTBpQmZ3azNLbGdKSmJzZkp0YlI0bW9mRE16UT09Ci0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + server: https://localhost:53658 + name: quarkus-cluster +# ... more clusters +contexts: +- context: + cluster: quarkus-cluster + namespace: default + user: quarkus-user + name: quarkus-context +# ... more contexts +users: +- name: quarkus-user + user: + client-certificate-data: LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSUJrakNDQVRlZ0F3SUJBZ0lJTlpCUDhySWZaTlV3Q2dZSUtvWkl6ajBFQXdJd0l6RWhNQjhHQTFVRUF3d1kKYXpOekxXTnNhV1Z1ZEMxallVQXhOelkzT1RjME5UQXdNQjRYRFRJMk1ERXdPVEUyTURFME1Gb1hEVEkzTURFdwpPVEUyTURFME1Gb3dNREVYTUJVR0ExVUVDaE1PYzNsemRHVnRPbTFoYzNSbGNuTXhGVEFUQmdOVkJBTVRESE41CmMzUmxiVHBoWkcxcGJqQlpNQk1HQnlxR1NNNDlBZ0VHQ0NxR1NNNDlBd0VIQTBJQUJIdnE1UWxVWVBFRldvYXEKRTJNRXI1cUM4TjBFMVkyRTJBTDcrYUl0a1YzYWZHRkkyMGtBODl1eEorc1phQ0ZzblJzYmE1ZmtuVEhPaGJtOApYZGpSeERxalNEQkdNQTRHQTFVZER3RUIvd1FFQXdJRm9EQVRCZ05WSFNVRUREQUtCZ2dyQmdFRkJRY0RBakFmCkJnTlZIU01FR0RBV2dCUWJ3RktrRXhOaitCZjl2YVVNSGxtUi9oeC9PekFLQmdncWhrak9QUVFEQWdOSkFEQkcKQWlFQXlncll6eFIrZWoxWk5CdGdsZW5WT01HWWYrRWlPbkR6L1dzK1dQc1hnd0VDSVFEcEZhMlJ2RkRIVXhLMwpEODkzcGNLUkt1eU5MdXp6ZVZGODNlMURpckF1ZXc9PQotLS0tLUVORCBDRVJUSUZJQ0FURS0tLS0tCi0tLS0tQkVHSU4gQ0VSVElGSUNBVEUtLS0tLQpNSUlCZGpDQ0FSMmdBd0lCQWdJQkFEQUtCZ2dxaGtqT1BRUURBakFqTVNFd0h3WURWUVFEREJock0zTXRZMnhwClpXNTBMV05oUURFM05qYzVOelExTURBd0hoY05Nall3TVRBNU1UWXdNVFF3V2hjTk16WXdNVEEzTVRZd01UUXcKV2pBak1TRXdId1lEVlFRRERCaHJNM010WTJ4cFpXNTBMV05oUURFM05qYzVOelExTURBd1dUQVRCZ2NxaGtqTwpQUUlCQmdncWhrak9QUU1CQndOQ0FBVGNWU1NCOHdNakJIbHVOekVZb2krUUU1di9iUWl3cS81d2Jtb2hKU0FGCmZ2aE95eUhCcDBweDRRR0l6YU5BVm9kdkFIazRFV0ViMy9sMWpZVXNCSGlTbzBJd1FEQU9CZ05WSFE4QkFmOEUKQkFNQ0FxUXdEd1lEVlIwVEFRSC9CQVV3QXdFQi96QWRCZ05WSFE0RUZnUVVHOEJTcEJNVFkvZ1gvYjJsREI1WgprZjRjZnpzd0NnWUlLb1pJemowRUF3SURSd0F3UkFJZ2FJemlrUzN6THNSakZTdit1Ny9BbmRNQzdDWTZaREF4Ckp4T1pKbzJVTmVRQ0lFaVlLQlpEVjhVZDZHN3BGU2doSVU5UVZYQ3FYSmx6MHNRbWpnRTJUeUZHCi0tLS0tRU5EIENFUlRJRklDQVRFLS0tLS0K + client-key-data: LS0tLS1CRUdJTiBFQyBQUklWQVRFIEtFWS0tLS0tCk1IY0NBUUVFSUFuN1dTOWJGZUhlaUpKMmJHcHFFTjBJc28vQzR3VEVNRFBSdENRNzNYMmhvQW9HQ0NxR1NNNDkKQXdFSG9VUURRZ0FFZStybENWUmc4UVZhaHFvVFl3U3Ztb0x3M1FUVmpZVFlBdnY1b2kyUlhkcDhZVWpiU1FEegoyN0VuNnhsb0lXeWRHeHRybCtTZE1jNkZ1YnhkMk5IRU9nPT0KLS0tLS1FTkQgRUMgUFJJVkFURSBLRVktLS0tLQo= +# ... more users +``` + ### 2. Create PostgreSQL Connection and Secret For the `postgresql` Dev Service, you can generate the necessary Custom Resources to test the operator: 1. From the Dev UI, get the `postgresql` Dev Service properties (username, password, host, port). -2. Ask the AI to convert the `postgresql` Dev Service properties to a **Basic Auth Secret** and a **ClusterConnection** CR instance. -3. Provide the AI with the `ClusterConnection` CRD definition from `build/kubernetes/clusterconnections.postgresql.aboutbits.it-v1.yml` as a reference. -4. Apply the generated files using IntelliJ or `kubectl`. +2. Convert the `postgresql` Dev Service properties to a **Basic Auth Secret** and a **ClusterConnection** CR instance. + For more details see class `ClusterConnectionSpec` or the `ClusterConnection` CRD definition from `build/kubernetes/clusterconnections.postgresql.aboutbits.it-v1.yml` as a reference. +3. Apply the generated files using IntelliJ or `kubectl`. ![Apply Cluster Connection](docs/apply-cluster-connection.png) **Example Secret (`secret.yml`):** @@ -113,9 +139,9 @@ spec: Similarly, you can create a `Role` resource: -1. Ask the AI to convert the desired role properties to a **Role** CR instance. -2. Provide the AI with the `Role` CRD definition from `build/kubernetes/roles.postgresql.aboutbits.it-v1.yml` as a reference. -3. Apply the file using IntelliJ or `kubectl`. +1. Manually create a **Role** CR instance. + For more details see class `RoleSpec` or the `Role` CRD definition from `build/kubernetes/roles.postgresql.aboutbits.it-v1.yml` as a reference. +2. Apply the file using IntelliJ or `kubectl`. **Example Role (`role.yml`):** From 1343797658afbc932ae6e605aeecea71e84fb63e Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Mon, 12 Jan 2026 10:30:08 +0100 Subject: [PATCH 31/31] the Java Operator SDK apparently does not like underscores in package names --- .../postgresql/PostgreSQLInstanceReadinessCheck.java | 2 +- .../java/it/aboutbits/postgresql/core/BaseReconciler.java | 2 +- .../java/it/aboutbits/postgresql/core/KubernetesService.java | 2 +- .../aboutbits/postgresql/core/PostgreSQLContextFactory.java | 2 +- .../ClusterConnection.java | 2 +- .../ClusterConnectionReconciler.java | 2 +- .../ClusterConnectionSpec.java | 2 +- src/main/java/it/aboutbits/postgresql/crd/grant/.gitkeep | 0 .../postgresql/PostgreSQLInstanceReadinessCheckTest.java | 2 +- .../testdata/persisted/creator/ClusterConnectionCreate.java | 4 ++-- .../ClusterConnectionReconcilerErrorTest.java | 2 +- .../ClusterConnectionReconcilerTest.java | 2 +- .../it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java | 2 +- 13 files changed, 13 insertions(+), 13 deletions(-) rename src/main/java/it/aboutbits/postgresql/crd/{cluster_connection => clusterconnection}/ClusterConnection.java (97%) rename src/main/java/it/aboutbits/postgresql/crd/{cluster_connection => clusterconnection}/ClusterConnectionReconciler.java (96%) rename src/main/java/it/aboutbits/postgresql/crd/{cluster_connection => clusterconnection}/ClusterConnectionSpec.java (91%) delete mode 100644 src/main/java/it/aboutbits/postgresql/crd/grant/.gitkeep rename src/test/java/it/aboutbits/postgresql/crd/{cluster_connection => clusterconnection}/ClusterConnectionReconcilerErrorTest.java (98%) rename src/test/java/it/aboutbits/postgresql/crd/{cluster_connection => clusterconnection}/ClusterConnectionReconcilerTest.java (98%) diff --git a/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java index 02587ef..20f16db 100644 --- a/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java +++ b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java @@ -2,7 +2,7 @@ import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; -import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; import lombok.RequiredArgsConstructor; import org.eclipse.microprofile.health.HealthCheck; import org.eclipse.microprofile.health.HealthCheckResponse; diff --git a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java index 4f0fc51..9cb5e21 100644 --- a/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java @@ -3,7 +3,7 @@ import io.fabric8.kubernetes.client.CustomResource; import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; -import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; import lombok.extern.slf4j.Slf4j; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; diff --git a/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java b/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java index 1fb4a74..2252b1a 100644 --- a/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java +++ b/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java @@ -1,7 +1,7 @@ package it.aboutbits.postgresql.core; import io.fabric8.kubernetes.client.KubernetesClient; -import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; import jakarta.inject.Singleton; import org.jspecify.annotations.NullMarked; diff --git a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java index 23fc3ea..8e471a6 100644 --- a/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java @@ -1,7 +1,7 @@ package it.aboutbits.postgresql.core; import io.fabric8.kubernetes.client.KubernetesClient; -import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; import jakarta.enterprise.context.ApplicationScoped; import lombok.RequiredArgsConstructor; import org.jooq.CloseableDSLContext; diff --git a/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnection.java b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnection.java similarity index 97% rename from src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnection.java rename to src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnection.java index 1e6f781..bbc6500 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnection.java +++ b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnection.java @@ -1,4 +1,4 @@ -package it.aboutbits.postgresql.crd.cluster_connection; +package it.aboutbits.postgresql.crd.clusterconnection; import com.fasterxml.jackson.annotation.JsonIgnore; import io.fabric8.crd.generator.annotation.AdditionalPrinterColumn; diff --git a/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconciler.java similarity index 96% rename from src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconciler.java rename to src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconciler.java index e212983..1afc816 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconciler.java +++ b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconciler.java @@ -1,4 +1,4 @@ -package it.aboutbits.postgresql.crd.cluster_connection; +package it.aboutbits.postgresql.crd.clusterconnection; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.api.reconciler.Reconciler; diff --git a/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionSpec.java b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java similarity index 91% rename from src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionSpec.java rename to src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java index 7a22912..8b3bc9a 100644 --- a/src/main/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionSpec.java +++ b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java @@ -1,4 +1,4 @@ -package it.aboutbits.postgresql.crd.cluster_connection; +package it.aboutbits.postgresql.crd.clusterconnection; import io.fabric8.generator.annotation.Required; import it.aboutbits.postgresql.core.SecretRef; diff --git a/src/main/java/it/aboutbits/postgresql/crd/grant/.gitkeep b/src/main/java/it/aboutbits/postgresql/crd/grant/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java index 1f0640e..dd04b70 100644 --- a/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java +++ b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java @@ -3,7 +3,7 @@ import io.fabric8.kubernetes.client.KubernetesClient; import io.quarkus.test.junit.QuarkusTest; import it.aboutbits.postgresql._support.testdata.persisted.Given; -import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; import jakarta.inject.Inject; import org.eclipse.microprofile.health.HealthCheckResponse; import org.eclipse.microprofile.health.Readiness; diff --git a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java index b402ec1..824e451 100644 --- a/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -5,8 +5,8 @@ import it.aboutbits.postgresql._support.testdata.base.TestDataCreator; import it.aboutbits.postgresql._support.testdata.persisted.Given; import it.aboutbits.postgresql.core.SecretRef; -import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; -import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnectionSpec; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnectionSpec; import lombok.AccessLevel; import lombok.Setter; import lombok.experimental.Accessors; diff --git a/src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerErrorTest.java b/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerErrorTest.java similarity index 98% rename from src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerErrorTest.java rename to src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerErrorTest.java index 5a2c4fa..60c976f 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerErrorTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerErrorTest.java @@ -1,4 +1,4 @@ -package it.aboutbits.postgresql.crd.cluster_connection; +package it.aboutbits.postgresql.crd.clusterconnection; import io.fabric8.kubernetes.api.model.ObjectMeta; import io.javaoperatorsdk.operator.api.reconciler.Context; diff --git a/src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java similarity index 98% rename from src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerTest.java rename to src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java index 4144cbe..02babfb 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/cluster_connection/ClusterConnectionReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java @@ -1,4 +1,4 @@ -package it.aboutbits.postgresql.crd.cluster_connection; +package it.aboutbits.postgresql.crd.clusterconnection; import io.fabric8.kubernetes.client.KubernetesClient; import io.quarkus.test.junit.QuarkusTest; diff --git a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java index 989ba83..d88e54a 100644 --- a/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -9,7 +9,7 @@ import it.aboutbits.postgresql.core.PostgreSQLAuthenticationService; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import it.aboutbits.postgresql.core.SecretRef; -import it.aboutbits.postgresql.crd.cluster_connection.ClusterConnection; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; import lombok.RequiredArgsConstructor; import org.jooq.DSLContext; import org.jooq.Field;