From d5843dd211e947c3bfdf35abd547a5606607480a Mon Sep 17 00:00:00 2001 From: Abhayanth K Date: Fri, 23 Oct 2026 21:01:37 +0530 Subject: [PATCH 1/3] fix: compare exposition type by name for gRPC TLS properties `commonExpositions.type` is an ExpositionType enum, but the web app template compared it to the String "INGRESS". The condition never matched, so grpc.server.certChainFilePath and grpc.server.privateKeyFilePath were never rendered, even with the default INGRESS exposition. Compare `type.name` instead, as done for the Kafka authentication type in #306. Fixes #330 Signed-off-by: Abhayanth K --- .../application.properties | 2 +- ...icrocksConfigMapDependentResourceTest.java | 37 ++++++++++++++++++- 2 files changed, 37 insertions(+), 2 deletions(-) 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/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); + } + } } From 4801270354dea75077e3d736a6c72494f0f2e905 Mon Sep 17 00:00:00 2001 From: Abhayanth K Date: Fri, 23 Oct 2026 21:56:18 +0530 Subject: [PATCH 2/3] fix: compare Amazon credentials type by name in async-minion template `credentialsType` is the AmazonCredentialsProviderType enum, but the SQS and SNS conditions compared it to the String 'profile'. They never matched, so credentials-profile-name and credentials-profile-location were never rendered. Compare `credentialsType.name` instead, as done for the Kafka authentication and exposition types. The SNS condition also checked `sqs.credentialsSecretRef` instead of `sns.credentialsSecretRef`. Refs #330 Signed-off-by: Abhayanth K --- .../application.properties | 4 +- ...cMinionConfigMapDependentResourceTest.java | 85 ++++++++++++++++++- 2 files changed, 86 insertions(+), 3 deletions(-) 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/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="); + } + } } From c58f57373f1c4f5129c27c27d8deec228c993e5c Mon Sep 17 00:00:00 2001 From: Abhayanth K Date: Fri, 23 Oct 2026 21:58:22 +0530 Subject: [PATCH 3/3] test: guard Qute templates against enum and string comparisons Fail when an {#if} or {#else if} condition compares an expression with a string literal and that expression resolves to an enum through the @CheckedTemplate parameter types. Qute never finds an enum equal to a String, so such a condition silently never matches (#330). Expressions that cannot be resolved also fail the test, so they get checked by hand. Refs #330 Signed-off-by: Abhayanth K --- .../QuteTemplateEnumComparisonTest.java | 134 ++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 operator/src/test/java/io/github/microcks/operator/base/resources/QuteTemplateEnumComparisonTest.java 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; + } + } +}