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/.gitattributes b/.gitattributes new file mode 100644 index 0000000..ec83a67 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +src/generated/** linguist-generated 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..f917d07 --- /dev/null +++ b/.gitignore @@ -0,0 +1,62 @@ +### PostgreSQL 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..991945e 100644 --- a/README.md +++ b/README.md @@ -1 +1,213 @@ # 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" + +Afterward, the project can be started in IntelliJ by navigating to `Run` -> `Run '...'`. + +## Test the CRD on the ephemeral Dev Services cluster + +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 + +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. 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. 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`):** + +```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. 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`):** + +```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: + +```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 charts +- 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..3772b98 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,250 @@ +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 + +plugins { + idea + java + checkstyle + id("io.quarkus") + alias(libs.plugins.errorPronePlugin) + alias(libs.plugins.jooqPlugin) +} + +description = "AboutBits PostgreSQL Operator" +group = "it.aboutbits.postgresql" +version = "0.0.1-SNAPSHOT" + +val quarkusPlatformGroupId: String by project +val quarkusPlatformArtifactId: String by project +val quarkusPlatformVersion: String by project + +val checkstyleConfig: Configuration 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("io.quarkus:quarkus-junit5-mockito") + testImplementation("org.awaitility:awaitility") + testImplementation(libs.assertj) + testImplementation(libs.datafaker) + + /** + * NullAway + */ + errorprone(libs.errorProne) + errorprone(libs.nullAway) +} + +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") + + options.errorprone { + check("NullAway", CheckSeverity.ERROR) + option("NullAway:AnnotatedPackages", "it.aboutbits.postgresql") + } +} + +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") + + val mockitoAgent = configurations.testRuntimeClasspath.get().find { + it.name.contains("mockito-core") + } + if (mockitoAgent != null) { + jvmArgs("-javaagent:${mockitoAgent.absolutePath}") + } + + 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/docs/apply-cluster-connection.png b/docs/apply-cluster-connection.png new file mode 100644 index 0000000..fb6bcfc Binary files /dev/null and b/docs/apply-cluster-connection.png differ diff --git a/docs/created-role.png b/docs/created-role.png new file mode 100644 index 0000000..b386e39 Binary files /dev/null and b/docs/created-role.png differ diff --git a/docs/established-cluster-connection.png b/docs/established-cluster-connection.png new file mode 100644 index 0000000..5a436db Binary files /dev/null and b/docs/established-cluster-connection.png differ diff --git a/docs/role-in-table-pg-authid.png b/docs/role-in-table-pg-authid.png new file mode 100644 index 0000000..5ca8635 Binary files /dev/null and b/docs/role-in-table-pg-authid.png differ diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..ac266ce --- /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.6 +# https://mvnrepository.com/artifact/io.quarkus.platform/quarkus-bom +quarkusPlatformGroupId=io.quarkus.platform +quarkusPlatformArtifactId=quarkus-bom +quarkusPlatformVersion=3.30.6 +systemProp.quarkus.analytics.disabled=true diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 0000000..681f6c7 --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,100 @@ +[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 = "13.0.0" +datafaker = "2.5.3" +errorProne = "2.45.0" +errorPronePlugin = "4.3.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 +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" } + +# 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/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..c1962a7 Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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..a8fed54 --- /dev/null +++ b/lombok.config @@ -0,0 +1,9 @@ +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 + +# Required for NullAway +lombok.addLombokGeneratedAnnotation = true 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..1f1607d --- /dev/null +++ b/src/main/docker/Dockerfile.jvm @@ -0,0 +1,97 @@ +#### +# 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://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' + +# 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/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java b/src/main/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheck.java new file mode 100644 index 0000000..20f16db --- /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.clusterconnection.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/main/java/it/aboutbits/postgresql/core/BaseReconciler.java b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java new file mode 100644 index 0000000..9cb5e21 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/BaseReconciler.java @@ -0,0 +1,100 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.kubernetes.client.CustomResource; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.api.reconciler.UpdateControl; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; +import lombok.extern.slf4j.Slf4j; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +import java.time.OffsetDateTime; +import java.time.ZoneOffset; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +@NullMarked +@Slf4j +public abstract class BaseReconciler & Named, S extends CRStatus> { + protected abstract S newStatus(); + + public S initializeStatus(CR resource) { + S status = resource.getStatus(); + + //noinspection ConstantConditions + if (status == null) { + status = newStatus(); + resource.setStatus(status); + } + + status.setName(resource.getName()); + status.setLastProbeTime(OffsetDateTime.now(ZoneOffset.UTC)); + status.setObservedGeneration(resource.getMetadata().getGeneration()); + + return status; + } + + public String getResourceNamespaceOrOwn( + CR resource, + @Nullable String resourceNamespace + ) { + if (resourceNamespace != null) { + return resourceNamespace; + } + + return resource.getMetadata().getNamespace(); + } + + public Optional getReferencedClusterConnection( + KubernetesClient kubernetesClient, + CR resource, + ClusterReference clusterRef + ) { + var connectionName = clusterRef.getName(); + var connectionNamespace = getResourceNamespaceOrOwn(resource, clusterRef.getNamespace()); + + var clusterConnection = kubernetesClient.resources(ClusterConnection.class) + .inNamespace(connectionNamespace) + .withName(connectionName) + .get(); + + //noinspection ConstantConditions + 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 + ) { + status.setPhase(CRPhase.ERROR) + .setMessage(exception.getMessage()); + + return UpdateControl.patchStatus(resource) + .rescheduleAfter(60, TimeUnit.SECONDS); + } +} 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..db7ac62 --- /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 Custom Resource as observed by the reconciler. + */ +@NullMarked +@Getter +@Setter +@Accessors(chain = true) +public class CRStatus { + /** + * The Custom Resource name (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..262ace8 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/ClusterReference.java @@ -0,0 +1,19 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.generator.annotation.Required; +import lombok.Getter; +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/KubernetesService.java b/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java new file mode 100644 index 0000000..2252b1a --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java @@ -0,0 +1,94 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.kubernetes.client.KubernetesClient; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; +import jakarta.inject.Singleton; +import org.jspecify.annotations.NullMarked; + +import java.nio.charset.Charset; +import java.util.Base64; + +@NullMarked +@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 Credentials getSecretRefCredentials( + KubernetesClient kubernetesClient, + ClusterConnection clusterConnection + ) { + return getSecretRefCredentials( + kubernetesClient, + clusterConnection.getSpec().getAdminSecretRef(), + clusterConnection.getMetadata().getNamespace() + ); + } + + public Credentials getSecretRefCredentials( + KubernetesClient kubernetesClient, + SecretRef secretRef, + String defaultNamespace + ) { + var secretNamespace = secretRef.getNamespace() != null + ? secretRef.getNamespace() + : defaultNamespace; + + var secretName = secretRef.getName(); + + var secret = kubernetesClient.secrets() + .inNamespace(secretNamespace) + .withName(secretName) + .get(); + + if (secret == null) { + 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 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, + secret.getType() + )); + } + + var data = secret.getData(); + if (data == null || data.isEmpty()) { + throw new IllegalStateException("The SecretRef has no data set [secret.namespace=%s, secret.name=%s]".formatted( + secretNamespace, + secretName + )); + } + + var usernameBase64 = data.get(SECRET_DATA_BASIC_AUTH_USERNAME_KEY); + var username = usernameBase64 == null + ? null + : new String( + Base64.getDecoder().decode(usernameBase64), + Charset.defaultCharset() + ); + + var passwordBase64 = data.get(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY); + if (passwordBase64 == null) { + throw new IllegalStateException("The SecretRef is missing required data password [secret.namespace=%s, secret.name=%s]".formatted( + secretNamespace, + secretName + )); + } + var password = new String( + Base64.getDecoder().decode(passwordBase64), + Charset.defaultCharset() + ); + + 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/PostgreSQLAuthenticationService.java b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationService.java new file mode 100644 index 0000000..4f6c849 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLAuthenticationService.java @@ -0,0 +1,219 @@ +package it.aboutbits.postgresql.core; + +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; + +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 +@Slf4j +@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 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 e) { + log.error("Invalid iterations format in PostgreSQL verifier: %s".formatted(postgresVerifier), e); + 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 e) { + log.error("Invalid salt or stored key format in PostgreSQL verifier: %s".formatted(postgresVerifier), e); + 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 e) { + log.error("Invalid MD5 format in PostgreSQL verifier: %s".formatted(postgresMd5), e); + 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..8e471a6 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java @@ -0,0 +1,59 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.kubernetes.client.KubernetesClient; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; +import jakarta.enterprise.context.ApplicationScoped; +import lombok.RequiredArgsConstructor; +import org.jooq.CloseableDSLContext; +import org.jooq.impl.DSL; +import org.jspecify.annotations.NullMarked; + +import java.util.Properties; + +@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 CloseableDSLContext getDSLContext(ClusterConnection clusterConnection) { + var credentials = kubernetesService.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() + ); + } + + return DSL.using( + jdbcUrl, + properties + ); + } +} 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/core/SecretRef.java b/src/main/java/it/aboutbits/postgresql/core/SecretRef.java new file mode 100644 index 0000000..ed8a784 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/core/SecretRef.java @@ -0,0 +1,23 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.generator.annotation.Required; +import lombok.Getter; +import lombok.Setter; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +@NullMarked +@Getter +@Setter +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/clusterconnection/ClusterConnection.java b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnection.java new file mode 100644 index 0000000..bbc6500 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnection.java @@ -0,0 +1,76 @@ +package it.aboutbits.postgresql.crd.clusterconnection; + +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; +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") +@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 { + @Override + @JsonIgnore + public String getName() { + var spec = getSpec(); + + var jdbcUrl = "jdbc:postgresql://%s:%d/%s".formatted( + spec.getHost(), + spec.getPort(), + spec.getMaintenanceDatabase() + ); + + if (spec.getParameters().isEmpty()) { + return jdbcUrl; + } + + 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/clusterconnection/ClusterConnectionReconciler.java b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconciler.java new file mode 100644 index 0000000..1afc816 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconciler.java @@ -0,0 +1,50 @@ +package it.aboutbits.postgresql.crd.clusterconnection; + +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.jspecify.annotations.NullMarked; + +@NullMarked +@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); + + 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 (Exception e) { + log.error("Failed to check database connectivity", e); + + return handleError( + resource, + status, + e + ); + } + } + + @Override + protected CRStatus newStatus() { + return new CRStatus(); + } +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java new file mode 100644 index 0000000..8b3bc9a --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java @@ -0,0 +1,30 @@ +package it.aboutbits.postgresql.crd.clusterconnection; + +import io.fabric8.generator.annotation.Required; +import it.aboutbits.postgresql.core.SecretRef; +import lombok.Getter; +import lombok.Setter; +import org.jspecify.annotations.NullMarked; + +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/role/Role.java b/src/main/java/it/aboutbits/postgresql/crd/role/Role.java new file mode 100644 index 0000000..c56df40 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/role/Role.java @@ -0,0 +1,49 @@ +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; +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") +@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 { + @Override + @JsonIgnore + public String getName() { + return getSpec().getName(); + } +} diff --git a/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlag.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlag.java new file mode 100644 index 0000000..1731ed2 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleFlag.java @@ -0,0 +1,45 @@ +package it.aboutbits.postgresql.crd.role; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.experimental.Accessors; +import org.jspecify.annotations.NullMarked; + +@NullMarked +@Getter +@Accessors(fluent = true) +@RequiredArgsConstructor +public enum RoleFlag { + 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..76395a8 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleReconciler.java @@ -0,0 +1,350 @@ +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.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; +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; +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; +import org.jooq.DSLContext; +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 + extends BaseReconciler + implements Reconciler, Cleaner { + private final RoleService roleService; + private final KubernetesService kubernetesService; + private final PostgreSQLAuthenticationService postgreSQLAuthenticationService; + + 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 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(); + + 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(resource, 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 passwordSecretRef = spec.getPasswordSecretRef(); + + String password; + if (passwordSecretRef != null) { + password = kubernetesService.getSecretRefCredentials( + kubernetesClient, + passwordSecretRef, + namespace + ).password(); + } else { + password = null; + } + + UpdateControl updateControl; + + try (var dsl = contextFactory.getDSLContext(clusterConnection)) { + // Run everything in a single transaction + updateControl = dsl.transactionResult( + cfg -> reconcileInTransaction( + cfg.dsl(), + resource, + status, + password + ) + ); + } catch (Exception e) { + return handleError( + resource, + status, + e + ); + } + + 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. + */ + @Override + public List> prepareEventSources(EventSourceContext context) { + // 1. Define the Mapper + // We define how to find the Primary Resource (Role) when a Secret changes + // 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) + .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); + } + + private UpdateControl reconcileInTransaction( + DSLContext tx, + Role resource, + CRStatus status, + @Nullable 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 (!roleService.roleExists(tx, spec)) { + log.info( + "Creating Role [resource={}/{}]", + namespace, + name + ); + + roleService.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 = roleService.roleLoginMatches(tx, spec); + var currentFlags = roleService.fetchCurrentFlags(tx, spec); + var flagsMatch = expectedFlags.equals(currentFlags); + var commentMatches = roleService.roleCommentMatches(tx, spec); + + var passwordSecretRef = spec.getPasswordSecretRef(); + var loginExpected = passwordSecretRef != null; + + if (loginExpected && password != null) { + passwordMatches = postgreSQLAuthenticationService.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) { + roleService.alterRole( + tx, + spec, + changePassword, + password + ); + } + + if (!flagsMatch) { + log.info( + "Updating Role membership [resource={}/{}]", + namespace, + name + ); + + roleService.reconcileRoleMembership( + tx, + spec, + expectedFlags, + currentFlags + ); + } + + if (!commentMatches) { + roleService.updateComment( + tx, + spec + ); + } + + status.setPhase(CRPhase.READY) + .setMessage(null); + + return UpdateControl.patchStatus(resource); + } + + @Override + protected 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/main/java/it/aboutbits/postgresql/crd/role/RoleService.java b/src/main/java/it/aboutbits/postgresql/crd/role/RoleService.java new file mode 100644 index 0000000..740b387 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleService.java @@ -0,0 +1,434 @@ +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; +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.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.field; +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 +@Singleton +public final class RoleService { + public boolean roleExists( + DSLContext tx, + RoleSpec spec + ) { + return tx.fetchExists(selectOne() + .from(PG_AUTHID) + .where(PG_AUTHID.ROLNAME.eq(spec.getName())) + ); + } + + public 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 != null && !comment.isBlank()) { + tx.execute( + buildCommentOnRole(roleName, comment) + ); + } + } + + public void alterRole( + DSLContext tx, + RoleSpec spec, + boolean changePassword, + @Nullable String password + ) { + var roleName = spec.getName(); + var flags = spec.getFlags(); + + tx.execute( + buildAlterRole( + roleName, + flags, + changePassword, + password + ) + ); + } + + public void updateComment( + DSLContext tx, + RoleSpec spec + ) { + var roleName = spec.getName(); + var expectedComment = normalizeComment(spec.getComment()); + + var currentComment = normalizeComment( + fetchCurrentRoleComment(tx, roleName) + ); + + if (!Objects.equals(currentComment, expectedComment)) { + tx.execute( + buildCommentOnRole(roleName, expectedComment) + ); + } + } + + public boolean roleCommentMatches( + DSLContext tx, + RoleSpec spec + ) { + var expectedComment = spec.getComment(); + + var currentComment = normalizeComment( + fetchCurrentRoleComment(tx, spec.getName()) + ); + + return Objects.equals(currentComment, expectedComment); + } + + public @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 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 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"), + field("nullif({0}, 'infinity')", PG_AUTHID.ROLVALIDUNTIL.getDataType(), 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); + } + + 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 + * 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(RoleFlag.LOGIN.flag())); + options.add(keyword(RoleFlag.PASSWORD.flag())); + options.add(val(password)); + } + + if (flags.isSuperuser()) { + options.add(keyword(RoleFlag.SUPERUSER.flag())); + } + if (flags.isCreatedb()) { + options.add(keyword(RoleFlag.CREATEDB.flag())); + } + if (flags.isCreaterole()) { + options.add(keyword(RoleFlag.CREATEROLE.flag())); + } + if (flags.isInherit()) { + options.add(keyword(RoleFlag.INHERIT.flag())); + } + if (flags.isReplication()) { + options.add(keyword(RoleFlag.REPLICATION.flag())); + } + if (flags.isBypassrls()) { + options.add(keyword(RoleFlag.BYPASSRLS.flag())); + } + if (flags.getConnectionLimit() >= 0) { + options.add(keyword(RoleFlag.CONNECTION_LIMIT.flag())); + options.add(val(flags.getConnectionLimit())); + } + + var validUntil = flags.getValidUntil(); + if (validUntil != null) { + options.add(keyword(RoleFlag.VALID_UNTIL.flag())); + options.add(val(validUntil.toString())); + } + + if (!flags.getInRole().isEmpty()) { + options.add(keyword(RoleFlag.IN_ROLE.flag())); + options.add(SQLUtil.concatenateQueryPartsWithComma( + flags.getInRole() + .stream() + .map(DSL::role) + .toList() + )); + } + if (!flags.getRole().isEmpty()) { + options.add(keyword(RoleFlag.ROLE.flag())); + options.add(SQLUtil.concatenateQueryPartsWithComma( + flags.getRole() + .stream() + .map(DSL::role) + .toList() + )); + } + + var optionsSql = options.isEmpty() + ? sql("") // nothing + : sql(" with {0}", SQLUtil.concatenateQueryPartsWithSpaces(options)); + + return query( + "create role {0}{1}", + role(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 + ? 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(RoleFlag.PASSWORD.flag())); + options.add(keyword("NULL")); + } else if (changePassword) { + 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() + ? RoleFlag.SUPERUSER.flag() + : RoleFlag.NO_SUPERUSER.flag() + )); + options.add(keyword(flags.isCreatedb() + ? RoleFlag.CREATEDB.flag() + : RoleFlag.NO_CREATEDB.flag() + )); + options.add(keyword(flags.isCreaterole() + ? RoleFlag.CREATEROLE.flag() + : RoleFlag.NO_CREATEROLE.flag() + )); + options.add(keyword(flags.isInherit() + ? RoleFlag.INHERIT.flag() + : RoleFlag.NO_INHERIT.flag() + )); + options.add(keyword(flags.isReplication() + ? RoleFlag.REPLICATION.flag() + : RoleFlag.NO_REPLICATION.flag() + )); + options.add(keyword(flags.isBypassrls() + ? RoleFlag.BYPASSRLS.flag() + : RoleFlag.NO_BYPASSRLS.flag() + )); + + options.add(keyword(RoleFlag.CONNECTION_LIMIT.flag())); + options.add(val(flags.getConnectionLimit())); + + var validUntil = flags.getValidUntil(); + options.add(keyword(RoleFlag.VALID_UNTIL.flag())); + if (validUntil != null) { + options.add(val(validUntil.toString())); + } else { + options.add(val("infinity")); + } + + return query( + "alter role {0} with {1}", + role(roleName), + SQLUtil.concatenateQueryPartsWithSpaces(options) + ); + } + + private static Query buildGrantRoleToMember( + String role, + String member + ) { + return query("grant {0} to {1}", role(role), role(member)); + } + + private 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 normalizeComment(@Nullable String comment) { + if (comment == null || comment.isBlank()) { + return null; + } + + return comment; + } +} 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..86c4bb6 --- /dev/null +++ b/src/main/java/it/aboutbits/postgresql/crd/role/RoleSpec.java @@ -0,0 +1,79 @@ +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; +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 + @ValidationRule( + value = "self == oldSelf", + message = "The Role name must not be changed once it is created" + ) + private String name = ""; + + @Nullable + @io.fabric8.generator.annotation.Nullable + 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(); + + @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; + + @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/resources/META-INF/branding/logo.png b/src/main/resources/META-INF/branding/logo.png new file mode 100644 index 0000000..915e323 Binary files /dev/null and b/src/main/resources/META-INF/branding/logo.png differ 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..d37b73f --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,8 @@ +quarkus: + operator-sdk: + activate-leader-election-for-profiles: + - test + log: + category: + "org.jooq": + level: DEBUG diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..b3783d2 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,158 @@ +quarkus: + kubernetes-client: + devservices: + enabled: true + # 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 + api-version: 1.34.1 + live-reload: + instrumentation: true + micrometer: + 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. + # In Kubernetes the env entries take precedence over envFrom entries + map-system-properties: false + home: https://github.com/aboutbits/postgresql-operator + sources: + - https://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 + 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} + 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 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/PostgreSQLInstanceReadinessCheckTest.java b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java new file mode 100644 index 0000000..dd04b70 --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/PostgreSQLInstanceReadinessCheckTest.java @@ -0,0 +1,102 @@ +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.clusterconnection.ClusterConnection; +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 java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; + +@NullMarked +@QuarkusTest +class PostgreSQLInstanceReadinessCheckTest { + @Inject + Given given; + + @Inject + @Readiness + PostgreSQLInstanceReadinessCheck readinessCheck; + + @Inject + KubernetesClient kubernetesClient; + + @BeforeEach + void cleanUp() { + kubernetesClient.resources(ClusterConnection.class) + .withTimeout(5, TimeUnit.SECONDS) + .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"); + + var dbStatus = Objects.requireNonNull( + data.get("test-db") + ); + + assertThat( + dbStatus.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"); + + 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 new file mode 100644 index 0000000..e7e871b --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/base/TestDataCreator.java @@ -0,0 +1,69 @@ +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; + +@NullMarked +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 T 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 T 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, length=%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..112bb84 --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/Given.java @@ -0,0 +1,110 @@ +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.RoleCreate; +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; + +import java.net.URI; + +@NullMarked +@ApplicationScoped +@RequiredArgsConstructor +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; + + DBConnectionDetails dbConnectionDetails() { + return new DBConnectionDetails( + parsePortFromJdbcUrl(jdbcUrl), + 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 + ); + } + + public ClusterConnectionCreate clusterConnection() { + return new ClusterConnectionCreate( + numberOfItems, + given, + kubernetesClient, + dbConnectionDetails() + ); + } + + public RoleCreate role() { + return new RoleCreate( + numberOfItems, + given, + kubernetesClient + ); + } + } + + public record DBConnectionDetails( + int port, + String username, + 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(); + } +} 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..824e451 --- /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.clusterconnection.ClusterConnection; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnectionSpec; +import lombok.AccessLevel; +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 +@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(); + + //noinspection ConstantConditions + return kubernetesClient.resources(ClusterConnection.class) + .inNamespace(namespace) + .withName(name) + .waitUntilCondition( + clusterConnection -> 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() + .withUsername(dbConnectionDetails.username()) + .withPassword(dbConnectionDetails.password()) + .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/RoleCreate.java b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java new file mode 100644 index 0000000..a1884e2 --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/RoleCreate.java @@ -0,0 +1,155 @@ +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.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 new file mode 100644 index 0000000..7033537 --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/SecretRefCreate.java @@ -0,0 +1,140 @@ +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.core.SecretRef; +import lombok.AccessLevel; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +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 +@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 + private String withName; + + @Nullable + private String withUsername; + @Setter(AccessLevel.NONE) + private boolean withoutUsername = false; + + @Nullable + private String withPassword; + @Setter(AccessLevel.NONE) + private boolean withoutPassword = false; + + public SecretRefCreate( + int numberOfItems, + KubernetesClient kubernetesClient + ) { + super(numberOfItems); + this.kubernetesClient = kubernetesClient; + } + + @SuppressWarnings("unused") + public SecretRefCreate withoutNamespace() { + withoutNamespace = true; + return this; + } + + @SuppressWarnings("unused") + public SecretRefCreate withoutUsername() { + withoutUsername = true; + 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, getUsername()) + .addToStringData(SECRET_DATA_BASIC_AUTH_PASSWORD_KEY, getPassword()) + .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 FAKER.credentials().username(); + } + + @Nullable + private String getPassword() { + if (withoutPassword) { + return null; + } + + if (withPassword != null) { + return withPassword; + } + + return FAKER.credentials().username(); + } +} 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/clusterconnection/ClusterConnectionReconcilerErrorTest.java b/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerErrorTest.java new file mode 100644 index 0000000..60c976f --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerErrorTest.java @@ -0,0 +1,109 @@ +package it.aboutbits.postgresql.crd.clusterconnection; + +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.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; + +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; + +@NullMarked +@QuarkusTest +class ClusterConnectionReconcilerErrorTest { + @SuppressWarnings("NullAway.Init") + @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() { + // given + var errorMessage = "Connection refused to database"; + + when(contextFactory.getDSLContext(resource)).thenThrow( + new RuntimeException(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() { + // given + var errorMessage = "Query execution failed"; + var dslContext = mock(CloseableDSLContext.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) + ); + } +} diff --git a/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java b/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java new file mode 100644 index 0000000..02babfb --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java @@ -0,0 +1,102 @@ +package it.aboutbits.postgresql.crd.clusterconnection; + +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.PostgreSQLContextFactory; +import lombok.RequiredArgsConstructor; +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; + +import java.time.OffsetDateTime; +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; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.within; + +@NullMarked +@QuarkusTest +@RequiredArgsConstructor +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") + void createsCustomResource_andReconcilerStatusIsReady() { + // given / when + var customResource = given.one() + .clusterConnection() + .withName("test-connection") + .returnFirst(); + + // then + AtomicReference<@Nullable DSLContext> dslAtomic = new AtomicReference<>(); + assertThatNoException().isThrownBy( + () -> dslAtomic.set(postgreSQLContextFactory.getDSLContext(customResource)) + ); + + var dsl = Objects.requireNonNull(dslAtomic.get()); + + var version = dsl.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(10, ChronoUnit.SECONDS) + ); + assertThat(status.getLastPhaseTransitionTime()).isCloseTo( + now, + within(10, 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..d88e54a --- /dev/null +++ b/src/test/java/it/aboutbits/postgresql/crd/role/RoleReconcilerTest.java @@ -0,0 +1,961 @@ +package it.aboutbits.postgresql.crd.role; + +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.PostgreSQLAuthenticationService; +import it.aboutbits.postgresql.core.PostgreSQLContextFactory; +import it.aboutbits.postgresql.core.SecretRef; +import it.aboutbits.postgresql.crd.clusterconnection.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; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +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; +import java.util.stream.Stream; + +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.awaitility.Awaitility.await; +import static org.jooq.impl.DSL.role; + +@NullMarked +@QuarkusTest +@RequiredArgsConstructor +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() { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-connection-role-login") + .returnFirst(); + + var now = OffsetDateTime.now(ZoneOffset.UTC); + var roleName = "test-role-login"; + + // 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( + role, + expectedStatus, + now + ); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + assertThat(roleService.roleExists(dsl, role.getSpec())).isTrue(); + assertThat(roleService.roleLoginMatches(dsl, role.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(); + + var now = OffsetDateTime.now(ZoneOffset.UTC); + var roleName = "test-role-nologin"; + + // when + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); + + var expectedStatus = new CRStatus() + .setName(roleName) + .setPhase(CRPhase.READY) + .setObservedGeneration(1L); + + assertThatRoleHasExpectedStatus( + role, + expectedStatus, + now + ); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + assertThat(roleService.roleExists(dsl, role.getSpec())).isTrue(); + assertThat(roleService.roleLoginMatches(dsl, role.getSpec())).isTrue(); + } + + @Test + @DisplayName("When a Role login state is changed, it should be updated correctly in pg_authid") + void toggleRoleLogin_updatesCorrectly() { + // 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"; + + // when + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); + + var spec = role.getSpec(); + + // then + assertThatRoleHasExpectedStatus( + role, + new CRStatus() + .setName(roleName) + .setPhase(CRPhase.READY) + .setObservedGeneration(1L), + now + ); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + assertThat( + roleService.roleExists(dsl, role.getSpec()) + ).isTrue(); + + assertThat( + getRoleFlagValue(dsl, roleName, PG_AUTHID.ROLCANLOGIN) + ).isFalse(); + + // 2. Add a passwordSecretRef to make it a login role + spec.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 + spec.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() { + // given + var roleName = "test-role-missing-cc"; + var missingClusterName = "non-existing-cc"; + + var now = OffsetDateTime.now(ZoneOffset.UTC); + + var dummySecretRef = new SecretRef(); + dummySecretRef.setName("dummy"); + + // when + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(missingClusterName) + .withClusterConnectionNamespace(kubernetesClient.getNamespace()) + .withPasswordSecretRef(dummySecretRef) + .returnFirst(); + + // then + assertThat(role).isNotNull(); + assertThat(role.getStatus()).isNotNull(); + + assertThat(role.getStatus().getPhase()).isEqualTo(CRPhase.PENDING); + assertThat(role.getStatus().getMessage()).startsWith( + "The specified ClusterConnection does not exist" + ); + assertThat(role.getStatus().getLastProbeTime()).isAfter( + now + ); + assertThat(role.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() { + // 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(); + + // when: create Role + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .withPasswordSecretRef(secretRef) + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + // then: password should match the initial one + // Wait for password to match because reconciliation might take a bit + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> postgreSQLAuthenticationService.passwordMatches( + dsl, + role.getSpec(), + initialPassword + )); + + // 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 + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> postgreSQLAuthenticationService.passwordMatches( + dsl, + role.getSpec(), + newPassword + )); + } + + @Test + @DisplayName( + "When a Role (LOGIN) changes its secret reference, it should trigger a re-reconciliation" + ) + void secretRefChange_triggersReconciliation() { + // 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(); + + // when: create Role + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .withPasswordSecretRef(initialSecretRef) + .returnFirst(); + + var spec = role.getSpec(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + // then: password should match the initial one + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> postgreSQLAuthenticationService.passwordMatches( + dsl, + role.getSpec(), + initialPassword + )); + + // when: update secret reference in the Role + spec.setPasswordSecretRef(newSecretRef); + + var updatedRole = applyRole(role); + + // then: password should eventually match the new one + await().atMost(10, TimeUnit.SECONDS) + .pollInterval(500, TimeUnit.MILLISECONDS) + .until(() -> postgreSQLAuthenticationService.passwordMatches( + dsl, + updatedRole.getSpec(), + newPassword + )); + } + + @Test + @DisplayName("When the comment is changed, it should be updated in the database") + void comment_updatesCorrectly() { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-role-comment") + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + var roleName = "test-role-comment"; + + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); + + 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( + roleService.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( + roleService.fetchCurrentRoleComment(dsl, roleName) + ).isEqualTo(newComment); + + // 3. Remove comment + spec.setComment(null); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 2 + ); + + // then + assertThat( + roleService.fetchCurrentRoleComment(dsl, roleName) + ).isNull(); + } + + @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 + ) { + // given + var clusterConnection = given.one() + .clusterConnection() + .withName("test-role-flags") + .returnFirst(); + + var dsl = postgreSQLContextFactory.getDSLContext(clusterConnection); + + var roleName = "test-role-" + field.getName(); + + var role = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); + + var spec = role.getSpec(); + + // 1. Enable flag (true) + setter.accept( + spec.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( + spec.getFlags(), + false + ); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 1 + ); + + // then + assertThat( + getRoleFlagValue( + dsl, + roleName, + field + ) + ).isFalse(); + } + + @Test + @DisplayName("When the CONNECTION LIMIT is changed, it should be updated in the database") + void connectionLimit_updatesCorrectly() { + // 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 = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); + + var spec = role.getSpec(); + + // 1. Set a connection limit + spec.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 + spec.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 + spec.getFlags().setConnectionLimit(-1); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 2 + ); + + // then + assertThat( + getRoleFlagValue(dsl, roleName, PG_AUTHID.ROLCONNLIMIT) + ).isEqualTo(-1); + } + + @Test + @DisplayName("When the VALID UNTIL is changed, it should be updated in the database") + void validUntil_updatesCorrectly() { + // 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 = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); + + var spec = role.getSpec(); + + var expiry = OffsetDateTime.now(ZoneOffset.UTC) + .plusDays(1) + .truncatedTo(ChronoUnit.SECONDS); + + // 1. Set a valid until date + spec.getFlags().setValidUntil(expiry); + + // when + var reconciled = applyRole(role); + var initialGeneration = reconciled.getStatus().getObservedGeneration(); + + var currentFlags = roleService.fetchCurrentFlags(dsl, spec); + + // then + assertThat( + currentFlags.getValidUntil() + ).isEqualTo(expiry); + + // 2. Change valid until date + var newExpiry = expiry.plusDays(1); + spec.getFlags().setValidUntil(newExpiry); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 1 + ); + + currentFlags = roleService.fetchCurrentFlags(dsl, spec); + + // then + assertThat( + currentFlags.getValidUntil() + ).isEqualTo(newExpiry); + + // 3. Reset valid until to null (infinity) + spec.getFlags().setValidUntil(null); + + // when + applyRole( + role, + r -> r.getStatus().getObservedGeneration() == initialGeneration + 2 + ); + + currentFlags = roleService.fetchCurrentFlags(dsl, spec); + + // then + assertThat( + currentFlags.getValidUntil() + ).isNull(); + } + + @Test + @DisplayName("When IN ROLE membership is changed, it should be updated in the database") + void inRole_updatesCorrectly() { + // 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 = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); + + 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( + roleService.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( + roleService.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( + 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 + @DisplayName("When ROLE membership is changed, it should be updated in the database") + void role_updatesCorrectly() { + // 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 = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); + + 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( + roleService.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( + roleService.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( + 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 + @DisplayName("When multiple ROLE memberships are added, they should be sorted and updated correctly") + void role_multipleMemberships_updatesCorrectly() { + // 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 = given.one() + .role() + .withName(roleName) + .withClusterConnectionName(clusterConnection.getMetadata().getName()) + .returnFirst(); + + var spec = role.getSpec(); + + // Add multiple roles out of order + spec.getFlags().setInRole( + List.of(roleC, roleA, roleB) + ); + + // when + applyRole(role); + + // then + assertThat( + 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)); + } + + @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, + Field field + ) { + return dsl.select(field) + .from(PG_AUTHID) + .where(PG_AUTHID.ROLNAME.eq(roleName)) + .fetchSingle(field); + } + + 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) { + 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 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( + Role role, + Predicate condition + ) { + var namespace = kubernetesClient.getNamespace(); + + 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(applied.getMetadata().getName()) + .waitUntilCondition( + condition, + 10, + TimeUnit.SECONDS + ); + } + + private static void assertThatRoleHasExpectedStatus( + Role role, + CRStatus expectedStatus, + OffsetDateTime now + ) { + assertThat(role) + .isNotNull() + .extracting(Role::getStatus) + .satisfies(status -> { + assertThat(status.getLastProbeTime()).isAfter( + now + ); + assertThat(status.getLastPhaseTransitionTime()).isAfter( + now + ); + }) + .usingRecursiveComparison() + .ignoringFields("lastProbeTime", "lastPhaseTransitionTime") + .isEqualTo(expectedStatus); + } +}