diff --git a/operator/src/main/resources/templates/AsyncMinionConfigMapDependentResource/application.properties b/operator/src/main/resources/templates/AsyncMinionConfigMapDependentResource/application.properties index 93e47dd..18bab04 100644 --- a/operator/src/main/resources/templates/AsyncMinionConfigMapDependentResource/application.properties +++ b/operator/src/main/resources/templates/AsyncMinionConfigMapDependentResource/application.properties @@ -177,7 +177,7 @@ keycloak.auth.url=http://{name}-keycloak.{namespace}.svc.{spec.clusterDomain}:80 {#if spec.features.async.sqs.region != null} %kube.amazonsqs.project={spec.features.async.sqs.region} %kube.amazonsqs.credentials-type={spec.features.async.sqs.credentialsType} -{#if spec.features.async.sqs.credentialsType == 'profile' && spec.features.async.sqs.credentialsSecretRef != null} +{#if spec.features.async.sqs.credentialsType.name == 'PROFILE' && spec.features.async.sqs.credentialsSecretRef != null} %kube.amazonsqs.credentials-profile-name={spec.features.async.sqs.credentialsProfile} %kube.amazonsqs.credentials-profile-location=/deployments/config/amazon-sqs/{spec.features.async.sqs.credentialsSecretRef.additionalProperties.get('fileKey')} {/if} @@ -187,7 +187,7 @@ keycloak.auth.url=http://{name}-keycloak.{namespace}.svc.{spec.clusterDomain}:80 {#if spec.features.async.sns.region != null} %kube.amazonsns.project={spec.features.async.sns.region} %kube.amazonsns.credentials-type={spec.features.async.sns.credentialsType} -{#if spec.features.async.sns.credentialsType == 'profile' && spec.features.async.sqs.credentialsSecretRef != null} +{#if spec.features.async.sns.credentialsType.name == 'PROFILE' && spec.features.async.sns.credentialsSecretRef != null} %kube.amazonsns.credentials-profile-name={spec.features.async.sns.credentialsProfile} %kube.amazonsns.credentials-profile-location=/deployments/config/amazon-sns/{spec.features.async.sns.credentialsSecretRef.additionalProperties.get('fileKey')} {/if} diff --git a/operator/src/main/resources/templates/MicrocksConfigMapDependentResource/application.properties b/operator/src/main/resources/templates/MicrocksConfigMapDependentResource/application.properties index 91bd049..c01d9c4 100644 --- a/operator/src/main/resources/templates/MicrocksConfigMapDependentResource/application.properties +++ b/operator/src/main/resources/templates/MicrocksConfigMapDependentResource/application.properties @@ -132,7 +132,7 @@ kafka.producers.asyncapi-triggers.properties.sasl.client.callback.handler.class= {/if} # Grpc server properties -{#if spec.commonExpositions.type == "INGRESS"} +{#if spec.commonExpositions.type.name == "INGRESS"} grpc.server.certChainFilePath=/deployments/config/grpc/tls.crt grpc.server.privateKeyFilePath=/deployments/config/grpc/tls.key {/if} diff --git a/operator/src/test/java/io/github/microcks/operator/base/resources/AsyncMinionConfigMapDependentResourceTest.java b/operator/src/test/java/io/github/microcks/operator/base/resources/AsyncMinionConfigMapDependentResourceTest.java index 1c32b1a..d5f5324 100644 --- a/operator/src/test/java/io/github/microcks/operator/base/resources/AsyncMinionConfigMapDependentResourceTest.java +++ b/operator/src/test/java/io/github/microcks/operator/base/resources/AsyncMinionConfigMapDependentResourceTest.java @@ -15,12 +15,15 @@ */ package io.github.microcks.operator.base.resources; +import io.github.microcks.operator.api.base.v1alpha1.AmazonServiceConnectionSpec; +import io.github.microcks.operator.api.base.v1alpha1.AmazonServiceConnectionSpec.AmazonCredentialsProviderType; import io.github.microcks.operator.api.base.v1alpha1.AsyncFeatureSpec; import io.github.microcks.operator.api.base.v1alpha1.KafkaAuthenticationSpec; import io.github.microcks.operator.api.base.v1alpha1.KafkaAuthenticationType; import io.github.microcks.operator.api.base.v1alpha1.KafkaSpec; import io.github.microcks.operator.api.base.v1alpha1.Microcks; import io.github.microcks.operator.api.base.v1alpha1.MicrocksSpec; +import io.github.microcks.operator.api.model.SecretReferenceSpec; import io.github.microcks.operator.base.MicrocksReconciler; import io.fabric8.kubernetes.api.model.ConfigMap; @@ -35,7 +38,8 @@ * {@code application.properties} template is correctly handled, i.e. that the * {@code microcks-asyncapi-triggers} Kafka properties are rendered for Microcks 1.14.0 and 1.15.0. * It also verifies that the optional SASL callback handler classes are rendered on all Kafka clients - * when configured, and omitted when they are not. + * when configured, and omitted when they are not. Finally, it checks that the Amazon SQS and SNS profile + * credentials are only rendered for the {@code profile} credentials type. * @author laurent */ @QuarkusTest @@ -58,6 +62,11 @@ class AsyncMinionConfigMapDependentResourceTest { "%kube.mp.messaging.incoming.microcks-services-updates.sasl.login.callback.handler.class=", "%kube.mp.messaging.incoming.microcks-asyncapi-triggers.sasl.login.callback.handler.class=" }; + private static final String SQS_PREFIX = "%kube.amazonsqs."; + private static final String SNS_PREFIX = "%kube.amazonsns."; + private static final String[] AMAZON_SERVICE_PREFIXES = { SQS_PREFIX, SNS_PREFIX }; + private static final String PROFILE_FILE_KEY = "aws.profile"; + private Microcks buildMicrocks(String version) throws Exception { MicrocksReconciler reconciler = new MicrocksReconciler(new KubernetesClientBuilder().build()); MicrocksSpec spec = reconciler.loadDefaultMicrocksSpec(version); @@ -96,6 +105,40 @@ private String renderApplicationPropertiesWithKafkaAuthentication(String version return render(microcks); } + /** Renders the async-minion properties with the given Amazon SQS and SNS connections. */ + private String renderApplicationPropertiesWithAmazonServices(AmazonServiceConnectionSpec sqs, + AmazonServiceConnectionSpec sns) throws Exception { + Microcks microcks = buildMicrocks("1.14.0"); + microcks.getSpec().getFeatures().getAsync().setSqs(sqs); + microcks.getSpec().getFeatures().getAsync().setSns(sns); + return render(microcks); + } + + /** Builds an Amazon service connection whose credentials secret holds an AWS profile file. */ + private static AmazonServiceConnectionSpec amazonConnection(AmazonCredentialsProviderType credentialsType, + String credentialsProfile) { + SecretReferenceSpec credentialsSecretRef = new SecretReferenceSpec(); + credentialsSecretRef.setName("aws-credentials"); + credentialsSecretRef.setAdditionalProperty("fileKey", PROFILE_FILE_KEY); + + AmazonServiceConnectionSpec connection = new AmazonServiceConnectionSpec(); + connection.setRegion("eu-west-3"); + connection.setCredentialsType(credentialsType); + connection.setCredentialsProfile(credentialsProfile); + connection.setCredentialsSecretRef(credentialsSecretRef); + return connection; + } + + private static void assertRendered(String applicationProperties, String property) { + Assertions.assertTrue(applicationProperties.contains(property), + property + " must be present in rendered application.properties"); + } + + private static void assertNotRendered(String applicationProperties, String property) { + Assertions.assertFalse(applicationProperties.contains(property), + property + " must be absent from rendered application.properties"); + } + private static void assertPropertiesRendered(String applicationProperties, String[] propertyKeys, String value) { for (String propertyKey : propertyKeys) { Assertions.assertTrue(applicationProperties.contains(propertyKey + value), @@ -155,4 +198,44 @@ void testSaslCallbackHandlerPropertiesNotRenderedWhenNotConfigured() throws Exce assertPropertiesNotRendered(applicationProperties, CLIENT_CALLBACK_HANDLER_KEYS); assertPropertiesNotRendered(applicationProperties, LOGIN_CALLBACK_HANDLER_KEYS); } + + @Test + void testSqsProfileCredentialsRendered() throws Exception { + String applicationProperties = renderApplicationPropertiesWithAmazonServices( + amazonConnection(AmazonCredentialsProviderType.PROFILE, "my-sqs-profile"), + new AmazonServiceConnectionSpec()); + + // Guard against a false pass: the SQS section must have been rendered with the profile credentials type. + assertRendered(applicationProperties, SQS_PREFIX + "credentials-type=profile"); + assertRendered(applicationProperties, SQS_PREFIX + "credentials-profile-name=my-sqs-profile"); + assertRendered(applicationProperties, + SQS_PREFIX + "credentials-profile-location=/deployments/config/amazon-sqs/" + PROFILE_FILE_KEY); + } + + @Test + void testSnsProfileCredentialsRenderedWithoutSqsCredentials() throws Exception { + // SQS is not configured, so only the SNS connection holds a credentials secret. + String applicationProperties = renderApplicationPropertiesWithAmazonServices(new AmazonServiceConnectionSpec(), + amazonConnection(AmazonCredentialsProviderType.PROFILE, "my-sns-profile")); + + // Guard against a false pass: the SNS section must have been rendered with the profile credentials type. + assertRendered(applicationProperties, SNS_PREFIX + "credentials-type=profile"); + assertRendered(applicationProperties, SNS_PREFIX + "credentials-profile-name=my-sns-profile"); + assertRendered(applicationProperties, + SNS_PREFIX + "credentials-profile-location=/deployments/config/amazon-sns/" + PROFILE_FILE_KEY); + } + + @Test + void testProfileCredentialsNotRenderedForEnvVariableCredentials() throws Exception { + String applicationProperties = renderApplicationPropertiesWithAmazonServices( + amazonConnection(AmazonCredentialsProviderType.ENV_VARIABLE, "my-sqs-profile"), + amazonConnection(AmazonCredentialsProviderType.ENV_VARIABLE, "my-sns-profile")); + + for (String prefix : AMAZON_SERVICE_PREFIXES) { + // Guard against a false pass: the section must have been rendered with the env-variable credentials type. + assertRendered(applicationProperties, prefix + "credentials-type=env-variable"); + assertNotRendered(applicationProperties, prefix + "credentials-profile-name="); + assertNotRendered(applicationProperties, prefix + "credentials-profile-location="); + } + } } diff --git a/operator/src/test/java/io/github/microcks/operator/base/resources/MicrocksConfigMapDependentResourceTest.java b/operator/src/test/java/io/github/microcks/operator/base/resources/MicrocksConfigMapDependentResourceTest.java index b50c637..e61c292 100644 --- a/operator/src/test/java/io/github/microcks/operator/base/resources/MicrocksConfigMapDependentResourceTest.java +++ b/operator/src/test/java/io/github/microcks/operator/base/resources/MicrocksConfigMapDependentResourceTest.java @@ -22,6 +22,7 @@ import io.github.microcks.operator.api.base.v1alpha1.Microcks; import io.github.microcks.operator.api.base.v1alpha1.MicrocksSpec; import io.github.microcks.operator.api.base.v1alpha1.MicrocksStatus; +import io.github.microcks.operator.api.model.ExpositionType; import io.github.microcks.operator.base.MicrocksReconciler; import io.fabric8.kubernetes.api.model.ConfigMap; @@ -35,7 +36,8 @@ * This is a unit test verifying the Kafka authentication section of the main web app * {@code application.properties} template: SASL properties must be rendered for an external Kafka cluster * ({@code install: false}), on both the pre-1.14 and the 1.14+ producer layouts, and must not be rendered - * when the operator installs Kafka itself. + * when the operator installs Kafka itself. It also checks that the gRPC TLS properties are only rendered for an + * {@code INGRESS} exposition. * @author Abhayanth K */ @QuarkusTest @@ -55,6 +57,11 @@ class MicrocksConfigMapDependentResourceTest { private static final String CLIENT_CALLBACK_HANDLER_KEY = "sasl.client.callback.handler.class="; private static final String LOGIN_CALLBACK_HANDLER_KEY = "sasl.login.callback.handler.class="; + private static final String GRPC_CERT_CHAIN_PROPERTY = "grpc.server.certChainFilePath=/deployments/config/grpc/tls.crt"; + private static final String GRPC_PRIVATE_KEY_PROPERTY = "grpc.server.privateKeyFilePath=/deployments/config/grpc/tls.key"; + + private static final ExpositionType[] NON_INGRESS_EXPOSITIONS = { ExpositionType.NONE, ExpositionType.GATEWAYROUTE }; + private Microcks buildMicrocks(String version) throws Exception { MicrocksReconciler reconciler = new MicrocksReconciler(new KubernetesClientBuilder().build()); MicrocksSpec spec = reconciler.loadDefaultMicrocksSpec(version); @@ -91,6 +98,14 @@ private String renderApplicationProperties(String version, boolean installKafka, return configMap.getData().get("application.properties"); } + private String renderApplicationProperties(ExpositionType expositionType) throws Exception { + Microcks microcks = buildMicrocks("1.14.0"); + microcks.getSpec().getCommonExpositions().setType(expositionType); + + ConfigMap configMap = new MicrocksConfigMapDependentResource().desired(microcks, null); + return configMap.getData().get("application.properties"); + } + private static KafkaAuthenticationSpec saslSslAuthentication(String saslMechanism) { KafkaAuthenticationSpec authentication = new KafkaAuthenticationSpec(); authentication.setType(KafkaAuthenticationType.SASL_SSL); @@ -168,4 +183,24 @@ void testKafkaAuthenticationNotRenderedForInstalledKafka() throws Exception { assertNotRendered(applicationProperties, producerPrefix + CLIENT_CALLBACK_HANDLER_KEY); } } + + @Test + void testGrpcTlsPropertiesRenderedForIngressExposition() throws Exception { + String applicationProperties = renderApplicationProperties(ExpositionType.INGRESS); + + assertRendered(applicationProperties, GRPC_CERT_CHAIN_PROPERTY); + assertRendered(applicationProperties, GRPC_PRIVATE_KEY_PROPERTY); + } + + @Test + void testGrpcTlsPropertiesNotRenderedForOtherExpositions() throws Exception { + for (ExpositionType expositionType : NON_INGRESS_EXPOSITIONS) { + String applicationProperties = renderApplicationProperties(expositionType); + + // Guard against a false pass: the template must have been rendered up to the gRPC section. + assertRendered(applicationProperties, "# Grpc server properties"); + assertNotRendered(applicationProperties, GRPC_CERT_CHAIN_PROPERTY); + assertNotRendered(applicationProperties, GRPC_PRIVATE_KEY_PROPERTY); + } + } } diff --git a/operator/src/test/java/io/github/microcks/operator/base/resources/QuteTemplateEnumComparisonTest.java b/operator/src/test/java/io/github/microcks/operator/base/resources/QuteTemplateEnumComparisonTest.java new file mode 100644 index 0000000..2f282e6 --- /dev/null +++ b/operator/src/test/java/io/github/microcks/operator/base/resources/QuteTemplateEnumComparisonTest.java @@ -0,0 +1,134 @@ +/* + * Copyright The Microcks Authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package io.github.microcks.operator.base.resources; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Method; +import java.lang.reflect.Parameter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; + +/** + * This is a guard test for the Qute templates: an {@code {#if}} condition must not compare an enum with a string + * literal. Qute compares the objects themselves, so an enum never equals a String and a condition such as + * {@code {#if spec.commonExpositions.type == "INGRESS"}} silently never matches. Compare the enum name instead: + * {@code {#if spec.commonExpositions.type.name == "INGRESS"}}. + *

