diff --git a/Dockerfile b/Dockerfile index 6cc1f6d..a916e8d 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,9 @@ -FROM gradle:7.5-jdk17 as builder +FROM gradle:9.4.1-jdk25 as builder USER root COPY . . RUN gradle --no-daemon build -FROM gcr.io/distroless/java17 +FROM gcr.io/distroless/java25 ENV JAVA_TOOL_OPTIONS -XX:+ExitOnOutOfMemoryError COPY --from=builder /home/gradle/build/libs/*.jar /data/app.jar CMD ["/data/app.jar"] diff --git a/build.gradle b/build.gradle index c4cd274..def3da5 100644 --- a/build.gradle +++ b/build.gradle @@ -1,13 +1,18 @@ plugins { - id 'org.springframework.boot' version '2.7.4' - id 'io.spring.dependency-management' version '1.0.14.RELEASE' + id 'org.springframework.boot' version '3.5.12' + id 'io.spring.dependency-management' version '1.1.7' id 'java' id 'groovy' } group = 'no.fintlabs' version = '0.0.1-SNAPSHOT' -sourceCompatibility = '17' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(25) + } +} jar { enabled = false @@ -30,22 +35,17 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-webflux' - implementation 'io.netty:netty-resolver-dns-native-macos:4.1.85.Final:osx-aarch_64' - - - //implementation 'no.fintlabs:flais-operator-starter:0-SNAPSHOT' - implementation 'no.fintlabs:flais-operator-starter:1.0.0-rc-9' + implementation 'no.fintlabs:flais-operator-starter:1.0.0' annotationProcessor 'io.fabric8:crd-generator-apt:6.2.0' - implementation 'org.apache.commons:commons-collections4:4.4' + implementation 'org.apache.commons:commons-collections4:4.5.0' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' + compileOnly 'org.projectlombok:lombok:1.18.44' + annotationProcessor 'org.projectlombok:lombok:1.18.44' testImplementation 'org.springframework.boot:spring-boot-starter-test' - //testImplementation 'io.projectreactor:reactor-test' testImplementation 'cglib:cglib-nodep:3.3.0' testImplementation 'org.spockframework:spock-spring:2.3-groovy-4.0' testImplementation 'org.spockframework:spock-core:2.3-groovy-4.0' diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar index 249e583..d997cfc 100644 Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8049c68..c61a118 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,7 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew index a69d9cb..739907d 100755 --- a/gradlew +++ b/gradlew @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright © 2015-2021 the original authors. +# Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -15,6 +15,8 @@ # See the License for the specific language governing permissions and # limitations under the License. # +# SPDX-License-Identifier: Apache-2.0 +# ############################################################################## # @@ -55,7 +57,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +82,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -114,7 +114,6 @@ case "$( uname )" in #( NONSTOP* ) nonstop=true ;; esac -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar # Determine the Java command to use to start the JVM. @@ -133,22 +132,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -165,7 +171,6 @@ fi # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) @@ -193,16 +198,19 @@ if "$cygwin" || "$msys" ; then done fi -# Collect all arguments for the java command; -# * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of -# shell script including quotes and variable substitutions, so put them in -# double quotes to make sure that they get re-expanded; and -# * put everything else in single quotes, so that it's not re-expanded. + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. diff --git a/gradlew.bat b/gradlew.bat index f127cfd..e509b2d 100644 --- a/gradlew.bat +++ b/gradlew.bat @@ -1,91 +1,93 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/src/main/java/no/fintlabs/aiven/AivenService.java b/src/main/java/no/fintlabs/aiven/AivenService.java index e0e24f7..e2b9adb 100644 --- a/src/main/java/no/fintlabs/aiven/AivenService.java +++ b/src/main/java/no/fintlabs/aiven/AivenService.java @@ -1,5 +1,6 @@ package no.fintlabs.aiven; +import jakarta.annotation.PostConstruct; import lombok.Getter; import lombok.extern.slf4j.Slf4j; import no.fintlabs.operator.KafkaUserAndAcl; @@ -11,8 +12,11 @@ import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; -import javax.annotation.PostConstruct; -import java.util.*; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.Optional; @Slf4j @Component diff --git a/src/main/java/no/fintlabs/aiven/AivenServiceUser.java b/src/main/java/no/fintlabs/aiven/AivenServiceUser.java index 282ab58..301156e 100644 --- a/src/main/java/no/fintlabs/aiven/AivenServiceUser.java +++ b/src/main/java/no/fintlabs/aiven/AivenServiceUser.java @@ -1,7 +1,12 @@ package no.fintlabs.aiven; import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; @Builder @Setter diff --git a/src/main/java/no/fintlabs/aiven/CreateKafkaAclEntryRequest.java b/src/main/java/no/fintlabs/aiven/CreateKafkaAclEntryRequest.java index 7cbd8ee..f4dbef3 100644 --- a/src/main/java/no/fintlabs/aiven/CreateKafkaAclEntryRequest.java +++ b/src/main/java/no/fintlabs/aiven/CreateKafkaAclEntryRequest.java @@ -1,6 +1,10 @@ package no.fintlabs.aiven; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; @Builder @Getter diff --git a/src/main/java/no/fintlabs/aiven/CreateKafkaUserResponse.java b/src/main/java/no/fintlabs/aiven/CreateKafkaUserResponse.java index db29009..0687e3f 100644 --- a/src/main/java/no/fintlabs/aiven/CreateKafkaUserResponse.java +++ b/src/main/java/no/fintlabs/aiven/CreateKafkaUserResponse.java @@ -1,6 +1,6 @@ package no.fintlabs.aiven; -import lombok.*; +import lombok.Data; import java.io.Serializable; diff --git a/src/main/java/no/fintlabs/aiven/KafkaAclEntry.java b/src/main/java/no/fintlabs/aiven/KafkaAclEntry.java index 5f81bbe..887edf3 100644 --- a/src/main/java/no/fintlabs/aiven/KafkaAclEntry.java +++ b/src/main/java/no/fintlabs/aiven/KafkaAclEntry.java @@ -1,6 +1,11 @@ package no.fintlabs.aiven; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; @Builder @Setter diff --git a/src/main/java/no/fintlabs/keystore/KeyStoreService.java b/src/main/java/no/fintlabs/keystore/KeyStoreService.java index d9b7aec..87acb51 100644 --- a/src/main/java/no/fintlabs/keystore/KeyStoreService.java +++ b/src/main/java/no/fintlabs/keystore/KeyStoreService.java @@ -5,7 +5,12 @@ import java.io.ByteArrayInputStream; import java.io.IOException; -import java.security.*; +import java.security.GeneralSecurityException; +import java.security.KeyFactory; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.PrivateKey; import java.security.cert.Certificate; import java.security.cert.CertificateException; import java.security.cert.CertificateFactory; diff --git a/src/main/java/no/fintlabs/operator/CertificateSecretDependentResource.java b/src/main/java/no/fintlabs/operator/CertificateSecretDependentResource.java index 4a21751..454dab5 100644 --- a/src/main/java/no/fintlabs/operator/CertificateSecretDependentResource.java +++ b/src/main/java/no/fintlabs/operator/CertificateSecretDependentResource.java @@ -14,7 +14,6 @@ import no.fintlabs.keystore.TrustStoreService; import org.springframework.stereotype.Component; -import java.util.Base64; import java.util.HashMap; import java.util.Optional; @@ -85,14 +84,14 @@ protected Secret desired(KafkaUserAndAclCrd resource, Context ts.getData().get("client.truststore.jks")) .map(ts -> trustStoreService.verifyTrustStore(ts, trustStorePassword)) .orElseGet(() -> { - log.info("No trust store available. Creating a new one!"); + log.info("No trust store available. Creating a new one!"); - return trustStoreService.createTrustStoreAndGetAsBase64( - aivenService.getCa(), - trustStorePassword.toCharArray() - ); - } - ); + return trustStoreService.createTrustStoreAndGetAsBase64( + aivenService.getCa(), + trustStorePassword.toCharArray() + ); + } + ); HashMap labels = new HashMap<>(resource.getMetadata().getLabels()); labels.put("app.kubernetes.io/managed-by", "kafkarator"); @@ -119,13 +118,4 @@ public static String getResourceName(KafkaUserAndAclCrd resource) { public Matcher.Result match(Secret actualResource, KafkaUserAndAclCrd primary, Context context) { return super.match(actualResource, primary, context); } - - private String encode(String value) { - - return Base64.getEncoder().encodeToString(value.getBytes()); - } - - private String decode(String value) { - return new String(Base64.getDecoder().decode(value.getBytes())); - } } diff --git a/src/main/java/no/fintlabs/operator/CertificateSecretDiscriminator.java b/src/main/java/no/fintlabs/operator/CertificateSecretDiscriminator.java index 9c01b4d..6ef608c 100644 --- a/src/main/java/no/fintlabs/operator/CertificateSecretDiscriminator.java +++ b/src/main/java/no/fintlabs/operator/CertificateSecretDiscriminator.java @@ -9,8 +9,6 @@ import java.util.Optional; -import static no.fintlabs.operator.CertificateSecretDependentResource.NAME_SUFFIX; - @Component public class CertificateSecretDiscriminator implements ResourceDiscriminator { @Override diff --git a/src/main/java/no/fintlabs/operator/KafkaSecretDependentResource.java b/src/main/java/no/fintlabs/operator/KafkaSecretDependentResource.java index 3d992fe..000fdfc 100644 --- a/src/main/java/no/fintlabs/operator/KafkaSecretDependentResource.java +++ b/src/main/java/no/fintlabs/operator/KafkaSecretDependentResource.java @@ -5,7 +5,6 @@ import io.fabric8.kubernetes.client.KubernetesClient; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.Matcher; -import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependentResourceConfig; import lombok.extern.slf4j.Slf4j; import no.fintlabs.FlaisKubernetesDependentResource; @@ -14,7 +13,6 @@ import org.apache.commons.lang3.RandomStringUtils; import org.springframework.stereotype.Component; -import java.util.Base64; import java.util.HashMap; import java.util.Optional; @@ -52,10 +50,10 @@ protected Secret desired(KafkaUserAndAclCrd resource, Context decode(secret.getData().get("spring.kafka.ssl.key-store-password"))) - .orElse(RandomStringUtils.randomAlphanumeric(32)); + .orElse(RandomStringUtils.secure().nextAlphanumeric(32)); String trustStorePassword = thisSecret .map(secret -> decode(secret.getData().get("spring.kafka.ssl.trust-store-password"))) - .orElse(RandomStringUtils.randomAlphabetic(32)); + .orElse(RandomStringUtils.secure().nextAlphanumeric(32)); return new SecretBuilder() .withNewMetadata() @@ -85,12 +83,4 @@ public static String getResourceName(KafkaUserAndAclCrd resource) { public Matcher.Result match(Secret actualResource, KafkaUserAndAclCrd primary, Context context) { return super.match(actualResource, primary, context); } - - private static String encode(String value) { - return Base64.getEncoder().encodeToString(value.getBytes()); - } - - private String decode(String value) { - return new String(Base64.getDecoder().decode(value.getBytes())); - } } diff --git a/src/main/java/no/fintlabs/operator/KafkaSecretDiscriminator.java b/src/main/java/no/fintlabs/operator/KafkaSecretDiscriminator.java index 7a84c13..de2880a 100644 --- a/src/main/java/no/fintlabs/operator/KafkaSecretDiscriminator.java +++ b/src/main/java/no/fintlabs/operator/KafkaSecretDiscriminator.java @@ -9,8 +9,6 @@ import java.util.Optional; -import static no.fintlabs.operator.KafkaSecretDependentResource.NAME_SUFFIX; - @Component public class KafkaSecretDiscriminator implements ResourceDiscriminator { diff --git a/src/main/java/no/fintlabs/operator/KafkaUserAndAcl.java b/src/main/java/no/fintlabs/operator/KafkaUserAndAcl.java index 3bf4b02..2760720 100644 --- a/src/main/java/no/fintlabs/operator/KafkaUserAndAcl.java +++ b/src/main/java/no/fintlabs/operator/KafkaUserAndAcl.java @@ -1,10 +1,15 @@ package no.fintlabs.operator; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.EqualsAndHashCode; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import no.fintlabs.aiven.AivenServiceUser; import no.fintlabs.aiven.CreateKafkaAclEntryResponse; import no.fintlabs.aiven.CreateKafkaUserResponse; import no.fintlabs.aiven.KafkaAclEntry; -import no.fintlabs.aiven.AivenServiceUser; import java.util.ArrayList; import java.util.List; @@ -18,6 +23,7 @@ @EqualsAndHashCode public class KafkaUserAndAcl { private AivenServiceUser user; + @Builder.Default private List aclEntries = new ArrayList<>(); public static KafkaUserAndAcl fromUserAndAclResponse(CreateKafkaUserResponse user, CreateKafkaAclEntryResponse acl) { diff --git a/src/main/java/no/fintlabs/operator/KafkaUserAndAclCrd.java b/src/main/java/no/fintlabs/operator/KafkaUserAndAclCrd.java index 8e5927d..869930e 100644 --- a/src/main/java/no/fintlabs/operator/KafkaUserAndAclCrd.java +++ b/src/main/java/no/fintlabs/operator/KafkaUserAndAclCrd.java @@ -5,7 +5,6 @@ import io.fabric8.kubernetes.model.annotation.Kind; import io.fabric8.kubernetes.model.annotation.Version; import no.fintlabs.FlaisCrd; -import no.fintlabs.FlaisStatus; @Group("fintlabs.no") @Version("v1alpha1") diff --git a/src/main/java/no/fintlabs/operator/KafkaUserAndAclSpec.java b/src/main/java/no/fintlabs/operator/KafkaUserAndAclSpec.java index 1fe5cb1..cf2b46e 100644 --- a/src/main/java/no/fintlabs/operator/KafkaUserAndAclSpec.java +++ b/src/main/java/no/fintlabs/operator/KafkaUserAndAclSpec.java @@ -1,6 +1,11 @@ package no.fintlabs.operator; -import lombok.*; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; import no.fintlabs.FlaisSpec; import no.fintlabs.aiven.KafkaAclEntry; @@ -13,6 +18,7 @@ @NoArgsConstructor @AllArgsConstructor public class KafkaUserAndAclSpec implements FlaisSpec { + @Builder.Default private List acls = new ArrayList<>(); @Data diff --git a/src/test/groovy/no/fintlabs/ApplicationContextTest.java b/src/test/groovy/no/fintlabs/ApplicationContextTest.java new file mode 100644 index 0000000..cf519d8 --- /dev/null +++ b/src/test/groovy/no/fintlabs/ApplicationContextTest.java @@ -0,0 +1,74 @@ +package no.fintlabs; + +import io.fabric8.kubernetes.client.KubernetesClient; +import no.fintlabs.aiven.AivenProperties; +import no.fintlabs.aiven.AivenService; +import no.fintlabs.keystore.KeyStoreService; +import no.fintlabs.keystore.TrustStoreService; +import no.fintlabs.operator.CertificateSecretDependentResource; +import no.fintlabs.operator.CertificateSecretDiscriminator; +import no.fintlabs.operator.KafkaSecretDependentResource; +import no.fintlabs.operator.KafkaSecretDiscriminator; +import no.fintlabs.operator.KafkaUserAclReconciler; +import no.fintlabs.operator.KafkaUserAndAclDependentResource; +import no.fintlabs.operator.KafkaUserAndAclWorkflow; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.ApplicationContext; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.junit.jupiter.SpringExtension; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.mockito.Mockito.mock; + +@ExtendWith(SpringExtension.class) +@ContextConfiguration(classes = ApplicationContextTest.TestConfig.class) +@TestPropertySource(properties = { + "fint.aiven.service=test-service", + "fint.aiven.kafka-bootstrap-servers=localhost:9092" +}) +class ApplicationContextTest { + + @Autowired + private ApplicationContext applicationContext; + + @Test + void contextLoadsCriticalOperatorBeans() { + assertNotNull(applicationContext.getBean(KafkaUserAclReconciler.class)); + assertNotNull(applicationContext.getBean(KafkaUserAndAclDependentResource.class)); + assertNotNull(applicationContext.getBean(KafkaSecretDependentResource.class)); + assertNotNull(applicationContext.getBean(CertificateSecretDependentResource.class)); + } + + @Configuration(proxyBeanMethods = false) + @EnableConfigurationProperties(AivenProperties.class) + @Import({ + KafkaUserAclReconciler.class, + KafkaUserAndAclWorkflow.class, + KafkaUserAndAclDependentResource.class, + KafkaSecretDependentResource.class, + CertificateSecretDependentResource.class, + KafkaSecretDiscriminator.class, + CertificateSecretDiscriminator.class, + KeyStoreService.class, + TrustStoreService.class + }) + static class TestConfig { + + @Bean + KubernetesClient kubernetesClient() { + return mock(KubernetesClient.class); + } + + @Bean + AivenService aivenService() { + return mock(AivenService.class); + } + } +} diff --git a/src/test/groovy/no/fintlabs/aiven/AivenServiceSpec.groovy b/src/test/groovy/no/fintlabs/aiven/AivenServiceSpec.groovy new file mode 100644 index 0000000..cd407e6 --- /dev/null +++ b/src/test/groovy/no/fintlabs/aiven/AivenServiceSpec.groovy @@ -0,0 +1,223 @@ +package no.fintlabs.aiven + +import no.fintlabs.operator.KafkaUserAndAcl +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.springframework.web.reactive.function.client.WebClient +import spock.lang.Specification + +import java.util.concurrent.TimeUnit + +class AivenServiceSpec extends Specification { + + private MockWebServer mockWebServer + private AivenProperties aivenProperties + + def setup() { + mockWebServer = new MockWebServer() + mockWebServer.start() + + aivenProperties = new AivenProperties() + aivenProperties.setBaseUrl(mockWebServer.url("/").toString()) + aivenProperties.setProject("test_project") + aivenProperties.setService("test_service") + } + + def cleanup() { + mockWebServer.shutdown() + } + + def "init fetches CA certificate from expected endpoint"() { + given: + mockWebServer.enqueue(jsonResponse('{ "certificate": "ca-cert" }')) + def service = new AivenService(webClient(), aivenProperties) + + when: + service.init() + + then: + service.getCa() == "ca-cert" + with(takeRequest()) { + method == "GET" + path == "/project/test_project/kms/ca" + } + } + + def "createUserForService posts username and returns created user"() { + given: + mockWebServer.enqueue(jsonResponse('{ "message": "created", "user": { "username": "test-user", "password": "secret", "access_cert": "cert", "access_key": "key", "type": "service" } }')) + def service = new AivenService(webClient(), aivenProperties) + + when: + def createdUser = service.createUserForService("test-user") + + then: + createdUser.username == "test-user" + createdUser.password == "secret" + with(takeRequest()) { + method == "POST" + path == "/project/test_project/service/test_service/user" + body.readUtf8() == '{"username":"test-user"}' + } + } + + def "deleteUserForService issues delete to expected endpoint"() { + given: + mockWebServer.enqueue(new MockResponse().setResponseCode(204)) + def service = new AivenService(webClient(), aivenProperties) + + when: + service.deleteUserForService("test-user") + + then: + with(takeRequest()) { + method == "DELETE" + path == "/project/test_project/service/test_service/user/test-user" + } + } + + def "createAclEntryForTopic posts ACL request and returns matching ACL"() { + given: + mockWebServer.enqueue(jsonResponse('{ "acl": [ { "id": "other", "permission": "read", "topic": "other-topic", "username": "other-user" }, { "id": "acl-1", "permission": "readwrite", "topic": "topic-a", "username": "test-user" } ], "success": true }')) + def service = new AivenService(webClient(), aivenProperties) + def aclEntry = KafkaAclEntry.builder() + .username("test-user") + .topic("topic-a") + .permission("readwrite") + .build() + + when: + def createdAcl = service.createAclEntryForTopic(aclEntry) + + then: + createdAcl.id == "acl-1" + createdAcl.topic == "topic-a" + createdAcl.username == "test-user" + with(takeRequest()) { + method == "POST" + path == "/project/test_project/service/test_service/acl" + body.readUtf8() == '{"permission":"readwrite","topic":"topic-a","username":"test-user"}' + } + } + + def "createAclEntryForTopic rejects unsupported permission before calling Aiven"() { + given: + def service = new AivenService(webClient(), aivenProperties) + def aclEntry = KafkaAclEntry.builder() + .username("test-user") + .topic("topic-a") + .permission("consume") + .build() + + when: + service.createAclEntryForTopic(aclEntry) + + then: + def exception = thrown(IllegalArgumentException) + exception.message == "consume is not a valid Kafka ACL permission" + mockWebServer.requestCount == 0 + } + + def "deleteAclEntryForService issues delete to expected endpoint"() { + given: + mockWebServer.enqueue(new MockResponse().setResponseCode(204)) + def service = new AivenService(webClient(), aivenProperties) + + when: + service.deleteAclEntryForService("acl-123") + + then: + with(takeRequest()) { + method == "DELETE" + path == "/project/test_project/service/test_service/acl/acl-123" + } + } + + def "updateAclEntries deletes removed ACLs creates missing ACLs and refetches current state"() { + given: + def expected = KafkaUserAndAcl.builder() + .user(AivenServiceUser.fromUsername("test-user")) + .aclEntries([ + KafkaAclEntry.builder().id("keep").username("test-user").topic("topic-a").permission("read").build(), + KafkaAclEntry.builder().id("new-id").username("test-user").topic("topic-b").permission("write").build() + ]) + .build() + def actual = KafkaUserAndAcl.builder() + .user(AivenServiceUser.fromUsername("test-user")) + .aclEntries([ + KafkaAclEntry.builder().id("remove-id").username("test-user").topic("topic-old").permission("read").build(), + KafkaAclEntry.builder().id("keep").username("test-user").topic("topic-a").permission("read").build() + ]) + .build() + def desired = KafkaUserAndAcl.builder() + .user(AivenServiceUser.fromUsername("test-user")) + .aclEntries([ + KafkaAclEntry.builder().username("test-user").topic("topic-a").permission("read").build(), + KafkaAclEntry.builder().username("test-user").topic("topic-b").permission("write").build() + ]) + .build() + def service = new TrackingAivenService(aivenProperties) + service.result = Optional.of(expected) + + when: + def updated = service.updateAclEntries(actual, desired) + + then: + service.deletedAclIds == ["remove-id"] + service.createdAclEntries*.username == ["test-user"] + service.createdAclEntries*.topic == ["topic-b"] + service.createdAclEntries*.permission == ["write"] + service.requestedUsers == ["test-user"] + updated == expected + } + + private WebClient webClient() { + WebClient.builder() + .baseUrl(mockWebServer.url("/").toString()) + .build() + } + + private static MockResponse jsonResponse(String body) { + new MockResponse() + .addHeader("Content-Type", "application/json") + .setBody(body) + } + + private RecordedRequest takeRequest() { + mockWebServer.takeRequest(1, TimeUnit.SECONDS) + } + + private static class TrackingAivenService extends AivenService { + List deletedAclIds = [] + List createdAclEntries = [] + List requestedUsers = [] + Optional result = Optional.empty() + + TrackingAivenService(AivenProperties properties) { + super(WebClient.builder().baseUrl("http://localhost").build(), properties) + } + + @Override + void deleteAclEntryForService(String aclId) { + deletedAclIds << aclId + } + + @Override + KafkaAclEntry createAclEntryForTopic(KafkaAclEntry aclEntry) { + createdAclEntries << aclEntry + KafkaAclEntry.builder() + .id("created-${createdAclEntries.size()}") + .username(aclEntry.username) + .topic(aclEntry.topic) + .permission(aclEntry.permission) + .build() + } + + @Override + Optional getUserAndAcl(String username) { + requestedUsers << username + result + } + } +} diff --git a/src/test/groovy/no/fintlabs/aiven/AivenServiceTest.java b/src/test/groovy/no/fintlabs/aiven/AivenServiceTest.java index c712ffb..cc904ff 100644 --- a/src/test/groovy/no/fintlabs/aiven/AivenServiceTest.java +++ b/src/test/groovy/no/fintlabs/aiven/AivenServiceTest.java @@ -14,7 +14,9 @@ import java.io.IOException; import java.util.Optional; -import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; class AivenServiceTest { diff --git a/src/test/groovy/no/fintlabs/aiven/WebClientConfigurationSpec.groovy b/src/test/groovy/no/fintlabs/aiven/WebClientConfigurationSpec.groovy new file mode 100644 index 0000000..f8be91f --- /dev/null +++ b/src/test/groovy/no/fintlabs/aiven/WebClientConfigurationSpec.groovy @@ -0,0 +1,51 @@ +package no.fintlabs.aiven + +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import spock.lang.Specification + +import java.util.concurrent.TimeUnit + +class WebClientConfigurationSpec extends Specification { + + private MockWebServer mockWebServer + + def setup() { + mockWebServer = new MockWebServer() + mockWebServer.start() + } + + def cleanup() { + mockWebServer.shutdown() + } + + def "webClient adds bearer auth header to outgoing requests"() { + given: + def properties = new AivenProperties() + properties.setBaseUrl(mockWebServer.url("/").toString()) + properties.setToken("super-secret-token") + def webClient = new WebClientConfiguration(properties).webClient() + mockWebServer.enqueue(new MockResponse() + .addHeader("Content-Type", "application/json") + .setBody('{ "ok": true }')) + + when: + webClient.get() + .uri("/project/test") + .retrieve() + .bodyToMono(String) + .block() + + then: + with(takeRequest()) { + method == "GET" + path == "/project/test" + getHeader("Authorization") == "Bearer super-secret-token" + } + } + + private RecordedRequest takeRequest() { + mockWebServer.takeRequest(1, TimeUnit.SECONDS) + } +} diff --git a/src/test/groovy/no/fintlabs/operator/AivenServiceUserAndAclModelSpec.groovy b/src/test/groovy/no/fintlabs/operator/AivenServiceUserAndAclModelSpec.groovy index ccca20d..95b89e8 100644 --- a/src/test/groovy/no/fintlabs/operator/AivenServiceUserAndAclModelSpec.groovy +++ b/src/test/groovy/no/fintlabs/operator/AivenServiceUserAndAclModelSpec.groovy @@ -1,7 +1,7 @@ package no.fintlabs.operator -import no.fintlabs.aiven.KafkaAclEntry import no.fintlabs.aiven.AivenServiceUser +import no.fintlabs.aiven.KafkaAclEntry import spock.lang.Specification class AivenServiceUserAndAclModelSpec extends Specification { diff --git a/src/test/groovy/no/fintlabs/operator/CertificateSecretDependentResourceSpec.groovy b/src/test/groovy/no/fintlabs/operator/CertificateSecretDependentResourceSpec.groovy new file mode 100644 index 0000000..13274a9 --- /dev/null +++ b/src/test/groovy/no/fintlabs/operator/CertificateSecretDependentResourceSpec.groovy @@ -0,0 +1,179 @@ +package no.fintlabs.operator + +import io.fabric8.kubernetes.api.model.Secret +import io.fabric8.kubernetes.api.model.SecretBuilder +import io.fabric8.kubernetes.client.KubernetesClient +import io.javaoperatorsdk.operator.api.reconciler.Context +import no.fintlabs.aiven.AivenProperties +import no.fintlabs.aiven.AivenService +import no.fintlabs.aiven.AivenServiceUser +import no.fintlabs.keystore.KeyStoreService +import no.fintlabs.keystore.TrustStoreService +import spock.lang.Specification + +class CertificateSecretDependentResourceSpec extends Specification { + + private AivenService aivenService + private KeyStoreService keyStoreService + private TrustStoreService trustStoreService + private CertificateSecretDependentResource resource + private Context context + + def setup() { + aivenService = Mock() + keyStoreService = Mock() + trustStoreService = Mock() + def workflow = new KafkaUserAndAclWorkflow() + def kafkaUserAndAclDependentResource = new KafkaUserAndAclDependentResource( + workflow, + aivenService, + new AivenProperties() + ) + def kafkaSecretDependentResource = new KafkaSecretDependentResource( + workflow, + Mock(KubernetesClient), + kafkaUserAndAclDependentResource, + new AivenProperties(), + new KafkaSecretDiscriminator() + ) + resource = new CertificateSecretDependentResource( + workflow, + Mock(KubernetesClient), + kafkaSecretDependentResource, + kafkaUserAndAclDependentResource, + aivenService, + keyStoreService, + new CertificateSecretDiscriminator(), + trustStoreService + ) + context = Mock() + } + + def "desired creates both stores when certificate secret does not exist"() { + given: + def primary = primaryResource() + def kafkaUserAndAcl = KafkaUserAndAcl.builder() + .user(AivenServiceUser.builder() + .username("resolved-user") + .accessCert("client-cert") + .accessKey("client-key") + .build()) + .build() + def kafkaSecret = kafkaSecret(primary, "key-pass", "trust-pass") + context.getSecondaryResource(KafkaUserAndAcl.class) >> Optional.of(kafkaUserAndAcl) + context.getSecondaryResources(Secret.class) >> ([kafkaSecret] as Set) + + when: + def secret = resource.desired(primary, context) + + then: + 2 * aivenService.getCa() >> "ca-cert" + 1 * keyStoreService.createKeyStoreAndGetAsBase64("client-cert", "client-key", "ca-cert", { + new String(it) == "key-pass" + }) >> "generated-key-store" + 1 * trustStoreService.createTrustStoreAndGetAsBase64("ca-cert", { + new String(it) == "trust-pass" + }) >> "generated-trust-store" + secret.metadata.name == "sample-user-kafka-certificates" + secret.metadata.labels["app.kubernetes.io/managed-by"] == "kafkarator" + secret.data["client.keystore.p12"] == "generated-key-store" + secret.data["client.truststore.jks"] == "generated-trust-store" + } + + def "desired reuses existing stores when verification succeeds"() { + given: + def primary = primaryResource() + def kafkaUserAndAcl = KafkaUserAndAcl.builder() + .user(AivenServiceUser.builder() + .username("resolved-user") + .accessCert("client-cert") + .accessKey("client-key") + .build()) + .build() + def kafkaSecret = kafkaSecret(primary, "key-pass", "trust-pass") + def existingSecret = new SecretBuilder() + .withNewMetadata() + .withName("sample-user-kafka-certificates") + .withNamespace("default") + .endMetadata() + .addToData("client.keystore.p12", "existing-key-store") + .addToData("client.truststore.jks", "existing-trust-store") + .build() + context.getSecondaryResource(KafkaUserAndAcl.class) >> Optional.of(kafkaUserAndAcl) + context.getSecondaryResources(Secret.class) >> ([kafkaSecret, existingSecret] as Set) + + when: + def secret = resource.desired(primary, context) + + then: + 1 * keyStoreService.verifyKeyStore("existing-key-store", "key-pass") >> "existing-key-store" + 1 * trustStoreService.verifyTrustStore("existing-trust-store", "trust-pass") >> "existing-trust-store" + 0 * keyStoreService.createKeyStoreAndGetAsBase64(_, _, _, _) + 0 * trustStoreService.createTrustStoreAndGetAsBase64(_, _) + secret.data["client.keystore.p12"] == "existing-key-store" + secret.data["client.truststore.jks"] == "existing-trust-store" + } + + def "desired regenerates stores when existing data fails verification"() { + given: + def primary = primaryResource() + def kafkaUserAndAcl = KafkaUserAndAcl.builder() + .user(AivenServiceUser.builder() + .username("resolved-user") + .accessCert("client-cert") + .accessKey("client-key") + .build()) + .build() + def kafkaSecret = kafkaSecret(primary, "key-pass", "trust-pass") + def existingSecret = new SecretBuilder() + .withNewMetadata() + .withName("sample-user-kafka-certificates") + .withNamespace("default") + .endMetadata() + .addToData("client.keystore.p12", "broken-key-store") + .addToData("client.truststore.jks", "broken-trust-store") + .build() + context.getSecondaryResource(KafkaUserAndAcl.class) >> Optional.of(kafkaUserAndAcl) + context.getSecondaryResources(Secret.class) >> ([kafkaSecret, existingSecret] as Set) + + when: + def secret = resource.desired(primary, context) + + then: + 1 * keyStoreService.verifyKeyStore("broken-key-store", "key-pass") >> null + 1 * trustStoreService.verifyTrustStore("broken-trust-store", "trust-pass") >> null + 2 * aivenService.getCa() >> "ca-cert" + 1 * keyStoreService.createKeyStoreAndGetAsBase64("client-cert", "client-key", "ca-cert", { + new String(it) == "key-pass" + }) >> "regenerated-key-store" + 1 * trustStoreService.createTrustStoreAndGetAsBase64("ca-cert", { + new String(it) == "trust-pass" + }) >> "regenerated-trust-store" + secret.data["client.keystore.p12"] == "regenerated-key-store" + secret.data["client.truststore.jks"] == "regenerated-trust-store" + } + + private static KafkaUserAndAclCrd primaryResource() { + def primary = new KafkaUserAndAclCrd() + primary.metadata.name = "sample-user" + primary.metadata.namespace = "default" + primary.metadata.labels.put("fintlabs.no/team", "platform") + primary.metadata.labels.put("fintlabs.no/org-id", "flais.io") + primary + } + + private static Secret kafkaSecret(KafkaUserAndAclCrd primary, String keyPassword, String trustPassword) { + new SecretBuilder() + .withNewMetadata() + .withName(KafkaSecretDependentResource.getResourceName(primary)) + .withNamespace(primary.metadata.namespace) + .endMetadata() + .addToData("spring.kafka.ssl.key-store-password", encode(keyPassword)) + .addToData("spring.kafka.ssl.trust-store-password", encode(trustPassword)) + .build() + } + + private static String encode(String value) { + Base64.encoder.encodeToString(value.bytes) + } +} diff --git a/src/test/groovy/no/fintlabs/operator/KafkaSecretDependentResourceSpec.groovy b/src/test/groovy/no/fintlabs/operator/KafkaSecretDependentResourceSpec.groovy new file mode 100644 index 0000000..a0d8139 --- /dev/null +++ b/src/test/groovy/no/fintlabs/operator/KafkaSecretDependentResourceSpec.groovy @@ -0,0 +1,94 @@ +package no.fintlabs.operator + +import io.fabric8.kubernetes.api.model.Secret +import io.fabric8.kubernetes.api.model.SecretBuilder +import io.fabric8.kubernetes.client.KubernetesClient +import io.javaoperatorsdk.operator.api.reconciler.Context +import no.fintlabs.aiven.AivenProperties +import no.fintlabs.aiven.AivenService +import spock.lang.Specification + +class KafkaSecretDependentResourceSpec extends Specification { + + private AivenProperties aivenProperties + private KafkaSecretDependentResource resource + private Context context + + def setup() { + aivenProperties = new AivenProperties(kafkaBootstrapServers: "broker-1:9092,broker-2:9092") + def workflow = new KafkaUserAndAclWorkflow() + def kafkaUserAndAclDependentResource = new KafkaUserAndAclDependentResource( + workflow, + Mock(AivenService), + new AivenProperties() + ) + resource = new KafkaSecretDependentResource( + workflow, + Mock(KubernetesClient), + kafkaUserAndAclDependentResource, + aivenProperties, + new KafkaSecretDiscriminator() + ) + context = Mock() + } + + def "desired creates secret with generated passwords and expected kafka settings"() { + given: + context.getSecondaryResources(Secret.class) >> ([] as Set) + + when: + def secret = resource.desired(primaryResource(), context) + + then: + secret.metadata.name == "sample-user-kafka" + secret.metadata.namespace == "default" + secret.metadata.labels["app.kubernetes.io/managed-by"] == "kafkarator" + decode(secret.data["fint.kafka.enable-ssl"]) == "true" + decode(secret.data["spring.kafka.bootstrap-servers"]) == "broker-1:9092,broker-2:9092" + decode(secret.data["spring.kafka.ssl.key-store-location"]) == "file:/credentials/client.keystore.p12" + decode(secret.data["spring.kafka.ssl.trust-store-location"]) == "file:/credentials/client.truststore.jks" + decode(secret.data["spring.kafka.ssl.key-store-type"]) == "PKCS12" + decode(secret.data["spring.kafka.ssl.trust-store-type"]) == "JKS" + decode(secret.data["spring.kafka.ssl.key-password"]) == decode(secret.data["spring.kafka.ssl.key-store-password"]) + decode(secret.data["spring.kafka.ssl.key-store-password"]).size() == 32 + decode(secret.data["spring.kafka.ssl.trust-store-password"]).size() == 32 + } + + def "desired reuses existing passwords when kafka secret already exists"() { + given: + def existing = new SecretBuilder() + .withNewMetadata() + .withName("sample-user-kafka") + .withNamespace("default") + .endMetadata() + .addToData("spring.kafka.ssl.key-store-password", encode("existing-key-password")) + .addToData("spring.kafka.ssl.trust-store-password", encode("existing-trust-password")) + .build() + context.getSecondaryResources(Secret.class) >> ([existing] as Set) + + when: + def secret = resource.desired(primaryResource(), context) + + then: + decode(secret.data["spring.kafka.ssl.key-store-password"]) == "existing-key-password" + decode(secret.data["spring.kafka.ssl.key-password"]) == "existing-key-password" + decode(secret.data["spring.kafka.ssl.trust-store-password"]) == "existing-trust-password" + } + + private static KafkaUserAndAclCrd primaryResource() { + def primary = new KafkaUserAndAclCrd() + primary.metadata.name = "sample-user" + primary.metadata.namespace = "default" + primary.metadata.labels.put("fintlabs.no/team", "platform") + primary.metadata.labels.put("fintlabs.no/org-id", "flais.io") + primary + } + + private static String encode(String value) { + Base64.encoder.encodeToString(value.bytes) + } + + private static String decode(String value) { + new String(Base64.decoder.decode(value)) + } +} diff --git a/src/test/groovy/no/fintlabs/operator/KafkaUserAndAclDependentResourceSpec.groovy b/src/test/groovy/no/fintlabs/operator/KafkaUserAndAclDependentResourceSpec.groovy new file mode 100644 index 0000000..2e04f7d --- /dev/null +++ b/src/test/groovy/no/fintlabs/operator/KafkaUserAndAclDependentResourceSpec.groovy @@ -0,0 +1,149 @@ +package no.fintlabs.operator + +import io.javaoperatorsdk.operator.api.reconciler.Context +import no.fintlabs.aiven.AivenProperties +import no.fintlabs.aiven.AivenService +import no.fintlabs.aiven.AivenServiceUser +import no.fintlabs.aiven.KafkaAclEntry +import spock.lang.Specification + +class KafkaUserAndAclDependentResourceSpec extends Specification { + + private AivenService aivenService = Mock() + private AivenProperties aivenProperties = new AivenProperties(service: "kafka-service") + private KafkaUserAndAclDependentResource resource = + new KafkaUserAndAclDependentResource(new KafkaUserAndAclWorkflow(), aivenService, aivenProperties) + private Context context = Mock() + + def "desired derives username from metadata and maps all ACLs"() { + given: + def primary = primaryResource() + + when: + def desired = resource.desired(primary, context) + + then: + desired.user.username == "flais-io_platform_sample-user" + desired.aclEntries*.topic == ["topic-a", "topic-b"] + desired.aclEntries*.permission == ["read", "write"] + desired.aclEntries*.username == ["flais-io_platform_sample-user", "flais-io_platform_sample-user"] + } + + def "create provisions user and ACLs through Aiven service"() { + given: + def desired = KafkaUserAndAcl.builder() + .user(AivenServiceUser.fromUsername("resolved-user")) + .aclEntries([ + KafkaAclEntry.builder().username("resolved-user").topic("topic-a").permission("read").build(), + KafkaAclEntry.builder().username("resolved-user").topic("topic-b").permission("write").build() + ]) + .build() + def createdUser = AivenServiceUser.builder() + .username("resolved-user") + .password("secret") + .accessCert("cert") + .accessKey("key") + .type("service") + .build() + + when: + def created = resource.create(desired, primaryResource(), context) + + then: + 1 * aivenService.createUserForService("resolved-user") >> createdUser + 1 * aivenService.createAclEntryForTopic({ + it.topic == "topic-a" && it.permission == "read" && it.username == "resolved-user" + }) >> KafkaAclEntry.builder().id("acl-1").username("resolved-user").topic("topic-a").permission("read").build() + 1 * aivenService.createAclEntryForTopic({ + it.topic == "topic-b" && it.permission == "write" && it.username == "resolved-user" + }) >> KafkaAclEntry.builder().id("acl-2").username("resolved-user").topic("topic-b").permission("write").build() + created.user == createdUser + created.aclEntries*.id == ["acl-1", "acl-2"] + } + + def "fetchResources returns singleton when Aiven has current resource"() { + given: + def current = KafkaUserAndAcl.builder() + .user(AivenServiceUser.fromUsername("flais-io_platform_sample-user")) + .build() + aivenService.getUserAndAcl("flais-io_platform_sample-user") >> Optional.of(current) + + expect: + resource.fetchResources(primaryResource()) == [current] as Set + } + + def "fetchResources returns empty set when Aiven has no current resource"() { + given: + aivenService.getUserAndAcl("flais-io_platform_sample-user") >> Optional.empty() + + expect: + resource.fetchResources(primaryResource()).isEmpty() + } + + def "update delegates ACL diff handling to Aiven service"() { + given: + def actual = KafkaUserAndAcl.builder().user(AivenServiceUser.fromUsername("user")).build() + def desired = KafkaUserAndAcl.builder().user(AivenServiceUser.fromUsername("user")).build() + def updated = KafkaUserAndAcl.builder().user(AivenServiceUser.fromUsername("user")).build() + + when: + def result = resource.update(actual, desired, primaryResource(), context) + + then: + 1 * aivenService.updateAclEntries(actual, desired) >> updated + result == updated + } + + def "delete removes all ACLs before deleting the user"() { + given: + def secondary = KafkaUserAndAcl.builder() + .user(AivenServiceUser.fromUsername("resolved-user")) + .aclEntries([ + KafkaAclEntry.builder().id("acl-1").username("resolved-user").topic("topic-a").permission("read").build(), + KafkaAclEntry.builder().id("acl-2").username("resolved-user").topic("topic-b").permission("write").build() + ]) + .build() + context.getSecondaryResource(KafkaUserAndAcl) >> Optional.of(secondary) + + when: + resource.delete(primaryResource(), context) + + then: + 1 * aivenService.deleteAclEntryForService("acl-1") + 1 * aivenService.deleteAclEntryForService("acl-2") + 1 * aivenService.deleteUserForService("resolved-user") + } + + def "delete is a no-op when no secondary resource exists"() { + given: + context.getSecondaryResource(KafkaUserAndAcl) >> Optional.empty() + + when: + resource.delete(primaryResource(), context) + + then: + 0 * aivenService._ + } + + private static KafkaUserAndAclCrd primaryResource() { + def primary = new KafkaUserAndAclCrd() + primary.metadata.name = "sample-user" + primary.metadata.namespace = "default" + primary.metadata.labels.put("fintlabs.no/team", "platform") + primary.metadata.labels.put("fintlabs.no/org-id", "flais.io") + primary.spec = KafkaUserAndAclSpec.builder() + .acls([ + acl("topic-a", "read"), + acl("topic-b", "write") + ]) + .build() + primary + } + + private static KafkaUserAndAclSpec.Acl acl(String topic, String permission) { + def acl = new KafkaUserAndAclSpec.Acl() + acl.topic = topic + acl.permission = permission + acl + } +}