diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/ConsoleSpec.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/ConsoleSpec.java index 9e9d14319..e2193261a 100644 --- a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/ConsoleSpec.java +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/ConsoleSpec.java @@ -4,6 +4,8 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.github.streamshub.console.api.v1alpha1.spec.containers.Containers; import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource; import io.fabric8.generator.annotation.Required; @@ -26,6 +28,16 @@ public class ConsoleSpec { @Required String hostname; + @JsonPropertyDescription(""" + Templates for Console instance containers. The templates allow \ + users to specify how the Kubernetes resources are generated. + """) + Containers containers; + + @JsonPropertyDescription(""" + DEPRECATED: Image overrides to be used for the API and UI servers. \ + Use `containers` property instead. + """) Images images; List metricsSources; @@ -34,6 +46,10 @@ public class ConsoleSpec { List kafkaClusters = new ArrayList<>(); + @JsonPropertyDescription(""" + DEPRECATED: Environment variables which should be applied to the API container. \ + Use `containers` property instead. + """) List env; public String getHostname() { @@ -44,6 +60,14 @@ public void setHostname(String hostname) { this.hostname = hostname; } + public Containers getContainers() { + return containers; + } + + public void setContainers(Containers containers) { + this.containers = containers; + } + public Images getImages() { return images; } diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/containers/ContainerTemplate.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/containers/ContainerTemplate.java new file mode 100644 index 000000000..7a710109f --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/containers/ContainerTemplate.java @@ -0,0 +1,49 @@ +package com.github.streamshub.console.api.v1alpha1.spec.containers; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.ResourceRequirements; +import io.sundr.builder.annotations.Buildable; + +@Buildable +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ContainerTemplate { + + @JsonPropertyDescription("Container image to be used for the container") + private String image; + + @JsonPropertyDescription("CPU and memory resources to reserve.") + private ResourceRequirements resources; + + @JsonPropertyDescription("Environment variables which should be applied to the container.") + private List env; + + public String getImage() { + return image; + } + + public void setImage(String image) { + this.image = image; + } + + public ResourceRequirements getResources() { + return resources; + } + + public void setResources(ResourceRequirements resources) { + this.resources = resources; + } + + public List getEnv() { + return env; + } + + public void setEnv(List env) { + this.env = env; + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/containers/Containers.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/containers/Containers.java new file mode 100644 index 000000000..c619e9b11 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/containers/Containers.java @@ -0,0 +1,35 @@ +package com.github.streamshub.console.api.v1alpha1.spec.containers; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import io.sundr.builder.annotations.Buildable; + +@Buildable +@JsonInclude(JsonInclude.Include.NON_NULL) +public class Containers { + + @JsonPropertyDescription("Template for the Console API server container. " + + "The template allows users to specify how the Kubernetes resources are generated.") + ContainerTemplate api; + + @JsonPropertyDescription("Template for the Console UI server container. " + + "The template allows users to specify how the Kubernetes resources are generated.") + ContainerTemplate ui; + + public ContainerTemplate getApi() { + return api; + } + + public void setApi(ContainerTemplate api) { + this.api = api; + } + + public ContainerTemplate getUi() { + return ui; + } + + public void setUi(ContainerTemplate ui) { + this.ui = ui; + } +} diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleDeployment.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleDeployment.java index 155d9d59a..062b2a218 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleDeployment.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleDeployment.java @@ -1,6 +1,5 @@ package com.github.streamshub.console.dependents; -import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; @@ -13,6 +12,8 @@ import com.github.streamshub.console.api.v1alpha1.Console; import com.github.streamshub.console.api.v1alpha1.spec.Images; +import com.github.streamshub.console.api.v1alpha1.spec.containers.ContainerTemplate; +import com.github.streamshub.console.api.v1alpha1.spec.containers.Containers; import com.github.streamshub.console.dependents.discriminators.ConsoleLabelDiscriminator; import io.fabric8.kubernetes.api.model.EnvVar; @@ -61,14 +62,13 @@ protected Deployment desired(Console primary, Context context) { String name = instanceName(primary); String configSecretName = secret.instanceName(primary); - var imagesSpec = Optional.ofNullable(primary.getSpec().getImages()); - String imageAPI = imagesSpec.map(Images::getApi).orElse(defaultAPIImage); - String imageUI = imagesSpec.map(Images::getUi).orElse(defaultUIImage); - - var envVars = new ArrayList<>(coalesce(primary.getSpec().getEnv(), Collections::emptyList)); - + var containers = Optional.ofNullable(primary.getSpec().getContainers()); + var templateAPI = containers.map(Containers::getApi); + var templateUI = containers.map(Containers::getUi); var trustResources = getTrustResources(context); - envVars.addAll(getResourcesByType(trustResources, EnvVar.class)); + + // deprecated + var images = Optional.ofNullable(primary.getSpec().getImages()); return desired.edit() .editMetadata() @@ -95,13 +95,26 @@ protected Deployment desired(Console primary, Context context) { .endSecret() .endVolume() .addAllToVolumes(getResourcesByType(trustResources, Volume.class)) + // Set API container image options .editMatchingContainer(c -> "console-api".equals(c.getName())) - .withImage(imageAPI) + .withImage(templateAPI.map(ContainerTemplate::getImage) + .or(() -> images.map(Images::getApi)) + .orElse(defaultAPIImage)) + .withResources(templateAPI.map(ContainerTemplate::getResources).orElse(null)) .addAllToVolumeMounts(getResourcesByType(trustResources, VolumeMount.class)) - .addAllToEnv(envVars) + // deprecated env list + .addAllToEnv(coalesce(primary.getSpec().getEnv(), Collections::emptyList)) + // Env from template + .addAllToEnv(templateAPI.map(ContainerTemplate::getEnv).orElseGet(Collections::emptyList)) + // Env for truststores + .addAllToEnv(getResourcesByType(trustResources, EnvVar.class)) .endContainer() + // Set UI container image options .editMatchingContainer(c -> "console-ui".equals(c.getName())) - .withImage(imageUI) + .withImage(templateUI.map(ContainerTemplate::getImage) + .or(() -> images.map(Images::getUi)) + .orElse(defaultUIImage)) + .withResources(templateUI.map(ContainerTemplate::getResources).orElse(null)) .editMatchingEnv(env -> "NEXTAUTH_URL".equals(env.getName())) .withValue(getAttribute(context, ConsoleIngress.NAME + ".url", String.class)) .endEnv() @@ -112,6 +125,7 @@ protected Deployment desired(Console primary, Context context) { .endSecretKeyRef() .endValueFrom() .endEnv() + .addAllToEnv(templateUI.map(ContainerTemplate::getEnv).orElseGet(Collections::emptyList)) .endContainer() .endSpec() .endTemplate() diff --git a/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTest.java b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTest.java index 8cd0da87a..12b34f6f7 100644 --- a/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTest.java +++ b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTest.java @@ -32,9 +32,13 @@ import io.fabric8.kubernetes.api.model.ConfigMap; import io.fabric8.kubernetes.api.model.ConfigMapBuilder; import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.EnvVarBuilder; import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.KeyToPath; import io.fabric8.kubernetes.api.model.NamespaceBuilder; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Quantity; +import io.fabric8.kubernetes.api.model.ResourceRequirementsBuilder; import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.kubernetes.api.model.SecretBuilder; import io.fabric8.kubernetes.api.model.Volume; @@ -278,6 +282,136 @@ void testBasicConsoleReconciliation() { }); } + @Test + void testConsoleReconciliationWithContainerOverrides() { + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName("console-1") + .withNamespace("ns2") + .build()) + .withNewSpec() + .withHostname("console.example.com") + .addNewMetricsSource() + .withName("metrics") + .withType(Type.STANDALONE) + .withUrl("http://prometheus.example.com") + .endMetricsSource() + .withNewImages() + .withApi("deprecated-api-image") + .withUi("deprecated-ui-image") + .endImages() + .addToEnv(new EnvVarBuilder() + .withName("DEPRECATED_API_VAR") + .withValue("value0") + .build()) + .withNewContainers() + .withNewApi() + .withImage("custom-api-image") + .withResources(new ResourceRequirementsBuilder() + .withRequests(Map.of("cpu", Quantity.parse("250m"))) + .withLimits(Map.of("cpu", Quantity.parse("500m"))) + .build()) + .addToEnv(new EnvVarBuilder() + .withName("CUSTOM_API_VAR") + .withValue("value1") + .build()) + .endApi() + .withNewUi() + .withImage("custom-ui-image") + .withResources(new ResourceRequirementsBuilder() + .withRequests(Map.of("cpu", Quantity.parse("100m"))) + .withLimits(Map.of("cpu", Quantity.parse("200m"))) + .build()) + .addToEnv(new EnvVarBuilder() + .withName("CUSTOM_UI_VAR") + .withValue("value2") + .build()) + .endUi() + .endContainers() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .withMetricsSource("metrics") + .endKafkaCluster() + .endSpec() + .build(); + + client.resource(consoleCR).create(); + + await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { + var console = client.resources(Console.class) + .inNamespace(consoleCR.getMetadata().getNamespace()) + .withName(consoleCR.getMetadata().getName()) + .get(); + assertEquals(1, console.getStatus().getConditions().size()); + var condition = console.getStatus().getConditions().get(0); + assertEquals("Ready", condition.getType()); + assertEquals("False", condition.getStatus()); + assertEquals("DependentsNotReady", condition.getReason()); + assertTrue(condition.getMessage().contains("ConsoleIngress"), condition::getMessage); + }); + + var consoleIngress = client.network().v1().ingresses() + .inNamespace(consoleCR.getMetadata().getNamespace()) + .withName("console-1-console-ingress") + .get(); + + consoleIngress = consoleIngress.edit() + .editOrNewStatus() + .withNewLoadBalancer() + .addNewIngress() + .withHostname("ingress.example.com") + .endIngress() + .endLoadBalancer() + .endStatus() + .build(); + client.resource(consoleIngress).patchStatus(); + LOGGER.info("Set ingress status for Console ingress"); + + await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { + var console = client.resources(Console.class) + .inNamespace(consoleCR.getMetadata().getNamespace()) + .withName(consoleCR.getMetadata().getName()) + .get(); + assertEquals(1, console.getStatus().getConditions().size()); + var condition = console.getStatus().getConditions().get(0); + assertEquals("Ready", condition.getType()); + assertEquals("False", condition.getStatus()); + assertEquals("DependentsNotReady", condition.getReason()); + assertTrue(condition.getMessage().contains("ConsoleDeployment")); + }); + + var consoleDeployment = client.apps().deployments() + .inNamespace(consoleCR.getMetadata().getNamespace()) + .withName("console-1-console-deployment") + .get(); + + var consoleContainers = consoleDeployment.getSpec().getTemplate().getSpec().getContainers(); + var apiContainer = consoleContainers.get(0); + + assertEquals("custom-api-image", apiContainer.getImage()); + assertEquals(new ResourceRequirementsBuilder() + .withRequests(Map.of("cpu", Quantity.parse("250m"))) + .withLimits(Map.of("cpu", Quantity.parse("500m"))) + .build(), apiContainer.getResources()); + assertEquals(3, apiContainer.getEnv().size()); // 2 overrides + 1 from YAML template + assertEquals("value0", apiContainer.getEnv().stream() + .filter(e -> e.getName().equals("DEPRECATED_API_VAR")).map(EnvVar::getValue).findFirst().orElseThrow()); + assertEquals("value1", apiContainer.getEnv().stream() + .filter(e -> e.getName().equals("CUSTOM_API_VAR")).map(EnvVar::getValue).findFirst().orElseThrow()); + + var uiContainer = consoleContainers.get(1); + assertEquals("custom-ui-image", uiContainer.getImage()); + assertEquals(new ResourceRequirementsBuilder() + .withRequests(Map.of("cpu", Quantity.parse("100m"))) + .withLimits(Map.of("cpu", Quantity.parse("200m"))) + .build(), uiContainer.getResources()); + assertEquals(7, uiContainer.getEnv().size()); // 1 override + 6 from YAML template + assertEquals("value2", uiContainer.getEnv().stream() + .filter(e -> e.getName().equals("CUSTOM_UI_VAR")).map(EnvVar::getValue).findFirst().orElseThrow()); + } + @Test void testConsoleReconciliationWithInvalidListenerName() { Console consoleCR = new ConsoleBuilder() @@ -952,31 +1086,22 @@ void testConsoleReconciliationWithTrustStores() { var volumes = podSpec.getVolumes().stream().collect(Collectors.toMap(Volume::getName, Function.identity())); assertEquals(4, volumes.size()); // cache, config + 2 volumes for truststores - var metricsVolName = "metrics-source-truststore-example-prometheus"; - var registryVolName = "schema-registry-truststore-example-registry"; - - var metricsVolume = volumes.get(metricsVolName); - assertEquals("metrics-source-truststore.example-prometheus.content", metricsVolume.getSecret().getItems().get(0).getKey()); - assertEquals("metrics-source-truststore.example-prometheus.jks", metricsVolume.getSecret().getItems().get(0).getPath()); - - var registryVolume = volumes.get(registryVolName); - assertEquals("schema-registry-truststore.example-registry.content", registryVolume.getSecret().getItems().get(0).getKey()); - assertEquals("schema-registry-truststore.example-registry.pem", registryVolume.getSecret().getItems().get(0).getPath()); - var mounts = containerSpecAPI.getVolumeMounts().stream().collect(Collectors.toMap(VolumeMount::getName, Function.identity())); assertEquals(3, mounts.size()); - var metricsMount = mounts.get(metricsVolName); + var envVars = containerSpecAPI.getEnv().stream().collect(Collectors.toMap(EnvVar::getName, Function.identity())); + + var metricsVolName = "metrics-source-truststore-example-prometheus"; var metricsMountPath = "/etc/ssl/metrics-source-truststore.example-prometheus.jks"; - assertEquals(metricsMountPath, metricsMount.getMountPath()); - assertEquals("metrics-source-truststore.example-prometheus.jks", metricsMount.getSubPath()); - var registryMount = mounts.get(registryVolName); - var registryMountPath = "/etc/ssl/schema-registry-truststore.example-registry.pem"; - assertEquals(registryMountPath, registryMount.getMountPath()); - assertEquals("schema-registry-truststore.example-registry.pem", registryMount.getSubPath()); - - var envVars = containerSpecAPI.getEnv().stream().collect(Collectors.toMap(EnvVar::getName, Function.identity())); + assertKeyToPath( + "metrics-source-truststore.example-prometheus.content", + "metrics-source-truststore.example-prometheus.jks", + volumes.get(metricsVolName).getSecret().getItems().get(0)); + assertMounthPaths( + metricsMountPath, + "metrics-source-truststore.example-prometheus.jks", + mounts.get(metricsVolName)); var metricsTrustPath = envVars.get("QUARKUS_TLS__METRICS_SOURCE_EXAMPLE_PROMETHEUS__TRUST_STORE_JKS_PATH"); assertEquals(metricsMountPath, metricsTrustPath.getValue()); @@ -987,12 +1112,35 @@ void testConsoleReconciliationWithTrustStores() { assertEquals("console-1-console-secret", metricsPasswordSource.getValueFrom().getSecretKeyRef().getName()); assertEquals("metrics-source-truststore.example-prometheus.password", metricsPasswordSource.getValueFrom().getSecretKeyRef().getKey()); + var registryVolName = "schema-registry-truststore-example-registry"; + var registryMountPath = "/etc/ssl/schema-registry-truststore.example-registry.pem"; + + assertKeyToPath( + "schema-registry-truststore.example-registry.content", + "schema-registry-truststore.example-registry.pem", + volumes.get(registryVolName).getSecret().getItems().get(0)); + + assertMounthPaths( + registryMountPath, + "schema-registry-truststore.example-registry.pem", + mounts.get(registryVolName)); + var registryTrustPath = envVars.get("QUARKUS_TLS__SCHEMA_REGISTRY_EXAMPLE_REGISTRY__TRUST_STORE_PEM_CERTS"); assertEquals(registryMountPath, registryTrustPath.getValue()); } // Utility + private void assertKeyToPath(String expectedKey, String expectedPath, KeyToPath keyPath) { + assertEquals(expectedKey, keyPath.getKey()); + assertEquals(expectedPath, keyPath.getPath()); + } + + private void assertMounthPaths(String expectedPath, String expectedSubpath, VolumeMount mount) { + assertEquals(expectedPath, mount.getMountPath()); + assertEquals(expectedSubpath, mount.getSubPath()); + } + private void assertConsoleConfig(Consumer assertion) { await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { var consoleSecret = client.secrets().inNamespace("ns2").withName("console-1-" + ConsoleSecret.NAME).get();