Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand All @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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=");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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"}}.
* <p>
* A template {@code templates/<Class>/<method>.<ext>} is checked against the parameter types of its
* {@code @CheckedTemplate} method {@code <Class>.Templates.<method>(...)}. 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<String> violations = new ArrayList<>();
try (Stream<Path> 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<String> checkTemplate(Path template) throws Exception {
Method templateMethod = findTemplateMethod(template);
List<String> lines = Files.readAllLines(template);
List<String> 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 <Class>.Templates.<method>} declaration bound to a {@code <Class>/<method>.<ext>} 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;
}
}
}
Loading