diff --git a/docs/sources/assemblies/assembly-deploying.adoc b/docs/sources/assemblies/assembly-deploying.adoc index 6eea85cbb..1d3ab1069 100644 --- a/docs/sources/assemblies/assembly-deploying.adoc +++ b/docs/sources/assemblies/assembly-deploying.adoc @@ -40,6 +40,8 @@ include::../modules/deploying/proc-deploying-operator-crd.adoc[leveloffset=+2] include::../modules/deploying/proc-connecting-console.adoc[leveloffset=+1] //TLS configuration for the console server include::../modules/deploying/ref-tls-configuration.adoc[leveloffset=+2] +//Deployment configuration (scheduling, container overrides) +include::../modules/deploying/ref-deployment-configuration.adoc[leveloffset=+2] //virtual kafka clusters include::../modules/deploying/ref-virtual-kafka-clusters.adoc[leveloffset=+2] //cluster connection authentication options diff --git a/docs/sources/modules/deploying/ref-deployment-configuration.adoc b/docs/sources/modules/deploying/ref-deployment-configuration.adoc new file mode 100644 index 000000000..70b7d8229 --- /dev/null +++ b/docs/sources/modules/deploying/ref-deployment-configuration.adoc @@ -0,0 +1,152 @@ +:_mod-docs-content-type: REFERENCE + +// Module included in the following assemblies: +// +// assembly-deploying.adoc + +[id='ref-deployment-configuration-{context}'] += Configuring the console deployment + +[role="_abstract"] +You can customize how the operator deploys the console by configuring the `spec.deployment` field of the `Console` custom resource. +This field provides a template that mirrors the structure of a Kubernetes `Deployment` manifest, giving you control over pod scheduling and container configuration. + +`spec.deployment` is optional. +When omitted, the operator deploys the console using default settings. + +== Deployment template properties + +`spec.deployment.metadata`:: Optional labels and annotations to apply to the `Deployment` resource itself. +`spec.deployment.metadata.labels`:: Additional labels to add to the `Deployment`. +`spec.deployment.metadata.annotations`:: Additional annotations to add to the `Deployment`. +`spec.deployment.spec.template`:: Template applied to the console pod. +`spec.deployment.spec.template.metadata`:: Optional labels and annotations to apply to the console pod. +`spec.deployment.spec.template.metadata.labels`:: Additional labels to add to the pod. +`spec.deployment.spec.template.metadata.annotations`:: Additional annotations to add to the pod. +`spec.deployment.spec.template.spec`:: Spec for the console pod. Configures scheduling constraints and the server container. + +=== Pod scheduling properties + +`spec.deployment.spec.template.spec.affinity`:: The pod's affinity rules. +Constrains which node the console pod is eligible to be scheduled on, based on node labels or the labels of other pods already running on a node. +See the https://kubernetes.io/docs/concepts/scheduling-eviction/assign-pod-node/#affinity-and-anti-affinity[Kubernetes affinity documentation] for details. +`spec.deployment.spec.template.spec.tolerations`:: The pod's tolerations. +Allows the console pod to be scheduled onto a node that has matching taints. +See the https://kubernetes.io/docs/concepts/scheduling-eviction/taint-and-toleration/[Kubernetes taints and tolerations documentation] for details. +`spec.deployment.spec.template.spec.topologySpreadConstraints`:: The pod's topology spread constraints. +Controls how the console pod is spread across failure-domains such as regions, zones, or nodes. +See the https://kubernetes.io/docs/concepts/scheduling-eviction/topology-spread-constraints/[Kubernetes topology spread constraints documentation] for details. +`spec.deployment.spec.template.spec.nodeSelector`:: A label selector that must match a node's labels for the console pod to be scheduled on that node. +Specified as a map of key-value pairs. + +=== Server container properties + +`spec.deployment.spec.template.spec.serverContainer`:: Template for the console server container. +`spec.deployment.spec.template.spec.serverContainer.image`:: Container image to use for the console server. +When omitted, the operator uses its built-in default image. +`spec.deployment.spec.template.spec.serverContainer.imagePullPolicy`:: Image pull policy for the console server image. +Accepted values are `Always`, `IfNotPresent`, and `Never`. +When omitted, the operator defaults to `IfNotPresent` for images specified by digest and `Always` for all other images. +`spec.deployment.spec.template.spec.serverContainer.resources`:: CPU and memory resource requests and limits for the console server container. +`spec.deployment.spec.template.spec.serverContainer.env`:: Additional environment variables to set on the console server container. + +== Examples + +=== Scheduling the console pod on specific nodes + +The following example uses a `nodeSelector` to schedule the console pod only on a node labeled `disktype: ssd`, and adds a toleration so that the pod can be placed on a node tainted `dedicated=console:NoSchedule`. + +[source,yaml] +---- +apiVersion: console.streamshub.github.com/v1alpha1 +kind: Console +metadata: + name: my-console +spec: + hostname: my-console. + deployment: + spec: + template: + spec: + nodeSelector: + disktype: ssd + tolerations: + - key: dedicated + operator: Equal + value: console + effect: NoSchedule + kafkaClusters: + - name: console-kafka + namespace: kafka + listener: secure + credentials: + kafkaUser: + name: console-kafka-user1 +---- + +=== Pinning the console to a specific availability zone using node affinity + +The following example uses a preferred node affinity rule to favor nodes in the `us-east-1a` availability zone. + +[source,yaml] +---- +apiVersion: console.streamshub.github.com/v1alpha1 +kind: Console +metadata: + name: my-console +spec: + hostname: my-console. + deployment: + spec: + template: + spec: + affinity: + nodeAffinity: + preferredDuringSchedulingIgnoredDuringExecution: + - weight: 1 + preference: + matchExpressions: + - key: topology.kubernetes.io/zone + operator: In + values: + - us-east-1a + kafkaClusters: + - name: console-kafka + namespace: kafka + listener: secure + credentials: + kafkaUser: + name: console-kafka-user1 +---- + +=== Overriding the server container image and resources + +[source,yaml] +---- +apiVersion: console.streamshub.github.com/v1alpha1 +kind: Console +metadata: + name: my-console +spec: + hostname: my-console. + deployment: + spec: + template: + spec: + serverContainer: + image: quay.io/my-org/console-api:latest + resources: + requests: + cpu: 250m + memory: 256Mi + limits: + cpu: 500m + memory: 512Mi + kafkaClusters: + - name: console-kafka + namespace: kafka + listener: secure + credentials: + kafkaUser: + name: console-kafka-user1 +---- diff --git a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java index cef8153bc..9c0fe0620 100644 --- a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java +++ b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java @@ -12,6 +12,8 @@ import java.util.function.Predicate; import java.util.stream.Collectors; +import org.jboss.logging.Logger; + import com.github.streamshub.console.api.v1alpha1.Console; import com.github.streamshub.console.api.v1alpha1.status.Condition; import com.github.streamshub.console.api.v1alpha1.status.ConditionBuilder; @@ -227,6 +229,7 @@ public ErrorStatusUpdateControl updateErrorStatus(Console resource, Context context, Exception e) { + Logger.getLogger(getClass()).debug("Exception during reconcile", e); var result = context.managedWorkflowAndDependentResourceContext().getWorkflowReconcileResult(); determineConditions(resource, result, e); 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 f15476793..7d2e5d1aa 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 @@ -8,6 +8,7 @@ import com.github.streamshub.console.api.v1alpha1.spec.containers.Containers; import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource; import com.github.streamshub.console.api.v1alpha1.spec.security.GlobalSecurity; +import com.github.streamshub.console.api.v1alpha1.spec.template.DeploymentTemplate; import io.fabric8.kubernetes.api.model.EnvVar; import io.sundr.builder.annotations.Buildable; @@ -34,14 +35,23 @@ public class ConsoleSpec { Tls tls; @JsonPropertyDescription(""" - Templates for Console instance containers. The templates allow \ - users to specify how the Kubernetes resources are generated. + Template for the Console Deployment. Allows customisation of the Deployment \ + and pod metadata, scheduling constraints (affinity, tolerations, topology \ + spread constraints, and node selector), and the server container image, \ + pull policy, resources, and environment variables. + """) + DeploymentTemplate deployment; + + @JsonPropertyDescription(""" + DEPRECATED: Templates for Console instance containers. The templates allow \ + users to specify how the Kubernetes resources are generated. \ + Use `deployment.spec.template.spec.serverContainer` property instead. """) Containers containers; @JsonPropertyDescription(""" DEPRECATED: Image overrides to be used for the API and UI servers. \ - Use `containers` property instead. + Use `deployment.spec.template.spec.serverContainer.image` property instead. """) Images images; @@ -57,7 +67,7 @@ public class ConsoleSpec { @JsonPropertyDescription(""" DEPRECATED: Environment variables which should be applied to the API container. \ - Use `containers` property instead. + Use `deployment.spec.template.spec.serverContainer.env` property instead. """) List env; @@ -77,6 +87,14 @@ public void setTls(Tls tls) { this.tls = tls; } + public DeploymentTemplate getDeployment() { + return deployment; + } + + public void setDeployment(DeploymentTemplate deployment) { + this.deployment = deployment; + } + public Containers getContainers() { return containers; } diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/ContainerTemplate.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/ContainerTemplate.java new file mode 100644 index 000000000..d979f5adf --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/ContainerTemplate.java @@ -0,0 +1,93 @@ +package com.github.streamshub.console.api.v1alpha1.spec.template; + +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.fasterxml.jackson.annotation.JsonValue; + +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.ResourceRequirements; +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class ContainerTemplate { + + @JsonPropertyDescription("Container image to be used for the container.") + private String image; + + @JsonPropertyDescription(""" + Image pull policy to be used for the container image. \ + One of Always, IfNotPresent, or Never. \ + Defaults to Always when the image tag is not a digest, \ + and IfNotPresent when a digest is specified. + """) + private ImagePullPolicy imagePullPolicy; + + @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 ImagePullPolicy getImagePullPolicy() { + return imagePullPolicy; + } + + public void setImagePullPolicy(ImagePullPolicy imagePullPolicy) { + this.imagePullPolicy = imagePullPolicy; + } + + 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; + } + + public enum ImagePullPolicy { + ALWAYS("Always"), + IF_NOT_PRESENT("IfNotPresent"), + NEVER("Never"); + + private final String value; + + ImagePullPolicy(String value) { + this.value = value; + } + + @JsonValue + public String value() { + return value; + } + + @JsonCreator + public static ImagePullPolicy fromValue(String value) { + for (var policy : values()) { + if (policy.value.equals(value)) { + return policy; + } + } + throw new IllegalArgumentException("Invalid imagePullPolicy: " + value); + } + } +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/DeploymentSpecTemplate.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/DeploymentSpecTemplate.java new file mode 100644 index 000000000..0934e0838 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/DeploymentSpecTemplate.java @@ -0,0 +1,28 @@ +package com.github.streamshub.console.api.v1alpha1.spec.template; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DeploymentSpecTemplate { + + @JsonPropertyDescription(""" + Template for the console pod. Allows configuration of pod metadata, \ + scheduling constraints (affinity, tolerations, topology spread constraints, \ + and node selector), and the server container image, pull policy, resources, \ + and environment variables. + """) + private PodTemplate template; + + public PodTemplate getTemplate() { + return template; + } + + public void setTemplate(PodTemplate template) { + this.template = template; + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/DeploymentTemplate.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/DeploymentTemplate.java new file mode 100644 index 000000000..fa96a10a3 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/DeploymentTemplate.java @@ -0,0 +1,40 @@ +package com.github.streamshub.console.api.v1alpha1.spec.template; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class DeploymentTemplate { + + @JsonPropertyDescription(""" + Labels and annotations to apply to the Console Deployment resource. + """) + private MetadataTemplate metadata; + + @JsonPropertyDescription(""" + Spec for the Console Deployment. Provides access to the pod template \ + to configure scheduling constraints, pod metadata, the server container, \ + and other pod-level settings. + """) + private DeploymentSpecTemplate spec; + + public MetadataTemplate getMetadata() { + return metadata; + } + + public void setMetadata(MetadataTemplate metadata) { + this.metadata = metadata; + } + + public DeploymentSpecTemplate getSpec() { + return spec; + } + + public void setSpec(DeploymentSpecTemplate spec) { + this.spec = spec; + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/MetadataTemplate.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/MetadataTemplate.java new file mode 100644 index 000000000..44f983e56 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/MetadataTemplate.java @@ -0,0 +1,36 @@ +package com.github.streamshub.console.api.v1alpha1.spec.template; + +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MetadataTemplate { + + @JsonPropertyDescription("Additional labels to apply to the resource.") + private Map labels; + + @JsonPropertyDescription("Additional annotations to apply to the resource.") + private Map annotations; + + public Map getLabels() { + return labels; + } + + public void setLabels(Map labels) { + this.labels = labels; + } + + public Map getAnnotations() { + return annotations; + } + + public void setAnnotations(Map annotations) { + this.annotations = annotations; + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/PodSpecTemplate.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/PodSpecTemplate.java new file mode 100644 index 000000000..c044e050e --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/PodSpecTemplate.java @@ -0,0 +1,88 @@ +package com.github.streamshub.console.api.v1alpha1.spec.template; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import io.fabric8.kubernetes.api.model.Affinity; +import io.fabric8.kubernetes.api.model.Toleration; +import io.fabric8.kubernetes.api.model.TopologySpreadConstraint; +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PodSpecTemplate { + + @JsonPropertyDescription(""" + The pod's affinity rules. Allows constraining which nodes the console \ + pod is eligible to be scheduled on, based on node labels or the labels \ + of other pods already running on a node. + """) + private Affinity affinity; + + @JsonPropertyDescription(""" + The pod's tolerations. Allows the console pod to be scheduled onto \ + nodes that have matching taints. + """) + private List tolerations; + + @JsonPropertyDescription(""" + The pod's topology spread constraints. Controls how the console pod \ + is spread across failure-domains such as regions, zones, or nodes. + """) + private List topologySpreadConstraints; + + @JsonPropertyDescription(""" + A selector which must match a node's labels for the console pod \ + to be scheduled on that node. + """) + private Map nodeSelector; + + @JsonPropertyDescription(""" + Template for the console container. Allows configuration image, \ + pull policy, resources, and environment variables. + """) + private ContainerTemplate serverContainer; + + public Affinity getAffinity() { + return affinity; + } + + public void setAffinity(Affinity affinity) { + this.affinity = affinity; + } + + public List getTolerations() { + return tolerations; + } + + public void setTolerations(List tolerations) { + this.tolerations = tolerations; + } + + public List getTopologySpreadConstraints() { + return topologySpreadConstraints; + } + + public void setTopologySpreadConstraints(List topologySpreadConstraints) { + this.topologySpreadConstraints = topologySpreadConstraints; + } + + public Map getNodeSelector() { + return nodeSelector; + } + + public void setNodeSelector(Map nodeSelector) { + this.nodeSelector = nodeSelector; + } + + public ContainerTemplate getServerContainer() { + return serverContainer; + } + + public void setServerContainer(ContainerTemplate serverContainer) { + this.serverContainer = serverContainer; + } +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/PodTemplate.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/PodTemplate.java new file mode 100644 index 000000000..f39910bbb --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/template/PodTemplate.java @@ -0,0 +1,40 @@ +package com.github.streamshub.console.api.v1alpha1.spec.template; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonPropertyDescription; + +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +@JsonInclude(JsonInclude.Include.NON_NULL) +public class PodTemplate { + + @JsonPropertyDescription(""" + Labels and annotations to apply to the console pod. + """) + private MetadataTemplate metadata; + + @JsonPropertyDescription(""" + Spec for the console pod. Allows configuration of scheduling constraints \ + (affinity, tolerations, topology spread constraints, and node selector) \ + and the server container image, pull policy, resources, and environment \ + variables. + """) + private PodSpecTemplate spec; + + public MetadataTemplate getMetadata() { + return metadata; + } + + public void setMetadata(MetadataTemplate metadata) { + this.metadata = metadata; + } + + public PodSpecTemplate getSpec() { + return spec; + } + + public void setSpec(PodSpecTemplate spec) { + this.spec = spec; + } +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/Condition.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/Condition.java index 29c51f7bc..01da3dfeb 100644 --- a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/Condition.java +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/Condition.java @@ -1,5 +1,7 @@ package com.github.streamshub.console.api.v1alpha1.status; +import java.util.Comparator; +import java.util.Locale; import java.util.Objects; import com.fasterxml.jackson.annotation.JsonIgnore; @@ -121,9 +123,22 @@ public static final class Types { private Types() { } - public static final String READY = "Ready"; - public static final String WARNING = "Warning"; - public static final String ERROR = "Error"; + enum ConditionType { + READY("Ready"), WARNING("Warning"), ERROR("Error"); + final String display; + ConditionType(String display) { + this.display = display; + } + } + + public static final String READY = ConditionType.READY.display; + public static final String WARNING = ConditionType.WARNING.display; + public static final String ERROR = ConditionType.ERROR.display; + + static final Comparator COMPARATOR = (t1, t2) -> + // declaration order + ConditionType.valueOf(t1.toUpperCase(Locale.ROOT)) + .compareTo(ConditionType.valueOf(t2.toUpperCase(Locale.ROOT))); } /** diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/ConsoleStatus.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/ConsoleStatus.java index a24168ce8..f557f51fa 100644 --- a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/ConsoleStatus.java +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/ConsoleStatus.java @@ -18,7 +18,7 @@ public class ConsoleStatus { private long observedGeneration; private final Set conditions = new TreeSet<>(Comparator - .comparing(Condition::getType).reversed() + .comparing(Condition::getType, Condition.Types.COMPARATOR) .thenComparing(Condition::getLastTransitionTime) .thenComparing(Condition::getStatus, Comparator.nullsLast(String::compareTo)) .thenComparing(Condition::getReason, Comparator.nullsLast(String::compareTo)) 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 93f61acce..a0d6cb956 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,7 +1,10 @@ package com.github.streamshub.console.dependents; +import java.time.Instant; import java.util.ArrayList; +import java.util.Collection; import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Optional; @@ -16,6 +19,16 @@ import com.github.streamshub.console.api.v1alpha1.spec.containers.ContainerSpec; import com.github.streamshub.console.api.v1alpha1.spec.containers.ContainerTemplateSpec; import com.github.streamshub.console.api.v1alpha1.spec.containers.Containers; +import com.github.streamshub.console.api.v1alpha1.spec.template.ContainerTemplate; +import com.github.streamshub.console.api.v1alpha1.spec.template.ContainerTemplate.ImagePullPolicy; +import com.github.streamshub.console.api.v1alpha1.spec.template.DeploymentSpecTemplate; +import com.github.streamshub.console.api.v1alpha1.spec.template.DeploymentTemplate; +import com.github.streamshub.console.api.v1alpha1.spec.template.MetadataTemplate; +import com.github.streamshub.console.api.v1alpha1.spec.template.PodSpecTemplate; +import com.github.streamshub.console.api.v1alpha1.spec.template.PodTemplate; +import com.github.streamshub.console.api.v1alpha1.status.Condition.Reasons; +import com.github.streamshub.console.api.v1alpha1.status.Condition.Types; +import com.github.streamshub.console.api.v1alpha1.status.ConditionBuilder; import io.fabric8.kubernetes.api.model.EnvVar; import io.fabric8.kubernetes.api.model.IntOrString; @@ -52,18 +65,34 @@ protected Deployment desired(Console primary, Context context) { String name = instanceName(primary); String configSecretName = secret.instanceName(primary); + var template = Optional.ofNullable(primary.getSpec().getDeployment()); + var metaTemplate = template.map(DeploymentTemplate::getMetadata); + + var podTemplate = template.map(DeploymentTemplate::getSpec).map(DeploymentSpecTemplate::getTemplate); + var podMetaTemplate = podTemplate.map(PodTemplate::getMetadata); + var podSpecTemplate = podTemplate.map(PodTemplate::getSpec); + var podRequiredLabels = Map.of(INSTANCE_LABEL, name); + var podRequiredAnnotations = Map.of("streamshub.github.com/dependency-digest", + serializeDigest(context, "console-digest")); + var containerTemplate = podSpecTemplate.map(PodSpecTemplate::getServerContainer); + + // deprecated var containers = Optional.ofNullable(primary.getSpec().getContainers()); var templateAPI = containers.map(Containers::getApi).map(ContainerTemplateSpec::getSpec); - // deprecated var images = Optional.ofNullable(primary.getSpec().getImages()); + var envs = Optional.ofNullable(primary.getSpec().getEnv()); + warnDeprecations(primary, containers, images, envs); - String imageAPI = templateAPI.map(ContainerSpec::getImage) + String image = containerTemplate.map(ContainerTemplate::getImage) + .or(() -> templateAPI.map(ContainerSpec::getImage)) .or(() -> images.map(Images::getApi)) .orElse(defaultAPIImage); - - List envVars = new ArrayList<>(); - envVars.addAll(coalesce(primary.getSpec().getEnv(), Collections::emptyList)); - envVars.addAll(templateAPI.map(ContainerSpec::getEnv).orElseGet(Collections::emptyList)); + String imagePullPolicy = containerTemplate.map(ContainerTemplate::getImagePullPolicy) + .map(ImagePullPolicy::value) + .orElseGet(() -> pullPolicy(image)); + var containerResources = containerTemplate.map(ContainerTemplate::getResources) + .or(() -> templateAPI.map(ContainerSpec::getResources)) + .orElse(null); boolean tls = primary.getSpec().getTls() != null; String portName = tls ? "https" : "http"; @@ -74,20 +103,41 @@ protected Deployment desired(Console primary, Context context) { .editMetadata() .withName(name) .withNamespace(primary.getMetadata().getNamespace()) - .withLabels(commonLabels("console")) + .withLabels(merge( + commonLabels("console"), + metaTemplate.map(MetadataTemplate::getLabels) + )) + .addToAnnotations(metaTemplate.map(MetadataTemplate::getAnnotations) + .orElseGet(Collections::emptyMap)) .endMetadata() .editSpec() .editSelector() - .withMatchLabels(Map.of(INSTANCE_LABEL, name)) + .withMatchLabels(podRequiredLabels) .endSelector() .editTemplate() .editMetadata() - .addToLabels(Map.of(INSTANCE_LABEL, name)) - .addToAnnotations( - "streamshub.github.com/dependency-digest", - serializeDigest(context, "console-digest")) + .addToLabels(merge( + podRequiredLabels, + podMetaTemplate.map(MetadataTemplate::getLabels) + )) + .addToAnnotations(merge( + podRequiredAnnotations, + podMetaTemplate.map(MetadataTemplate::getAnnotations) + )) .endMetadata() .editSpec() + .withAffinity(podSpecTemplate + .map(PodSpecTemplate::getAffinity) + .orElse(null)) + .withTolerations(podSpecTemplate + .map(PodSpecTemplate::getTolerations) + .orElse(null)) + .withTopologySpreadConstraints(podSpecTemplate + .map(PodSpecTemplate::getTopologySpreadConstraints) + .orElse(null)) + .withNodeSelector(podSpecTemplate + .map(PodSpecTemplate::getNodeSelector) + .orElse(null)) .withServiceAccountName(serviceAccount.instanceName(primary)) .editMatchingVolume(vol -> "config".equals(vol.getName())) .editSecret() @@ -96,10 +146,16 @@ protected Deployment desired(Console primary, Context context) { .endVolume() // Set API container image and port options .editMatchingContainer(c -> "console-api".equals(c.getName())) - .withImage(imageAPI) - .withImagePullPolicy(pullPolicy(imageAPI)) - .withResources(templateAPI.map(ContainerSpec::getResources).orElse(null)) - .addAllToEnv(envVars) + .withImage(image) + .withImagePullPolicy(imagePullPolicy) + .withResources(containerResources) + .withEnv(buildEnvVars(desired, List.of( + // new way, preferred + containerTemplate.map(ContainerTemplate::getEnv), + // deprecated, lower precedence + templateAPI.map(ContainerSpec::getEnv), + envs) + )) .editFirstPort() .withName(portName) .withContainerPort(containerPort) @@ -129,7 +185,79 @@ protected Deployment desired(Console primary, Context context) { .build(); } - private String pullPolicy(String image) { - return image.contains("sha256:") ? "IfNotPresent" : "Always"; + private static Map merge(Map required, Optional> configured) { + Map result = new LinkedHashMap<>(required); + configured.ifPresent(values -> values.forEach(result::putIfAbsent)); + return result; + } + + private static List buildEnvVars(Deployment base, List>> configured) { + List envVars = new ArrayList<>(base.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv()); + + configured.stream() + .filter(Optional::isPresent) + .map(Optional::get) + .flatMap(Collection::stream) + .forEach(envVar -> { + String configuredName = envVar.getName(); + + if (envVars.stream().map(EnvVar::getName).noneMatch(configuredName::equals)) { + envVars.add(envVar); + } + }); + + return envVars; + } + + private static String pullPolicy(String image) { + return image.contains("sha256:") + ? ImagePullPolicy.IF_NOT_PRESENT.value() + : ImagePullPolicy.ALWAYS.value(); + } + + private static void warnDeprecations(Console primary, Optional containers, Optional images, Optional envs) { + StringBuilder deprecationWarnings = new StringBuilder(); + + if (containers.isPresent()) { + deprecationWarnings.append(""" + spec.containers is deprecated and will be removed \ + in a future release. Please use \ + spec.deployment.spec.template.spec.serverContainer \ + to configure the server container."""); + } + + if (images.isPresent()) { + if (!deprecationWarnings.isEmpty()) { + deprecationWarnings.append(' '); + } + + deprecationWarnings.append(""" + spec.images is deprecated and will be removed \ + in a future release. Please use \ + spec.deployment.spec.template.spec.serverContainer.image \ + to configure the server container image."""); + } + + if (envs.isPresent()) { + if (!deprecationWarnings.isEmpty()) { + deprecationWarnings.append(' '); + } + + deprecationWarnings.append(""" + spec.env is deprecated and will be removed \ + in a future release. Please use \ + spec.deployment.spec.template.spec.serverContainer.env \ + to configure the server container."""); + } + + if (!deprecationWarnings.isEmpty()) { + primary.getOrCreateStatus().updateCondition(new ConditionBuilder() + .withType(Types.WARNING) + .withStatus("True") + .withLastTransitionTime(Instant.now().toString()) + .withReason(Reasons.DEPRECATED_CONFIGURATION) + .withMessage(deprecationWarnings.toString()) + .build()); + } } } diff --git a/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTemplateTest.java b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTemplateTest.java new file mode 100644 index 000000000..8efcbf8fd --- /dev/null +++ b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTemplateTest.java @@ -0,0 +1,275 @@ +package com.github.streamshub.console; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.Test; + +import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.api.v1alpha1.ConsoleBuilder; +import com.github.streamshub.console.dependents.ConsoleDeployment; +import com.github.streamshub.console.dependents.ConsoleResource; +import com.github.streamshub.console.dependents.PrometheusDeployment; + +import io.fabric8.kubernetes.api.model.AffinityBuilder; +import io.fabric8.kubernetes.api.model.EnvVarBuilder; +import io.fabric8.kubernetes.api.model.NodeAffinityBuilder; +import io.fabric8.kubernetes.api.model.NodeSelectorRequirementBuilder; +import io.fabric8.kubernetes.api.model.NodeSelectorTermBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.TolerationBuilder; +import io.quarkus.test.junit.QuarkusTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +class ConsoleReconcilerTemplateTest extends ConsoleReconcilerTestBase { + + @Test + void testConsoleReconciliationWithDeploymentTemplate() { + var affinity = new AffinityBuilder() + .withNodeAffinity(new NodeAffinityBuilder() + .addNewPreferredDuringSchedulingIgnoredDuringExecution() + .withWeight(1) + .withPreference(new NodeSelectorTermBuilder() + .addToMatchExpressions(new NodeSelectorRequirementBuilder() + .withKey("topology.kubernetes.io/zone") + .withOperator("In") + .withValues("us-east-1a") + .build()) + .build()) + .endPreferredDuringSchedulingIgnoredDuringExecution() + .build()) + .build(); + var toleration = new TolerationBuilder() + .withKey("dedicated") + .withOperator("Equal") + .withValue("console") + .withEffect("NoSchedule") + .build(); + + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName(CONSOLE_NAME) + .withNamespace(CONSOLE_NS) + .build()) + .withNewSpec() + .withHostname("console.example.com") + .withNewDeployment() + .withNewSpec() + .withNewTemplate() + .withNewSpec() + .withAffinity(affinity) + .withTolerations(toleration) + .withNodeSelector(Map.of("disktype", "ssd")) + .endSpec() + .endTemplate() + .endSpec() + .endDeployment() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .endKafkaCluster() + .endSpec() + .build(); + + client.resource(consoleCR).create(); + + awaitDependentsNotReady(consoleCR, "ConsoleDeployment", "PrometheusDeployment"); + setDeploymentReady(consoleCR, PrometheusDeployment.NAME); + awaitDependentsNotReady(consoleCR, "ConsoleDeployment"); + var consoleDeployment = setDeploymentReady(consoleCR, ConsoleDeployment.NAME); + awaitDependentsNotReady(consoleCR, "ConsoleIngress"); + setConsoleIngressReady(consoleCR); + + var podSpec = consoleDeployment.getSpec().getTemplate().getSpec(); + assertEquals(affinity, podSpec.getAffinity()); + assertEquals(List.of(toleration), podSpec.getTolerations()); + assertEquals(Map.of("disktype", "ssd"), podSpec.getNodeSelector()); + + awaitReady(consoleCR); + } + + @Test + void testDeploymentAndPodMetadataTemplateApplied() { + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName(CONSOLE_NAME) + .withNamespace(CONSOLE_NS) + .build()) + .withNewSpec() + .withHostname("console.example.com") + .withNewDeployment() + .withNewMetadata() + .withLabels(Map.of("custom-deploy-label", "deploy-value")) + .withAnnotations(Map.of("custom-deploy-annotation", "deploy-ann-value")) + .endMetadata() + .withNewSpec() + .withNewTemplate() + .withNewMetadata() + .withLabels(Map.of("custom-pod-label", "pod-value")) + .withAnnotations(Map.of("custom-pod-annotation", "pod-ann-value")) + .endMetadata() + .endTemplate() + .endSpec() + .endDeployment() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .endKafkaCluster() + .endSpec() + .build(); + + client.resource(consoleCR).create(); + + awaitDependentsNotReady(consoleCR, "ConsoleDeployment", "PrometheusDeployment"); + setDeploymentReady(consoleCR, PrometheusDeployment.NAME); + awaitDependentsNotReady(consoleCR, "ConsoleDeployment"); + var consoleDeployment = setDeploymentReady(consoleCR, ConsoleDeployment.NAME); + awaitDependentsNotReady(consoleCR, "ConsoleIngress"); + setConsoleIngressReady(consoleCR); + + // Deployment metadata — user labels merged alongside required system labels + var deployLabels = consoleDeployment.getMetadata().getLabels(); + assertEquals("deploy-value", deployLabels.get("custom-deploy-label")); + // System labels must not be overwritten + assertEquals(ConsoleResource.MANAGER, deployLabels.get("app.kubernetes.io/managed-by")); + assertEquals("console", deployLabels.get("app.kubernetes.io/name")); + + var deployAnnotations = consoleDeployment.getMetadata().getAnnotations(); + assertEquals("deploy-ann-value", deployAnnotations.get("custom-deploy-annotation")); + + // Pod template metadata — user labels merged alongside required instance label + var podLabels = consoleDeployment.getSpec().getTemplate().getMetadata().getLabels(); + assertEquals("pod-value", podLabels.get("custom-pod-label")); + // Required instance label must be present and not overwritten + String expectedInstanceLabel = CONSOLE_NAME + "-" + ConsoleDeployment.NAME; + assertEquals(expectedInstanceLabel, podLabels.get("app.kubernetes.io/instance")); + + var podAnnotations = consoleDeployment.getSpec().getTemplate().getMetadata().getAnnotations(); + assertEquals("pod-ann-value", podAnnotations.get("custom-pod-annotation")); + + awaitReady(consoleCR); + } + + @Test + void testSystemLabelsCannotBeOverwrittenByDeploymentTemplate() { + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName(CONSOLE_NAME) + .withNamespace(CONSOLE_NS) + .build()) + .withNewSpec() + .withHostname("console.example.com") + .withNewDeployment() + .withNewMetadata() + // Attempt to overwrite system-managed labels + .withLabels(Map.of( + "app.kubernetes.io/managed-by", "my-own-operator", + "app.kubernetes.io/name", "my-app")) + .endMetadata() + .withNewSpec() + .withNewTemplate() + .withNewMetadata() + // Attempt to overwrite the required instance label on the pod + .withLabels(Map.of("app.kubernetes.io/instance", "tampered")) + .endMetadata() + .endTemplate() + .endSpec() + .endDeployment() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .endKafkaCluster() + .endSpec() + .build(); + + client.resource(consoleCR).create(); + + awaitDependentsNotReady(consoleCR, "ConsoleDeployment", "PrometheusDeployment"); + setDeploymentReady(consoleCR, PrometheusDeployment.NAME); + awaitDependentsNotReady(consoleCR, "ConsoleDeployment"); + var consoleDeployment = setDeploymentReady(consoleCR, ConsoleDeployment.NAME); + awaitDependentsNotReady(consoleCR, "ConsoleIngress"); + setConsoleIngressReady(consoleCR); + + var deployLabels = consoleDeployment.getMetadata().getLabels(); + assertEquals(ConsoleResource.MANAGER, deployLabels.get("app.kubernetes.io/managed-by")); + assertEquals("console", deployLabels.get("app.kubernetes.io/name")); + + String expectedInstanceLabel = CONSOLE_NAME + "-" + ConsoleDeployment.NAME; + var podLabels = consoleDeployment.getSpec().getTemplate().getMetadata().getLabels(); + assertEquals(expectedInstanceLabel, podLabels.get("app.kubernetes.io/instance")); + + awaitReady(consoleCR); + } + + @Test + void testServerContainerEnvOverridePreventedForSystemVar() { + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName(CONSOLE_NAME) + .withNamespace(CONSOLE_NS) + .build()) + .withNewSpec() + .withHostname("console.example.com") + .withNewDeployment() + .withNewSpec() + .withNewTemplate() + .withNewSpec() + .withNewServerContainer() + // Attempt to override the system-seeded CONSOLE_CONFIG_PATH + .addToEnv(new EnvVarBuilder() + .withName("CONSOLE_CONFIG_PATH") + .withValue("/tampered/path") + .build()) + // A custom var that should be added normally + .addToEnv(new EnvVarBuilder() + .withName("CUSTOM_VAR") + .withValue("custom-value") + .build()) + .endServerContainer() + .endSpec() + .endTemplate() + .endSpec() + .endDeployment() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .endKafkaCluster() + .endSpec() + .build(); + + client.resource(consoleCR).create(); + + awaitDependentsNotReady(consoleCR, "ConsoleDeployment", "PrometheusDeployment"); + setDeploymentReady(consoleCR, PrometheusDeployment.NAME); + awaitDependentsNotReady(consoleCR, "ConsoleDeployment"); + var consoleDeployment = setDeploymentReady(consoleCR, ConsoleDeployment.NAME); + awaitDependentsNotReady(consoleCR, "ConsoleIngress"); + setConsoleIngressReady(consoleCR); + + var env = consoleDeployment.getSpec().getTemplate().getSpec().getContainers().get(0).getEnv(); + + // The system-seeded value must not be overwritten + var configPath = env.stream() + .filter(e -> "CONSOLE_CONFIG_PATH".equals(e.getName())) + .findFirst() + .orElseThrow(); + assertNotEquals("/tampered/path", configPath.getValue(), + "CONSOLE_CONFIG_PATH must not be overrideable by the deployment template"); + + // The custom var must be present + assertTrue(env.stream().anyMatch(e -> "CUSTOM_VAR".equals(e.getName()) + && "custom-value".equals(e.getValue())), + "CUSTOM_VAR should be added to the container env"); + + awaitReady(consoleCR); + } +}