diff --git a/.gitignore b/.gitignore index f917d07..279947e 100644 --- a/.gitignore +++ b/.gitignore @@ -20,6 +20,7 @@ config/ !.idea/misc.xml !.idea/sqldialects.xml !.idea/vcs.xml +**/out/ *.iml *.ipr diff --git a/docs/cluster-connection.md b/docs/cluster-connection.md index 139fcfb..86905b9 100644 --- a/docs/cluster-connection.md +++ b/docs/cluster-connection.md @@ -7,13 +7,16 @@ 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. | Yes | 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. ### ResourceRef (`adminSecretRef`) @@ -24,7 +27,59 @@ Other Custom Resources (like `Database`, `Role`, `Schema`, `Grant`, `DefaultPriv The referenced secret must be of type `kubernetes.io/basic-auth` and contain the keys `username` and `password`. -### Example +### FileRef (`adminSecretFileRef`) + +| Field | Type | Description | Required | +|--------|----------|----------------------------------------------------------------|----------| +| `path` | `string` | The path 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 + +The file must contain JSON with the following fields: + +```json +{ + "username": "root", + "password": "password" +} +``` + +- `password` **required** +- `username` **required** + +#### 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: + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: postgresql-operator +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 +``` + +> **Note:** The volume source can be any type that provides a file. + +> **Note:** The Helm chart does not support extra volumes yet. + +### Examples + +#### Using a Kubernetes Secret (`adminSecretRef`) ```yaml apiVersion: v1 @@ -54,3 +109,19 @@ spec: #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 +metadata: + name: quarkus-postgres-connection +spec: + adminSecretFileRef: + path: "/mnt/secrets/db-credentials.json" + host: localhost + port: 5432 + database: postgres +``` + diff --git a/docs/docker-environment.md b/docs/docker-environment.md index 37cf7cb..134ae3d 100644 --- a/docs/docker-environment.md +++ b/docs/docker-environment.md @@ -39,7 +39,16 @@ users: ## 2. Create PostgreSQL Connection and Secret -For the `postgresql` Dev Service, you can generate the necessary Custom Resources to test the Operator: +For the `postgresql` Dev Service, you can generate the necessary Custom Resources to test the Operator. + +A `ClusterConnection` requires admin credentials, which can be provided in one of two ways: + +- **`adminSecretRef`** references a Kubernetes `basic-auth` Secret (username + password). +- **`adminSecretFileRef`** references a JSON file mounted into the operator pod. + +Exactly one of these must be specified. + +### Using a Kubernetes Secret (`adminSecretRef`) 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. diff --git a/docs/terraform.md b/docs/terraform.md index 6bc4817..5253c74 100644 --- a/docs/terraform.md +++ b/docs/terraform.md @@ -95,7 +95,7 @@ Every optional field of every Custom Resource is affected, in particular: | Custom Resource | Optional fields | |---------------------|------------------------------------------------------------------------------------------------| -| `ClusterConnection` | `parameters`, `adminSecretRef.namespace` | +| `ClusterConnection` | `parameters`, `adminSecretRef`, `adminSecretRef.namespace`, `adminSecretFileRef` | | `Database` | `owner`, `reclaimPolicy`, `clusterRef.namespace` | | `Schema` | `owner`, `reclaimPolicy`, `clusterRef.namespace` | | `Role` | `comment`, `passwordSecretRef`, `flags` (including `flags.validUntil`), `clusterRef.namespace` | diff --git a/operator/build.gradle.kts b/operator/build.gradle.kts index 0e8edf0..ca06d13 100644 --- a/operator/build.gradle.kts +++ b/operator/build.gradle.kts @@ -62,6 +62,7 @@ dependencies { */ testImplementation("io.quarkus:quarkus-junit") testImplementation("io.quarkus:quarkus-junit-mockito") + testImplementation("io.fabric8:kubernetes-server-mock") testImplementation("org.awaitility:awaitility") testImplementation(libs.assertj) testImplementation(libs.datafaker) diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/FileRef.java b/operator/src/main/java/it/aboutbits/postgresql/core/FileRef.java new file mode 100644 index 0000000..ab73258 --- /dev/null +++ b/operator/src/main/java/it/aboutbits/postgresql/core/FileRef.java @@ -0,0 +1,33 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.generator.annotation.Required; +import io.fabric8.generator.annotation.ValidationRule; +import lombok.Getter; +import lombok.Setter; +import org.jspecify.annotations.NullMarked; + +/// A reference to a file inside the operator container. +/// +/// This class is used wherever a CRD spec needs to point to a specific file +/// The [#path] field identifies the file location within the container. +/// +/// ### Example usage in a CR manifest +/// +/// ```yaml +/// spec: +/// adminSecretFileRef: +/// path: "/mnt/secrets/db-credentials.json" +/// ``` +@Getter +@Setter +@NullMarked +public class FileRef { + /// The path to the file. + /// Must not be blank. + @Required + @ValidationRule( + value = "self.trim().size() > 0", + message = "The path must not be empty." + ) + private String path = ""; +} diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java b/operator/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java index a5763ba..33a4d78 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java +++ b/operator/src/main/java/it/aboutbits/postgresql/core/KubernetesService.java @@ -1,29 +1,80 @@ package it.aboutbits.postgresql.core; +import com.fasterxml.jackson.databind.ObjectMapper; import io.fabric8.kubernetes.client.KubernetesClient; import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; import jakarta.inject.Singleton; +import lombok.RequiredArgsConstructor; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; +import java.io.IOException; import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; import java.util.Base64; @Singleton +@RequiredArgsConstructor @NullMarked public final class KubernetesService { + private final ObjectMapper objectMapper; + + private record FileCredentials( + @Nullable String username, + @Nullable String password + ) { + } + 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( + public Credentials getAdminCredentials( KubernetesClient kubernetesClient, ClusterConnection clusterConnection ) { - return getSecretRefCredentials( - kubernetesClient, - clusterConnection.getSpec().getAdminSecretRef(), - clusterConnection.getMetadata().getNamespace() - ); + var spec = clusterConnection.getSpec(); + if (spec.getAdminSecretRef() != null) { + var secretRef = spec.getAdminSecretRef(); + var defaultNamespace = clusterConnection.getMetadata().getNamespace(); + var credentials = getSecretRefCredentials(kubernetesClient, secretRef, defaultNamespace); + if (credentials.username() == null) { + var secretNamespace = getSecretNamespace(secretRef, defaultNamespace); + throw new IllegalStateException( + "The Secret reference is missing required data username [secret.namespace=%s, secret.name=%s]".formatted( + secretNamespace, secretRef.getName())); + } + return credentials; + } else if (spec.getAdminSecretFileRef() != null) { + return getSecretFileRefCredentials(spec.getAdminSecretFileRef()); + } + + throw new IllegalStateException("Exactly one of 'adminSecretRef' or 'adminSecretFileRef' must be provided"); + } + + public Credentials getSecretFileRefCredentials(FileRef fileRef) { + var path = Path.of(fileRef.getPath()); + + try (var in = Files.newInputStream(path)) { + var file = objectMapper.readValue(in, FileCredentials.class); + if (file.username() == null) { + throw new IllegalStateException( + "Credentials file is missing required field 'username' [path=%s]".formatted(path)); + } + if (file.password() == null) { + throw new IllegalStateException( + "Credentials file is missing required field 'password' [path=%s]".formatted(path)); + } + return new Credentials(file.username(), file.password()); + } catch (NoSuchFileException e) { + throw new IllegalStateException( + "Credentials file not found [path=%s]".formatted(path), e); + } catch (IOException e) { + throw new IllegalStateException( + "Failed to read the credentials file [path=%s]".formatted(path), e); + } } public Credentials getSecretRefCredentials( @@ -31,9 +82,7 @@ public Credentials getSecretRefCredentials( ResourceRef secretRef, String defaultNamespace ) { - var secretNamespace = secretRef.getNamespace() != null - ? secretRef.getNamespace() - : defaultNamespace; + var secretNamespace = getSecretNamespace(secretRef, defaultNamespace); var secretName = secretRef.getName(); @@ -91,4 +140,10 @@ public Credentials getSecretRefCredentials( password ); } + + private String getSecretNamespace(ResourceRef secretRef, String defaultNamespace) { + return secretRef.getNamespace() != null + ? secretRef.getNamespace() + : defaultNamespace; + } } diff --git a/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java b/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java index c2a2faa..711519e 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java +++ b/operator/src/main/java/it/aboutbits/postgresql/core/PostgreSQLContextFactory.java @@ -34,7 +34,7 @@ public CloseableDSLContext getDSLContext( ClusterConnection clusterConnection, String database ) throws DataAccessException { - var credentials = kubernetesService.getSecretRefCredentials( + var credentials = kubernetesService.getAdminCredentials( kubernetesClient, clusterConnection ); diff --git a/operator/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java b/operator/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java index 41d7914..ebde156 100644 --- a/operator/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java +++ b/operator/src/main/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionSpec.java @@ -5,11 +5,13 @@ import io.fabric8.generator.annotation.Min; import io.fabric8.generator.annotation.Required; import io.fabric8.generator.annotation.ValidationRule; +import it.aboutbits.postgresql.core.FileRef; import it.aboutbits.postgresql.core.ResourceRef; import it.aboutbits.postgresql.core.schema_customizer.HostCustomizer; import lombok.Getter; import lombok.Setter; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; import java.util.HashMap; import java.util.Map; @@ -18,6 +20,10 @@ @Setter @SchemaCustomizer(value = HostCustomizer.class, input = "host") @NullMarked +@ValidationRule( + value = "(has(self.adminSecretRef) ? 1 : 0) + (has(self.adminSecretFileRef) ? 1 : 0) == 1", + message = "Exactly one of 'adminSecretRef' or 'adminSecretFileRef' must be provided" +) public class ClusterConnectionSpec { @Required @ValidationRule( @@ -38,8 +44,11 @@ public class ClusterConnectionSpec { ) private String database = "postgres"; - @Required - private ResourceRef adminSecretRef = new ResourceRef(); + @io.fabric8.generator.annotation.Nullable + private @Nullable ResourceRef adminSecretRef; + + @io.fabric8.generator.annotation.Nullable + private @Nullable FileRef adminSecretFileRef; @io.fabric8.generator.annotation.Nullable private Map parameters = new HashMap<>(); diff --git a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java index fe2d70b..72e41b4 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java +++ b/operator/src/test/java/it/aboutbits/postgresql/_support/testdata/persisted/creator/ClusterConnectionCreate.java @@ -4,6 +4,7 @@ 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.FileRef; import it.aboutbits.postgresql.core.ResourceRef; import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnectionSpec; @@ -41,6 +42,11 @@ public class ClusterConnectionCreate extends TestDataCreator private @Nullable ResourceRef withAdminSecretRef; + private @Nullable FileRef withAdminSecretFileRef; + + @Setter(AccessLevel.NONE) + private boolean withoutAdminSecret = false; + private @Nullable String withApplicationName; public ClusterConnectionCreate( @@ -61,6 +67,16 @@ public ClusterConnectionCreate withoutNamespace() { return this; } + public ClusterConnectionCreate withAdminSecretFileRef(FileRef fileRef) { + this.withAdminSecretFileRef = fileRef; + return this; + } + + public ClusterConnectionCreate withoutAdminSecret() { + this.withoutAdminSecret = true; + return this; + } + @Override protected ClusterConnection create(int index) { // given @@ -81,6 +97,9 @@ protected ClusterConnection create(int index) { spec.setPort(getPort()); spec.setDatabase(getDatabase()); spec.setAdminSecretRef(getAdminSecretRef()); + if (withAdminSecretFileRef != null) { + spec.setAdminSecretFileRef(withAdminSecretFileRef); + } spec.setParameters(getParameters()); item.setSpec(spec); @@ -154,7 +173,11 @@ private String getDatabase() { return withDatabase; } - private ResourceRef getAdminSecretRef() { + private @Nullable ResourceRef getAdminSecretRef() { + if (withoutAdminSecret) { + return null; + } + if (withAdminSecretRef != null) { return withAdminSecretRef; } diff --git a/operator/src/test/java/it/aboutbits/postgresql/core/KubernetesServiceTest.java b/operator/src/test/java/it/aboutbits/postgresql/core/KubernetesServiceTest.java new file mode 100644 index 0000000..481133d --- /dev/null +++ b/operator/src/test/java/it/aboutbits/postgresql/core/KubernetesServiceTest.java @@ -0,0 +1,406 @@ +package it.aboutbits.postgresql.core; + +import io.fabric8.kubernetes.api.model.ObjectMeta; +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.server.mock.EnableKubernetesMockClient; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnection; +import it.aboutbits.postgresql.crd.clusterconnection.ClusterConnectionSpec; +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.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import java.io.IOException; +import java.nio.charset.Charset; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@NullMarked +@EnableKubernetesMockClient(crud = true) +class KubernetesServiceTest { + private final KubernetesService service = new KubernetesService(new ObjectMapper()); + + @SuppressWarnings("NullAway.Init") + static KubernetesClient client; + + @TempDir + Path tempDir; + + @BeforeEach + void clearSecrets() { + client.secrets().inAnyNamespace().delete(); + } + + @Nested + class GetSecretFileRefCredentials { + @Test + @DisplayName("when both username and password present, should return credentials") + void whenBothUsernameAndPassword_shouldReturnCredentials() throws IOException { + // given + var file = tempDir.resolve("secret.json"); + Files.writeString(file, """ + {"username": "admin", "password": "s3cret"} + """); + + var fileRef = new FileRef(); + fileRef.setPath(file.toString()); + + // when + var result = service.getSecretFileRefCredentials(fileRef); + + // then + assertThat(result.username()).isEqualTo("admin"); + assertThat(result.password()).isEqualTo("s3cret"); + } + + @ParameterizedTest(name = "when username {0}, should throw") + @ValueSource(strings = { + "{\"password\": \"s3cret\"}", + "{\"username\": null, \"password\": \"s3cret\"}" + }) + void whenUsernameMissingOrNull_shouldThrow(String json) throws IOException { + // given + var file = tempDir.resolve("secret.json"); + Files.writeString(file, json); + + var fileRef = new FileRef(); + fileRef.setPath(file.toString()); + + // when / then + assertThatThrownBy(() -> service.getSecretFileRefCredentials(fileRef)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missing required field 'username'"); + } + + @ParameterizedTest(name = "when password {0}, should throw") + @ValueSource(strings = { + "{\"username\": \"admin\"}", + "{\"username\": \"admin\", \"password\": null}" + }) + void whenPasswordMissingOrNull_shouldThrow(String json) throws IOException { + // given + var file = tempDir.resolve("secret.json"); + Files.writeString(file, json); + + var fileRef = new FileRef(); + fileRef.setPath(file.toString()); + + // when / then + assertThatThrownBy(() -> service.getSecretFileRefCredentials(fileRef)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missing required field 'password'"); + } + + @ParameterizedTest(name = "when field has wrong type {0}, should throw") + @ValueSource(strings = { + "{\"username\": {}, \"password\": \"s3cret\"}", + "{\"username\": [], \"password\": \"s3cret\"}", + "{\"username\": \"admin\", \"password\": {}}", + "{\"username\": \"admin\", \"password\": []}" + }) + void whenFieldHasWrongType_shouldThrow(String json) throws IOException { + // given + var file = tempDir.resolve("secret.json"); + Files.writeString(file, json); + + var fileRef = new FileRef(); + fileRef.setPath(file.toString()); + + // when / then + assertThatThrownBy(() -> service.getSecretFileRefCredentials(fileRef)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to read"); + } + + @Test + @DisplayName("when file not found, should throw") + void whenFileNotFound_shouldThrow() { + // given + var fileRef = new FileRef(); + fileRef.setPath(tempDir.resolve("nonexistent.json").toString()); + + // when / then + assertThatThrownBy(() -> service.getSecretFileRefCredentials(fileRef)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Credentials file not found"); + } + + @Test + @DisplayName("when invalid JSON, should throw") + void whenInvalidJson_shouldThrow() throws IOException { + // given + var file = tempDir.resolve("secret.json"); + Files.writeString(file, "not valid json {{{"); + + var fileRef = new FileRef(); + fileRef.setPath(file.toString()); + + // when / then + assertThatThrownBy(() -> service.getSecretFileRefCredentials(fileRef)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Failed to read"); + } + } + + @Nested + class GetSecretRefCredentials { + @Test + @DisplayName("when secret exists, should return decoded credentials") + void whenSecretExists_shouldReturnDecodedCredentials() { + // given + var secret = basicAuthSecret("my-ns", "my-secret", "admin", "s3cret"); + client.secrets().inNamespace("my-ns").resource(secret).create(); + + // when + var result = service.getSecretRefCredentials(client, secretRef("my-ns", "my-secret"), "default-ns"); + + // then + assertThat(result.username()).isEqualTo("admin"); + assertThat(result.password()).isEqualTo("s3cret"); + } + + @Test + @DisplayName("when secret not found, should throw") + void whenSecretNotFound_shouldThrow() { + // given — no secret created + + // when / then + assertThatThrownBy(() -> service.getSecretRefCredentials(client, secretRef("my-ns", "my-secret"), "default-ns")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Secret reference not found"); + } + + @Test + @DisplayName("when secret wrong type, should throw") + void whenSecretWrongType_shouldThrow() { + // given + var secret = new SecretBuilder() + .withNewMetadata().withNamespace("my-ns").withName("my-secret").endMetadata() + .withType("Opaque") + .addToData("password", base64("s3cret")) + .build(); + client.secrets().inNamespace("my-ns").resource(secret).create(); + + // when / then + assertThatThrownBy(() -> service.getSecretRefCredentials(client, secretRef("my-ns", "my-secret"), "default-ns")) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("wrong type"); + } + + @Test + @DisplayName("when secret has no data, should throw") + void whenSecretHasNoData_shouldThrow() { + // given + var secret = new SecretBuilder() + .withNewMetadata().withNamespace("my-ns").withName("my-secret").endMetadata() + .withType(KubernetesService.SECRET_TYPE_BASIC_AUTH) + .build(); + client.secrets().inNamespace("my-ns").resource(secret).create(); + + // when / then + assertThatThrownBy(() -> service.getSecretRefCredentials(client, secretRef("my-ns", "my-secret"), "default-ns")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("has no data set"); + } + + @Test + @DisplayName("when namespace null, should use default namespace") + void whenNamespaceNull_shouldUseDefaultNamespace() { + // given + var secret = basicAuthSecret("default-ns", "my-secret", "admin", "s3cret"); + client.secrets().inNamespace("default-ns").resource(secret).create(); + + // when + var result = service.getSecretRefCredentials(client, secretRef(null, "my-secret"), "default-ns"); + + // then + assertThat(result.username()).isEqualTo("admin"); + assertThat(result.password()).isEqualTo("s3cret"); + } + + @Test + @DisplayName("when secret has empty data map, should throw") + void whenSecretHasEmptyDataMap_shouldThrow() { + // given + var secret = new SecretBuilder() + .withNewMetadata().withNamespace("my-ns").withName("my-secret").endMetadata() + .withType(KubernetesService.SECRET_TYPE_BASIC_AUTH) + .withData(Map.of()) + .build(); + client.secrets().inNamespace("my-ns").resource(secret).create(); + + // when / then + assertThatThrownBy(() -> service.getSecretRefCredentials(client, secretRef("my-ns", "my-secret"), "default-ns")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("has no data set"); + } + + @Test + @DisplayName("when secret has username only (no password), should throw") + void whenSecretHasUsernameOnly_shouldThrow() { + // given + var secret = new SecretBuilder() + .withNewMetadata().withNamespace("my-ns").withName("my-secret").endMetadata() + .withType(KubernetesService.SECRET_TYPE_BASIC_AUTH) + .addToData("username", base64("admin")) + .build(); + client.secrets().inNamespace("my-ns").resource(secret).create(); + + // when / then + assertThatThrownBy(() -> service.getSecretRefCredentials(client, secretRef("my-ns", "my-secret"), "default-ns")) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missing required data password"); + } + + @Test + @DisplayName("when secret has password only (no username), should return null username") + void whenSecretHasPasswordOnly_shouldReturnNullUsername() { + // given + var secret = new SecretBuilder() + .withNewMetadata().withNamespace("my-ns").withName("my-secret").endMetadata() + .withType(KubernetesService.SECRET_TYPE_BASIC_AUTH) + .addToData("password", base64("s3cret")) + .build(); + client.secrets().inNamespace("my-ns").resource(secret).create(); + + // when + var result = service.getSecretRefCredentials(client, secretRef("my-ns", "my-secret"), "default-ns"); + + // then + assertThat(result.username()).isNull(); + assertThat(result.password()).isEqualTo("s3cret"); + } + } + + @Nested + class GetAdminCredentials { + @Test + @DisplayName("when only adminSecretRef set, should delegate to secret ref") + void whenOnlyAdminSecretRefSet_shouldDelegateToSecretRef() { + // given + var secret = basicAuthSecret("my-ns", "my-secret", "admin", "s3cret"); + client.secrets().inNamespace("my-ns").resource(secret).create(); + + var spec = new ClusterConnectionSpec(); + spec.setAdminSecretRef(secretRef("my-ns", "my-secret")); + + var clusterConnection = buildClusterConnection(spec, "cr-ns"); + + // when + var result = service.getAdminCredentials(client, clusterConnection); + + // then + assertThat(result.username()).isEqualTo("admin"); + assertThat(result.password()).isEqualTo("s3cret"); + } + + @Test + @DisplayName("when only adminSecretFileRef set, should delegate to file ref") + void whenOnlyAdminSecretFileRefSet_shouldDelegateToFileRef() throws IOException { + // given + var file = tempDir.resolve("secret.json"); + Files.writeString(file, """ + {"username": "file-admin", "password": "file-s3cret"} + """); + + var fileRef = new FileRef(); + fileRef.setPath(file.toString()); + + var spec = new ClusterConnectionSpec(); + spec.setAdminSecretFileRef(fileRef); + + var clusterConnection = buildClusterConnection(spec, "cr-ns"); + + // when + var result = service.getAdminCredentials(client, clusterConnection); + + // then + assertThat(result.username()).isEqualTo("file-admin"); + assertThat(result.password()).isEqualTo("file-s3cret"); + } + + @Test + @DisplayName("when adminSecretRef missing username, should throw with message not NPE") + void whenAdminSecretRefMissingUsername_shouldThrowWithMessage() { + // given + var secret = new SecretBuilder() + .withNewMetadata().withNamespace("my-ns").withName("my-secret").endMetadata() + .withType(KubernetesService.SECRET_TYPE_BASIC_AUTH) + .addToData("password", base64("s3cret")) + .build(); + client.secrets().inNamespace("my-ns").resource(secret).create(); + + var spec = new ClusterConnectionSpec(); + spec.setAdminSecretRef(secretRef("my-ns", "my-secret")); + + var clusterConnection = buildClusterConnection(spec, "cr-ns"); + + // when / then + assertThatThrownBy(() -> service.getAdminCredentials(client, clusterConnection)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("missing required data username"); + } + + @Test + @DisplayName("when neither ref set, should throw") + void whenNeitherRefSet_shouldThrow() { + // given + var spec = new ClusterConnectionSpec(); + + var clusterConnection = buildClusterConnection(spec, "cr-ns"); + + // when / then + assertThatThrownBy(() -> service.getAdminCredentials(client, clusterConnection)) + .isInstanceOf(IllegalStateException.class) + .hasMessageContaining("Exactly one of"); + } + } + + private static ClusterConnection buildClusterConnection(ClusterConnectionSpec spec, String namespace) { + var meta = new ObjectMeta(); + meta.setNamespace(namespace); + + var clusterConnection = new ClusterConnection(); + clusterConnection.setSpec(spec); + clusterConnection.setMetadata(meta); + + return clusterConnection; + } + + private static ResourceRef secretRef(@Nullable String namespace, String name) { + var ref = new ResourceRef(); + ref.setNamespace(namespace); + ref.setName(name); + return ref; + } + + private static Secret basicAuthSecret(String namespace, String name, + @Nullable String username, String password) { + var builder = new SecretBuilder() + .withNewMetadata().withNamespace(namespace).withName(name).endMetadata() + .withType(KubernetesService.SECRET_TYPE_BASIC_AUTH) + .addToData("password", base64(password)); + if (username != null) { + builder.addToData("username", base64(username)); + } + return builder.build(); + } + + private static String base64(String value) { + return Base64.getEncoder().encodeToString(value.getBytes(Charset.defaultCharset())); + } +} diff --git a/operator/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java b/operator/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java index 22054be..e5b29dd 100644 --- a/operator/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java +++ b/operator/src/test/java/it/aboutbits/postgresql/crd/clusterconnection/ClusterConnectionReconcilerTest.java @@ -1,20 +1,28 @@ package it.aboutbits.postgresql.crd.clusterconnection; import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.KubernetesClientException; import io.quarkus.test.junit.QuarkusTest; import it.aboutbits.postgresql._support.testdata.base.TestUtil; import it.aboutbits.postgresql._support.testdata.persisted.Given; +import it.aboutbits.postgresql._support.valuesource.BlankSource; import it.aboutbits.postgresql.core.CRPhase; import it.aboutbits.postgresql.core.CRStatus; +import it.aboutbits.postgresql.core.FileRef; import it.aboutbits.postgresql.core.PostgreSQLContextFactory; import lombok.RequiredArgsConstructor; +import org.eclipse.microprofile.config.inject.ConfigProperty; 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.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import java.io.IOException; +import java.nio.file.Files; import java.time.OffsetDateTime; import java.time.ZoneOffset; import java.time.temporal.ChronoUnit; @@ -23,6 +31,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.within; @QuarkusTest @@ -35,6 +44,14 @@ class ClusterConnectionReconcilerTest { private final KubernetesClient kubernetesClient; + @SuppressWarnings("NullAway.Init") + @ConfigProperty(name = "quarkus.datasource.devservices.username") + String dbUsername; + + @SuppressWarnings("NullAway.Init") + @ConfigProperty(name = "quarkus.datasource.devservices.password") + String dbPassword; + @BeforeEach void resetEnvironment() { TestUtil.resetEnvironment(kubernetesClient); @@ -69,6 +86,94 @@ void createsCustomResource_andReconcilerStatusIsReady() { ); } + @Test + @DisplayName("When a ClusterConnection with adminSecretFileRef is created, the status should be ready") + void createsCustomResourceWithFileRef_andReconcilerStatusIsReady() throws IOException { + // given + var credentialsFile = Files.createTempFile("db-credentials", ".json"); + try { + Files.writeString(credentialsFile, """ + {"username": "%s", "password": "%s"} + """.formatted(dbUsername, dbPassword)); + + var fileRef = new FileRef(); + fileRef.setPath(credentialsFile.toAbsolutePath().toString()); + + // when + var customResource = given.one() + .clusterConnection() + .withName("test-connection-file-ref") + .withoutAdminSecret() + .withAdminSecretFileRef(fileRef) + .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) + ); + } finally { + Files.deleteIfExists(credentialsFile); + } + } + + @Nested + class CRDValidation { + @Test + @DisplayName("when both adminSecretRef and adminSecretFileRef set, should reject") + void whenBothSet_shouldReject() { + var fileRef = new FileRef(); + fileRef.setPath("/mnt/secrets/db-credentials.json"); + + assertThatThrownBy(() -> given.one() + .clusterConnection() + .withAdminSecretFileRef(fileRef) + .returnFirst() + ).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("Exactly one of"); + } + + @Test + @DisplayName("when neither adminSecretRef nor adminSecretFileRef set, should reject") + void whenNeitherSet_shouldReject() { + assertThatThrownBy(() -> given.one() + .clusterConnection() + .withoutAdminSecret() + .returnFirst() + ).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("Exactly one of"); + } + + @ParameterizedTest + @BlankSource + @DisplayName("when adminSecretFileRef has blank path, should reject") + void whenFileRefBlankPath_shouldReject(String blankOrEmptyString) { + var fileRef = new FileRef(); + fileRef.setPath(blankOrEmptyString); + + assertThatThrownBy(() -> given.one() + .clusterConnection() + .withoutAdminSecret() + .withAdminSecretFileRef(fileRef) + .returnFirst() + ).isInstanceOf(KubernetesClientException.class) + .hasMessageContaining("must not be empty"); + } + } + private static void assertThatClusterConnectionHasExpectedStatus( ClusterConnection clusterConnection, CRStatus expectedStatus,