diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/EnvironmentVariables.java b/operator/controller/src/main/java/io/apicurio/registry/operator/EnvironmentVariables.java index a97a5961a0..3d72573cf4 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/EnvironmentVariables.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/EnvironmentVariables.java @@ -10,4 +10,6 @@ public class EnvironmentVariables { public static final String APICURIO_REST_DELETION_ARTIFACT_ENABLED = "APICURIO_REST_DELETION_ARTIFACT_ENABLED"; public static final String APICURIO_REST_DELETION_GROUP_ENABLED = "APICURIO_REST_DELETION_GROUP_ENABLED"; + public static final String APICURIO_REST_MUTABILITY_ARTIFACT_VERSION_CONTENT_ENABLED = "APICURIO_REST_MUTABILITY_ARTIFACT-VERSION-CONTENT_ENABLED"; + } diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/feat/Cors.java b/operator/controller/src/main/java/io/apicurio/registry/operator/feat/Cors.java new file mode 100644 index 0000000000..21397369fe --- /dev/null +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/feat/Cors.java @@ -0,0 +1,73 @@ +package io.apicurio.registry.operator.feat; + +import io.apicurio.registry.operator.EnvironmentVariables; +import io.apicurio.registry.operator.api.v1.ApicurioRegistry3; +import io.apicurio.registry.operator.api.v1.ApicurioRegistry3Spec; +import io.apicurio.registry.operator.api.v1.spec.ComponentSpec; +import io.apicurio.registry.operator.resource.ResourceFactory; +import io.apicurio.registry.operator.utils.IngressUtils; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.EnvVarBuilder; + +import java.util.Arrays; +import java.util.LinkedHashMap; +import java.util.Optional; +import java.util.Set; +import java.util.TreeSet; + +/** + * Helper class used to handle CORS related configuration. + */ +public class Cors { + /** + * Configure the QUARKUS_HTTP_CORS_ORIGINS environment variable with the following: + * + * + * @param primary + * @param envVars + */ + public static void configureAllowedOrigins(ApicurioRegistry3 primary, + LinkedHashMap envVars) { + TreeSet allowedOrigins = new TreeSet<>(); + + // If the QUARKUS_HTTP_CORS_ORIGINS env var is configured in the "env" section of the CR, + // then make sure to add those configured values to the set of allowed origins we want to + // configure. + Optional.ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getApp).map(ComponentSpec::getEnv) + .ifPresent(env -> { + env.stream().filter( + envVar -> envVar.getName().equals(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS)) + .forEach(envVar -> { + Optional.ofNullable(envVar.getValue()).ifPresent(envVarValue -> { + Arrays.stream(envVarValue.split(",")).forEach(allowedOrigins::add); + }); + }); + }); + + // If not, let's try to figure it out from other sources. + if (allowedOrigins.isEmpty()) { + // If there is a configured Ingress host for the UI or the Studio UI, add them to the allowed + // origins. + Set.of(ResourceFactory.COMPONENT_UI, ResourceFactory.COMPONENT_STUDIO_UI).forEach(component -> { + String host = IngressUtils.getConfiguredHost(component, primary); + if (host != null) { + allowedOrigins.add("http://" + host); + allowedOrigins.add("https://" + host); + } + }); + } + + // If we still do not have anything, then default to "*" + if (allowedOrigins.isEmpty()) { + allowedOrigins.add("*"); + } + + // Join the values in allowedOrigins into a String and set it as the new value of the env var. + String envVarValue = String.join(",", allowedOrigins); + envVars.put(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS, new EnvVarBuilder() + .withName(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS).withValue(envVarValue).build()); + } +} diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceFactory.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceFactory.java index 9f232cf525..dd65faaab2 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceFactory.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/ResourceFactory.java @@ -274,8 +274,20 @@ public static T deserialize(String path, Class klass) { } } + public static T deserialize(String path, Class klass, ClassLoader classLoader) { + try { + return YAML_MAPPER.readValue(load(path, classLoader), klass); + } catch (JsonProcessingException ex) { + throw new OperatorException("Could not deserialize resource: " + path, ex); + } + } + public static String load(String path) { - try (var stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(path)) { + return load(path, Thread.currentThread().getContextClassLoader()); + } + + public static String load(String path, ClassLoader classLoader) { + try (var stream = classLoader.getResourceAsStream(path)) { return new String(stream.readAllBytes(), Charset.defaultCharset()); } catch (Exception ex) { throw new OperatorException("Could not read resource: " + path, ex); diff --git a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppDeploymentResource.java b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppDeploymentResource.java index 00361bd198..8eeba252e6 100644 --- a/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppDeploymentResource.java +++ b/operator/controller/src/main/java/io/apicurio/registry/operator/resource/app/AppDeploymentResource.java @@ -7,6 +7,7 @@ import io.apicurio.registry.operator.api.v1.spec.AppFeaturesSpec; import io.apicurio.registry.operator.api.v1.spec.AppSpec; import io.apicurio.registry.operator.api.v1.spec.StorageSpec; +import io.apicurio.registry.operator.feat.Cors; import io.apicurio.registry.operator.feat.KafkaSql; import io.apicurio.registry.operator.feat.PostgresSql; import io.fabric8.kubernetes.api.model.Container; @@ -60,7 +61,6 @@ protected Deployment desired(ApicurioRegistry3 primary, Context { addEnvVar(envVars, - new EnvVarBuilder().withName("APICURIO_REST_MUTABILITY_ARTIFACT-VERSION-CONTENT_ENABLED") + new EnvVarBuilder().withName(EnvironmentVariables.APICURIO_REST_MUTABILITY_ARTIFACT_VERSION_CONTENT_ENABLED) .withValue("true").build()); }); + // spotless:on + + // Configure the storage (Postgresql or KafkaSql). ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getApp).map(AppSpec::getStorage) .map(StorageSpec::getType).ifPresent(storageType -> { switch (storageType) { @@ -92,6 +97,7 @@ protected Deployment desired(ApicurioRegistry3 primary, Context ofNullable(p.getSpec()).map(ApicurioRegistry3Spec::getApp) + case COMPONENT_APP -> ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getApp) .map(AppSpec::getIngress).map(IngressSpec::getHost).filter(h -> !isBlank(h)).orElse(null); - case COMPONENT_UI -> ofNullable(p.getSpec()).map(ApicurioRegistry3Spec::getUi) + case COMPONENT_UI -> ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getUi) .map(UiSpec::getIngress).map(IngressSpec::getHost).filter(h -> !isBlank(h)).orElse(null); - case COMPONENT_STUDIO_UI -> - ofNullable(p.getSpec()).map(ApicurioRegistry3Spec::getStudioUi).map(StudioUiSpec::getIngress) - .map(IngressSpec::getHost).filter(h -> !isBlank(h)).orElse(null); + case COMPONENT_STUDIO_UI -> ofNullable(primary.getSpec()).map(ApicurioRegistry3Spec::getStudioUi) + .map(StudioUiSpec::getIngress).map(IngressSpec::getHost).filter(h -> !isBlank(h)) + .orElse(null); default -> throw new OperatorException("Unexpected value: " + component); }; + return host; + } + + /** + * Get the host for an ingress. If not configured, a default value is returned. + * + * @param component + * @param primary + */ + public static String getHost(String component, ApicurioRegistry3 primary) { + String host = getConfiguredHost(component, primary); if (host == null) { // TODO: This is not used because of the current activation conditions. - host = "%s-%s.%s%s".formatted(p.getMetadata().getName(), component, - p.getMetadata().getNamespace(), Configuration.getDefaultBaseHost()); + host = "%s-%s.%s%s".formatted(primary.getMetadata().getName(), component, + primary.getMetadata().getNamespace(), Configuration.getDefaultBaseHost()); } log.debug("Host for component {} is {}", component, host); return host; diff --git a/operator/controller/src/test/java/io/apicurio/registry/operator/it/AppFeaturesITTest.java b/operator/controller/src/test/java/io/apicurio/registry/operator/it/AppFeaturesITTest.java index 2420afebc2..aaadc08dc8 100644 --- a/operator/controller/src/test/java/io/apicurio/registry/operator/it/AppFeaturesITTest.java +++ b/operator/controller/src/test/java/io/apicurio/registry/operator/it/AppFeaturesITTest.java @@ -7,8 +7,6 @@ import io.fabric8.kubernetes.api.model.EnvVar; import io.quarkus.test.junit.QuarkusTest; import org.junit.jupiter.api.Test; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; import static io.apicurio.registry.operator.api.v1.ContainerNames.REGISTRY_APP_CONTAINER_NAME; import static io.apicurio.registry.operator.resource.app.AppDeploymentResource.getContainerFromDeployment; @@ -17,8 +15,6 @@ @QuarkusTest public class AppFeaturesITTest extends ITBase { - private static final Logger log = LoggerFactory.getLogger(AppFeaturesITTest.class); - @Test void testAllowDeletesTrue() { ApicurioRegistry3 registry = ResourceFactory diff --git a/operator/controller/src/test/java/io/apicurio/registry/operator/it/SmokeITTest.java b/operator/controller/src/test/java/io/apicurio/registry/operator/it/SmokeITTest.java index eb8a51a074..33d69ec956 100644 --- a/operator/controller/src/test/java/io/apicurio/registry/operator/it/SmokeITTest.java +++ b/operator/controller/src/test/java/io/apicurio/registry/operator/it/SmokeITTest.java @@ -1,5 +1,6 @@ package io.apicurio.registry.operator.it; +import io.apicurio.registry.operator.EnvironmentVariables; import io.apicurio.registry.operator.api.v1.ApicurioRegistry3; import io.apicurio.registry.operator.resource.ResourceFactory; import io.fabric8.kubernetes.api.model.Container; @@ -15,9 +16,11 @@ import java.net.URI; +import static io.apicurio.registry.operator.api.v1.ContainerNames.REGISTRY_APP_CONTAINER_NAME; import static io.apicurio.registry.operator.api.v1.ContainerNames.REGISTRY_UI_CONTAINER_NAME; import static io.apicurio.registry.operator.resource.ResourceFactory.COMPONENT_APP; import static io.apicurio.registry.operator.resource.ResourceFactory.COMPONENT_UI; +import static io.apicurio.registry.operator.resource.app.AppDeploymentResource.getContainerFromDeployment; import static io.restassured.RestAssured.given; import static org.assertj.core.api.Assertions.assertThat; import static org.awaitility.Awaitility.await; @@ -70,6 +73,18 @@ void smoke() { .get(0).getHost()).isEqualTo(registry.getSpec().getUi().getIngress().getHost()); return true; }); + + // Check CORS allowed origins is set on the app, with the value based on the UI ingress host + String uiIngressHost = client.network().v1().ingresses().inNamespace(namespace) + .withName(registry.getMetadata().getName() + "-ui-ingress").get().getSpec().getRules().get(0) + .getHost(); + String corsOriginsExpectedValue = "http://" + uiIngressHost + "," + "https://" + uiIngressHost; + var appEnv = getContainerFromDeployment( + client.apps().deployments().inNamespace(namespace) + .withName(registry.getMetadata().getName() + "-app-deployment").get(), + REGISTRY_APP_CONTAINER_NAME).getEnv(); + assertThat(appEnv).map(ev -> ev.getName() + "=" + ev.getValue()) + .contains(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS + "=" + corsOriginsExpectedValue); } @Test diff --git a/operator/controller/src/test/java/io/apicurio/registry/operator/unit/CorsTest.java b/operator/controller/src/test/java/io/apicurio/registry/operator/unit/CorsTest.java new file mode 100644 index 0000000000..cd6d203cdc --- /dev/null +++ b/operator/controller/src/test/java/io/apicurio/registry/operator/unit/CorsTest.java @@ -0,0 +1,48 @@ +package io.apicurio.registry.operator.unit; + +import io.apicurio.registry.operator.EnvironmentVariables; +import io.apicurio.registry.operator.OperatorException; +import io.apicurio.registry.operator.api.v1.ApicurioRegistry3; +import io.apicurio.registry.operator.feat.Cors; +import io.apicurio.registry.operator.resource.ResourceFactory; +import io.fabric8.kubernetes.api.model.EnvVar; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.nio.charset.Charset; +import java.util.LinkedHashMap; +import java.util.Set; + +public class CorsTest { + + @Test + public void testConfigureAllowedOrigins() throws Exception { + doTestAllowedOrigins("k8s/examples/cors/example-default.yaml", "*"); + doTestAllowedOrigins("k8s/examples/cors/example-ingress.yaml", + "http://simple-ui.apps.cluster.example", "https://simple-ui.apps.cluster.example"); + doTestAllowedOrigins("k8s/examples/cors/example-env-vars.yaml", "https://ui.example.org"); + doTestAllowedOrigins("k8s/examples/cors/example-env-vars-and-ingress.yaml", "https://ui.example.org"); + } + + private void doTestAllowedOrigins(String crPath, String... values) { + ClassLoader classLoader = CorsTest.class.getClassLoader(); + ApicurioRegistry3 registry = ResourceFactory.deserialize(crPath, ApicurioRegistry3.class, + classLoader); + + LinkedHashMap envVars = new LinkedHashMap<>(); + Cors.configureAllowedOrigins(registry, envVars); + Assertions.assertThat(envVars.keySet()).contains(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS); + String allowedOriginsValue = envVars.get(EnvironmentVariables.QUARKUS_HTTP_CORS_ORIGINS).getValue(); + Set allowedOrigins = Set.of(allowedOriginsValue.split(",")); + Assertions.assertThat(allowedOrigins).containsExactlyInAnyOrder(values); + } + + public static String load(String path) { + try (var stream = Thread.currentThread().getContextClassLoader().getResourceAsStream(path)) { + return new String(stream.readAllBytes(), Charset.defaultCharset()); + } catch (Exception ex) { + throw new OperatorException("Could not read resource: " + path, ex); + } + } + +} diff --git a/operator/controller/src/test/resources/k8s/examples/cors/example-default.yaml b/operator/controller/src/test/resources/k8s/examples/cors/example-default.yaml new file mode 100644 index 0000000000..53e6d43ef7 --- /dev/null +++ b/operator/controller/src/test/resources/k8s/examples/cors/example-default.yaml @@ -0,0 +1,5 @@ +apiVersion: registry.apicur.io/v1 +kind: ApicurioRegistry3 +metadata: + name: simple +spec: {} diff --git a/operator/controller/src/test/resources/k8s/examples/cors/example-env-vars-and-ingress.yaml b/operator/controller/src/test/resources/k8s/examples/cors/example-env-vars-and-ingress.yaml new file mode 100644 index 0000000000..10a0a16c8a --- /dev/null +++ b/operator/controller/src/test/resources/k8s/examples/cors/example-env-vars-and-ingress.yaml @@ -0,0 +1,14 @@ +apiVersion: registry.apicur.io/v1 +kind: ApicurioRegistry3 +metadata: + name: simple +spec: + app: + ingress: + host: simple-app.apps.cluster.example + env: + - name: QUARKUS_HTTP_CORS_ORIGINS + value: https://ui.example.org + ui: + ingress: + host: simple-ui.apps.cluster.example diff --git a/operator/controller/src/test/resources/k8s/examples/cors/example-env-vars.yaml b/operator/controller/src/test/resources/k8s/examples/cors/example-env-vars.yaml new file mode 100644 index 0000000000..9525aee1ac --- /dev/null +++ b/operator/controller/src/test/resources/k8s/examples/cors/example-env-vars.yaml @@ -0,0 +1,9 @@ +apiVersion: registry.apicur.io/v1 +kind: ApicurioRegistry3 +metadata: + name: simple +spec: + app: + env: + - name: QUARKUS_HTTP_CORS_ORIGINS + value: https://ui.example.org diff --git a/operator/controller/src/test/resources/k8s/examples/cors/example-ingress.yaml b/operator/controller/src/test/resources/k8s/examples/cors/example-ingress.yaml new file mode 100644 index 0000000000..2cd8be6262 --- /dev/null +++ b/operator/controller/src/test/resources/k8s/examples/cors/example-ingress.yaml @@ -0,0 +1,11 @@ +apiVersion: registry.apicur.io/v1 +kind: ApicurioRegistry3 +metadata: + name: simple +spec: + app: + ingress: + host: simple-app.apps.cluster.example + ui: + ingress: + host: simple-ui.apps.cluster.example