diff --git a/operator/build.gradle.kts b/operator/build.gradle.kts index 1f2f0bb..0e8edf0 100644 --- a/operator/build.gradle.kts +++ b/operator/build.gradle.kts @@ -71,11 +71,29 @@ tasks.quarkusAppPartsBuild { doNotTrackState("Always execute Gradle task quarkusAppPartsBuild to generate the K8s deploy manifest kubernetes.yml, the CRDs, and to publish the Helm chart") } -tasks.withType { - val mockitoAgent = configurations.testRuntimeClasspath.get().find { - it.name.contains("mockito-core") - } - if (mockitoAgent != null) { - jvmArgs("-javaagent:${mockitoAgent.absolutePath}") +val mockitoAgentProvider = configurations.named("testRuntimeClasspath").map { classpath -> + classpath.find { it.name.contains("mockito-core") } +} + +tasks.withType().configureEach { + // Required for the HelmTest + dependsOn(tasks.quarkusAppPartsBuild) + + jvmArgumentProviders.add(MockitoArgumentProvider(mockitoAgentProvider)) +} + +class MockitoArgumentProvider( + @get:Optional + @get:InputFile + @get:PathSensitive(PathSensitivity.NONE) + val agentProvider: Provider +) : CommandLineArgumentProvider { + override fun asArguments(): Iterable { + val agentFile = agentProvider.orNull + return if (agentFile != null) { + listOf("-javaagent:${agentFile.absolutePath}") + } else { + emptyList() + } } } diff --git a/operator/src/main/kubernetes/kubernetes.yml b/operator/src/main/kubernetes/kubernetes.yml index 74ac1e1..037464e 100644 --- a/operator/src/main/kubernetes/kubernetes.yml +++ b/operator/src/main/kubernetes/kubernetes.yml @@ -7,3 +7,4 @@ spec: template: spec: affinity: {} + imagePullSecrets: [~] diff --git a/operator/src/main/resources/application.yml b/operator/src/main/resources/application.yml index f37d4c6..fa71a49 100644 --- a/operator/src/main/resources/application.yml +++ b/operator/src/main/resources/application.yml @@ -84,6 +84,14 @@ quarkus: value: IfNotPresent paths: - (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: "{{- toYaml .Values.app.imagePullSecrets | nindent 8 }}" + description: Kubernetes image pull secrets to use if the OCI image is hosted on a private registry resource-requests-cpu: property: resources.requests.cpu value: ${quarkus.kubernetes.resources.requests.cpu} @@ -99,12 +107,6 @@ quarkus: 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 - description: Kubernetes image pull secret to use if the OCI image is hosted on a private registry affinity: property: affinity value-as-map: {} @@ -149,8 +151,6 @@ quarkus: 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} diff --git a/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java b/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java new file mode 100644 index 0000000..2dea840 --- /dev/null +++ b/operator/src/test/java/it/aboutbits/postgresql/helm/HelmTest.java @@ -0,0 +1,247 @@ +package it.aboutbits.postgresql.helm; + +import io.fabric8.kubernetes.api.model.ConfigBuilder; +import io.fabric8.kubernetes.api.model.LocalObjectReference; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.utils.Serialization; +import io.quarkus.test.junit.QuarkusTest; +import io.smallrye.common.process.ProcessBuilder; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jspecify.annotations.NullMarked; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.concurrent.TimeUnit; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +@Slf4j +@QuarkusTest +@NullMarked +class HelmTest { + private static final String ENV_VAR_KUBECONFIG = "KUBECONFIG"; + + private static final String CRD_GROUP = "postgresql.aboutbits.it"; + private static final List CRD_NAMES = List.of( + "clusterconnection", + "database", + "schema", + "role", + "grant", + "defaultprivilege" + ); + + private final String chartName; + private final String rootValuesAlias; + private final KubernetesClient kubernetesClient; + + HelmTest( + KubernetesClient kubernetesClient, + @ConfigProperty(name = "quarkus.helm.name") String chartName, + @ConfigProperty(name = "quarkus.helm.values-root-alias", defaultValue = "app") String rootValuesAlias + ) { + this.kubernetesClient = kubernetesClient; + this.chartName = chartName; + this.rootValuesAlias = rootValuesAlias; + } + + @SuppressWarnings("checkstyle:MethodLength") + @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); + + assertThat(chartPath) + .withFailMessage("Helm chart not found at %s. Ensure that the chart is generated before running this test.", chartPath) + .exists(); + + // 1. Verify files exist and contain expected data + // ./Chart.yaml + @SuppressWarnings("unchecked") + Map chartMetadata = Serialization.yamlMapper() + .readValue( + chartPath.resolve("Chart.yaml").toFile(), + Map.class + ); + + assertThat(chartMetadata.get("name")).isEqualTo(chartName); + + // ./values.yaml + @SuppressWarnings("unchecked") + Map values = Serialization.yamlMapper() + .readValue( + chartPath.resolve("values.yaml").toFile(), + Map.class + ); + + assertThat(values).containsKey(rootValuesAlias); + + @SuppressWarnings("unchecked") + var appValues = (Map) values.get(rootValuesAlias); + + Objects.requireNonNull(appValues, "appValues should not be null"); + assertThat(appValues.get("image")).isNotNull(); + + assertThat(chartPath.resolve("LICENSE")).exists(); + assertThat(chartPath.resolve("README.md")).exists(); + assertThat(chartPath.resolve("values.schema.json")).exists(); + + // ./crds/ + for (var crdName : CRD_NAMES) { + assertThat(chartPath.resolve("crds/%ss.%s-v1.yml".formatted( + crdName, + CRD_GROUP + ))).exists(); + } + + // ./templates/ + assertThat(chartPath.resolve("templates/clusterrole.yaml")).exists(); + assertThat(chartPath.resolve("templates/clusterrolebinding.yaml")).exists(); + assertThat(chartPath.resolve("templates/deployment.yaml")).exists(); + assertThat(chartPath.resolve("templates/rolebinding.yaml")).exists(); + assertThat(chartPath.resolve("templates/service.yaml")).exists(); + assertThat(chartPath.resolve("templates/serviceaccount.yaml")).exists(); + assertThat(chartPath.resolve("templates/validating-clusterrolebinding.yaml")).exists(); + + for (var crdName : CRD_NAMES) { + assertThat(chartPath.resolve("templates/%sreconciler-crd-role-binding.yaml".formatted( + crdName + ))).exists(); + } + + // 2. Prepare a temporary KubeConfig for the 'helm' CLI + // This ensures 'helm' uses the same Kubernetes cluster as the test environment (e.g., provided by DevServices). + var kubeConfigPath = createTempKubeConfig(); + + try { + // 3. Install the Helm chart using 'helm install' + var releaseName = "helm-install-test-" + System.nanoTime(); + + var holder = new Object() { + int exitCode; + }; + var installOutput = new StringBuilder(); + + ProcessBuilder.newBuilder( + "helm", + "install", releaseName, chartPath.toAbsolutePath().toString(), "--set", rootValuesAlias + ".image=postgresql-operator:test" + ).environment(Map.of( + ENV_VAR_KUBECONFIG, + kubeConfigPath.toAbsolutePath().toString() + )) + .exitCodeChecker(ec -> { + holder.exitCode = ec; + return true; + }) + .error().redirect() + .output() + .consumeLinesWith(65536, line -> installOutput.append(line).append(System.lineSeparator())) + .run(); + + int installExitCode = holder.exitCode; + assertThat(installExitCode) + .withFailMessage("Helm install failed with output:\n" + installOutput) + .isZero(); + + try { + // 4. Verify that the deployment is created in Kubernetes + await().atMost(10, TimeUnit.SECONDS).untilAsserted(() -> { + var deployment = kubernetesClient.apps().deployments().withName(chartName).get(); + + assertThat(deployment).isNotNull(); + + // Helm sets labels based on the release name + assertThat(deployment.getMetadata()) + .isNotNull() + .satisfies(metadata -> assertThat(metadata.getLabels()) + .containsAllEntriesOf(Map.of( + "app.kubernetes.io/name", releaseName, + "app.kubernetes.io/managed-by", "Helm" + )) + ); + + assertThat(deployment.getSpec()) + .isNotNull() + .satisfies(spec -> assertThat(spec.getTemplate().getSpec().getImagePullSecrets()) + .isNotEmpty() + .element(0) + .isNotNull() + .isEqualTo(new LocalObjectReference(null)) + ); + + var selector = deployment.getSpec().getSelector(); + + var pods = kubernetesClient.pods() + .withLabelSelector(selector) + .list() + .getItems(); + + assertThat(pods).isNotEmpty(); + }); + } finally { + // 5. Cleanup the created resources using 'helm uninstall' + ProcessBuilder.newBuilder( + "helm", + "uninstall", releaseName + ).environment(Map.of( + ENV_VAR_KUBECONFIG, + kubeConfigPath.toAbsolutePath().toString() + )) + .error().consumeLinesWith( + 8192, + log::error + ) + .run(); + } + } finally { + Files.deleteIfExists(kubeConfigPath); + } + } + + private Path createTempKubeConfig() throws IOException { + var clientConfig = kubernetesClient.getConfiguration(); + + var kubeConfig = new ConfigBuilder() + .addNewCluster() + .withName("dev-cluster") + .withNewCluster() + .withServer(clientConfig.getMasterUrl()) + .withCertificateAuthorityData(clientConfig.getCaCertData()) + .endCluster() + .endCluster() + .addNewUser() + .withName("dev-user") + .withNewUser() + .withClientCertificateData(clientConfig.getClientCertData()) + .withClientKeyData(clientConfig.getClientKeyData()) + .endUser() + .endUser() + .addNewContext() + .withName("dev-context") + .withNewContext() + .withCluster("dev-cluster") + .withUser("dev-user") + .withNamespace(clientConfig.getNamespace()) + .endContext() + .endContext() + .withCurrentContext("dev-context") + .build(); + + var path = Files.createTempFile("kubeconfig-helm-test-", ".yaml"); + + Files.writeString(path, Serialization.asYaml(kubeConfig)); + + return path; + } +}