diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppIngressResource.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppIngressResource.java index 096cfb2841..204066eb63 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppIngressResource.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppIngressResource.java @@ -3,12 +3,14 @@ import io.apicurio.registry.operator.api.v1.ApicurioRegistry3; import io.apicurio.registry.operator.utils.Utils; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.api.model.networking.v1.IngressTLS; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; import java.util.Objects; import static io.apicurio.registry.operator.AnnotationManager.updateResourceAnnotations; @@ -16,8 +18,7 @@ import static io.apicurio.registry.operator.resource.ResourceFactory.COMPONENT_APP; import static io.apicurio.registry.operator.resource.ResourceKey.APP_INGRESS_KEY; import static io.apicurio.registry.operator.resource.ResourceKey.APP_SERVICE_KEY; -import static io.apicurio.registry.operator.utils.IngressUtils.getHost; -import static io.apicurio.registry.operator.utils.IngressUtils.withIngressRule; +import static io.apicurio.registry.operator.utils.IngressUtils.*; import static io.apicurio.registry.operator.utils.Mapper.toYAML; import static io.apicurio.registry.operator.utils.Utils.isBlank; import static io.apicurio.registry.operator.utils.Utils.updateResourceManually; @@ -39,6 +40,12 @@ protected Ingress desired(ApicurioRegistry3 primary, Context var sOpt = Utils.getSecondaryResource(context, primary, APP_SERVICE_KEY); sOpt.ifPresent(s -> withIngressRule(s, i, rule -> rule.setHost(getHost(COMPONENT_APP, primary)))); + List tlsList = getTls(COMPONENT_APP, primary); + + if (!tlsList.isEmpty()) { + i.getSpec().setTls(tlsList); + } + // Standard approach does not work properly :( updateResourceAnnotations(context, i, getCRContext(primary).getAppIngressAnnotations(), primary.withSpec().withApp().withIngress().getAnnotations()); diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ui/UIIngressResource.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ui/UIIngressResource.java index 5686e7b4e2..063386ce0e 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ui/UIIngressResource.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ui/UIIngressResource.java @@ -3,12 +3,14 @@ import io.apicurio.registry.operator.api.v1.ApicurioRegistry3; import io.apicurio.registry.operator.utils.Utils; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.api.model.networking.v1.IngressTLS; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; import java.util.Objects; import static io.apicurio.registry.operator.AnnotationManager.updateResourceAnnotations; @@ -16,8 +18,7 @@ import static io.apicurio.registry.operator.resource.ResourceFactory.COMPONENT_UI; import static io.apicurio.registry.operator.resource.ResourceKey.UI_INGRESS_KEY; import static io.apicurio.registry.operator.resource.ResourceKey.UI_SERVICE_KEY; -import static io.apicurio.registry.operator.utils.IngressUtils.getHost; -import static io.apicurio.registry.operator.utils.IngressUtils.withIngressRule; +import static io.apicurio.registry.operator.utils.IngressUtils.*; import static io.apicurio.registry.operator.utils.Mapper.toYAML; import static io.apicurio.registry.operator.utils.Utils.isBlank; import static io.apicurio.registry.operator.utils.Utils.updateResourceManually; @@ -39,6 +40,12 @@ protected Ingress desired(ApicurioRegistry3 primary, Context var sOpt = Utils.getSecondaryResource(context, primary, UI_SERVICE_KEY); sOpt.ifPresent(s -> withIngressRule(s, i, rule -> rule.setHost(getHost(COMPONENT_UI, primary)))); + List tlsList = getTls(COMPONENT_UI, primary); + + if (!tlsList.isEmpty()) { + i.getSpec().setTls(tlsList); + } + // Standard approach does not work properly :( updateResourceAnnotations(context, i, getCRContext(primary).getUiIngressAnnotations(), primary.withSpec().withUi().withIngress().getAnnotations()); diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/utils/IngressUtils.java b/operator/controller/src/main/java/io/apicurio/registry/operator/utils/IngressUtils.java index 8e7a0e9bbf..3265b1263c 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/utils/IngressUtils.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/utils/IngressUtils.java @@ -11,9 +11,13 @@ import io.fabric8.kubernetes.api.model.networking.v1.HTTPIngressPath; import io.fabric8.kubernetes.api.model.networking.v1.Ingress; import io.fabric8.kubernetes.api.model.networking.v1.IngressRule; +import io.fabric8.kubernetes.api.model.networking.v1.IngressTLS; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; import java.util.function.Consumer; import static io.apicurio.registry.operator.resource.ResourceFactory.COMPONENT_APP; @@ -73,4 +77,42 @@ public static void withIngressRule(Service s, Ingress i, Consumer a } } } + + /** + * Get the TLS hosts and secrets for an ingress. If not configured, an empty list is returned. + * + * @param component + * @param primary + */ + public static List getTls(String component, ApicurioRegistry3 primary) { + Map> tlsSecrets = switch (component) { + case COMPONENT_APP -> ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getApp) + .map(AppSpec::getIngress).map(IngressSpec::getTlsSecrets).orElse(null); + case COMPONENT_UI -> ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getUi) + .map(UiSpec::getIngress).map(IngressSpec::getTlsSecrets).orElse(null); + default -> throw new OperatorException("Unexpected value: " + component); + }; + + List tlsList = new ArrayList<>(); + + if (tlsSecrets != null && !tlsSecrets.isEmpty()) { + for (Map.Entry> entry : tlsSecrets.entrySet()) { + String secretName = entry.getKey(); + List hosts = entry.getValue(); + if (!Utils.isBlank(secretName) && hosts != null && !hosts.isEmpty()) { + List validHosts = hosts.stream() + .filter(host -> !Utils.isBlank(host)) + .toList(); + if (!validHosts.isEmpty()) { + IngressTLS tls = new IngressTLS(); + tls.setHosts(validHosts); + tls.setSecretName(secretName); + tlsList.add(tls); + } + } + } + } + log.trace("TLS list for component {} is {}", component, tlsList); + return tlsList; + } } diff --git a/operator/controller/src/test/java/io/apicurio/registry/operator/it/IngressITTest.java b/operator/controller/src/test/java/io/apicurio/registry/operator/it/IngressITTest.java index 41a7fdf649..8e8b1b8fc5 100644 --- a/operator/controller/src/test/java/io/apicurio/registry/operator/it/IngressITTest.java +++ b/operator/controller/src/test/java/io/apicurio/registry/operator/it/IngressITTest.java @@ -6,6 +6,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.util.List; import java.util.Map; import static io.apicurio.registry.operator.resource.ResourceFactory.deserialize; @@ -155,4 +156,140 @@ void ingressClassName() { assertThat(appIngress.get().getSpec().getIngressClassName()).isNotEqualTo("test---nginx"); }); } + + @Test + void multiHostsPerSecret() { + final var primary = k8sCellCreate(client, () -> { + var p = deserialize("/k8s/examples/ingress/ingress-tls.apicurioregistry3.yaml", ApicurioRegistry3.class); + + p.getSpec().getApp().getIngress().setHost(ingressManager.getIngressHost("app")); + p.getSpec().getUi().getIngress().setHost(ingressManager.getIngressHost("ui")); + + return p; + }); + + final var appIngress = k8sCell(client, () -> client.network().v1().ingresses() + .withName(primary.get().getMetadata().getName() + "-app-ingress").get()); + final var uiIngress = k8sCell(client, () -> client.network().v1().ingresses() + .withName(primary.get().getMetadata().getName() + "-ui-ingress").get()); + + // Verify the ingress is created with correct TLS configuration + await().atMost(SHORT_DURATION).ignoreExceptions().untilAsserted(() -> { + assertThat(appIngress.get()).isNotNull(); + + var tlsConfig = appIngress.getCached().getSpec().getTls(); + assertThat(tlsConfig).isNotNull(); + assertThat(tlsConfig).hasSize(2); + + // Verify first TLS entry (app-secret with multiple hosts) + var firstTls = tlsConfig.get(0); + assertThat(firstTls.getSecretName()).isEqualTo("app-secret"); + assertThat(firstTls.getHosts()).hasSize(2); + assertThat(firstTls.getHosts()).contains( + "ingress-class-name-app.apps.cluster.example", + "another-host.example.com" + ); + + // Verify second TLS entry (wildcard-secret) + var secondTls = tlsConfig.get(1); + assertThat(secondTls.getSecretName()).isEqualTo("wildcard-secret"); + assertThat(secondTls.getHosts()).hasSize(1); + assertThat(secondTls.getHosts()).contains("*.example.com"); + }); + + // Get the configured app host for consistency in assertions + String expectedAppHost = primary.get().getSpec().getApp().getIngress().getHost(); + + // Test updating the TLS configuration + primary.update(p -> { + Map> updatedTlsSecrets = Map.of( + "app-secret", List.of(expectedAppHost), + "new-secret", List.of("new-host.example.com", "another-new-host.example.com") + ); + p.getSpec().getApp().getIngress().setTlsSecrets(updatedTlsSecrets); + }); + + // Verify the ingress is updated with new TLS configuration + await().atMost(SHORT_DURATION).ignoreExceptions().untilAsserted(() -> { + var tlsConfig = appIngress.get().getSpec().getTls(); + assertThat(tlsConfig).hasSize(2); + + // Find the entries by secret name (order might vary) + var appSecretTls = tlsConfig.stream() + .filter(tls -> "app-secret".equals(tls.getSecretName())) + .findFirst().orElse(null); + var newSecretTls = tlsConfig.stream() + .filter(tls -> "new-secret".equals(tls.getSecretName())) + .findFirst().orElse(null); + + assertThat(appSecretTls).isNotNull(); + assertThat(appSecretTls.getHosts()).hasSize(1); + assertThat(appSecretTls.getHosts()).contains(expectedAppHost); + + assertThat(newSecretTls).isNotNull(); + assertThat(newSecretTls.getHosts()).hasSize(2); + assertThat(newSecretTls.getHosts()).contains("new-host.example.com", "another-new-host.example.com"); + }); + + await().atMost(SHORT_DURATION).ignoreExceptions().untilAsserted(() -> { + assertThat(uiIngress.get()).isNotNull(); + + var tlsConfig = uiIngress.getCached().getSpec().getTls(); + assertThat(tlsConfig).isNotNull(); + assertThat(tlsConfig).hasSize(2); + + // Verify first TLS entry (app-secret with multiple hosts) + var firstTls = tlsConfig.get(0); + assertThat(firstTls.getSecretName()).isEqualTo("ui-secret"); + assertThat(firstTls.getHosts()).hasSize(1); + assertThat(firstTls.getHosts()).contains( + "ingress-class-name-ui.apps.cluster.example" + ); + + // Verify second TLS entry (wildcard-secret) + var secondTls = tlsConfig.get(1); + assertThat(secondTls.getSecretName()).isEqualTo("wildcard-secret"); + assertThat(secondTls.getHosts()).hasSize(2); + assertThat(secondTls.getHosts()).contains( + "*.example.com", + "another-host.example.com" + ); + }); + + String expectedUiHost = primary.get().getSpec().getUi().getIngress().getHost(); + + // Test updating the TLS configuration + primary.update(p -> { + Map> updatedTlsSecrets = Map.of( + "new-ui-secret", List.of(expectedUiHost), + "new-secret", List.of("new-host.example.com", "another-new-host.example.com") + ); + p.getSpec().getUi().getIngress().setTlsSecrets(updatedTlsSecrets); + }); + + // Verify the ingress is updated with new TLS configuration + await().atMost(SHORT_DURATION).ignoreExceptions().untilAsserted(() -> { + var tlsConfig = uiIngress.get().getSpec().getTls(); + assertThat(tlsConfig).hasSize(2); + + // Find the entries by secret name (order might vary) + var uiSecretTls = tlsConfig.stream() + .filter(tls -> "new-ui-secret".equals(tls.getSecretName())) + .findFirst().orElse(null); + var newSecretTls = tlsConfig.stream() + .filter(tls -> "new-secret".equals(tls.getSecretName())) + .findFirst().orElse(null); + + assertThat(uiSecretTls).isNotNull(); + assertThat(uiSecretTls.getHosts()).hasSize(1); + assertThat(uiSecretTls.getHosts()).contains(expectedUiHost); + + assertThat(newSecretTls).isNotNull(); + assertThat(newSecretTls.getHosts()).hasSize(2); + assertThat(newSecretTls.getHosts()).contains( + "new-host.example.com", + "another-new-host.example.com" + ); + }); + } } diff --git a/operator/controller/src/test/resources/k8s/examples/ingress/ingress-tls.apicurioregistry3.yaml b/operator/controller/src/test/resources/k8s/examples/ingress/ingress-tls.apicurioregistry3.yaml new file mode 100644 index 0000000000..69a2780a78 --- /dev/null +++ b/operator/controller/src/test/resources/k8s/examples/ingress/ingress-tls.apicurioregistry3.yaml @@ -0,0 +1,29 @@ +# IMPORTANT: This example CR uses the in-memory storage for simplicity. +# This storage type is not supported because it is not suitable for production deployments. +# Please refer to the PostgreSQL and KafkaSQL examples +# for information on how to configure a production-ready storage. +apiVersion: registry.apicur.io/v1 +kind: ApicurioRegistry3 +metadata: + name: ingress-class-name +spec: + app: + ingress: + host: ingress-class-name-app.apps.cluster.example + ingressClassName: haproxy-app + tlsSecrets: + app-secret: + - "ingress-class-name-app.apps.cluster.example" + - "another-host.example.com" + wildcard-secret: + - "*.example.com" + ui: + ingress: + host: ingress-class-name-ui.apps.cluster.example + ingressClassName: haproxy-ui + tlsSecrets: + ui-secret: + - "ingress-class-name-ui.apps.cluster.example" + wildcard-secret: + - "*.example.com" + - "another-host.example.com" diff --git a/operator/install/install.yaml b/operator/install/install.yaml index 2e4c030696..7c01adcb3e 100644 --- a/operator/install/install.yaml +++ b/operator/install/install.yaml @@ -7,7 +7,7 @@ metadata: app.kubernetes.io/instance: apicurio-registry-operator app.kubernetes.io/name: apicurio-registry-operator app.kubernetes.io/part-of: apicurio-registry - app.kubernetes.io/version: 3.0.15 + app.kubernetes.io/version: 3.1.0-SNAPSHOT name: apicurioregistries3.registry.apicur.io spec: group: registry.apicur.io @@ -295,6 +295,17 @@ spec: See https://kubernetes.io/docs/concepts/services-networking/ingress/#ingress-class. type: string + tlsSecrets: + additionalProperties: + items: + type: string + type: array + description: 'Configure TLS configuration with secret-to-hosts + mapping. This allows multiple hosts to share the same TLS + secret. Format: { "secret-name": ["host1", "host2"], "another-secret": + ["host3"] } If tlsSecrets is specified, it will be used + for TLS configuration.' + type: object type: object kafkasql: description: |- @@ -3645,6 +3656,17 @@ spec: See https://kubernetes.io/docs/concepts/services-networking/ingress/#ingress-class. type: string + tlsSecrets: + additionalProperties: + items: + type: string + type: array + description: 'Configure TLS configuration with secret-to-hosts + mapping. This allows multiple hosts to share the same TLS + secret. Format: { "secret-name": ["host1", "host2"], "another-secret": + ["host3"] } If tlsSecrets is specified, it will be used + for TLS configuration.' + type: object type: object networkPolicy: description: |2 @@ -6701,7 +6723,7 @@ metadata: app.kubernetes.io/instance: apicurio-registry-operator app.kubernetes.io/name: apicurio-registry-operator app.kubernetes.io/part-of: apicurio-registry - app.kubernetes.io/version: 3.0.15 + app.kubernetes.io/version: 3.1.0-SNAPSHOT name: apicurio-registry-operator --- apiVersion: rbac.authorization.k8s.io/v1 @@ -6713,7 +6735,7 @@ metadata: app.kubernetes.io/instance: apicurio-registry-operator app.kubernetes.io/name: apicurio-registry-operator app.kubernetes.io/part-of: apicurio-registry - app.kubernetes.io/version: 3.0.15 + app.kubernetes.io/version: 3.1.0-SNAPSHOT name: apicurio-registry-operator-clusterrole rules: - apiGroups: @@ -6782,7 +6804,7 @@ metadata: app.kubernetes.io/instance: apicurio-registry-operator app.kubernetes.io/name: apicurio-registry-operator app.kubernetes.io/part-of: apicurio-registry - app.kubernetes.io/version: 3.0.15 + app.kubernetes.io/version: 3.1.0-SNAPSHOT name: apicurio-registry-operator-clusterrolebinding roleRef: apiGroup: rbac.authorization.k8s.io @@ -6802,8 +6824,8 @@ metadata: app.kubernetes.io/instance: apicurio-registry-operator app.kubernetes.io/name: apicurio-registry-operator app.kubernetes.io/part-of: apicurio-registry - app.kubernetes.io/version: 3.0.15 - name: apicurio-registry-operator-v3.0.15 + app.kubernetes.io/version: 3.1.0-SNAPSHOT + name: apicurio-registry-operator-v3.1.0-snapshot namespace: PLACEHOLDER_NAMESPACE spec: replicas: 1 @@ -6814,7 +6836,7 @@ spec: app.kubernetes.io/instance: apicurio-registry-operator app.kubernetes.io/name: apicurio-registry-operator app.kubernetes.io/part-of: apicurio-registry - app.kubernetes.io/version: 3.0.15 + app.kubernetes.io/version: 3.1.0-SNAPSHOT template: metadata: labels: @@ -6823,7 +6845,7 @@ spec: app.kubernetes.io/instance: apicurio-registry-operator app.kubernetes.io/name: apicurio-registry-operator app.kubernetes.io/part-of: apicurio-registry - app.kubernetes.io/version: 3.0.15 + app.kubernetes.io/version: 3.1.0-SNAPSHOT spec: containers: - env: diff --git a/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/IngressSpec.java b/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/IngressSpec.java index 3a0d9a162b..0dadbace02 100644 --- a/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/IngressSpec.java +++ b/operator/model/src/main/java/io/apicurio/registry/operator/api/v1/spec/IngressSpec.java @@ -16,6 +16,7 @@ import lombok.experimental.SuperBuilder; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import static com.fasterxml.jackson.annotation.JsonInclude.Include.NON_EMPTY; @@ -90,4 +91,20 @@ by a transitive connection (controller -> IngressClass -> Ingress resource). See https://kubernetes.io/docs/concepts/services-networking/ingress/#ingress-class.""") @JsonSetter(nulls = SKIP) private String ingressClassName; + + /** + * Configure TLS configuration with secret-to-hosts mapping. + * This allows multiple hosts to share the same TLS secret. + * Format: { "secret-name": ["host1", "host2"], "another-secret": ["host3"] } + * If tlsSecrets is specified, it will be used for TLS configuration. + */ + @JsonProperty("tlsSecrets") + @JsonPropertyDescription(""" + Configure TLS configuration with secret-to-hosts mapping. \ + This allows multiple hosts to share the same TLS secret. \ + Format: { "secret-name": ["host1", "host2"], "another-secret": ["host3"] } \ + If tlsSecrets is specified, it will be used for TLS configuration.""") + @JsonSetter(nulls = SKIP) + @JsonInclude(NON_EMPTY) + private Map> tlsSecrets = new LinkedHashMap<>(); }