From db11b93806482b5c3ed7510d2f40c0aba89b13e8 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Fri, 4 Sep 2026 14:28:51 +0200 Subject: [PATCH 1/5] Add `volumes` and `volumeMounts` values to the Helm chart for file-based credentials Follow-up to #60. The chart now exposes `app.volumes` and `app.volumeMounts`, so a user of the published chart can mount a credentials file for `adminSecretFileRef` through `values.yaml`. `app.imagePullSecrets` moves onto the same pattern and loses its `- {}` default. Co-Authored-By: Claude Opus 5 --- .gitignore | 2 + docs/cluster-connection.md | 58 ++++- operator/src/main/helm/values.yaml | 15 ++ operator/src/main/kubernetes/kubernetes.yml | 6 + operator/src/main/resources/application.yml | 32 ++- .../aboutbits/postgresql/helm/HelmTest.java | 203 +++++++++++++++++- 6 files changed, 305 insertions(+), 11 deletions(-) create mode 100644 operator/src/main/helm/values.yaml diff --git a/.gitignore b/.gitignore index 279947e..1dfb045 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ ### PostgreSQL Operator ### config/ +# Written by the fabric8 Kubernetes client when the tests run against the Dev Service +.kube/ ### STS ### .apt_generated diff --git a/docs/cluster-connection.md b/docs/cluster-connection.md index 8f1b922..7eecdce 100644 --- a/docs/cluster-connection.md +++ b/docs/cluster-connection.md @@ -75,7 +75,63 @@ spec: > **Note:** The volume source can be any type that provides a file. -> **Note:** The Helm chart does not support extra volumes yet. +##### With the Helm chart + +The chart exposes the `app.volumes` and `app.volumeMounts` values. Both take the raw Kubernetes syntax, so any volume source works. Pass them in your own values file: + +```yaml +app: + volumes: + - name: db-credentials + secret: + secretName: db-credentials-secret + volumeMounts: + - name: db-credentials + mountPath: /mnt/secrets + readOnly: true +``` + +```bash +helm install postgresql-operator --values values.yaml +``` + +See the [installation section](../README.md#helm-chart) of the README for the chart URL. + +##### With the Secrets Store CSI driver + +Use this option to read the credentials from an external secret store, for example AWS Secrets Manager. The chart does not create the `SecretProviderClass`, so you have to apply it yourself: + +```yaml +apiVersion: secrets-store.csi.x-k8s.io/v1 +kind: SecretProviderClass +metadata: + name: db-credentials +spec: + provider: aws + parameters: + objects: | + - objectName: "my/db/credentials" + objectAlias: "db-credentials.json" +``` + +Then reference it from the chart values: + +```yaml +app: + volumes: + - name: db-credentials + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: db-credentials + volumeMounts: + - name: db-credentials + mountPath: /mnt/secrets + readOnly: true +``` + +> **Note:** The `SecretProviderClass` must live in the namespace of the operator. ### Examples diff --git a/operator/src/main/helm/values.yaml b/operator/src/main/helm/values.yaml new file mode 100644 index 0000000..cf2dacb --- /dev/null +++ b/operator/src/main/helm/values.yaml @@ -0,0 +1,15 @@ +--- +# This file overrides the default values that the quarkus-helm extension generates. +# +# Why it exists: a list field of the operator Deployment becomes a Helm value only if the key +# already exists in `src/main/kubernetes/kubernetes.yml`. An empty list `[]` does not survive +# there. The fabric8 model marks `PodSpec.imagePullSecrets`, `PodSpec.volumes` and +# `Container.volumeMounts` with `@JsonInclude(NON_EMPTY)`. A list with one null element does +# survive, but the generated default then reads `- {}`. That is not a usable default, so this +# file replaces it with a real empty list. +# +# See https://github.com/quarkiverse/quarkus-helm/issues/453 +app: + imagePullSecrets: [] + volumes: [] + volumeMounts: [] diff --git a/operator/src/main/kubernetes/kubernetes.yml b/operator/src/main/kubernetes/kubernetes.yml index 037464e..39bf90d 100644 --- a/operator/src/main/kubernetes/kubernetes.yml +++ b/operator/src/main/kubernetes/kubernetes.yml @@ -7,4 +7,10 @@ spec: template: spec: affinity: {} + # The `[~]` placeholders are required, see operator/src/main/helm/values.yaml for the reason. imagePullSecrets: [~] + volumes: [~] + containers: + # The name must match `quarkus.kubernetes.name`, otherwise Dekorate adds a second container. + - name: postgresql-operator + volumeMounts: [~] diff --git a/operator/src/main/resources/application.yml b/operator/src/main/resources/application.yml index 3b5f766..b16a3a2 100644 --- a/operator/src/main/resources/application.yml +++ b/operator/src/main/resources/application.yml @@ -86,12 +86,9 @@ quarkus: - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).imagePullPolicy image-pull-secrets: property: imagePullSecrets - value: - - null paths: - (kind == Deployment).spec.template.spec.imagePullSecrets - expression: "{{- if eq (toYaml .Values.app.imagePullSecrets | trim) \"- {}\" }} null{{- else }}{{- toYaml .Values.app.imagePullSecrets | nindent 8 }}{{- end }}" - description: Kubernetes image pull secrets to use if the OCI image is hosted on a private registry + expression: "{{- toYaml (.Values.app.imagePullSecrets | default list) | nindent 8 }}" resource-requests-cpu: property: resources.requests.cpu value: ${quarkus.kubernetes.resources.requests.cpu} @@ -113,6 +110,16 @@ quarkus: paths: - (kind == Deployment).spec.template.spec.affinity description: Kubernetes affinity configuration for Pod scheduling + volumes: + property: volumes + paths: + - (kind == Deployment).spec.template.spec.volumes + expression: "{{- toYaml (.Values.app.volumes | default list) | nindent 8 }}" + volume-mounts: + property: volumeMounts + paths: + - (kind == Deployment).spec.template.spec.containers.(name == ${quarkus.kubernetes.name}).volumeMounts + expression: "{{- toYaml (.Values.app.volumeMounts | default list) | nindent 12 }}" console-color: property: envs.QUARKUS_CONSOLE_COLOR value-as-bool: ${quarkus.console.color} @@ -127,9 +134,26 @@ quarkus: description: Specify the format of the produced JSON. Supported values are "DEFAULT", "ECS", and "GCP". values-schema: properties: + # The type must be set explicitly for every non-scalar value, because the generated + # schema otherwise falls back to `string`. + # + # A value that `src/main/helm/values.yaml` provides also loses the `description` of its + # `quarkus.helm.values` entry, so the description belongs here instead. "affinity": name: app.affinity type: object + "imagePullSecrets": + name: app.imagePullSecrets + type: array + description: Kubernetes image pull secrets to use if the OCI image is hosted on a private registry + "volumes": + name: app.volumes + type: array + description: Additional volumes for the operator Pod, for example a Secret volume or a Secrets Store CSI volume + "volumeMounts": + name: app.volumeMounts + type: array + description: Additional volume mounts for the operator container expressions: release-name-labels: expression: "{{ .Release.Name }}" diff --git a/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java b/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java index 7b864be..43f1327 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java @@ -1,6 +1,10 @@ package it.aboutbits.postgresql.helm; import io.fabric8.kubernetes.api.model.ConfigBuilder; +import io.fabric8.kubernetes.api.model.LocalObjectReference; +import io.fabric8.kubernetes.api.model.Volume; +import io.fabric8.kubernetes.api.model.VolumeMount; +import io.fabric8.kubernetes.api.model.apps.Deployment; import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.utils.Serialization; import io.quarkus.test.junit.QuarkusTest; @@ -11,7 +15,9 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import java.io.ByteArrayInputStream; import java.io.IOException; +import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; @@ -30,6 +36,16 @@ class HelmTest { private static final String ENV_VAR_KUBECONFIG = "KUBECONFIG"; + /// Must match `quarkus.kubernetes.name`. + private static final String CONTAINER_NAME = "postgresql-operator"; + + /// The Pod and Container list fields that the chart exposes as free-form Helm values. + private static final List LIST_VALUES = List.of( + "imagePullSecrets", + "volumes", + "volumeMounts" + ); + private static final String CRD_GROUP = "postgresql.aboutbits.it"; private static final List CRD_NAMES = List.of( "clusterconnection", @@ -58,9 +74,8 @@ class HelmTest { @Test @DisplayName("When the Helm chart is installed, the operator deployment should be created") void helmInstall_createsDeployment() throws IOException { - // The chart is generated by the quarkus-helm extension in the build directory. - // For Gradle, it's build/helm/kubernetes/postgresql-operator - var chartPath = Paths.get("build", "helm", "kubernetes", chartName); + // given + var chartPath = chartPath(); assertThat(chartPath) .withFailMessage("Helm chart not found at %s. Ensure that the chart is generated before running this test.", chartPath) @@ -93,10 +108,41 @@ void helmInstall_createsDeployment() throws IOException { Objects.requireNonNull(appValues, "appValues should not be null"); assertThat(appValues.get("image")).isNotNull(); + // The list values must default to a real empty list, not to `- {}`. + // `operator/src/main/helm/values.yaml` provides these defaults. + for (var listValue : LIST_VALUES) { + assertThat(appValues.get(listValue)) + .withFailMessage("app.%s should default to an empty list, but was %s", listValue, appValues.get(listValue)) + .isEqualTo(List.of()); + } + assertThat(chartPath.resolve("LICENSE")).exists(); assertThat(chartPath.resolve("README.md")).exists(); assertThat(chartPath.resolve("values.schema.json")).exists(); + // ./values.schema.json + // The type must be declared for every list value, otherwise the generated schema + // falls back to `string` and `helm install` rejects a list. + var valuesSchema = Serialization.jsonMapper() + .readTree(chartPath.resolve("values.schema.json").toFile()); + + for (var listValue : LIST_VALUES) { + var schemaProperty = valuesSchema.at("/properties/%s/properties/%s".formatted( + rootValuesAlias, + listValue + )); + + assertThat(schemaProperty.path("type").asText()) + .withFailMessage("app.%s should be typed as an array in values.schema.json", listValue) + .isEqualTo("array"); + + // A value that `src/main/helm/values.yaml` provides loses the description of its + // `quarkus.helm.values` entry, so the description has to come from the schema. + assertThat(schemaProperty.path("description").asText()) + .withFailMessage("app.%s should have a description in values.schema.json", listValue) + .isNotBlank(); + } + // ./crds/ for (var crdName : CRD_NAMES) { assertThat(chartPath.resolve("crds/%ss.%s-v1.yml".formatted( @@ -114,6 +160,14 @@ void helmInstall_createsDeployment() throws IOException { assertThat(chartPath.resolve("templates/serviceaccount.yaml")).exists(); assertThat(chartPath.resolve("templates/validating-clusterrolebinding.yaml")).exists(); + // The indent of each expression has to match the depth of its field in the Deployment. + // A wrong `nindent` produces invalid YAML as soon as a user sets the value. + assertThat(chartPath.resolve("templates/deployment.yaml")) + .content() + .contains("imagePullSecrets: {{- toYaml (.Values.app.imagePullSecrets | default list) | nindent 8 }}") + .contains("volumes: {{- toYaml (.Values.app.volumes | default list) | nindent 8 }}") + .contains("volumeMounts: {{- toYaml (.Values.app.volumeMounts | default list) | nindent 12 }}"); + for (var crdName : CRD_NAMES) { assertThat(chartPath.resolve("templates/%sreconciler-crd-role-binding.yaml".formatted( crdName @@ -173,9 +227,21 @@ void helmInstall_createsDeployment() throws IOException { assertThat(deployment.getSpec()) .isNotNull() - .satisfies(spec -> - assertThat(spec.getTemplate().getSpec().getImagePullSecrets()).isEmpty() - ); + .satisfies(spec -> { + var podSpec = spec.getTemplate().getSpec(); + + assertThat(podSpec.getImagePullSecrets()).isEmpty(); + assertThat(podSpec.getVolumes()).isEmpty(); + + // The baseline `kubernetes.yml` names the container, so Dekorate must + // not add a second one. + assertThat(podSpec.getContainers()) + .singleElement() + .satisfies(container -> { + assertThat(container.getName()).isEqualTo(CONTAINER_NAME); + assertThat(container.getVolumeMounts()).isEmpty(); + }); + }); var selector = deployment.getSpec().getSelector(); @@ -206,6 +272,131 @@ void helmInstall_createsDeployment() throws IOException { } } + @Test + @DisplayName("When the chart is rendered with volumes, the deployment should mount them") + void helmTemplate_rendersVolumes() throws IOException { + // given + var chartPath = chartPath(); + + assertThat(chartPath) + .withFailMessage("Helm chart not found at %s. Ensure that the chart is generated before running this test.", chartPath) + .exists(); + + var valuesPath = createTempValuesWithVolumes(); + + try { + // `helm template` needs no cluster, and it validates the values against values.schema.json. + var holder = new Object() { + int exitCode; + }; + var renderedOutput = new StringBuilder(); + + // when + ProcessBuilder.newBuilder( + "helm", + "template", "volumes-render-test", chartPath.toAbsolutePath().toString(), + "--values", valuesPath.toAbsolutePath().toString() + ) + .exitCodeChecker(ec -> { + holder.exitCode = ec; + return true; + }) + .error().consumeLinesWith(8192, log::error) + .output() + .consumeLinesWith(65536, line -> renderedOutput.append(line).append(System.lineSeparator())) + .run(); + + // then + assertThat(holder.exitCode) + .withFailMessage("Helm template failed, see the logged error output") + .isZero(); + + var deployment = kubernetesClient.load(new ByteArrayInputStream( + renderedOutput.toString().getBytes(StandardCharsets.UTF_8) + )) + .items() + .stream() + .filter(Deployment.class::isInstance) + .map(Deployment.class::cast) + .findFirst() + .orElseThrow(() -> new AssertionError( + "The rendered chart contains no Deployment:%n%s".formatted(renderedOutput) + )); + + var podSpec = deployment.getSpec().getTemplate().getSpec(); + + assertThat(podSpec.getVolumes()) + .extracting(Volume::getName) + .containsExactly("db-credentials", "aws-secrets"); + + // A Secrets Store CSI volume is the case that `adminSecretFileRef` was added for. + assertThat(podSpec.getVolumes()) + .filteredOn(volume -> "aws-secrets".equals(volume.getName())) + .singleElement() + .satisfies(volume -> assertThat(volume.getCsi()) + .isNotNull() + .satisfies(csi -> { + assertThat(csi.getDriver()).isEqualTo("secrets-store.csi.k8s.io"); + assertThat(csi.getVolumeAttributes()) + .containsEntry("secretProviderClass", "db-credentials"); + }) + ); + + assertThat(podSpec.getImagePullSecrets()) + .extracting(LocalObjectReference::getName) + .containsExactly("my-registry-secret"); + + assertThat(podSpec.getContainers()) + .singleElement() + .satisfies(container -> { + assertThat(container.getName()).isEqualTo(CONTAINER_NAME); + assertThat(container.getVolumeMounts()) + .extracting(VolumeMount::getMountPath) + .containsExactly("/mnt/secrets", "/mnt/aws"); + }); + } finally { + Files.deleteIfExists(valuesPath); + } + } + + /// The chart is generated by the quarkus-helm extension in the build directory. + /// For Gradle, it's build/helm/kubernetes/postgresql-operator + private Path chartPath() { + return Paths.get("build", "helm", "kubernetes", chartName); + } + + private static Path createTempValuesWithVolumes() throws IOException { + var values = """ + app: + image: postgresql-operator:test + imagePullSecrets: + - name: my-registry-secret + volumes: + - name: db-credentials + secret: + secretName: db-credentials-secret + - name: aws-secrets + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: db-credentials + volumeMounts: + - name: db-credentials + mountPath: /mnt/secrets + readOnly: true + - name: aws-secrets + mountPath: /mnt/aws + readOnly: true + """; + + var path = Files.createTempFile("values-volumes-helm-test-", ".yaml"); + + Files.writeString(path, values); + + return path; + } + private Path createTempKubeConfig() throws IOException { var clientConfig = kubernetesClient.getConfiguration(); From 7c165bb013706352d7aeafd1559b7f29f426ae9a Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Fri, 4 Sep 2026 17:15:16 +0200 Subject: [PATCH 2/5] clarify Helm `values.yaml` comments on unusable generated defaults for empty lists --- operator/src/main/helm/values.yaml | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/operator/src/main/helm/values.yaml b/operator/src/main/helm/values.yaml index cf2dacb..9f702ce 100644 --- a/operator/src/main/helm/values.yaml +++ b/operator/src/main/helm/values.yaml @@ -5,8 +5,15 @@ # already exists in `src/main/kubernetes/kubernetes.yml`. An empty list `[]` does not survive # there. The fabric8 model marks `PodSpec.imagePullSecrets`, `PodSpec.volumes` and # `Container.volumeMounts` with `@JsonInclude(NON_EMPTY)`. A list with one null element does -# survive, but the generated default then reads `- {}`. That is not a usable default, so this -# file replaces it with a real empty list. +# survive, but the generated default is then unusable, so this file replaces it with a real +# empty list. +# +# The unusable default takes one of two shapes, and the path of the value decides which: +# - A plain path, such as `spec.template.spec.volumes`, produces `- {}`, a list that holds +# one empty object. A user who copies that default and appends an entry gets invalid YAML. +# - A container-filtered path, such as +# `spec.template.spec.containers.(name == postgresql-operator).volumeMounts`, produces +# `{}`, an object. That shape also contradicts the `type: array` of `values.schema.json`. # # See https://github.com/quarkiverse/quarkus-helm/issues/453 app: From 933a6142e363f9c3fd3a7d781efbee3e67e8e828 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Sat, 5 Sep 2026 00:53:12 +0200 Subject: [PATCH 3/5] align Kubernetes deployment and `HelmTest` with `quarkus.kubernetes.name` to ensure consistency and prevent drift --- operator/src/main/kubernetes/kubernetes.yml | 3 ++ .../aboutbits/postgresql/helm/HelmTest.java | 33 ++++++++++++------- 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/operator/src/main/kubernetes/kubernetes.yml b/operator/src/main/kubernetes/kubernetes.yml index 39bf90d..913e29f 100644 --- a/operator/src/main/kubernetes/kubernetes.yml +++ b/operator/src/main/kubernetes/kubernetes.yml @@ -1,7 +1,10 @@ --- +# See https://quarkus.io/guides/deploying-to-kubernetes#using-existing-resources apiVersion: apps/v1 kind: Deployment metadata: + # The name must match `quarkus.kubernetes.name`, otherwise Dekorate adds a second Deployment. + # `HelmTest` has a test that makes sure this never drifts apart. name: postgresql-operator spec: template: diff --git a/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java b/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java index 43f1327..6e284a2 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java @@ -36,9 +36,6 @@ class HelmTest { private static final String ENV_VAR_KUBECONFIG = "KUBECONFIG"; - /// Must match `quarkus.kubernetes.name`. - private static final String CONTAINER_NAME = "postgresql-operator"; - /// The Pod and Container list fields that the chart exposes as free-form Helm values. private static final List LIST_VALUES = List.of( "imagePullSecrets", @@ -57,16 +54,20 @@ class HelmTest { ); private final String chartName; + /// Dekorate uses this value for the Deployment name and for the container name. + private final String kubernetesName; private final String rootValuesAlias; private final KubernetesClient kubernetesClient; HelmTest( KubernetesClient kubernetesClient, @ConfigProperty(name = "quarkus.helm.name") String chartName, + @ConfigProperty(name = "quarkus.kubernetes.name") String kubernetesName, @ConfigProperty(name = "quarkus.helm.values-root-alias", defaultValue = "app") String rootValuesAlias ) { this.kubernetesClient = kubernetesClient; this.chartName = chartName; + this.kubernetesName = kubernetesName; this.rootValuesAlias = rootValuesAlias; } @@ -238,7 +239,7 @@ void helmInstall_createsDeployment() throws IOException { assertThat(podSpec.getContainers()) .singleElement() .satisfies(container -> { - assertThat(container.getName()).isEqualTo(CONTAINER_NAME); + assertThat(container.getName()).isEqualTo(kubernetesName); assertThat(container.getVolumeMounts()).isEmpty(); }); }); @@ -275,7 +276,7 @@ void helmInstall_createsDeployment() throws IOException { @Test @DisplayName("When the chart is rendered with volumes, the deployment should mount them") void helmTemplate_rendersVolumes() throws IOException { - // given + // given var chartPath = chartPath(); assertThat(chartPath) @@ -311,17 +312,24 @@ void helmTemplate_rendersVolumes() throws IOException { .withFailMessage("Helm template failed, see the logged error output") .isZero(); - var deployment = kubernetesClient.load(new ByteArrayInputStream( + var deployments = kubernetesClient.load(new ByteArrayInputStream( renderedOutput.toString().getBytes(StandardCharsets.UTF_8) )) .items() .stream() .filter(Deployment.class::isInstance) .map(Deployment.class::cast) - .findFirst() - .orElseThrow(() -> new AssertionError( - "The rendered chart contains no Deployment:%n%s".formatted(renderedOutput) - )); + .toList(); + + assertThat(deployments) + .withFailMessage("The rendered chart must contain exactly one Deployment:%n%s", renderedOutput) + .hasSize(1); + + var deployment = deployments.getFirst(); + + // The baseline `kubernetes.yml` must name the Deployment `quarkus.kubernetes.name`. + // Dekorate keeps a different name as a second Deployment. + assertThat(deployment.getMetadata().getName()).isEqualTo(kubernetesName); var podSpec = deployment.getSpec().getTemplate().getSpec(); @@ -349,7 +357,7 @@ void helmTemplate_rendersVolumes() throws IOException { assertThat(podSpec.getContainers()) .singleElement() .satisfies(container -> { - assertThat(container.getName()).isEqualTo(CONTAINER_NAME); + assertThat(container.getName()).isEqualTo(kubernetesName); assertThat(container.getVolumeMounts()) .extracting(VolumeMount::getMountPath) .containsExactly("/mnt/secrets", "/mnt/aws"); @@ -366,7 +374,8 @@ private Path chartPath() { } private static Path createTempValuesWithVolumes() throws IOException { - var values = """ + var values = + """ app: image: postgresql-operator:test imagePullSecrets: From 465ee82bc1ffa79df2ddb707703ba56227dbe7f1 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Sat, 5 Sep 2026 00:55:27 +0200 Subject: [PATCH 4/5] fix the .gitignore `.kube/` directory exclusion --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 1dfb045..f3e9c81 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,7 @@ ### PostgreSQL Operator ### config/ # Written by the fabric8 Kubernetes client when the tests run against the Dev Service -.kube/ +operator/.kube/ ### STS ### .apt_generated From acfecfa2bb9ef1f1a0824260219ef8d3c99f9316 Mon Sep 17 00:00:00 2001 From: Thomas Sapelza Date: Sat, 5 Sep 2026 00:56:08 +0200 Subject: [PATCH 5/5] rework the ClusterConnection docs with the new file reference option --- docs/cluster-connection.md | 198 ++++++++++++++++++++++--------------- 1 file changed, 116 insertions(+), 82 deletions(-) diff --git a/docs/cluster-connection.md b/docs/cluster-connection.md index 7eecdce..cbfc74a 100644 --- a/docs/cluster-connection.md +++ b/docs/cluster-connection.md @@ -7,14 +7,14 @@ Other Custom Resources (like `Database`, `Role`, `Schema`, `Grant`, `DefaultPriv ## Spec -| Field | Type | Description | Required | Mutable | -|----------------------|----------------------|-----------------------------------------------------------------------|----------|---------| -| `host` | `string` | The hostname of the PostgreSQL instance. | Yes | Yes | -| `port` | `integer` | The port of the PostgreSQL instance (1-65535). | Yes | Yes | -| `database` | `string` | The database to connect to (usually `postgres` for admin operations). | Yes | Yes | -| `adminSecretRef` | `ResourceRef` | Reference to the Kubernetes Secret containing the admin credentials. | No | Yes | -| `adminSecretFileRef` | `FileRef` | Reference to a file containing the admin credentials. | No | Yes | -| `parameters` | `map[string]string` | Additional connection parameters. | No | Yes | +| Field | Type | Description | Required | Mutable | +|----------------------|---------------------|-----------------------------------------------------------------------|----------|---------| +| `host` | `string` | The hostname of the PostgreSQL instance. | Yes | Yes | +| `port` | `integer` | The port of the PostgreSQL instance (1-65535). | Yes | Yes | +| `database` | `string` | The database to connect to (usually `postgres` for admin operations). | Yes | Yes | +| `adminSecretRef` | `ResourceRef` | Reference to the Kubernetes Secret containing the admin credentials. | No | Yes | +| `adminSecretFileRef` | `FileRef` | Reference to a file containing the admin credentials. | No | Yes | +| `parameters` | `map[string]string` | Additional connection parameters. | No | Yes | > **Note:** Exactly one of `adminSecretRef` or `adminSecretFileRef` must be provided. @@ -29,11 +29,12 @@ The referenced secret must be of type `kubernetes.io/basic-auth` and contain the ### FileRef (`adminSecretFileRef`) -| Field | Type | Description | Required | -|--------|----------|----------------------------------------------------------------|----------| -| `path` | `string` | The path to the file containing the admin credentials. | Yes | +Use this option when the credentials should be mounted as a file inside the operator Pod instead of reading a Kubernetes Secret directly. + +| Field | Type | Description | Required | +|--------|----------|-----------------------------------------------------------------------------------------|----------| +| `path` | `string` | The absolute path inside the operator Pod to the file containing the admin credentials. | Yes | -Use this option when the credentials are mounted as a file instead of a Kubernetes Secret. #### File format @@ -51,33 +52,93 @@ The file must contain JSON with the following fields: #### Mount the credentials file -The file must be accessible inside the operator pod at the path specified in `adminSecretFileRef.path`. Mount it using a Volume and VolumeMount on the operator Deployment: +The file must be accessible inside the operator Pod at the path in `adminSecretFileRef.path`. + +The Helm chart exposes the `app.volumes` and `app.volumeMounts` values for this. +Both take the raw Kubernetes syntax, so any volume source that provides a file works. + +The value of `adminSecretFileRef.path` is the `mountPath` plus the name of the file. The volume source decides the file name: + +| Volume source | The file name comes from | +|----------------------------------|---------------------------------| +| `secret` | the key of the Secret | +| `csi` (Secrets Store CSI driver) | the `objectAlias` of the object | + +See [Using a file reference](#using-a-file-reference-adminsecretfileref) in the examples for a complete setup with each volume source. + +## Examples + +### Using a Kubernetes Secret (`adminSecretRef`) ```yaml -apiVersion: apps/v1 -kind: Deployment +apiVersion: v1 +kind: Secret metadata: - name: postgresql-operator + name: my-db-secret +type: kubernetes.io/basic-auth +stringData: + username: postgres + password: password +``` + +```yaml +apiVersion: postgresql.aboutbits.it/v1 +kind: ClusterConnection +metadata: + name: my-postgres-connection spec: - template: - spec: - containers: - - name: postgresql-operator - volumeMounts: - - name: db-credentials - mountPath: /mnt/secrets - readOnly: true - volumes: - - name: db-credentials - secret: - secretName: db-credentials-secret + adminSecretRef: + name: my-db-secret + host: localhost + port: 5432 + database: postgres + # Example parameters + parameters: + ApplicationName: "k8s-operator" # Helps identify this connection in Postgres logs + #sslmode: "require" # Enforce SSL encryption + #connectTimeout: "10" # Timeout in seconds for connection attempts ``` -> **Note:** The volume source can be any type that provides a file. +### Using a file reference (`adminSecretFileRef`) -##### With the Helm chart +```yaml +apiVersion: postgresql.aboutbits.it/v1 +kind: ClusterConnection +metadata: + name: my-postgres-connection +spec: + adminSecretFileRef: + path: "/mnt/secrets/db-credentials.json" + host: localhost + port: 5432 + database: postgres + # Example parameters + parameters: + ApplicationName: "k8s-operator" # Helps identify this connection in Postgres logs + #sslmode: "require" # Enforce SSL encryption + #connectTimeout: "10" # Timeout in seconds for connection attempts +``` + +The mount that creates `/mnt/secrets/db-credentials.json` depends on the volume source. -The chart exposes the `app.volumes` and `app.volumeMounts` values. Both take the raw Kubernetes syntax, so any volume source works. Pass them in your own values file: +#### From a Secret volume + +Create the Secret. Its key becomes the file name: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: db-credentials-secret +stringData: + db-credentials.json: | + { + "username": "root", + "password": "password" + } +``` + +Then mount it through the chart values: ```yaml app: @@ -91,15 +152,13 @@ app: readOnly: true ``` -```bash -helm install postgresql-operator --values values.yaml -``` +#### From the Secrets Store CSI driver -See the [installation section](../README.md#helm-chart) of the README for the chart URL. +Use this option to read the credentials from an external secret store, for example AWS Secrets Manager. -##### With the Secrets Store CSI driver +> **Note:** Install the [Secrets Store CSI driver](https://secrets-store-csi-driver.sigs.k8s.io/getting-started/installation) and the [provider](https://secrets-store-csi-driver.sigs.k8s.io/providers) for your secret store first. Neither the operator nor the chart installs them. Without the driver, the operator Pod stays in `ContainerCreating` and reports a failed mount. -Use this option to read the credentials from an external secret store, for example AWS Secrets Manager. The chart does not create the `SecretProviderClass`, so you have to apply it yourself: +The chart does not create the `SecretProviderClass`, so you have to apply it yourself. Its `objectAlias` becomes the file name: ```yaml apiVersion: secrets-store.csi.x-k8s.io/v1 @@ -114,7 +173,9 @@ spec: objectAlias: "db-credentials.json" ``` -Then reference it from the chart values: +> **Note:** The `SecretProviderClass` must live in the namespace of the operator. + +Then mount it through the chart values: ```yaml app: @@ -131,53 +192,26 @@ app: readOnly: true ``` -> **Note:** The `SecretProviderClass` must live in the namespace of the operator. - -### Examples +#### Without the Helm chart -#### Using a Kubernetes Secret (`adminSecretRef`) +If you deploy the operator directly from the OCI image, set the same `volumes` and `volumeMounts` fields on the Deployment: ```yaml -apiVersion: v1 -kind: Secret -metadata: - name: my-db-secret -type: kubernetes.io/basic-auth -stringData: - username: postgres - password: password -``` - -```yaml -apiVersion: postgresql.aboutbits.it/v1 -kind: ClusterConnection -metadata: - name: my-postgres-connection -spec: - adminSecretRef: - name: my-db-secret - host: localhost - port: 5432 - database: postgres - # Example parameters - parameters: - ApplicationName: "k8s-operator" # Helps identify this connection in Postgres logs - #sslmode: "require" # Enforce SSL encryption - #connectTimeout: "10" # Timeout in seconds for connection attempts -``` - -#### Using a file reference (`adminSecretFileRef`) - -```yaml -apiVersion: postgresql.aboutbits.it/v1 -kind: ClusterConnection +apiVersion: apps/v1 +kind: Deployment metadata: - name: quarkus-postgres-connection + name: postgresql-operator spec: - adminSecretFileRef: - path: "/mnt/secrets/db-credentials.json" - host: localhost - port: 5432 - database: postgres + template: + spec: + containers: + - name: postgresql-operator + volumeMounts: + - name: db-credentials + mountPath: /mnt/secrets + readOnly: true + volumes: + - name: db-credentials + secret: + secretName: db-credentials-secret ``` -