+ * A template {@code templates//.} is checked against the parameter types of its + * {@code @CheckedTemplate} method {@code .Templates.(...)}. An expression that cannot be resolved + * also fails the test, so that it is checked by hand rather than silently skipped. + * @author Abhayanth K + */ +class QuteTemplateEnumComparisonTest { + + private static final Path TEMPLATES_DIR = Paths.get("src/main/resources/templates"); + + /** The condition of an {@code {#if ...}} or {@code {#else if ...}} section. */ + private static final Pattern CONDITION = Pattern.compile("\\{#(?:else )?if\\s+([^}]*)}"); + + /** A dotted expression compared with a string literal, using any of the Qute equality operators. */ + private static final Pattern STRING_COMPARISON = Pattern + .compile("([A-Za-z_][\\w.]*)\\s*(?:==|!=|\\beq\\b|\\bne\\b|\\bis\\b)\\s*(['\"])[^'\"]*\\2"); + + @Test + void testNoEnumComparedWithStringLiteral() throws Exception { + List violations = new ArrayList<>(); + try (Stream templates = Files.walk(TEMPLATES_DIR)) { + for (Path template : templates.filter(Files::isRegularFile).sorted().toList()) { + violations.addAll(checkTemplate(template)); + } + } + Assertions.assertTrue(violations.isEmpty(), + "Compare enums by name in Qute conditions:\n" + String.join("\n", violations)); + } + + private List checkTemplate(Path template) throws Exception { + Method templateMethod = findTemplateMethod(template); + List lines = Files.readAllLines(template); + List violations = new ArrayList<>(); + + for (int i = 0; i < lines.size(); i++) { + Matcher condition = CONDITION.matcher(lines.get(i)); + while (condition.find()) { + Matcher comparison = STRING_COMPARISON.matcher(condition.group(1)); + while (comparison.find()) { + Class type = resolveType(templateMethod, comparison.group(1)); + String location = TEMPLATES_DIR.relativize(template) + ":" + (i + 1) + ": " + comparison.group(); + if (type == null) { + violations.add(location + " (cannot resolve " + comparison.group(1) + ", check it by hand)"); + } else if (type.isEnum()) { + violations.add(location + " (" + type.getSimpleName() + " is an enum)"); + } + } + } + } + return violations; + } + + /** Finds the {@code .Templates.} declaration bound to a {@code /.} template. */ + private Method findTemplateMethod(Path template) throws ClassNotFoundException { + String className = getClass().getPackageName() + "." + template.getParent().getFileName() + "$Templates"; + String fileName = template.getFileName().toString(); + String methodName = fileName.substring(0, fileName.indexOf('.')); + + Class templates = Class.forName(className, false, getClass().getClassLoader()); + return Arrays.stream(templates.getDeclaredMethods()).filter(method -> method.getName().equals(methodName)) + .findFirst() + .orElseThrow(() -> new AssertionError("No " + className + "." + methodName + " for " + template)); + } + + /** Resolves the type of a dotted expression such as {@code spec.commonExpositions.type}, or null if unknown. */ + private static Class resolveType(Method templateMethod, String expression) { + String[] segments = expression.split("\\."); + Class type = null; + for (Parameter parameter : templateMethod.getParameters()) { + Assertions.assertTrue(parameter.isNamePresent(), "Compile with -parameters to resolve template parameters"); + if (parameter.getName().equals(segments[0])) { + type = parameter.getType(); + } + } + for (int i = 1; i < segments.length && type != null; i++) { + type = propertyType(type, segments[i]); + } + return type; + } + + /** Mirrors the Qute reflection resolver: a no-arg method named after the property, its getter, or a field. */ + private static Class propertyType(Class type, String property) { + String capitalized = Character.toUpperCase(property.charAt(0)) + property.substring(1); + for (String methodName : List.of(property, "get" + capitalized, "is" + capitalized)) { + try { + return type.getMethod(methodName).getReturnType(); + } catch (NoSuchMethodException e) { + // Try the next naming convention. + } + } + try { + return type.getField(property).getType(); + } catch (NoSuchFieldException e) { + return null; + } + } +}