diff --git a/api/src/main/java/com/github/streamshub/console/api/security/ConsoleAuthenticationMechanism.java b/api/src/main/java/com/github/streamshub/console/api/security/ConsoleAuthenticationMechanism.java index b28d4f089..00b854810 100644 --- a/api/src/main/java/com/github/streamshub/console/api/security/ConsoleAuthenticationMechanism.java +++ b/api/src/main/java/com/github/streamshub/console/api/security/ConsoleAuthenticationMechanism.java @@ -81,6 +81,8 @@ public class ConsoleAuthenticationMechanism implements HttpAuthenticationMechani .setPrincipal(new QuarkusPrincipal("ANONYMOUS")) .build(); + private static final Set UNAUTHENTICATED_PATHS = Set.of("/health", "/metrics", "/openapi", "/swagger-ui"); + @Inject Logger log; @@ -102,6 +104,12 @@ boolean oidcEnabled() { @Override public Uni authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { + final String requestPath = context.normalizedPath(); + + if (UNAUTHENTICATED_PATHS.stream().anyMatch(requestPath::startsWith)) { + return Uni.createFrom().nullItem(); + } + if (oidcEnabled()) { return oidc.authenticate(context, identityProviderManager) .map(identity -> augmentIdentity(context, identity)) @@ -171,7 +179,13 @@ public Uni getChallenge(RoutingContext context) { var category = ErrorCategory.get(ErrorCategory.NotAuthenticated.class); Error error = category.createError("Authentication credentials missing or invalid", null, null); var responseBody = new ErrorResponse(List.of(error)); - return new PayloadChallengeData(data, responseBody); + return (ChallengeData) new PayloadChallengeData(data, responseBody); + }) + .onFailure().recoverWithItem(t -> { + var category = ErrorCategory.get(ErrorCategory.ServerError.class); + Error error = category.createError("Authentication failed due to internal server error", null, null); + var responseBody = new ErrorResponse(List.of(error)); + return new PayloadChallengeData(500, null, null, responseBody); }); } diff --git a/api/src/main/java/com/github/streamshub/console/api/security/OidcTenantConfigResolver.java b/api/src/main/java/com/github/streamshub/console/api/security/OidcTenantConfigResolver.java index f91189fa0..61f3ab8e6 100644 --- a/api/src/main/java/com/github/streamshub/console/api/security/OidcTenantConfigResolver.java +++ b/api/src/main/java/com/github/streamshub/console/api/security/OidcTenantConfigResolver.java @@ -1,16 +1,32 @@ package com.github.streamshub.console.api.security; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermissions; +import java.security.KeyStore; import java.util.List; +import java.util.Optional; +import java.util.UUID; import jakarta.annotation.PostConstruct; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + import com.github.streamshub.console.config.ConsoleConfig; import io.quarkus.oidc.OidcRequestContext; import io.quarkus.oidc.OidcTenantConfig; import io.quarkus.oidc.TenantConfigResolver; +import io.quarkus.tls.TlsConfiguration; +import io.quarkus.tls.TlsConfigurationRegistry; import io.smallrye.mutiny.Uni; import io.vertx.ext.web.RoutingContext; @@ -22,6 +38,16 @@ @ApplicationScoped public class OidcTenantConfigResolver implements TenantConfigResolver { + @Inject + Logger logger; + + @Inject + @ConfigProperty(name = "console.work-path") + String workPath; + + @Inject + TlsConfigurationRegistry tlsRegistry; + @Inject ConsoleConfig consoleConfig; @@ -40,6 +66,56 @@ void initialize() { if (oidc.getIssuer() != null) { oidcConfig.getToken().setIssuer(oidc.getIssuer()); } + + getTlsConfiguration().map(TlsConfiguration::getTrustStore).ifPresentOrElse( + this::configureTruststore, + () -> logger.infof("No truststore configured for OIDC provider") + ); + } + + Optional getTlsConfiguration() { + String dotSeparatedSource = "oidc.provider.trust"; + String dashSeparatedSource = "oidc-provider-trust"; + return tlsRegistry.get(dotSeparatedSource).or(() -> tlsRegistry.get(dashSeparatedSource)); + } + + /** + * The OIDC subsystem takes the path to a truststore, so we need to write the + * one from the TLS registry to a working file to provide to OIDC. This should + * no longer be necessary in the next Quarkus LTS where OIDC is aware of the TLS + * registry. + */ + void configureTruststore(KeyStore truststore) { + File workDir = new File(workPath); + Path truststorePath; + File truststoreFile; + + try { + truststorePath = Files.createTempFile( + workDir.toPath(), + "oidc-provider-trust", + "." + truststore.getType(), + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rw-------"))); + truststoreFile = truststorePath.toFile(); + truststoreFile.deleteOnExit(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + String secret = UUID.randomUUID().toString(); + + try (OutputStream out = new FileOutputStream(truststoreFile)) { + truststore.store(out, secret.toCharArray()); + } catch (Exception e) { + throw new RuntimeException(e); + } + + // No default provided, set to empty to avoid NPE + oidcConfig.tls.trustStoreProvider = Optional.empty(); + oidcConfig.tls.setTrustStoreFile(truststorePath); + oidcConfig.tls.setTrustStorePassword(secret); + // Future: map the certificate alias if provided + // oidcConfig.tls.setTrustStoreCertAlias(null); } @Override diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index 5d4494bc9..7c5aff231 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -68,6 +68,7 @@ quarkus.arc.exclude-types=io.apicurio.registry.rest.JacksonDateTimeCustomizer quarkus.index-dependency.strimzi-api.group-id=io.strimzi quarkus.index-dependency.strimzi-api.artifact-id=api +console.work-path=${java.io.tmpdir} console.kafka.admin.request.timeout.ms=10000 console.kafka.admin.default.api.timeout.ms=10000 diff --git a/api/src/test/java/com/github/streamshub/console/kafka/systemtest/deployment/KeycloakResourceManager.java b/api/src/test/java/com/github/streamshub/console/kafka/systemtest/deployment/KeycloakResourceManager.java index 407797fc9..950c1155f 100644 --- a/api/src/test/java/com/github/streamshub/console/kafka/systemtest/deployment/KeycloakResourceManager.java +++ b/api/src/test/java/com/github/streamshub/console/kafka/systemtest/deployment/KeycloakResourceManager.java @@ -1,8 +1,10 @@ package com.github.streamshub.console.kafka.systemtest.deployment; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.nio.file.Files; import java.time.Duration; import java.util.Map; @@ -12,6 +14,8 @@ import org.testcontainers.containers.wait.strategy.Wait; import org.testcontainers.images.builder.Transferable; +import com.github.streamshub.console.test.TlsHelper; + import io.quarkus.test.common.QuarkusTestResourceLifecycleManager; public class KeycloakResourceManager implements QuarkusTestResourceLifecycleManager { @@ -29,26 +33,56 @@ public Map start() { throw new UncheckedIOException(ioe); } + int port = 8443; + TlsHelper tls = TlsHelper.newInstance(); + String keystorePath = "/opt/keycloak/keystore.p12"; + keycloak = new GenericContainer<>("quay.io/keycloak/keycloak:26.0") .withLogConsumer(new Slf4jLogConsumer(LoggerFactory.getLogger("systemtests.keycloak"), true)) - .withExposedPorts(8080) + .withExposedPorts(port) .withEnv(Map.of( "KC_BOOTSTRAP_ADMIN_USERNAME", "admin", "KC_BOOTSTRAP_ADMIN_PASSWORD", "admin", "PROXY_ADDRESS_FORWARDING", "true")) + .withCopyToContainer( + Transferable.of(tls.getKeyStoreBytes()), + keystorePath) .withCopyToContainer( Transferable.of(realmConfig), "/opt/keycloak/data/import/console-realm.json") - .withCommand("start", "--hostname=localhost", "--http-enabled=true", "--import-realm") - .waitingFor(Wait.forHttp("/realms/console-authz").withStartupTimeout(Duration.ofMinutes(1))); + .withCommand( + "start", + "--hostname=localhost", + "--http-enabled=false", + "--https-key-store-file=%s".formatted(keystorePath), + "--https-key-store-password=%s".formatted(String.copyValueOf(tls.getPassphrase())), + "--import-realm" + ) + .waitingFor(Wait.forHttps("/realms/console-authz") + .allowInsecure() + .withStartupTimeout(Duration.ofMinutes(1))); + + File truststoreFile; + + try { + truststoreFile = File.createTempFile("oidc-provider-trust", "." + tls.getTrustStore().getType()); + Files.write(truststoreFile.toPath(), tls.getTrustStoreBytes()); + truststoreFile.deleteOnExit(); + } catch (IOException e) { + throw new UncheckedIOException(e); + } keycloak.start(); - String urlTemplate = "http://localhost:%d/realms/console-authz"; - var oidcUrl = urlTemplate.formatted(keycloak.getMappedPort(8080)); + String urlTemplate = "https://localhost:%d/realms/console-authz"; + var oidcUrl = urlTemplate.formatted(keycloak.getMappedPort(port)); return Map.of( "console.test.oidc-url", oidcUrl, - "console.test.oidc-issuer", urlTemplate.formatted(8080)); + "console.test.oidc-host", "localhost:%d".formatted(port), + "console.test.oidc-issuer", urlTemplate.formatted(port), + "quarkus.tls.\"oidc-provider-trust\".trust-store.jks.path", truststoreFile.getAbsolutePath(), + "quarkus.tls.\"oidc-provider-trust\".trust-store.jks.password", String.copyValueOf(tls.getPassphrase()) + ); } @Override diff --git a/api/src/test/java/com/github/streamshub/console/kafka/systemtest/utils/TokenUtils.java b/api/src/test/java/com/github/streamshub/console/kafka/systemtest/utils/TokenUtils.java index 9af0a021f..3dadd4044 100644 --- a/api/src/test/java/com/github/streamshub/console/kafka/systemtest/utils/TokenUtils.java +++ b/api/src/test/java/com/github/streamshub/console/kafka/systemtest/utils/TokenUtils.java @@ -4,10 +4,14 @@ import java.io.StringReader; import java.net.URI; import java.net.http.HttpClient; +import java.net.http.HttpClient.Version; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.util.UUID; +import javax.net.ssl.SSLContext; + +import jakarta.enterprise.inject.spi.CDI; import jakarta.json.Json; import jakarta.json.JsonObject; import jakarta.json.JsonReader; @@ -15,14 +19,26 @@ import org.eclipse.microprofile.config.Config; +import io.quarkus.tls.TlsConfigurationRegistry; import io.restassured.http.Header; public class TokenUtils { final String tokenEndpoint; + final String tokenEndpointHost; + final SSLContext tls; public TokenUtils(Config config) { this.tokenEndpoint = config.getValue("console.test.oidc-url", String.class) + "/protocol/openid-connect/token"; + this.tokenEndpointHost = config.getValue("console.test.oidc-host", String.class); + + var tlsRegistry = CDI.current().select(TlsConfigurationRegistry.class).get(); + + try { + tls = tlsRegistry.get("oidc-provider-trust").get().createSSLContext(); + } catch (Exception e) { + throw new RuntimeException(e); + } } public Header authorizationHeader(String username) { @@ -47,11 +63,14 @@ public JsonObject getTokenObject(String username) { + "password=%1$s-password&" + "client_id=console-client", username); - HttpClient client = HttpClient.newBuilder().build(); + HttpClient client = HttpClient.newBuilder() + .sslContext(tls) + .version(Version.HTTP_1_1) + .build(); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(tokenEndpoint)) - .header("Host", "localhost:8080") + .header("Host", tokenEndpointHost) .header("Content-Type", "application/x-www-form-urlencoded") .POST(HttpRequest.BodyPublishers.ofString(form)) .build(); diff --git a/api/src/test/java/com/github/streamshub/console/test/TlsHelper.java b/api/src/test/java/com/github/streamshub/console/test/TlsHelper.java new file mode 100644 index 000000000..857d7a06e --- /dev/null +++ b/api/src/test/java/com/github/streamshub/console/test/TlsHelper.java @@ -0,0 +1,226 @@ +package com.github.streamshub.console.test; + +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.math.BigInteger; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.SecureRandom; +import java.security.Security; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.X509Certificate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.Base64; +import java.util.Date; +import java.util.UUID; + +import org.bouncycastle.asn1.x500.X500Name; +import org.bouncycastle.asn1.x509.BasicConstraints; +import org.bouncycastle.asn1.x509.Extension; +import org.bouncycastle.asn1.x509.GeneralName; +import org.bouncycastle.asn1.x509.GeneralNames; +import org.bouncycastle.asn1.x509.KeyUsage; +import org.bouncycastle.cert.X509CertificateHolder; +import org.bouncycastle.cert.X509v3CertificateBuilder; +import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; +import org.bouncycastle.cert.jcajce.JcaX509ExtensionUtils; +import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; +import org.bouncycastle.jce.provider.BouncyCastleProvider; +import org.bouncycastle.operator.ContentSigner; +import org.bouncycastle.operator.OperatorCreationException; +import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; +import org.bouncycastle.pkcs.PKCS10CertificationRequest; +import org.bouncycastle.pkcs.PKCS10CertificationRequestBuilder; +import org.bouncycastle.pkcs.jcajce.JcaPKCS10CertificationRequestBuilder; + +public class TlsHelper { + + private static final String BC_PROVIDER = "BC"; + private static final String KEY_ALGORITHM = "RSA"; + private static final String SIGNATURE_ALGORITHM = "SHA256withRSA"; + private static final String PKCS12 = "PKCS12"; + + static { + // Add the BouncyCastle Provider + Security.addProvider(new BouncyCastleProvider()); + } + + public static TlsHelper newInstance() { + try { + return new TlsHelper(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + private Certificate rootCA; + private KeyStore keyStore; + private KeyStore trustStore; + private final char[] passphrase = UUID.randomUUID().toString().toCharArray(); + + public Certificate getRootCA() { + return rootCA; + } + + public String getRootCAPem() { + try { + return pemEncodeCertificate(rootCA); + } catch (CertificateEncodingException | IOException e) { + throw new RuntimeException(e); + } + } + + public KeyStore getKeyStore() { + return keyStore; + } + + public byte[] getKeyStoreBytes() { + return getBytes(keyStore, passphrase); + } + + public KeyStore getTrustStore() { + return trustStore; + } + + public byte[] getTrustStoreBytes() { + return getBytes(trustStore, passphrase); + } + + public char[] getPassphrase() { + return passphrase; + } + + private byte[] getBytes(KeyStore store, char[] passphrase) { + try { + return toByteArray(store, passphrase); + } catch (GeneralSecurityException | IOException e) { + throw new RuntimeException(e); + } + } + + private TlsHelper() throws Exception { + // Initialize a new KeyPair generator + KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance(KEY_ALGORITHM, BC_PROVIDER); + keyPairGenerator.initialize(2048); + + LocalDateTime now = LocalDateTime.now(); + Date notBefore = Date.from(now.minusDays(1).toInstant(ZoneOffset.UTC)); + Date notAfter = Date.from(now.plusYears(1).toInstant(ZoneOffset.UTC)); + + KeyPair rootKeyPair = keyPairGenerator.generateKeyPair(); + X509Certificate rootCert = buildCACertificate(rootKeyPair, notBefore, notAfter); + + KeyPair issuedCertKeyPair = keyPairGenerator.generateKeyPair(); + Certificate issuedCert = buildServerCertificate(issuedCertKeyPair, rootKeyPair, rootCert, notBefore, notAfter); + + rootCA = rootCert; + + trustStore = KeyStore.getInstance("JKS"); + trustStore.load(null, passphrase); + trustStore.setCertificateEntry("CACert", rootCert); + + keyStore = KeyStore.getInstance(PKCS12, BC_PROVIDER); + keyStore.load(null, passphrase); + keyStore.setKeyEntry("localhost", issuedCertKeyPair.getPrivate(), null, new Certificate[] { + issuedCert, + rootCert + }); + keyStore.setCertificateEntry("CACert", rootCert); + } + + private X509Certificate buildCACertificate(KeyPair keyPair, Date notBefore, Date notAfter) + throws OperatorCreationException, IOException, GeneralSecurityException { + + BigInteger rootSerialNum = new BigInteger(Long.toString(new SecureRandom().nextLong())); + + // Issued By and Issued To same for root certificate + X500Name rootCertIssuer = new X500Name("CN=root-cert"); + X500Name rootCertSubject = rootCertIssuer; + ContentSigner rootCertContentSigner = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM).setProvider(BC_PROVIDER) + .build(keyPair.getPrivate()); + X509v3CertificateBuilder rootCertBuilder = new JcaX509v3CertificateBuilder(rootCertIssuer, rootSerialNum, + notBefore, notAfter, rootCertSubject, keyPair.getPublic()); + + // Add Extensions + // A BasicConstraint to mark root certificate as CA certificate + JcaX509ExtensionUtils rootCertExtUtils = new JcaX509ExtensionUtils(); + rootCertBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(true)); + rootCertBuilder.addExtension(Extension.subjectKeyIdentifier, false, + rootCertExtUtils.createSubjectKeyIdentifier(keyPair.getPublic())); + + // Create a cert holder and export to X509Certificate + X509CertificateHolder rootCertHolder = rootCertBuilder.build(rootCertContentSigner); + return new JcaX509CertificateConverter().setProvider(BC_PROVIDER) + .getCertificate(rootCertHolder); + } + + private Certificate buildServerCertificate(KeyPair keyPair, KeyPair signerKeyPair, X509Certificate signerCert, Date notBefore, Date notAfter) + throws GeneralSecurityException, IOException, OperatorCreationException { + + // Generate a new KeyPair and sign it using the Root Cert Private Key + // by generating a CSR (Certificate Signing Request) + X500Name issuedCertSubject = new X500Name("CN=localhost,O=com.github.streamshub"); + BigInteger issuedCertSerialNum = new BigInteger(Long.toString(new SecureRandom().nextLong())); + + PKCS10CertificationRequestBuilder p10Builder = new JcaPKCS10CertificationRequestBuilder(issuedCertSubject, + keyPair.getPublic()); + JcaContentSignerBuilder csrBuilder = new JcaContentSignerBuilder(SIGNATURE_ALGORITHM).setProvider(BC_PROVIDER); + + // Sign the new KeyPair with the root cert Private Key + ContentSigner csrContentSigner = csrBuilder.build(signerKeyPair.getPrivate()); + PKCS10CertificationRequest csr = p10Builder.build(csrContentSigner); + + // Use the Signed KeyPair and CSR to generate an issued Certificate + // Here serial number is randomly generated. In general, CAs use + // a sequence to generate Serial number and avoid collisions + var issuer = new X500Name(signerCert.getSubjectX500Principal().getName()); + X509v3CertificateBuilder issuedCertBuilder = new X509v3CertificateBuilder(issuer, issuedCertSerialNum, + notBefore, notAfter, csr.getSubject(), csr.getSubjectPublicKeyInfo()); + + JcaX509ExtensionUtils issuedCertExtUtils = new JcaX509ExtensionUtils(); + + // Add Extensions + // Use BasicConstraints to say that this Cert is not a CA + issuedCertBuilder.addExtension(Extension.basicConstraints, true, new BasicConstraints(false)); + + // Add Issuer cert identifier as Extension + issuedCertBuilder.addExtension(Extension.authorityKeyIdentifier, false, issuedCertExtUtils.createAuthorityKeyIdentifier(signerCert)); + + // Add intended key usage extension if needed + issuedCertBuilder.addExtension(Extension.keyUsage, false, new KeyUsage(KeyUsage.digitalSignature | KeyUsage.keyEncipherment)); + + // Add DNS name is cert is to used for SSL + GeneralNames subjectAltName = new GeneralNames(new GeneralName[] { + new GeneralName(GeneralName.dNSName, "localhost") + }); + issuedCertBuilder.addExtension(Extension.subjectAlternativeName, false, subjectAltName); + + X509CertificateHolder issuedCertHolder = issuedCertBuilder.build(csrContentSigner); + X509Certificate issuedCert = new JcaX509CertificateConverter().setProvider(BC_PROVIDER) + .getCertificate(issuedCertHolder); + + // Verify the issued cert signature against the root (issuer) cert + issuedCert.verify(signerCert.getPublicKey(), BC_PROVIDER); + return issuedCert; + } + + private byte[] toByteArray(KeyStore store, char[] passphrase) throws GeneralSecurityException, IOException { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + store.store(buffer, passphrase); + return buffer.toByteArray(); + } + + private String pemEncodeCertificate(Certificate certificate) throws IOException, CertificateEncodingException { + ByteArrayOutputStream certificateOut = new ByteArrayOutputStream(); + certificateOut.write("-----BEGIN CERTIFICATE-----\n".getBytes(StandardCharsets.UTF_8)); + certificateOut.write(Base64.getMimeEncoder(80, new byte[] {'\n'}).encode(certificate.getEncoded())); + certificateOut.write("\n-----END CERTIFICATE-----\n".getBytes(StandardCharsets.UTF_8)); + certificateOut.close(); + return new String(certificateOut.toByteArray(), StandardCharsets.UTF_8); + } +} diff --git a/operator/pom.xml b/operator/pom.xml index fd97474c3..a3cb5c200 100644 --- a/operator/pom.xml +++ b/operator/pom.xml @@ -40,6 +40,11 @@ jakarta.validation jakarta.validation-api + + io.quarkus + quarkus-hibernate-validator + + diff --git a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java index a88f84316..2ae9d6bd9 100644 --- a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java +++ b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java @@ -5,9 +5,12 @@ import java.util.Collections; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; import java.util.stream.Collectors; import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.api.v1alpha1.status.Condition; +import com.github.streamshub.console.api.v1alpha1.status.ConditionBuilder; import com.github.streamshub.console.dependents.ConsoleClusterRole; import com.github.streamshub.console.dependents.ConsoleClusterRoleBinding; import com.github.streamshub.console.dependents.ConsoleDeployment; @@ -23,6 +26,7 @@ import com.github.streamshub.console.dependents.PrometheusDeployment; import com.github.streamshub.console.dependents.PrometheusService; import com.github.streamshub.console.dependents.PrometheusServiceAccount; +import com.github.streamshub.console.dependents.ConfigurationProcessor; import com.github.streamshub.console.dependents.conditions.DeploymentReadyCondition; import com.github.streamshub.console.dependents.conditions.IngressReadyCondition; import com.github.streamshub.console.dependents.conditions.PrometheusPrecondition; @@ -56,25 +60,33 @@ interval = 60, timeUnit = TimeUnit.SECONDS), dependents = { + @Dependent( + name = ConfigurationProcessor.NAME, + type = ConfigurationProcessor.class, + readyPostcondition = ConfigurationProcessor.Postcondition.class), @Dependent( name = PrometheusClusterRole.NAME, type = PrometheusClusterRole.class, + dependsOn = ConfigurationProcessor.NAME, reconcilePrecondition = PrometheusPrecondition.class), @Dependent( name = PrometheusServiceAccount.NAME, type = PrometheusServiceAccount.class, + dependsOn = ConfigurationProcessor.NAME, reconcilePrecondition = PrometheusPrecondition.class), @Dependent( name = PrometheusClusterRoleBinding.NAME, type = PrometheusClusterRoleBinding.class, reconcilePrecondition = PrometheusPrecondition.class, dependsOn = { + ConfigurationProcessor.NAME, PrometheusClusterRole.NAME, PrometheusServiceAccount.NAME }), @Dependent( name = PrometheusConfigMap.NAME, type = PrometheusConfigMap.class, + dependsOn = ConfigurationProcessor.NAME, reconcilePrecondition = PrometheusPrecondition.class), @Dependent( name = PrometheusDeployment.NAME, @@ -94,10 +106,12 @@ }), @Dependent( name = ConsoleClusterRole.NAME, - type = ConsoleClusterRole.class), + type = ConsoleClusterRole.class, + dependsOn = ConfigurationProcessor.NAME), @Dependent( name = ConsoleServiceAccount.NAME, - type = ConsoleServiceAccount.class), + type = ConsoleServiceAccount.class, + dependsOn = ConfigurationProcessor.NAME), @Dependent( name = ConsoleClusterRoleBinding.NAME, type = ConsoleClusterRoleBinding.class, @@ -114,10 +128,12 @@ }), @Dependent( name = ConsoleSecret.NAME, - type = ConsoleSecret.class), + type = ConsoleSecret.class, + dependsOn = ConfigurationProcessor.NAME), @Dependent( name = ConsoleService.NAME, - type = ConsoleService.class), + type = ConsoleService.class, + dependsOn = ConfigurationProcessor.NAME), @Dependent( name = ConsoleIngress.NAME, type = ConsoleIngress.class, @@ -199,7 +215,8 @@ public Map prepareEventSources(EventSourceContext @Override public UpdateControl reconcile(Console resource, Context context) { determineReadyCondition(resource, context); - return UpdateControl.patchStatus(resource); + resource.getStatus().clearStaleConditions(); + return UpdateControl.updateStatus(resource); } @Override @@ -209,11 +226,6 @@ public ErrorStatusUpdateControl updateErrorStatus(Console resource, determineReadyCondition(resource, context); - var status = resource.getOrCreateStatus(); - var warning = status.getCondition("Warning"); - warning.setStatus("True"); - warning.setReason("ReconcileException"); - Throwable rootCause = e; while (rootCause.getCause() != null && rootCause.getCause() != rootCause) { @@ -230,8 +242,19 @@ public ErrorStatusUpdateControl updateErrorStatus(Console resource, message = rootCause.getMessage(); } - warning.setMessage(message); - return ErrorStatusUpdateControl.patchStatus(resource); + var status = resource.getStatus(); + + status.updateCondition(new ConditionBuilder() + .withType(Condition.Types.ERROR) + .withStatus("True") + .withLastTransitionTime(Instant.now().toString()) + .withReason(Condition.Reasons.RECONCILIATION_EXCEPTION) + .withMessage(message) + .build()); + + status.clearStaleConditions(); + + return ErrorStatusUpdateControl.updateStatus(resource); } @Override @@ -243,11 +266,11 @@ private void determineReadyCondition(Console resource, Context context) var result = context.managedDependentResourceContext().getWorkflowReconcileResult(); var status = resource.getOrCreateStatus(); var readyCondition = status.getCondition("Ready"); - var notReady = result.map(r -> r.getNotReadyDependents()); - boolean isReady = notReady.filter(Collection::isEmpty).map(r -> Boolean.TRUE) - .orElse(Boolean.FALSE); + var notReady = result.map(r -> r.getNotReadyDependents()).filter(Predicate.not(Collection::isEmpty)); + boolean isReady = notReady.isEmpty(); String readyStatus = isReady ? "True" : "False"; + readyCondition.setActive(true); if (!readyStatus.equals(readyCondition.getStatus())) { readyCondition.setStatus(readyStatus); @@ -257,15 +280,20 @@ private void determineReadyCondition(Console resource, Context context) if (isReady) { readyCondition.setReason(null); readyCondition.setMessage("All resources ready"); - status.clearCondition("Warning"); } else { - readyCondition.setReason("DependentsNotReady"); - readyCondition.setMessage(notReady.map(Collection::stream) - .map(deps -> "Resources not ready: %s" - .formatted(deps.map(ConsoleResource.class::cast) + var notReadyResources = notReady.get(); + + if (notReadyResources.stream().anyMatch(ConfigurationProcessor.class::isInstance)) { + readyCondition.setReason(Condition.Reasons.INVALID_CONFIGURATION); + readyCondition.setMessage("Console resource configuration is invalid"); + } else { + readyCondition.setReason(Condition.Reasons.DEPENDENTS_NOT_READY); + readyCondition.setMessage("Resources not ready: %s" + .formatted(notReadyResources.stream() + .map(ConsoleResource.class::cast) .map(r -> "%s[%s]".formatted(r.getClass().getSimpleName(), r.instanceName(resource))) - .collect(Collectors.joining("; ")))) - .orElse("")); + .collect(Collectors.joining("; ")))); + } } } } diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/ConsoleSpec.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/ConsoleSpec.java index 9e9d14319..53c7df9db 100644 --- a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/ConsoleSpec.java +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/ConsoleSpec.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource; +import com.github.streamshub.console.api.v1alpha1.spec.security.GlobalSecurity; import io.fabric8.generator.annotation.Required; import io.fabric8.kubernetes.api.model.EnvVar; @@ -28,6 +29,8 @@ public class ConsoleSpec { Images images; + GlobalSecurity security; + List metricsSources; List schemaRegistries; @@ -52,6 +55,14 @@ public void setImages(Images images) { this.images = images; } + public GlobalSecurity getSecurity() { + return security; + } + + public void setSecurity(GlobalSecurity security) { + this.security = security; + } + public List getMetricsSources() { return metricsSources; } diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/KafkaCluster.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/KafkaCluster.java index 8cb4db305..ae8ea4642 100644 --- a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/KafkaCluster.java +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/KafkaCluster.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.github.streamshub.console.api.v1alpha1.spec.security.KafkaSecurity; import io.fabric8.generator.annotation.Required; import io.fabric8.generator.annotation.ValidationRule; @@ -51,6 +52,14 @@ public class KafkaCluster { private Credentials credentials; + @JsonPropertyDescription(""" + Security configuration to be applied only to this Kafka cluster. This \ + includes the configuration of subjects (e.g. non-OIDC Kafka users), role \ + policies for this cluster's resources, and audit rules for access to \ + cluster's resources. + """) + private KafkaSecurity security; + @JsonPropertyDescription(""" Name of a configured Prometheus metrics source to use for this Kafka \ cluster to display resource utilization charts in the console. @@ -111,6 +120,14 @@ public void setCredentials(Credentials credentials) { this.credentials = credentials; } + public KafkaSecurity getSecurity() { + return security; + } + + public void setSecurity(KafkaSecurity security) { + this.security = security; + } + public String getMetricsSource() { return metricsSource; } diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/TrustStore.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/TrustStore.java index a210758b5..a468b65b0 100644 --- a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/TrustStore.java +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/TrustStore.java @@ -4,15 +4,18 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import io.fabric8.generator.annotation.Required; import io.sundr.builder.annotations.Buildable; @Buildable @JsonInclude(JsonInclude.Include.NON_NULL) public class TrustStore { + @Required @JsonProperty("type") private Type type; // NOSONAR + @Required @JsonProperty("content") @JsonPropertyDescription("Content of the trust store") private Value content; diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/Value.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/Value.java index eab9230cc..6f180a605 100644 --- a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/Value.java +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/Value.java @@ -1,6 +1,5 @@ package com.github.streamshub.console.api.v1alpha1.spec; -import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyDescription; @@ -19,18 +18,6 @@ public class Value { @JsonPropertyDescription("Reference to an external source to use for this value") private ValueReference valueFrom; - public Value() { - } - - private Value(String value) { - this.value = value; - } - - @JsonIgnore - public static Value of(String value) { - return value != null ? new Value(value) : null; - } - public String getValue() { return value; } diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/AuditRule.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/AuditRule.java new file mode 100644 index 000000000..79ba606ac --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/AuditRule.java @@ -0,0 +1,38 @@ +package com.github.streamshub.console.api.v1alpha1.spec.security; + +import java.util.Locale; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import io.fabric8.generator.annotation.Required; +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +public class AuditRule extends Rule { + + @Required + Decision decision; + + public Decision getDecision() { + return decision; + } + + public void setDecision(Decision decision) { + this.decision = decision; + } + + public enum Decision { + ALLOWED, + DENIED, + ALL; + + @JsonCreator + public static Decision forValue(String value) { + if ("*".equals(value)) { + return ALL; + } + return valueOf(value.toUpperCase(Locale.ROOT)); + } + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/GlobalSecurity.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/GlobalSecurity.java new file mode 100644 index 000000000..10f9ee068 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/GlobalSecurity.java @@ -0,0 +1,17 @@ +package com.github.streamshub.console.api.v1alpha1.spec.security; + +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +public class GlobalSecurity extends Security { + + private Oidc oidc; + + public Oidc getOidc() { + return oidc; + } + + public void setOidc(Oidc oidc) { + this.oidc = oidc; + } +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/KafkaSecurity.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/KafkaSecurity.java new file mode 100644 index 000000000..ae4fadaf3 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/KafkaSecurity.java @@ -0,0 +1,7 @@ +package com.github.streamshub.console.api.v1alpha1.spec.security; + +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +public class KafkaSecurity extends Security { +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Oidc.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Oidc.java new file mode 100644 index 000000000..cd45f8e08 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Oidc.java @@ -0,0 +1,66 @@ +package com.github.streamshub.console.api.v1alpha1.spec.security; + +import com.fasterxml.jackson.annotation.JsonPropertyDescription; +import com.github.streamshub.console.api.v1alpha1.spec.TrustStore; +import com.github.streamshub.console.api.v1alpha1.spec.Value; + +import io.fabric8.generator.annotation.Required; +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +public class Oidc { + + @Required + private String authServerUrl; + private String issuer; + @Required + private String clientId; + @Required + private Value clientSecret; + + @JsonPropertyDescription(""" + Trust store configuration for when the OIDC provider uses \ + TLS certificates signed by an unknown CA. + """) + private TrustStore trustStore; + + public String getAuthServerUrl() { + return authServerUrl; + } + + public void setAuthServerUrl(String authServerUrl) { + this.authServerUrl = authServerUrl; + } + + public String getIssuer() { + return issuer; + } + + public void setIssuer(String issuer) { + this.issuer = issuer; + } + + public String getClientId() { + return clientId; + } + + public void setClientId(String clientId) { + this.clientId = clientId; + } + + public Value getClientSecret() { + return clientSecret; + } + + public void setClientSecret(Value clientSecret) { + this.clientSecret = clientSecret; + } + + public TrustStore getTrustStore() { + return trustStore; + } + + public void setTrustStore(TrustStore trustStore) { + this.trustStore = trustStore; + } +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Role.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Role.java new file mode 100644 index 000000000..7c31896a5 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Role.java @@ -0,0 +1,32 @@ +package com.github.streamshub.console.api.v1alpha1.spec.security; + +import java.util.List; + +import io.fabric8.generator.annotation.Required; +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +public class Role { + + @Required + private String name; + + private List rules; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public List getRules() { + return rules; + } + + public void setRules(List rules) { + this.rules = rules; + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Rule.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Rule.java new file mode 100644 index 000000000..724864c96 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Rule.java @@ -0,0 +1,72 @@ +package com.github.streamshub.console.api.v1alpha1.spec.security; + +import java.util.List; +import java.util.Locale; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import io.fabric8.generator.annotation.Required; +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +public class Rule { + + /** + * Resources to which this rule applies (required) + */ + @Required + List resources; + + /** + * Specific resource names to which this rule applies (optional) + */ + List resourceNames; + + /** + * Privileges/actions that may be performed for subjects having this rule + */ + @Required + List privileges; + + public List getResources() { + return resources; + } + + public void setResources(List resources) { + this.resources = resources; + } + + public List getResourceNames() { + return resourceNames; + } + + public void setResourceNames(List resourceNames) { + this.resourceNames = resourceNames; + } + + public List getPrivileges() { + return privileges; + } + + public void setPrivileges(List privileges) { + this.privileges = privileges; + } + + public enum Privilege { + CREATE, + DELETE, + GET, + LIST, + UPDATE, + ALL; + + @JsonCreator + public static Privilege forValue(String value) { + if ("*".equals(value)) { + return ALL; + } + return valueOf(value.toUpperCase(Locale.ROOT)); + } + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Security.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Security.java new file mode 100644 index 000000000..a7dbc08c8 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Security.java @@ -0,0 +1,38 @@ +package com.github.streamshub.console.api.v1alpha1.spec.security; + +import java.util.List; + +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +public abstract class Security { + + private List subjects; + private List roles; + private List audit; + + public List getSubjects() { + return subjects; + } + + public void setSubjects(List subjects) { + this.subjects = subjects; + } + + public List getRoles() { + return roles; + } + + public void setRoles(List roles) { + this.roles = roles; + } + + public List getAudit() { + return audit; + } + + public void setAudit(List audit) { + this.audit = audit; + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Subject.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Subject.java new file mode 100644 index 000000000..d293b3305 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/security/Subject.java @@ -0,0 +1,40 @@ +package com.github.streamshub.console.api.v1alpha1.spec.security; + +import java.util.List; + +import io.fabric8.generator.annotation.Required; +import io.sundr.builder.annotations.Buildable; + +@Buildable(editableEnabled = false) +public class Subject { + + private String claim; + @Required + private List include; + private List roleNames; + + public String getClaim() { + return claim; + } + + public void setClaim(String claim) { + this.claim = claim; + } + + public List getInclude() { + return include; + } + + public void setInclude(List include) { + this.include = include; + } + + public List getRoleNames() { + return roleNames; + } + + public void setRoleNames(List roleNames) { + this.roleNames = roleNames; + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/Condition.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/Condition.java index 84b44160f..b5ad29c79 100644 --- a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/Condition.java +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/Condition.java @@ -1,5 +1,8 @@ package com.github.streamshub.console.api.v1alpha1.status; +import java.util.Objects; + +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonPropertyDescription; @@ -15,6 +18,9 @@ public class Condition { private String type; private String lastTransitionTime; + @JsonIgnore + private boolean active = false; + @JsonPropertyDescription("The status of the condition, either True, False or Unknown.") public String getStatus() { return status; @@ -61,4 +67,73 @@ public String getMessage() { public void setMessage(String message) { this.message = message; } + + public boolean isActive() { + return active; + } + + public void setActive(boolean active) { + this.active = active; + } + + @Override + public int hashCode() { + return Objects.hash(message, reason, status, type); + } + + /** + * For the purposes of equality, we do not consider the + * {@link lastTransitionTime} or {@link active}. The {@link active} flag is only + * used within a single reconcile cycle and determines which conditions should + * be set in the CR status and which are no longer relevant and may be removed. + */ + @Override + public boolean equals(Object obj) { + if (this == obj) + return true; + if (obj == null) + return false; + if (!(obj instanceof Condition)) + return false; + Condition other = (Condition) obj; + return Objects.equals(message, other.message) + && Objects.equals(reason, other.reason) + && Objects.equals(status, other.status) + && Objects.equals(type, other.type); + } + + @Override + public String toString() { + return """ + { \ + type = "%s", \ + status = "%s", \ + reason = "%s", \ + message = "%s", \ + lastTransitionTime = "%s" \ + }""".formatted(type, status, reason, message, lastTransitionTime); + } + + /** + * Constant values for the types used for conditions + */ + public static final class Types { + private Types() { + } + + public static final String READY = "Ready"; + public static final String ERROR = "Error"; + } + + /** + * Constant values for the reasons used for conditions + */ + public static final class Reasons { + private Reasons() { + } + + public static final String DEPENDENTS_NOT_READY = "DependentsNotReady"; + public static final String INVALID_CONFIGURATION = "InvalidConfiguration"; + public static final String RECONCILIATION_EXCEPTION = "ReconciliationException"; + } } diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/ConsoleStatus.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/ConsoleStatus.java index db857297b..96280ad04 100644 --- a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/ConsoleStatus.java +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/status/ConsoleStatus.java @@ -1,8 +1,10 @@ package com.github.streamshub.console.api.v1alpha1.status; import java.time.Instant; -import java.util.ArrayList; -import java.util.List; +import java.util.Comparator; +import java.util.Set; +import java.util.TreeSet; +import java.util.function.Predicate; import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonInclude; @@ -14,14 +16,20 @@ @JsonInclude(JsonInclude.Include.NON_NULL) public class ConsoleStatus extends ObservedGenerationAwareStatus { - private List conditions = new ArrayList<>(); + private final Set conditions = new TreeSet<>(Comparator + .comparing(Condition::getType).reversed() + .thenComparing(Condition::getLastTransitionTime) + .thenComparing(Condition::getStatus, Comparator.nullsLast(String::compareTo)) + .thenComparing(Condition::getReason, Comparator.nullsLast(String::compareTo)) + .thenComparing(Condition::getMessage, Comparator.nullsLast(String::compareTo))); - public List getConditions() { + public Set getConditions() { return conditions; } - public void setConditions(List conditions) { - this.conditions = conditions; + @JsonIgnore + public boolean hasCondition(String type) { + return conditions.stream().anyMatch(c -> type.equals(c.getType())); } @JsonIgnore @@ -40,7 +48,19 @@ public Condition getCondition(String type) { } @JsonIgnore - public void clearCondition(String type) { - conditions.removeIf(c -> type.equals(c.getType())); + public void updateCondition(Condition condition) { + condition.setActive(true); + + conditions.stream() + .filter(condition::equals) + .findFirst() + .ifPresentOrElse( + c -> c.setActive(true), + () -> conditions.add(condition)); + } + + @JsonIgnore + public void clearStaleConditions() { + conditions.removeIf(Predicate.not(Condition::isActive)); } } diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConfigurationProcessor.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConfigurationProcessor.java new file mode 100644 index 000000000..de664ba22 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConfigurationProcessor.java @@ -0,0 +1,745 @@ +package com.github.streamshub.console.dependents; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.SecureRandom; +import java.security.cert.Certificate; +import java.security.cert.CertificateEncodingException; +import java.security.cert.CertificateException; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Base64; +import java.util.Collection; +import java.util.Collections; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Random; +import java.util.function.Predicate; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.validation.Validator; + +import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.common.config.SaslConfigs; +import org.apache.kafka.common.config.SslConfigs; +import org.jboss.logging.Logger; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.github.streamshub.console.ReconciliationException; +import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.api.v1alpha1.spec.Credentials; +import com.github.streamshub.console.api.v1alpha1.spec.KafkaCluster; +import com.github.streamshub.console.api.v1alpha1.spec.SchemaRegistry; +import com.github.streamshub.console.api.v1alpha1.spec.TrustStore; +import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource; +import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource.Type; +import com.github.streamshub.console.api.v1alpha1.spec.security.GlobalSecurity; +import com.github.streamshub.console.api.v1alpha1.spec.security.Oidc; +import com.github.streamshub.console.api.v1alpha1.spec.security.Security; +import com.github.streamshub.console.api.v1alpha1.status.Condition.Reasons; +import com.github.streamshub.console.api.v1alpha1.status.Condition.Types; +import com.github.streamshub.console.api.v1alpha1.status.ConditionBuilder; +import com.github.streamshub.console.api.v1alpha1.status.ConsoleStatus; +import com.github.streamshub.console.config.ConsoleConfig; +import com.github.streamshub.console.config.KafkaClusterConfig; +import com.github.streamshub.console.config.PrometheusConfig; +import com.github.streamshub.console.config.SchemaRegistryConfig; +import com.github.streamshub.console.config.security.AuditConfigBuilder; +import com.github.streamshub.console.config.security.Decision; +import com.github.streamshub.console.config.security.OidcConfigBuilder; +import com.github.streamshub.console.config.security.Privilege; +import com.github.streamshub.console.config.security.RoleConfigBuilder; +import com.github.streamshub.console.config.security.RuleConfigBuilder; +import com.github.streamshub.console.config.security.SecurityConfig; +import com.github.streamshub.console.config.security.SubjectConfigBuilder; +import com.github.streamshub.console.dependents.support.ConfigSupport; + +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.EnvVarBuilder; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.Volume; +import io.fabric8.kubernetes.api.model.VolumeBuilder; +import io.fabric8.kubernetes.api.model.VolumeMount; +import io.fabric8.kubernetes.api.model.VolumeMountBuilder; +import io.fabric8.openshift.api.model.Route; +import io.fabric8.openshift.api.model.RouteIngress; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.api.reconciler.dependent.ReconcileResult; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; +import io.strimzi.api.kafka.model.kafka.Kafka; +import io.strimzi.api.kafka.model.kafka.KafkaStatus; +import io.strimzi.api.kafka.model.kafka.listener.GenericKafkaListener; +import io.strimzi.api.kafka.model.kafka.listener.ListenerStatus; +import io.strimzi.api.kafka.model.user.KafkaUser; +import io.strimzi.api.kafka.model.user.KafkaUserStatus; + +import static com.github.streamshub.console.dependents.support.ConfigSupport.getResource; +import static com.github.streamshub.console.dependents.support.ConfigSupport.getValue; +import static com.github.streamshub.console.dependents.support.ConfigSupport.setConfigVars; +import static com.github.streamshub.console.support.StringSupport.replaceNonAlphanumeric; +import static com.github.streamshub.console.support.StringSupport.toEnv; + +/** + * Virtual resource that is a dependency of all other resources (directly or + * indirectly). This resource handles the processing of the configuration stored + * in the Console custom resource, mapping it to the corresponding console + * application configurations or other Kubernetes resources (e.g. Volumes and + * VolumeMounts) that will be used later in the reconciliation process. + */ +@ApplicationScoped +public class ConfigurationProcessor implements DependentResource, ConsoleResource { + + public static final String NAME = "ConfigurationProcessor"; + + private static final Logger LOGGER = Logger.getLogger(ConfigurationProcessor.class); + private static final String EMBEDDED_METRICS_NAME = "streamshub.console.embedded-prometheus"; + private static final String OIDC_TRUST_PREFIX = "oidc-truststore."; + private static final String METRICS_TRUST_PREFIX = "metrics-source-truststore."; + private static final String REGISTRY_TRUST_PREFIX = "schema-registry-truststore."; + private static final Random RANDOM = new SecureRandom(); + + @Inject + Validator validator; + + @Inject + ObjectMapper objectMapper; + + @Inject + PrometheusService prometheusService; + + @Inject + ConsoleSecret configurationSecret; + + public static class Postcondition implements Condition { + @Override + public boolean isMet(DependentResource dependentResource, + Console primary, + Context context) { + + return context.managedDependentResourceContext().getMandatory(NAME, Boolean.class); + } + } + + @Override + public ReconcileResult reconcile(Console primary, Context context) { + if (buildSecretData(primary, context)) { + LOGGER.debugf("Validation gate passed: %s", primary.getMetadata().getName()); + context.managedDependentResourceContext().put(NAME, Boolean.TRUE); + } else { + LOGGER.debugf("Validation gate failed: %s", primary.getMetadata().getName()); + context.managedDependentResourceContext().put(NAME, Boolean.FALSE); + } + + return ReconcileResult.noOperation(primary); + } + + @Override + public Class resourceType() { + return HasMetadata.class; + } + + @Override + public String resourceName() { + return NAME; + } + + private boolean buildSecretData(Console primary, Context context) { + ConsoleStatus status = primary.getOrCreateStatus(); + Map data = new LinkedHashMap<>(); + + var nextAuth = context.getSecondaryResource(Secret.class).map(s -> s.getData().get("NEXTAUTH_SECRET")); + var nextAuthSecret = nextAuth.orElseGet(() -> encodeString(generateRandomBase64EncodedSecret(32))); + data.put("NEXTAUTH_SECRET", nextAuthSecret); + + try { + buildConsoleConfig(primary, context, data); + buildTrustStores(primary, context, data); + } catch (Exception e) { + if (!(e instanceof ReconciliationException)) { + LOGGER.warnf(e, "Exception processing console configuration from %s/%s", primary.getMetadata().getNamespace(), primary.getMetadata().getName()); + } + status.updateCondition(new ConditionBuilder() + .withType(Types.ERROR) + .withStatus("True") + .withLastTransitionTime(Instant.now().toString()) + .withReason(Reasons.RECONCILIATION_EXCEPTION) + .withMessage(e.getMessage()) + .build()); + } + + context.managedDependentResourceContext().put("ConsoleSecretData", data); + return !status.hasCondition(Types.ERROR); + } + + private void buildConsoleConfig(Console primary, Context context, + Map data) { + + var consoleConfig = buildConfig(primary, context); + var violations = validator.validate(consoleConfig); + + if (!violations.isEmpty()) { + for (var violation : violations) { + StringBuilder message = new StringBuilder(); + if (!violation.getPropertyPath().toString().isBlank()) { + message.append(violation.getPropertyPath().toString()); + message.append(' '); + } + message.append(violation.getMessage()); + + primary.getStatus().updateCondition(new ConditionBuilder() + .withType(Types.ERROR) + .withStatus("True") + .withLastTransitionTime(Instant.now().toString()) + .withReason(Reasons.INVALID_CONFIGURATION) + .withMessage(message.toString()) + .build()); + } + + return; + } + + try { + var yaml = objectMapper.copyWith(new YAMLFactory()); + data.put("console-config.yaml", encodeString(yaml.writeValueAsString(consoleConfig))); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + } + + class TrustStoreProcessor { + final Console primary; + final Context context; + final Map data; + final String namespace; + final String secretName; + final Map, List> deploymentResources = new HashMap<>(); + final Map, List> deploymentResourcesUI = new HashMap<>(); + + TrustStoreProcessor(Console primary, Context context, Map data) { + this.primary = primary; + this.context = context; + this.data = data; + this.namespace = primary.getMetadata().getNamespace(); + this.secretName = configurationSecret.instanceName(primary); + } + + void process() { + var oidcTruststore = Optional.ofNullable(primary.getSpec().getSecurity()) + .map(GlobalSecurity::getOidc) + .map(Oidc::getTrustStore) + .orElse(null); + + if (oidcTruststore != null) { + reconcileTrustStore("trust", OIDC_TRUST_PREFIX, oidcTruststore, "oidc-provider", true); + } + + for (var metricsSource : Optional.ofNullable(primary.getSpec().getMetricsSources()) + .orElse(Collections.emptyList())) { + var truststore = metricsSource.getTrustStore(); + + if (truststore != null) { + reconcileTrustStore(metricsSource.getName(), METRICS_TRUST_PREFIX, truststore, "metrics-source", false); + } + } + + for (var registry : Optional.ofNullable(primary.getSpec().getSchemaRegistries()) + .orElse(Collections.emptyList())) { + var truststore = registry.getTrustStore(); + + if (truststore != null) { + reconcileTrustStore(registry.getName(), REGISTRY_TRUST_PREFIX, truststore, "schema-registry", false); + } + } + + context.managedDependentResourceContext().put("TrustStoreResources", deploymentResources); + context.managedDependentResourceContext().put("TrustStoreResourcesUI", deploymentResourcesUI); + } + + private void reconcileTrustStore(String sourceName, String sourcePrefix, TrustStore truststore, String bucketPrefix, boolean uiServer) { + String typeCode = truststore.getType().toString(); + String volumeName = replaceNonAlphanumeric(sourcePrefix + sourceName, '-'); + String fileName = sourcePrefix + sourceName + "." + typeCode; + + @SuppressWarnings("unchecked") + List volumes = (List) deploymentResources.computeIfAbsent(Volume.class, k -> new ArrayList<>()); + + volumes.add(new VolumeBuilder() + .withName(volumeName) + .withNewSecret() + .withSecretName(secretName) + .addNewItem() + .withKey(sourcePrefix + sourceName + ".content") + .withPath(fileName) + .endItem() + .withDefaultMode(420) + .endSecret() + .build()); + + @SuppressWarnings("unchecked") + List mounts = (List) deploymentResources.computeIfAbsent(VolumeMount.class, k -> new ArrayList<>()); + + mounts.add(new VolumeMountBuilder() + .withName(volumeName) + .withMountPath("/etc/ssl/" + fileName) + .withSubPath(fileName) + .build()); + + String configTemplate = "quarkus.tls.\"" + bucketPrefix + "-%s\".trust-store.%s.%s"; + + @SuppressWarnings("unchecked") + List vars = (List) deploymentResources.computeIfAbsent(EnvVar.class, k -> new ArrayList<>()); + + @SuppressWarnings("unchecked") + List varsUI = (List) deploymentResourcesUI.computeIfAbsent(EnvVar.class, k -> new ArrayList<>()); + + byte[] content = getValue(context, namespace, truststore.getContent()); + byte[] password = getValue(context, namespace, truststore.getPassword()); + String alias = truststore.getAlias(); + + // `content` is required by the CRD so we don't need to test that it was put in the data map + putTrustStoreValue(data, sourcePrefix + sourceName, "content", content); + + String pathKey = switch (truststore.getType()) { + case JKS, PKCS12 -> "path"; + case PEM -> "certs"; + }; + + vars.add(new EnvVarBuilder() + .withName(toEnv(configTemplate.formatted(sourceName, typeCode, pathKey))) + .withValue("/etc/ssl/" + fileName) + .build()); + + if (putTrustStoreValue(data, sourcePrefix + sourceName, "password", password)) { + vars.add(new EnvVarBuilder() + .withName(toEnv(configTemplate.formatted(sourceName, typeCode, "password"))) + .withNewValueFrom() + .withNewSecretKeyRef(sourcePrefix + sourceName + ".password", secretName, false) + .endValueFrom() + .build()); + } + + if (putTrustStoreValue(data, sourcePrefix + sourceName, "alias", + alias != null ? alias.getBytes(StandardCharsets.UTF_8) : null)) { + vars.add(new EnvVarBuilder() + .withName(toEnv(configTemplate.formatted(sourceName, typeCode, "alias"))) + .withNewValueFrom() + .withNewSecretKeyRef(sourcePrefix + sourceName + ".alias", secretName, false) + .endValueFrom() + .build()); + } + + if (uiServer && truststore.getType() != TrustStore.Type.PEM) { + KeyStore keystore; + + try (InputStream in = new ByteArrayInputStream(content)) { + keystore = KeyStore.getInstance(truststore.getType().toString()); + char[] secret = password != null + ? new String(password, StandardCharsets.UTF_8).toCharArray() : null; + + keystore.load(in, secret); + } catch (IOException | CertificateException | KeyStoreException | NoSuchAlgorithmException e) { + throw new ReconciliationException("Truststore %s could not be loaded. %s" + .formatted(sourceName, e.getMessage())); + } + + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + try { + if (alias != null) { + var certificate = keystore.getCertificate(alias); + encodeCertificate(buffer, certificate); + } else { + for (var aliases = keystore.aliases().asIterator(); aliases.hasNext(); ) { + alias = aliases.next(); + + if (keystore.isCertificateEntry(alias)) { + var certificate = keystore.getCertificate(alias); + encodeCertificate(buffer, certificate); + } + } + } + } catch (KeyStoreException | IOException | CertificateEncodingException e) { + throw new ReconciliationException("Truststore %s could not be loaded. %s" + .formatted(sourceName, e.getMessage())); + } + + putTrustStoreValue(data, sourcePrefix + sourceName, "content.pem", buffer.toByteArray()); + + varsUI.add(new EnvVarBuilder() + .withName("CONSOLE_SECURITY_OIDC_TRUSTSTORE") + .withNewValueFrom() + .withNewSecretKeyRef(sourcePrefix + sourceName + ".content.pem", secretName, false) + .endValueFrom() + .build()); + } else if (uiServer) { + varsUI.add(new EnvVarBuilder() + .withName("CONSOLE_SECURITY_OIDC_TRUSTSTORE") + .withNewValueFrom() + .withNewSecretKeyRef(sourcePrefix + sourceName + ".content", secretName, false) + .endValueFrom() + .build()); + } + } + + private boolean putTrustStoreValue(Map data, String sourceName, String key, byte[] value) { + if (value != null) { + data.put(sourceName + "." + key, ConfigSupport.encodeBytes(value)); + return true; + } + return false; + } + + private void encodeCertificate(OutputStream buffer, Certificate certificate) throws IOException, CertificateEncodingException { + buffer.write("-----BEGIN CERTIFICATE-----\n".getBytes(StandardCharsets.UTF_8)); + buffer.write(Base64.getMimeEncoder(80, new byte[] {'\n'}).encode(certificate.getEncoded())); + buffer.write("\n-----END CERTIFICATE-----\n".getBytes(StandardCharsets.UTF_8)); + } + } + + /** + * Generate additional entries in the secret for metric source trust stores. Also, this + * method will add to the context the resources to be added to the console deployment to + * access the secret entries. + */ + private void buildTrustStores(Console primary, Context context, Map data) { + var processor = new TrustStoreProcessor(primary, context, data); + processor.process(); + } + + private static String generateRandomBase64EncodedSecret(int length) { + ByteArrayOutputStream buffer = new ByteArrayOutputStream(); + + RANDOM.ints().limit(length).forEach(value -> { + try (OutputStream out = Base64.getEncoder().wrap(buffer)) { + out.write(value); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + + return new String(buffer.toByteArray()).substring(0, length); + } + + private ConsoleConfig buildConfig(Console primary, Context context) { + ConsoleConfig config = new ConsoleConfig(); + + addSecurity(primary, config, context); + addMetricsSources(primary, config, context); + addSchemaRegistries(primary, config); + + for (var kafkaRef : primary.getSpec().getKafkaClusters()) { + var kafkaConfig = addConfig(primary, context, config, kafkaRef); + addSecurity(kafkaRef.getSecurity(), kafkaConfig.getSecurity()); + } + + return config; + } + + private void addSecurity(Console primary, ConsoleConfig config, Context context) { + var security = primary.getSpec().getSecurity(); + + if (security == null) { + return; + } + + var securityConfig = config.getSecurity(); + var oidc = security.getOidc(); + + if (oidc != null) { + String clientSecret = Optional + .ofNullable(getValue(context, primary.getMetadata().getNamespace(), oidc.getClientSecret())) + .map(String::new) + .orElse(null); + + securityConfig.setOidc(new OidcConfigBuilder() + .withAuthServerUrl(oidc.getAuthServerUrl()) + .withIssuer(oidc.getIssuer()) + .withClientId(oidc.getClientId()) + .withClientSecret(clientSecret) + .build()); + } + + addSecurity(security, securityConfig); + } + + private void addSecurity(Security source, SecurityConfig target) { + if (source == null) { + return; + } + + var subjects = coalesce(source.getSubjects(), Collections::emptyList); + + for (var subject : subjects) { + target.getSubjects().add(new SubjectConfigBuilder() + .withClaim(subject.getClaim()) + .withInclude(subject.getInclude()) + .withRoleNames(subject.getRoleNames()) + .build()); + } + + var roles = coalesce(source.getRoles(), Collections::emptyList); + + for (var role : roles) { + var rules = coalesce(role.getRules(), Collections::emptyList) + .stream() + .map(rule -> new RuleConfigBuilder() + .withResources(rule.getResources()) + .withResourceNames(rule.getResourceNames()) + .withPrivileges(rule.getPrivileges() + .stream() + .map(Enum::name) + .map(Privilege::valueOf) + .toList()) + .build()) + .toList(); + + target.getRoles().add(new RoleConfigBuilder() + .withName(role.getName()) + .withRules(rules) + .build()); + } + + var audits = coalesce(source.getAudit(), Collections::emptyList); + + for (var audit : audits) { + target.getAudit().add(new AuditConfigBuilder() + .withDecision(Decision.valueOf(audit.getDecision().name())) + .withResources(audit.getResources()) + .withResourceNames(audit.getResourceNames()) + .withPrivileges(audit.getPrivileges() + .stream() + .map(Enum::name) + .map(Privilege::valueOf) + .toList()) + .build()); + } + } + + private void addMetricsSources(Console primary, ConsoleConfig config, Context context) { + var metricsSources = coalesce(primary.getSpec().getMetricsSources(), Collections::emptyList); + + if (metricsSources.isEmpty()) { + var prometheusConfig = new PrometheusConfig(); + prometheusConfig.setName(EMBEDDED_METRICS_NAME); + prometheusConfig.setUrl(prometheusService.getUrl(primary, context)); + config.getMetricsSources().add(prometheusConfig); + return; + } + + for (MetricsSource metricsSource : metricsSources) { + var prometheusConfig = new PrometheusConfig(); + prometheusConfig.setName(metricsSource.getName()); + + if (metricsSource.getType() == Type.OPENSHIFT_MONITORING) { + prometheusConfig.setType(PrometheusConfig.Type.OPENSHIFT_MONITORING); + prometheusConfig.setUrl(getOpenShiftMonitoringUrl(context)); + } else { + // embedded Prometheus used like standalone by console + prometheusConfig.setType(PrometheusConfig.Type.STANDALONE); + + if (metricsSource.getType() == Type.EMBEDDED) { + prometheusConfig.setUrl(prometheusService.getUrl(primary, context)); + } else { + prometheusConfig.setUrl(metricsSource.getUrl()); + } + } + + var metricsAuthn = metricsSource.getAuthentication(); + + if (metricsAuthn != null) { + if (metricsAuthn.getToken() == null) { + var basicConfig = new PrometheusConfig.Basic(); + basicConfig.setUsername(metricsAuthn.getUsername()); + basicConfig.setPassword(metricsAuthn.getPassword()); + prometheusConfig.setAuthentication(basicConfig); + } else { + var bearerConfig = new PrometheusConfig.Bearer(); + bearerConfig.setToken(metricsAuthn.getToken()); + prometheusConfig.setAuthentication(bearerConfig); + } + } + + config.getMetricsSources().add(prometheusConfig); + } + } + + private String getOpenShiftMonitoringUrl(Context context) { + Route thanosQuerier = getResource(context, Route.class, "openshift-monitoring", "thanos-querier"); + + String host = thanosQuerier.getStatus() + .getIngress() + .stream() + .map(RouteIngress::getHost) + .findFirst() + .orElseThrow(() -> new ReconciliationException( + "Ingress host not found on openshift-monitoring/thanos-querier route")); + + return "https://" + host; + } + + private void addSchemaRegistries(Console primary, ConsoleConfig config) { + for (SchemaRegistry registry : coalesce(primary.getSpec().getSchemaRegistries(), Collections::emptyList)) { + var registryConfig = new SchemaRegistryConfig(); + registryConfig.setName(registry.getName()); + registryConfig.setUrl(registry.getUrl()); + config.getSchemaRegistries().add(registryConfig); + } + } + + private KafkaClusterConfig addConfig(Console primary, Context context, ConsoleConfig config, KafkaCluster kafkaRef) { + String namespace = kafkaRef.getNamespace(); + String name = kafkaRef.getName(); + String listenerName = kafkaRef.getListener(); + + KafkaClusterConfig kcConfig = new KafkaClusterConfig(); + kcConfig.setId(kafkaRef.getId()); + kcConfig.setNamespace(namespace); + kcConfig.setName(name); + kcConfig.setListener(listenerName); + kcConfig.setSchemaRegistry(kafkaRef.getSchemaRegistry()); + + if (kafkaRef.getMetricsSource() == null) { + if (config.getMetricsSources().stream().anyMatch(src -> src.getName().equals(EMBEDDED_METRICS_NAME))) { + kcConfig.setMetricsSource(EMBEDDED_METRICS_NAME); + } + } else { + kcConfig.setMetricsSource(kafkaRef.getMetricsSource()); + } + + config.getKubernetes().setEnabled(Objects.nonNull(namespace)); + config.getKafka().getClusters().add(kcConfig); + + setConfigVars(primary, context, kcConfig.getProperties(), kafkaRef.getProperties()); + setConfigVars(primary, context, kcConfig.getAdminProperties(), kafkaRef.getAdminProperties()); + setConfigVars(primary, context, kcConfig.getConsumerProperties(), kafkaRef.getConsumerProperties()); + setConfigVars(primary, context, kcConfig.getProducerProperties(), kafkaRef.getProducerProperties()); + + if (namespace != null && listenerName != null) { + // Changes in the Kafka resource picked up during periodic reconciliation + Kafka kafka = getResource(context, Kafka.class, namespace, name); + setListenerConfig(kcConfig.getProperties(), kafka, listenerName); + } + + if (!kcConfig.getProperties().containsKey(SaslConfigs.SASL_JAAS_CONFIG)) { + Optional.ofNullable(kafkaRef.getCredentials()) + .map(Credentials::getKafkaUser) + .ifPresent(user -> { + String userNs = Optional.ofNullable(user.getNamespace()).orElse(namespace); + setKafkaUserConfig( + context, + getResource(context, KafkaUser.class, userNs, user.getName()), + kcConfig.getProperties()); + }); + } + + return kcConfig; + } + + void setListenerConfig(Map properties, Kafka kafka, String listenerName) { + GenericKafkaListener listenerSpec = kafka.getSpec() + .getKafka() + .getListeners() + .stream() + .filter(l -> l.getName().equals(listenerName)) + .findFirst() + .orElseThrow(() -> new ReconciliationException("Listener '%s' not found on Kafka %s/%s" + .formatted(listenerName, kafka.getMetadata().getNamespace(), kafka.getMetadata().getName()))); + + StringBuilder protocol = new StringBuilder(); + String mechanism = null; + + if (listenerSpec.getAuth() != null) { + protocol.append("SASL_"); + + var auth = listenerSpec.getAuth(); + switch (auth.getType()) { + case "oauth": + mechanism = "OAUTHBEARER"; + break; + case "scram-sha-512": + mechanism = "SCRAM-SHA-512"; + break; + case "tls", "custom": + default: + // Nothing yet + break; + } + } + + if (listenerSpec.isTls()) { + protocol.append("SSL"); + } else { + protocol.append("PLAINTEXT"); + } + + properties.putIfAbsent(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, protocol.toString()); + + if (mechanism != null) { + properties.putIfAbsent(SaslConfigs.SASL_MECHANISM, mechanism); + } + + Optional listenerStatus = Optional.ofNullable(kafka.getStatus()) + .map(KafkaStatus::getListeners) + .orElseGet(Collections::emptyList) + .stream() + .filter(l -> l.getName().equals(listenerName)) + .findFirst(); + + properties.computeIfAbsent( + CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, + key -> listenerStatus.map(ListenerStatus::getBootstrapServers) + .orElseThrow(() -> new ReconciliationException(""" + Bootstrap servers could not be found for listener '%s' on Kafka %s/%s \ + and no configuration was given in the Console resource""" + .formatted(listenerName, kafka.getMetadata().getNamespace(), kafka.getMetadata().getName())))); + + if (!properties.containsKey(SslConfigs.SSL_TRUSTSTORE_TYPE_CONFIG) + && !properties.containsKey(SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG)) { + listenerStatus.map(ListenerStatus::getCertificates) + .filter(Objects::nonNull) + .filter(Predicate.not(Collection::isEmpty)) + .map(certificates -> String.join("\n", certificates).trim()) + .ifPresent(certificates -> { + properties.put(SslConfigs.SSL_TRUSTSTORE_TYPE_CONFIG, "PEM"); + properties.put(SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG, certificates); + }); + } + } + + void setKafkaUserConfig(Context context, KafkaUser user, Map properties) { + // Changes in the KafkaUser resource and referenced Secret picked up during periodic reconciliation + var secretName = Optional.ofNullable(user.getStatus()) + .map(KafkaUserStatus::getSecret) + .orElseThrow(() -> new ReconciliationException("KafkaUser %s/%s missing .status.secret" + .formatted(user.getMetadata().getNamespace(), user.getMetadata().getName()))); + + String secretNs = user.getMetadata().getNamespace(); + Secret userSecret = getResource(context, Secret.class, secretNs, secretName); + String jaasConfig = userSecret.getData().get(SaslConfigs.SASL_JAAS_CONFIG); + + if (jaasConfig == null) { + throw new ReconciliationException("Secret %s/%s missing key '%s'" + .formatted(secretNs, secretName, SaslConfigs.SASL_JAAS_CONFIG)); + } + + properties.put(SaslConfigs.SASL_JAAS_CONFIG, decodeString(jaasConfig)); + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleDeployment.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleDeployment.java index 155d9d59a..cd18a72cd 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleDeployment.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleDeployment.java @@ -67,9 +67,12 @@ protected Deployment desired(Console primary, Context context) { var envVars = new ArrayList<>(coalesce(primary.getSpec().getEnv(), Collections::emptyList)); - var trustResources = getTrustResources(context); + var trustResources = getTrustResources("TrustStoreResources", context); envVars.addAll(getResourcesByType(trustResources, EnvVar.class)); + var trustResourcesUI = getTrustResources("TrustStoreResourcesUI", context); + var envVarsUI = getResourcesByType(trustResourcesUI, EnvVar.class); + return desired.edit() .editMetadata() .withName(name) @@ -97,11 +100,13 @@ protected Deployment desired(Console primary, Context context) { .addAllToVolumes(getResourcesByType(trustResources, Volume.class)) .editMatchingContainer(c -> "console-api".equals(c.getName())) .withImage(imageAPI) + .withImagePullPolicy(pullPolicy(imageAPI)) .addAllToVolumeMounts(getResourcesByType(trustResources, VolumeMount.class)) .addAllToEnv(envVars) .endContainer() .editMatchingContainer(c -> "console-ui".equals(c.getName())) .withImage(imageUI) + .withImagePullPolicy(pullPolicy(imageUI)) .editMatchingEnv(env -> "NEXTAUTH_URL".equals(env.getName())) .withValue(getAttribute(context, ConsoleIngress.NAME + ".url", String.class)) .endEnv() @@ -112,6 +117,7 @@ protected Deployment desired(Console primary, Context context) { .endSecretKeyRef() .endValueFrom() .endEnv() + .addAllToEnv(envVarsUI) .endContainer() .endSpec() .endTemplate() @@ -120,8 +126,8 @@ protected Deployment desired(Console primary, Context context) { } @SuppressWarnings("unchecked") - Map, List> getTrustResources(Context context) { - return context.managedDependentResourceContext().getMandatory("TrustStoreResources", Map.class); + Map, List> getTrustResources(String key, Context context) { + return context.managedDependentResourceContext().getMandatory(key, Map.class); } @SuppressWarnings("unchecked") @@ -130,4 +136,8 @@ List getResourcesByType( Class key) { return (List) resources.getOrDefault(key, Collections.emptyList()); } + + private String pullPolicy(String image) { + return image.contains("sha256:") ? "IfNotPresent" : "Always"; + } } diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleResource.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleResource.java index ba771b13c..1414db1df 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleResource.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleResource.java @@ -6,7 +6,6 @@ import java.nio.charset.StandardCharsets; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; -import java.util.Base64; import java.util.Comparator; import java.util.HexFormat; import java.util.LinkedHashMap; @@ -17,6 +16,7 @@ import java.util.function.Supplier; import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.dependents.support.ConfigSupport; import io.fabric8.kubernetes.api.model.HasMetadata; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -105,16 +105,14 @@ default String serializeDigest(Context context, String digestName) { } default String encodeString(String value) { - return Base64.getEncoder().encodeToString(value.getBytes(StandardCharsets.UTF_8)); + return ConfigSupport.encodeString(value); } default String decodeString(String encodedValue) { - return new String(Base64.getDecoder().decode(encodedValue), StandardCharsets.UTF_8); + return ConfigSupport.decodeString(encodedValue); } default List coalesce(List value, Supplier> defaultValue) { return value != null ? value : defaultValue.get(); } - - } diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleSecret.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleSecret.java index f8df349f0..6bd02ef50 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleSecret.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleSecret.java @@ -1,77 +1,16 @@ package com.github.streamshub.console.dependents; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.OutputStream; -import java.io.UncheckedIOException; -import java.security.SecureRandom; -import java.util.ArrayList; -import java.util.Base64; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.LinkedHashMap; -import java.util.List; import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.Random; -import java.util.function.Function; -import java.util.function.Predicate; import jakarta.enterprise.context.ApplicationScoped; -import jakarta.inject.Inject; -import org.apache.kafka.clients.CommonClientConfigs; -import org.apache.kafka.common.config.SaslConfigs; -import org.apache.kafka.common.config.SslConfigs; - -import com.fasterxml.jackson.core.JsonProcessingException; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; -import com.github.streamshub.console.ReconciliationException; import com.github.streamshub.console.api.v1alpha1.Console; -import com.github.streamshub.console.api.v1alpha1.spec.ConfigVars; -import com.github.streamshub.console.api.v1alpha1.spec.Credentials; -import com.github.streamshub.console.api.v1alpha1.spec.KafkaCluster; -import com.github.streamshub.console.api.v1alpha1.spec.SchemaRegistry; -import com.github.streamshub.console.api.v1alpha1.spec.TrustStore; -import com.github.streamshub.console.api.v1alpha1.spec.Value; -import com.github.streamshub.console.api.v1alpha1.spec.ValueReference; -import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource; -import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource.Type; -import com.github.streamshub.console.config.ConsoleConfig; -import com.github.streamshub.console.config.KafkaClusterConfig; -import com.github.streamshub.console.config.PrometheusConfig; -import com.github.streamshub.console.config.SchemaRegistryConfig; -import io.fabric8.kubernetes.api.model.ConfigMap; -import io.fabric8.kubernetes.api.model.EnvVar; -import io.fabric8.kubernetes.api.model.EnvVarBuilder; -import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.kubernetes.api.model.SecretBuilder; -import io.fabric8.kubernetes.api.model.Volume; -import io.fabric8.kubernetes.api.model.VolumeBuilder; -import io.fabric8.kubernetes.api.model.VolumeMount; -import io.fabric8.kubernetes.api.model.VolumeMountBuilder; -import io.fabric8.kubernetes.client.KubernetesClientException; -import io.fabric8.openshift.api.model.Route; -import io.fabric8.openshift.api.model.RouteIngress; import io.javaoperatorsdk.operator.api.reconciler.Context; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.CRUDKubernetesDependentResource; import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; -import io.strimzi.api.kafka.model.kafka.Kafka; -import io.strimzi.api.kafka.model.kafka.KafkaStatus; -import io.strimzi.api.kafka.model.kafka.listener.GenericKafkaListener; -import io.strimzi.api.kafka.model.kafka.listener.GenericKafkaListenerConfiguration; -import io.strimzi.api.kafka.model.kafka.listener.GenericKafkaListenerConfigurationBootstrap; -import io.strimzi.api.kafka.model.kafka.listener.ListenerStatus; -import io.strimzi.api.kafka.model.user.KafkaUser; -import io.strimzi.api.kafka.model.user.KafkaUserStatus; - -import static com.github.streamshub.console.support.StringSupport.replaceNonAlphanumeric; -import static com.github.streamshub.console.support.StringSupport.toEnv; @ApplicationScoped @KubernetesDependent(labelSelector = ConsoleResource.MANAGEMENT_SELECTOR) @@ -79,17 +18,6 @@ public class ConsoleSecret extends CRUDKubernetesDependentResource context) { - var nextAuth = context.getSecondaryResource(Secret.class).map(s -> s.getData().get("NEXTAUTH_SECRET")); - Map data = new LinkedHashMap<>(2); - - var nextAuthSecret = nextAuth.orElseGet(() -> encodeString(base64String(32))); - data.put("NEXTAUTH_SECRET", nextAuthSecret); - - var consoleConfig = buildConfig(primary, context); - - try { - var yaml = objectMapper.copyWith(new YAMLFactory()); - data.put("console-config.yaml", encodeString(yaml.writeValueAsString(consoleConfig))); - } catch (JsonProcessingException e) { - throw new UncheckedIOException(e); - } - - buildTrustStores(primary, context, data); + @SuppressWarnings("unchecked") + Map data = context.managedDependentResourceContext() + .getMandatory("ConsoleSecretData", Map.class); updateDigest(context, "console-digest", data); @@ -129,471 +44,4 @@ protected Secret desired(Console primary, Context context) { .withData(data) .build(); } - - /** - * Generate additional entries in the secret for metric source trust stores. Also, this - * method will add to the context the resources to be added to the console deployment to - * access the secret entries. - */ - private void buildTrustStores(Console primary, Context context, Map data) { - Map, List> deploymentResources = new HashMap<>(); - - for (var metricsSource : Optional.ofNullable(primary.getSpec().getMetricsSources()) - .orElse(Collections.emptyList())) { - var truststore = metricsSource.getTrustStore(); - - if (truststore != null) { - reconcileTrustStore(primary, context, data, metricsSource.getName(), METRICS_TRUST_PREFIX, truststore, "metrics-source", deploymentResources); - } - } - - for (var registry : Optional.ofNullable(primary.getSpec().getSchemaRegistries()) - .orElse(Collections.emptyList())) { - var truststore = registry.getTrustStore(); - - if (truststore != null) { - reconcileTrustStore(primary, context, data, registry.getName(), REGISTRY_TRUST_PREFIX, truststore, "schema-registry", deploymentResources); - } - } - - context.managedDependentResourceContext().put("TrustStoreResources", deploymentResources); - } - - @SuppressWarnings("java:S107") // Ignore Sonar warning for too many args - private void reconcileTrustStore( - Console primary, - Context context, - Map data, - String sourceName, - String sourcePrefix, - TrustStore truststore, - String bucketPrefix, - Map, List> deploymentResources) { - - String namespace = primary.getMetadata().getNamespace(); - String secretName = instanceName(primary); - String typeCode = truststore.getType().toString(); - String volumeName = replaceNonAlphanumeric(sourcePrefix + sourceName, '-'); - String fileName = sourcePrefix + sourceName + "." + typeCode; - - @SuppressWarnings("unchecked") - List volumes = (List) deploymentResources.computeIfAbsent(Volume.class, k -> new ArrayList<>()); - - volumes.add(new VolumeBuilder() - .withName(volumeName) - .withNewSecret() - .withSecretName(secretName) - .addNewItem() - .withKey(sourcePrefix + sourceName + ".content") - .withPath(fileName) - .endItem() - .withDefaultMode(420) - .endSecret() - .build()); - - @SuppressWarnings("unchecked") - List mounts = (List) deploymentResources.computeIfAbsent(VolumeMount.class, k -> new ArrayList<>()); - - mounts.add(new VolumeMountBuilder() - .withName(volumeName) - .withMountPath("/etc/ssl/" + fileName) - .withSubPath(fileName) - .build()); - - String configTemplate = "quarkus.tls.\"" + bucketPrefix + "-%s\".trust-store.%s.%s"; - - @SuppressWarnings("unchecked") - List vars = (List) deploymentResources.computeIfAbsent(EnvVar.class, k -> new ArrayList<>()); - - if (putMetricsTrustStoreValue(data, sourceName, "content", getValue(context, namespace, truststore.getContent()))) { - String pathKey = switch (truststore.getType()) { - case JKS, PKCS12 -> "path"; - case PEM -> "certs"; - }; - - vars.add(new EnvVarBuilder() - .withName(toEnv(configTemplate.formatted(sourceName, typeCode, pathKey))) - .withValue("/etc/ssl/" + fileName) - .build()); - } - - if (putMetricsTrustStoreValue(data, sourceName, "password", getValue(context, namespace, truststore.getPassword()))) { - vars.add(new EnvVarBuilder() - .withName(toEnv(configTemplate.formatted(sourceName, typeCode, "password"))) - .withNewValueFrom() - .withNewSecretKeyRef(sourcePrefix + sourceName + ".password", secretName, false) - .endValueFrom() - .build()); - } - - if (putMetricsTrustStoreValue(data, sourceName, "alias", getValue(context, namespace, Value.of(truststore.getAlias())))) { - vars.add(new EnvVarBuilder() - .withName(toEnv(configTemplate.formatted(sourceName, typeCode, "alias"))) - .withNewValueFrom() - .withNewSecretKeyRef(sourcePrefix + sourceName + ".alias", secretName, false) - .endValueFrom() - .build()); - } - } - - private boolean putMetricsTrustStoreValue(Map data, String sourceName, String key, String value) { - if (value != null) { - data.put(METRICS_TRUST_PREFIX + sourceName + "." + key, value); - return true; - } - return false; - } - - private static String base64String(int length) { - ByteArrayOutputStream buffer = new ByteArrayOutputStream(); - OutputStream out = Base64.getEncoder().wrap(buffer); - - RANDOM.ints().limit(length).forEach(value -> { - try { - out.write(value); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }); - - return new String(buffer.toByteArray()).substring(0, length); - } - - private ConsoleConfig buildConfig(Console primary, Context context) { - ConsoleConfig config = new ConsoleConfig(); - - addMetricsSources(primary, config, context); - addSchemaRegistries(primary, config); - - for (var kafkaRef : primary.getSpec().getKafkaClusters()) { - addConfig(primary, context, config, kafkaRef); - } - - return config; - } - - private void addMetricsSources(Console primary, ConsoleConfig config, Context context) { - var metricsSources = coalesce(primary.getSpec().getMetricsSources(), Collections::emptyList); - - if (metricsSources.isEmpty()) { - var prometheusConfig = new PrometheusConfig(); - prometheusConfig.setName(EMBEDDED_METRICS_NAME); - prometheusConfig.setUrl(prometheusService.getUrl(primary, context)); - config.getMetricsSources().add(prometheusConfig); - return; - } - - for (MetricsSource metricsSource : metricsSources) { - var prometheusConfig = new PrometheusConfig(); - prometheusConfig.setName(metricsSource.getName()); - - if (metricsSource.getType() == Type.OPENSHIFT_MONITORING) { - prometheusConfig.setType(PrometheusConfig.Type.OPENSHIFT_MONITORING); - prometheusConfig.setUrl(getOpenShiftMonitoringUrl(context)); - } else { - // embedded Prometheus used like standalone by console - prometheusConfig.setType(PrometheusConfig.Type.STANDALONE); - - if (metricsSource.getType() == Type.EMBEDDED) { - prometheusConfig.setUrl(prometheusService.getUrl(primary, context)); - } else { - prometheusConfig.setUrl(metricsSource.getUrl()); - } - } - - var metricsAuthn = metricsSource.getAuthentication(); - - if (metricsAuthn != null) { - if (metricsAuthn.getToken() == null) { - var basicConfig = new PrometheusConfig.Basic(); - basicConfig.setUsername(metricsAuthn.getUsername()); - basicConfig.setPassword(metricsAuthn.getPassword()); - prometheusConfig.setAuthentication(basicConfig); - } else { - var bearerConfig = new PrometheusConfig.Bearer(); - bearerConfig.setToken(metricsAuthn.getToken()); - prometheusConfig.setAuthentication(bearerConfig); - } - } - - config.getMetricsSources().add(prometheusConfig); - } - } - - private String getOpenShiftMonitoringUrl(Context context) { - Route thanosQuerier = getResource(context, Route.class, "openshift-monitoring", "thanos-querier"); - - String host = thanosQuerier.getStatus() - .getIngress() - .stream() - .map(RouteIngress::getHost) - .findFirst() - .orElseThrow(() -> new ReconciliationException( - "Ingress host not found on openshift-monitoring/thanos-querier route")); - - return "https://" + host; - } - - private void addSchemaRegistries(Console primary, ConsoleConfig config) { - for (SchemaRegistry registry : coalesce(primary.getSpec().getSchemaRegistries(), Collections::emptyList)) { - var registryConfig = new SchemaRegistryConfig(); - registryConfig.setName(registry.getName()); - registryConfig.setUrl(registry.getUrl()); - config.getSchemaRegistries().add(registryConfig); - } - } - - private void addConfig(Console primary, Context context, ConsoleConfig config, KafkaCluster kafkaRef) { - String namespace = kafkaRef.getNamespace(); - String name = kafkaRef.getName(); - String listenerName = kafkaRef.getListener(); - - KafkaClusterConfig kcConfig = new KafkaClusterConfig(); - kcConfig.setId(kafkaRef.getId()); - kcConfig.setNamespace(namespace); - kcConfig.setName(name); - kcConfig.setListener(listenerName); - kcConfig.setSchemaRegistry(kafkaRef.getSchemaRegistry()); - - if (kafkaRef.getMetricsSource() == null) { - if (config.getMetricsSources().stream().anyMatch(src -> src.getName().equals(EMBEDDED_METRICS_NAME))) { - kcConfig.setMetricsSource(EMBEDDED_METRICS_NAME); - } - } else { - kcConfig.setMetricsSource(kafkaRef.getMetricsSource()); - } - - config.getKubernetes().setEnabled(Objects.nonNull(namespace)); - config.getKafka().getClusters().add(kcConfig); - - setConfigVars(primary, context, kcConfig.getProperties(), kafkaRef.getProperties()); - setConfigVars(primary, context, kcConfig.getAdminProperties(), kafkaRef.getAdminProperties()); - setConfigVars(primary, context, kcConfig.getConsumerProperties(), kafkaRef.getConsumerProperties()); - setConfigVars(primary, context, kcConfig.getProducerProperties(), kafkaRef.getProducerProperties()); - - if (namespace != null && listenerName != null) { - // Changes in the Kafka resource picked up during periodic reconciliation - Kafka kafka = getResource(context, Kafka.class, namespace, name); - setListenerConfig(kcConfig.getProperties(), kafka, listenerName); - } - - if (!kcConfig.getProperties().containsKey(SaslConfigs.SASL_JAAS_CONFIG)) { - Optional.ofNullable(kafkaRef.getCredentials()) - .map(Credentials::getKafkaUser) - .ifPresent(user -> { - String userNs = Optional.ofNullable(user.getNamespace()).orElse(namespace); - setKafkaUserConfig( - context, - getResource(context, KafkaUser.class, userNs, user.getName()), - kcConfig.getProperties()); - }); - } - } - - void setListenerConfig(Map properties, Kafka kafka, String listenerName) { - GenericKafkaListener listenerSpec = kafka.getSpec() - .getKafka() - .getListeners() - .stream() - .filter(l -> l.getName().equals(listenerName)) - .findFirst() - .orElseThrow(() -> new ReconciliationException("Listener '%s' not found on Kafka %s/%s" - .formatted(listenerName, kafka.getMetadata().getNamespace(), kafka.getMetadata().getName()))); - - StringBuilder protocol = new StringBuilder(); - String mechanism = null; - - if (listenerSpec.getAuth() != null) { - protocol.append("SASL_"); - - var auth = listenerSpec.getAuth(); - switch (auth.getType()) { - case "oauth": - mechanism = "OAUTHBEARER"; - break; - case "scram-sha-512": - mechanism = "SCRAM-SHA-512"; - break; - case "tls", "custom": - default: - // Nothing yet - break; - } - } - - if (listenerSpec.isTls()) { - protocol.append("SSL"); - } else { - protocol.append("PLAINTEXT"); - } - - properties.putIfAbsent(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, protocol.toString()); - - if (mechanism != null) { - properties.putIfAbsent(SaslConfigs.SASL_MECHANISM, mechanism); - } - - Optional listenerStatus = Optional.ofNullable(kafka.getStatus()) - .map(KafkaStatus::getListeners) - .orElseGet(Collections::emptyList) - .stream() - .filter(l -> l.getName().equals(listenerName)) - .findFirst(); - - properties.computeIfAbsent( - CommonClientConfigs.BOOTSTRAP_SERVERS_CONFIG, - key -> listenerStatus.map(ListenerStatus::getBootstrapServers) - .or(() -> Optional.ofNullable(listenerSpec.getConfiguration()) - .map(GenericKafkaListenerConfiguration::getBootstrap) - .map(GenericKafkaListenerConfigurationBootstrap::getHost)) - .orElseThrow(() -> new ReconciliationException(""" - Bootstrap servers could not be found for listener '%s' on Kafka %s/%s \ - and no configuration was given in the Console resource""" - .formatted(listenerName, kafka.getMetadata().getNamespace(), kafka.getMetadata().getName())))); - - if (!properties.containsKey(SslConfigs.SSL_TRUSTSTORE_TYPE_CONFIG) - && !properties.containsKey(SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG)) { - listenerStatus.map(ListenerStatus::getCertificates) - .filter(Objects::nonNull) - .filter(Predicate.not(Collection::isEmpty)) - .map(certificates -> String.join("\n", certificates).trim()) - .ifPresent(certificates -> { - properties.put(SslConfigs.SSL_TRUSTSTORE_TYPE_CONFIG, "PEM"); - properties.put(SslConfigs.SSL_TRUSTSTORE_CERTIFICATES_CONFIG, certificates); - }); - } - } - - void setKafkaUserConfig(Context context, KafkaUser user, Map properties) { - // Changes in the KafkaUser resource and referenced Secret picked up during periodic reconciliation - var secretName = Optional.ofNullable(user.getStatus()) - .map(KafkaUserStatus::getSecret) - .orElseThrow(() -> new ReconciliationException("KafkaUser %s/%s missing .status.secret" - .formatted(user.getMetadata().getNamespace(), user.getMetadata().getName()))); - - String secretNs = user.getMetadata().getNamespace(); - Secret userSecret = getResource(context, Secret.class, secretNs, secretName); - String jaasConfig = userSecret.getData().get(SaslConfigs.SASL_JAAS_CONFIG); - - if (jaasConfig == null) { - throw new ReconciliationException("Secret %s/%s missing key '%s'" - .formatted(secretNs, secretName, SaslConfigs.SASL_JAAS_CONFIG)); - } - - properties.put(SaslConfigs.SASL_JAAS_CONFIG, decodeString(jaasConfig)); - } - - void setConfigVars(Console primary, Context context, Map target, ConfigVars source) { - String namespace = primary.getMetadata().getNamespace(); - - source.getValuesFrom().stream().forEach(fromSource -> { - String prefix = fromSource.getPrefix(); - var configMapRef = fromSource.getConfigMapRef(); - var secretRef = fromSource.getSecretRef(); - - if (configMapRef != null) { - copyData(context, target, ConfigMap.class, namespace, configMapRef.getName(), prefix, configMapRef.getOptional(), ConfigMap::getData); - } - - if (secretRef != null) { - copyData(context, target, Secret.class, namespace, secretRef.getName(), prefix, secretRef.getOptional(), Secret::getData); - } - }); - - source.getValues().forEach(configVar -> target.put(configVar.getName(), configVar.getValue())); - } - - @SuppressWarnings("java:S107") // Ignore Sonar warning for too many args - void copyData(Context context, - Map target, - Class sourceType, - String namespace, - String name, - String prefix, - Boolean optional, - Function> dataProvider) { - - S source = getResource(context, sourceType, namespace, name, Boolean.TRUE.equals(optional)); - - if (source != null) { - copyData(target, dataProvider.apply(source), prefix, Secret.class.equals(sourceType)); - } - } - - void copyData(Map target, Map source, String prefix, boolean decode) { - source.forEach((key, value) -> { - if (prefix != null) { - key = prefix + key; - } - target.put(key, decode ? decodeString(value) : value); - }); - } - - /** - * Fetch the string value from the given valueSpec. The return string - * will be encoded for use in the Console secret data map. - */ - String getValue(Context context, String namespace, Value valueSpec) { - if (valueSpec == null) { - return null; - } - - return Optional.ofNullable(valueSpec.getValue()) - .map(this::encodeString) - .or(() -> Optional.ofNullable(valueSpec.getValueFrom()) - .map(ValueReference::getConfigMapKeyRef) - .flatMap(ref -> getValue(context, ConfigMap.class, namespace, ref.getName(), ref.getKey(), ref.getOptional(), ConfigMap::getData) - .map(this::encodeString) - .or(() -> getValue(context, ConfigMap.class, namespace, ref.getName(), ref.getKey(), ref.getOptional(), ConfigMap::getBinaryData)))) - .or(() -> Optional.ofNullable(valueSpec.getValueFrom()) - .map(ValueReference::getSecretKeyRef) - .flatMap(ref -> getValue(context, Secret.class, namespace, ref.getName(), ref.getKey(), ref.getOptional(), Secret::getData)) - /* No need to call encodeString, the value is already encoded from Secret */) - .orElse(null); - } - - Optional getValue(Context context, - Class sourceType, - String namespace, - String name, - String key, - Boolean optional, - Function> dataProvider) { - - S source = getResource(context, sourceType, namespace, name, Boolean.TRUE.equals(optional)); - - if (source != null) { - return Optional.ofNullable(dataProvider.apply(source).get(key)); - } - - return Optional.empty(); - } - - static T getResource( - Context context, Class resourceType, String namespace, String name) { - return getResource(context, resourceType, namespace, name, false); - } - - static T getResource( - Context context, Class resourceType, String namespace, String name, boolean optional) { - - T resource; - - try { - resource = context.getClient() - .resources(resourceType) - .inNamespace(namespace) - .withName(name) - .get(); - } catch (KubernetesClientException e) { - throw new ReconciliationException("Failed to retrieve %s resource: %s/%s. Message: %s" - .formatted(resourceType.getSimpleName(), namespace, name, e.getMessage())); - } - - if (resource == null && !optional) { - throw new ReconciliationException("No such %s resource: %s/%s".formatted(resourceType.getSimpleName(), namespace, name)); - } - - return resource; - } } diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/DeploymentReadyCondition.java b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/DeploymentReadyCondition.java index ed495570e..e00d3cecd 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/conditions/DeploymentReadyCondition.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/conditions/DeploymentReadyCondition.java @@ -1,5 +1,7 @@ package com.github.streamshub.console.dependents.conditions; +import java.util.Objects; + import org.jboss.logging.Logger; import com.github.streamshub.console.api.v1alpha1.Console; @@ -18,10 +20,47 @@ public boolean isMet(DependentResource dependentResource, C return dependentResource.getSecondaryResource(primary, context).map(this::isReady).orElse(false); } + /** + * Check the deployment's status in a similar way to kubectl. + * + * @see https://github.com/kubernetes/kubectl/blob/24d21a0ee42ecb5e5bed731f36b2d2c9c0244c35/pkg/polymorphichelpers/rollout_status.go#L76-L89 + */ private boolean isReady(Deployment deployment) { - var readyReplicas = deployment.getStatus().getReadyReplicas(); - var ready = deployment.getSpec().getReplicas().equals(readyReplicas); - LOGGER.debugf("Deployment %s ready: %s", deployment.getMetadata().getName(), ready); - return ready; + String deploymentName = deployment.getMetadata().getName(); + var status = deployment.getStatus(); + var deploymentTimedOut = status.getConditions().stream() + .filter(c -> "Progressing".equals(c.getType())) + .findFirst() + .map(c -> "ProgressDeadlineExceeded".equals(c.getReason())) + .orElse(false) + .booleanValue(); + + if (deploymentTimedOut) { + LOGGER.warnf("Deployment %s has timed out", deployment.getMetadata().getName()); + return false; + } + + var desiredReplicas = deployment.getSpec().getReplicas(); + var replicas = Objects.requireNonNullElse(status.getReplicas(), 0); + var updatedReplicas = Objects.requireNonNullElse(status.getUpdatedReplicas(), 0); + var availableReplicas = Objects.requireNonNullElse(status.getAvailableReplicas(), 0); + + if (desiredReplicas != null && updatedReplicas < desiredReplicas) { + LOGGER.debugf("Waiting for deployment %s rollout to finish: %d out of %d new replicas have been updated...", deploymentName, updatedReplicas, desiredReplicas); + return false; + } + + if (replicas > updatedReplicas) { + LOGGER.debugf("Waiting for deployment %s rollout to finish: %d old replicas are pending termination...", deploymentName, replicas - updatedReplicas); + return false; + } + + if (availableReplicas < updatedReplicas) { + LOGGER.debugf("Waiting for deployment %s rollout to finish: %d of %d updated replicas are available...", deploymentName, availableReplicas, updatedReplicas); + return false; + } + + LOGGER.debugf("Deployment %s ready", deployment.getMetadata().getName()); + return true; } } diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/support/ConfigSupport.java b/operator/src/main/java/com/github/streamshub/console/dependents/support/ConfigSupport.java new file mode 100644 index 000000000..c03475ca3 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/dependents/support/ConfigSupport.java @@ -0,0 +1,175 @@ +package com.github.streamshub.console.dependents.support; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; + +import com.github.streamshub.console.ReconciliationException; +import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.api.v1alpha1.spec.ConfigVars; +import com.github.streamshub.console.api.v1alpha1.spec.Value; +import com.github.streamshub.console.api.v1alpha1.spec.ValueReference; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapKeySelector; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretKeySelector; +import io.fabric8.kubernetes.client.KubernetesClientException; +import io.javaoperatorsdk.operator.api.reconciler.Context; + +public class ConfigSupport { + + private ConfigSupport() { + } + + public static String encodeString(String value) { + return encodeBytes(value.getBytes(StandardCharsets.UTF_8)); + } + + public static String encodeBytes(byte[] value) { + return Base64.getEncoder().encodeToString(value); + } + + public static String decodeString(String encodedValue) { + return new String(decodeBytes(encodedValue), StandardCharsets.UTF_8); + } + + public static byte[] decodeBytes(String encodedValue) { + return Base64.getDecoder().decode(encodedValue); + } + + public static void setConfigVars(Console primary, Context context, Map target, ConfigVars source) { + String namespace = primary.getMetadata().getNamespace(); + + source.getValuesFrom().stream().forEach(fromSource -> { + String prefix = fromSource.getPrefix(); + var configMapRef = fromSource.getConfigMapRef(); + var secretRef = fromSource.getSecretRef(); + + if (configMapRef != null) { + copyData(context, target, ConfigMap.class, namespace, configMapRef.getName(), prefix, configMapRef.getOptional(), ConfigMap::getData); + } + + if (secretRef != null) { + copyData(context, target, Secret.class, namespace, secretRef.getName(), prefix, secretRef.getOptional(), Secret::getData); + } + }); + + source.getValues().forEach(configVar -> target.put(configVar.getName(), configVar.getValue())); + } + + @SuppressWarnings("java:S107") // Ignore Sonar warning for too many args + public static void copyData(Context context, + Map target, + Class sourceType, + String namespace, + String name, + String prefix, + Boolean optional, + Function> dataProvider) { + + S source = getResource(context, sourceType, namespace, name, Boolean.TRUE.equals(optional)); + + if (source != null) { + copyData(target, dataProvider.apply(source), prefix, Secret.class.equals(sourceType)); + } + } + + public static void copyData(Map target, Map source, String prefix, boolean decode) { + source.forEach((key, value) -> { + if (prefix != null) { + key = prefix + key; + } + target.put(key, decode ? decodeString(value) : value); + }); + } + + /** + * Fetch the value from the given valueSpec. The return value + * will be the decoded raw bytes from the data source. + */ + public static byte[] getValue(Context context, String namespace, Value valueSpec) { + if (valueSpec == null) { + return null; // NOSONAR : empty array is not wanted when the valueSpec is null + } + + return Optional.ofNullable(valueSpec.getValue()) + .map(ConfigSupport::toBytes) + .or(() -> Optional.ofNullable(valueSpec.getValueFrom()) + .map(ValueReference::getConfigMapKeyRef) + .flatMap(ref -> getValue(context, namespace, ref))) + .or(() -> Optional.ofNullable(valueSpec.getValueFrom()) + .map(ValueReference::getSecretKeyRef) + .flatMap(ref -> getValue(context, namespace, ref))) + .orElse(null); + } + + private static byte[] toBytes(String value) { + return value.getBytes(StandardCharsets.UTF_8); + } + + private static Optional getValue(Context context, + String namespace, + ConfigMapKeySelector ref) { + + ConfigMap source = getResource(context, ConfigMap.class, namespace, ref.getName(), Boolean.TRUE.equals(ref.getOptional())); + + if (source != null) { + return Optional.ofNullable(source.getData()) + .map(data -> data.get(ref.getKey())) + .map(ConfigSupport::toBytes) + .or(() -> Optional.ofNullable(source.getBinaryData()) + .map(data -> data.get(ref.getKey())) + .map(ConfigSupport::decodeBytes)); + } + + return Optional.empty(); + } + + private static Optional getValue(Context context, + String namespace, + SecretKeySelector ref) { + + Secret source = getResource(context, Secret.class, namespace, ref.getName(), Boolean.TRUE.equals(ref.getOptional())); + + if (source != null) { + return Optional.ofNullable(source.getData()) + .map(data -> data.get(ref.getKey())) + .map(ConfigSupport::decodeBytes); + } + + return Optional.empty(); + } + + public static T getResource( + Context context, Class resourceType, String namespace, String name) { + return getResource(context, resourceType, namespace, name, false); + } + + public static T getResource( + Context context, Class resourceType, String namespace, String name, boolean optional) { + + T resource; + + try { + resource = context.getClient() + .resources(resourceType) + .inNamespace(namespace) + .withName(name) + .get(); + } catch (KubernetesClientException e) { + throw new ReconciliationException("Failed to retrieve %s resource: %s/%s. Message: %s" + .formatted(resourceType.getSimpleName(), namespace, name, e.getMessage())); + } + + if (resource == null && !optional) { + throw new ReconciliationException("No such %s resource: %s/%s".formatted(resourceType.getSimpleName(), namespace, name)); + } + + return resource; + } + +} diff --git a/operator/src/main/resources/com/github/streamshub/console/dependents/console.deployment.yaml b/operator/src/main/resources/com/github/streamshub/console/dependents/console.deployment.yaml index cf36e14cf..e96e86879 100644 --- a/operator/src/main/resources/com/github/streamshub/console/dependents/console.deployment.yaml +++ b/operator/src/main/resources/com/github/streamshub/console/dependents/console.deployment.yaml @@ -13,7 +13,7 @@ spec: spec: serviceAccountName: placeholder volumes: - - name: cache + - name: work emptyDir: {} - name: config secret: @@ -29,9 +29,13 @@ spec: - name: config mountPath: /deployments/console-config.yaml subPath: console-config.yaml + - name: work + mountPath: /deployments/work env: - name: CONSOLE_CONFIG_PATH value: /deployments/console-config.yaml + - name: CONSOLE_WORK_PATH + value: /deployments/work startupProbe: httpGet: path: /health/started @@ -75,11 +79,11 @@ spec: - name: console-ui image: quay.io/streamshub/console-ui volumeMounts: - - name: cache - mountPath: /app/.next/cache - name: config mountPath: /app/console-config.yaml subPath: console-config.yaml + - name: work + mountPath: /app/.next/cache ports: - containerPort: 3000 name: http diff --git a/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerSecurityTest.java b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerSecurityTest.java new file mode 100644 index 000000000..4cb958f61 --- /dev/null +++ b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerSecurityTest.java @@ -0,0 +1,352 @@ +package com.github.streamshub.console; + +import java.io.ByteArrayInputStream; +import java.io.InputStream; +import java.security.cert.Certificate; +import java.security.cert.CertificateFactory; +import java.util.Base64; +import java.util.Collection; +import java.util.List; +import java.util.function.Function; +import java.util.function.Supplier; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.Test; + +import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.api.v1alpha1.ConsoleBuilder; +import com.github.streamshub.console.api.v1alpha1.spec.TrustStore; +import com.github.streamshub.console.api.v1alpha1.spec.security.AuditRule.Decision; +import com.github.streamshub.console.api.v1alpha1.spec.security.Rule; +import com.github.streamshub.console.api.v1alpha1.status.Condition; +import com.github.streamshub.console.config.security.Privilege; +import com.github.streamshub.console.dependents.ConsoleDeployment; +import com.github.streamshub.console.dependents.ConsoleResource; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.ConfigMapBuilder; +import io.fabric8.kubernetes.api.model.EnvVar; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.SecretBuilder; +import io.fabric8.kubernetes.api.model.Volume; +import io.fabric8.kubernetes.api.model.VolumeMount; +import io.quarkus.test.junit.QuarkusTest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +class ConsoleReconcilerSecurityTest extends ConsoleReconcilerTestBase { + + @Test + void testConsoleReconciliationWithSecurity() { + createConsole(new ConsoleBuilder() + .withNewSpec() + .withHostname("example.com") + .withNewSecurity() + .withNewOidc() + .withAuthServerUrl("https://example.com/.well-known/openid-connect") + .withIssuer("https://example.com") + .withClientId("client-id") + .withNewClientSecret() + .withValue("client-secret") + .endClientSecret() + .endOidc() + .addNewSubject() + .addToInclude("user-1") + .addToRoleNames("role-1") + .endSubject() + .addNewRole() + .withName("role-1") + .addNewRule() + .addToResources("kafkas") + .addToPrivileges(Rule.Privilege.ALL) + .endRule() + .endRole() + .endSecurity() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .withNewSecurity() + .addNewRole() + .withName("role-1") + .addNewRule() + .addToResources("topics", "consumerGroups") + .addToPrivileges(Rule.Privilege.ALL) + .endRule() + .endRole() + .endSecurity() + .endKafkaCluster() + .endSpec()); + + assertConsoleConfig(consoleConfig -> { + var securityConfig = consoleConfig.getSecurity(); + + var oidc = securityConfig.getOidc(); + assertEquals("https://example.com/.well-known/openid-connect", oidc.getAuthServerUrl()); + assertEquals("https://example.com", oidc.getIssuer()); + assertEquals("client-id", oidc.getClientId()); + assertEquals("client-secret", oidc.getClientSecret()); + + var subjects = securityConfig.getSubjects(); + assertEquals(1, subjects.size()); + assertEquals(List.of("user-1"), subjects.get(0).getInclude()); + assertEquals(List.of("role-1"), subjects.get(0).getRoleNames()); + + var roles = securityConfig.getRoles(); + assertEquals(1, roles.size()); + assertEquals("role-1", roles.get(0).getName()); + + var rules = roles.get(0).getRules(); + assertEquals(1, rules.size()); + assertEquals(List.of("kafkas"), rules.get(0).getResources()); + assertEquals(List.of(Privilege.ALL), rules.get(0).getPrivileges()); + + var kafkaSecurity = consoleConfig.getKafka().getClusters().get(0).getSecurity(); + var kafkaSubjects = kafkaSecurity.getSubjects(); + assertEquals(0, kafkaSubjects.size()); + + var kafkaRoles = kafkaSecurity.getRoles(); + assertEquals(1, kafkaRoles.size()); + assertEquals("role-1", kafkaRoles.get(0).getName()); + + var kafkaRules = kafkaRoles.get(0).getRules(); + assertEquals(1, kafkaRules.size()); + assertEquals(List.of("topics", "consumerGroups"), kafkaRules.get(0).getResources()); + assertEquals(List.of(Privilege.ALL), kafkaRules.get(0).getPrivileges()); + }); + } + + @Test + void testConsoleReconciliationWithKafkaSecurityAudit() { + createConsole(new ConsoleBuilder() + .withNewSpec() + .withHostname("example.com") + .withNewSecurity() + .addNewRole() + .withName("role-1") + .addNewRule() + .addToResources("kafkas") + .addToPrivileges(Rule.Privilege.ALL) + .endRule() + .endRole() + .endSecurity() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .withNewSecurity() + .addNewSubject() + .addToInclude("kafka-user-1") + .addToRoleNames("role-1") + .endSubject() + .addNewRole() + .withName("role-1") + .addNewRule() + .addToResources("topics", "consumerGroups") + .addToPrivileges(Rule.Privilege.ALL) + .endRule() + .endRole() + .addNewAudit() + .withDecision(Decision.ALLOWED) + .withResources("topics") + .withResourceNames("top-secret") + .withPrivileges(Rule.Privilege.GET) + .endAudit() + .endSecurity() + .endKafkaCluster() + .endSpec()); + + assertConsoleConfig(consoleConfig -> { + var securityConfig = consoleConfig.getSecurity(); + + var roles = securityConfig.getRoles(); + assertEquals(1, roles.size()); + assertEquals("role-1", roles.get(0).getName()); + + var rules = roles.get(0).getRules(); + assertEquals(1, rules.size()); + assertEquals(List.of("kafkas"), rules.get(0).getResources()); + assertEquals(List.of(Privilege.ALL), rules.get(0).getPrivileges()); + + var kafkaSecurity = consoleConfig.getKafka().getClusters().get(0).getSecurity(); + var kafkaSubjects = kafkaSecurity.getSubjects(); + assertEquals(1, kafkaSubjects.size()); + assertEquals(List.of("kafka-user-1"), kafkaSubjects.get(0).getInclude()); + assertEquals(List.of("role-1"), kafkaSubjects.get(0).getRoleNames()); + + var kafkaRoles = kafkaSecurity.getRoles(); + assertEquals(1, kafkaRoles.size()); + assertEquals("role-1", kafkaRoles.get(0).getName()); + + var kafkaRules = kafkaRoles.get(0).getRules(); + assertEquals(1, kafkaRules.size()); + assertEquals(List.of("topics", "consumerGroups"), kafkaRules.get(0).getResources()); + assertEquals(List.of(Privilege.ALL), kafkaRules.get(0).getPrivileges()); + + var kafkaAudit = kafkaSecurity.getAudit(); + assertEquals(1, kafkaAudit.size()); + assertEquals(List.of("topics"), kafkaAudit.get(0).getResources()); + assertEquals(List.of("top-secret"), kafkaAudit.get(0).getResourceNames()); + assertEquals(List.of(Privilege.GET), kafkaAudit.get(0).getPrivileges()); + }); + } + + @Test + void testConsoleReconciliationWithMissingRules() { + var consoleCR = createConsole(new ConsoleBuilder() + .withNewSpec() + .withHostname("example.com") + .withNewSecurity() + .addNewSubject() + .addToInclude("user-1") + .addToRoleNames("role-1") + .endSubject() + .addNewRole() + .withName("role-1") + .endRole() + .endSecurity() + .endSpec()); + + assertInvalidConfiguration(consoleCR, errorConditions -> { + assertEquals(1, errorConditions.size()); + var errorCondition = errorConditions.get(0); + Supplier errorString = errorCondition::toString; + + assertEquals(Condition.Types.ERROR, errorCondition.getType(), errorString); + assertEquals("True", errorCondition.getStatus(), errorString); + assertEquals(Condition.Reasons.INVALID_CONFIGURATION, errorCondition.getReason(), errorString); + assertEquals("security.roles[0].rules must not be empty", errorCondition.getMessage(), errorString); + }); + } + + @Test + void testConsoleReconciliationWithOidcTrustStore() throws Exception { + Secret passwordSecret = new SecretBuilder() + .withNewMetadata() + .withName("my-secret") + .withNamespace("ns2") + .addToLabels(ConsoleResource.MANAGEMENT_LABEL) + .endMetadata() + .addToData("pass", Base64.getEncoder().encodeToString("changeit".getBytes())) + .build(); + + client.resource(passwordSecret).create(); + + try (InputStream in = getClass().getResourceAsStream("kube-certs.jks")) { + byte[] truststore = in.readAllBytes(); + + ConfigMap contentConfigMap = new ConfigMapBuilder() + .withNewMetadata() + .withName("my-configmap") + .withNamespace(CONSOLE_NS) + .addToLabels(ConsoleResource.MANAGEMENT_LABEL) + .endMetadata() + .addToBinaryData("truststore", Base64.getEncoder().encodeToString(truststore)) + .build(); + client.resource(contentConfigMap).create(); + } + + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName(CONSOLE_NAME) + .withNamespace(CONSOLE_NS) + .build()) + .withNewSpec() + .withHostname("example.com") + .withNewSecurity() + .withNewOidc() + .withAuthServerUrl("https://example.com/.well-known/openid-connect") + .withIssuer("https://example.com") + .withClientId("client-id") + .withNewClientSecret() + .withValue("client-secret") + .endClientSecret() + .withNewTrustStore() + .withType(TrustStore.Type.JKS) + .withNewPassword() + .withNewValueFrom() + .withNewSecretKeyRef("pass", "my-secret", Boolean.FALSE) + .endValueFrom() + .endPassword() + .withNewContent() + .withNewValueFrom() + .withNewConfigMapKeyRef("truststore", "my-configmap", Boolean.FALSE) + .endValueFrom() + .endContent() + .endTrustStore() + .endOidc() + .endSecurity() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .endKafkaCluster() + .endSpec() + .build(); + + client.resource(consoleCR).create(); + + awaitDependentsNotReady(consoleCR, "ConsoleIngress"); + setConsoleIngressReady(consoleCR); + awaitDependentsNotReady(consoleCR, "ConsoleDeployment"); + var consoleDeployment = setDeploymentReady(consoleCR, ConsoleDeployment.NAME); + + var podSpec = consoleDeployment.getSpec().getTemplate().getSpec(); + var containerSpecAPI = podSpec.getContainers().get(0); + + var volumes = podSpec.getVolumes().stream().collect(Collectors.toMap(Volume::getName, Function.identity())); + assertEquals(3, volumes.size()); // cache, config + 1 volume for truststore + + var truststoreVolName = "oidc-truststore-trust"; + + var truststoreVolume = volumes.get(truststoreVolName); + assertEquals("oidc-truststore.trust.content", truststoreVolume.getSecret().getItems().get(0).getKey()); + assertEquals("oidc-truststore.trust.jks", truststoreVolume.getSecret().getItems().get(0).getPath()); + + var mounts = containerSpecAPI.getVolumeMounts().stream().collect(Collectors.toMap(VolumeMount::getName, Function.identity())); + assertEquals(3, mounts.size(), mounts::toString); + + var truststoreMount = mounts.get(truststoreVolName); + var truststoreMountPath = "/etc/ssl/oidc-truststore.trust.jks"; + assertEquals(truststoreMountPath, truststoreMount.getMountPath()); + assertEquals("oidc-truststore.trust.jks", truststoreMount.getSubPath()); + + var envVarsAPI = containerSpecAPI.getEnv().stream().collect(Collectors.toMap(EnvVar::getName, Function.identity())); + + var truststorePath = envVarsAPI.get("QUARKUS_TLS__OIDC_PROVIDER_TRUST__TRUST_STORE_JKS_PATH"); + assertEquals(truststoreMountPath, truststorePath.getValue()); + var truststorePasswordSource = envVarsAPI.get("QUARKUS_TLS__OIDC_PROVIDER_TRUST__TRUST_STORE_JKS_PASSWORD"); + assertEquals("console-1-console-secret", truststorePasswordSource.getValueFrom().getSecretKeyRef().getName()); + assertEquals("oidc-truststore.trust.password", truststorePasswordSource.getValueFrom().getSecretKeyRef().getKey()); + + var containerSpecUI = podSpec.getContainers().get(1); + var envVarsUI = containerSpecUI.getEnv().stream().collect(Collectors.toMap(EnvVar::getName, Function.identity())); + var truststorePemRef = envVarsUI.get("CONSOLE_SECURITY_OIDC_TRUSTSTORE").getValueFrom().getSecretKeyRef(); + var truststorePemSecret = client.resources(Secret.class) + .inNamespace(CONSOLE_NS) + .withName(truststorePemRef.getName()) + .get(); + var truststorePemValue = Base64.getDecoder().decode(truststorePemSecret.getData().get(truststorePemRef.getKey())); + + CertificateFactory fact = CertificateFactory.getInstance("X.509"); + Collection expectedCertificates; + Collection actualCertificates; + + try (InputStream in = getClass().getResourceAsStream("kube-certs.pem")) { + expectedCertificates = fact.generateCertificates(in); + } + + try (InputStream in = new ByteArrayInputStream(truststorePemValue)) { + actualCertificates = fact.generateCertificates(in); + } + + assertEquals(expectedCertificates.size(), actualCertificates.size()); + + for (Certificate exp : expectedCertificates) { + assertTrue(actualCertificates.contains(exp)); + } + } +} diff --git a/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTest.java b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTest.java index 8cd0da87a..0af21d4ef 100644 --- a/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTest.java +++ b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTest.java @@ -1,209 +1,58 @@ package com.github.streamshub.console; -import java.time.Duration; +import java.util.ArrayList; import java.util.Base64; -import java.util.Map; -import java.util.Optional; +import java.util.List; import java.util.UUID; -import java.util.function.Consumer; +import java.util.concurrent.atomic.AtomicReference; import java.util.function.Function; import java.util.stream.Collectors; -import jakarta.inject.Inject; - import org.apache.kafka.common.config.SaslConfigs; -import org.eclipse.microprofile.config.Config; -import org.jboss.logging.Logger; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; -import com.fasterxml.jackson.databind.ObjectMapper; -import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.github.streamshub.console.api.v1alpha1.Console; import com.github.streamshub.console.api.v1alpha1.ConsoleBuilder; import com.github.streamshub.console.api.v1alpha1.spec.TrustStore; import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource; import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource.Type; +import com.github.streamshub.console.api.v1alpha1.status.Condition; import com.github.streamshub.console.config.ConsoleConfig; import com.github.streamshub.console.config.PrometheusConfig; +import com.github.streamshub.console.dependents.ConsoleDeployment; import com.github.streamshub.console.dependents.ConsoleResource; import com.github.streamshub.console.dependents.ConsoleSecret; +import com.github.streamshub.console.dependents.PrometheusDeployment; import io.fabric8.kubernetes.api.model.ConfigMap; import io.fabric8.kubernetes.api.model.ConfigMapBuilder; import io.fabric8.kubernetes.api.model.EnvVar; -import io.fabric8.kubernetes.api.model.HasMetadata; import io.fabric8.kubernetes.api.model.NamespaceBuilder; import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.kubernetes.api.model.SecretBuilder; import io.fabric8.kubernetes.api.model.Volume; import io.fabric8.kubernetes.api.model.VolumeMount; -import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinitionBuilder; -import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.fabric8.kubernetes.api.model.networking.v1.Ingress; -import io.fabric8.kubernetes.client.KubernetesClient; import io.fabric8.kubernetes.client.KubernetesClientException; import io.fabric8.openshift.api.model.Route; import io.fabric8.openshift.api.model.RouteBuilder; -import io.javaoperatorsdk.operator.Operator; import io.quarkus.test.junit.QuarkusTest; -import io.strimzi.api.kafka.Crds; -import io.strimzi.api.kafka.model.kafka.Kafka; -import io.strimzi.api.kafka.model.kafka.KafkaBuilder; -import io.strimzi.api.kafka.model.kafka.listener.KafkaListenerAuthenticationScramSha512; -import io.strimzi.api.kafka.model.kafka.listener.KafkaListenerType; import io.strimzi.api.kafka.model.user.KafkaUser; import io.strimzi.api.kafka.model.user.KafkaUserBuilder; import io.strimzi.api.kafka.model.user.KafkaUserScramSha512ClientAuthentication; import static org.awaitility.Awaitility.await; -import static org.junit.Assert.assertNull; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; @QuarkusTest -class ConsoleReconcilerTest { - - private static final Logger LOGGER = Logger.getLogger(ConsoleReconcilerTest.class); - private static final Duration LIMIT = Duration.ofSeconds(10); - private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); - - @Inject - KubernetesClient client; - - @Inject - Config config; - - @Inject - Operator operator; - - Kafka kafkaCR; - - public static T apply(KubernetesClient client, T resource) { - client.resource(resource).serverSideApply(); - return client.resource(resource).patchStatus(); - } - - @BeforeEach - void setUp() throws Exception { - client.resource(Crds.kafka()).serverSideApply(); - client.resource(Crds.kafkaUser()).serverSideApply(); - client.resource(new CustomResourceDefinitionBuilder() - .withNewMetadata() - .withName("routes.route.openshift.io") - .endMetadata() - .withNewSpec() - .withScope("Namespaced") - .withGroup("route.openshift.io") - .addNewVersion() - .withName("v1") - .withNewSubresources() - .withNewStatus() - .endStatus() - .endSubresources() - .withNewSchema() - .withNewOpenAPIV3Schema() - .withType("object") - .withXKubernetesPreserveUnknownFields(true) - .endOpenAPIV3Schema() - .endSchema() - .withStorage(true) - .withServed(true) - .endVersion() - .withNewNames() - .withSingular("route") - .withPlural("routes") - .withKind("Route") - .endNames() - .endSpec() - .build()) - .serverSideApply(); - - var allConsoles = client.resources(Console.class).inAnyNamespace(); - var allKafkas = client.resources(Kafka.class).inAnyNamespace(); - var allKafkaUsers = client.resources(KafkaUser.class).inAnyNamespace(); - var allDeployments = client.resources(Deployment.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL); - var allConfigMaps = client.resources(ConfigMap.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL); - var allSecrets = client.resources(Secret.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL); - var allIngresses = client.resources(Ingress.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL); - - allConsoles.delete(); - allKafkas.delete(); - allKafkaUsers.delete(); - allDeployments.delete(); - allConfigMaps.delete(); - allSecrets.delete(); - allIngresses.delete(); - - await().atMost(LIMIT).untilAsserted(() -> { - assertTrue(allConsoles.list().getItems().isEmpty()); - assertTrue(allKafkas.list().getItems().isEmpty()); - assertTrue(allKafkaUsers.list().getItems().isEmpty()); - assertTrue(allDeployments.list().getItems().isEmpty()); - assertTrue(allConfigMaps.list().getItems().isEmpty()); - assertTrue(allSecrets.list().getItems().isEmpty()); - assertTrue(allIngresses.list().getItems().isEmpty()); - }); - - operator.start(); - - client.resource(new NamespaceBuilder() - .withNewMetadata() - .withName("ns1") - .withLabels(Map.of("streamshub-operator/test", "true")) - .endMetadata() - .build()) - .serverSideApply(); - - kafkaCR = new KafkaBuilder() - .withNewMetadata() - .withName("kafka-1") - .withNamespace("ns1") - .endMetadata() - .withNewSpec() - .withNewKafka() - .addNewListener() - .withName("listener1") - .withType(KafkaListenerType.INGRESS) - .withPort(9093) - .withTls(true) - .withAuth(new KafkaListenerAuthenticationScramSha512()) - .endListener() - .endKafka() - .endSpec() - .withNewStatus() - .withClusterId(UUID.randomUUID().toString()) - .addNewListener() - .withName("listener1") - .addNewAddress() - .withHost("kafka-bootstrap.example.com") - .withPort(9093) - .endAddress() - .endListener() - .endStatus() - .build(); - - kafkaCR = apply(client, kafkaCR); - - client.resource(new NamespaceBuilder() - .withNewMetadata() - .withName("ns2") - .withLabels(Map.of("streamshub-operator/test", "true")) - .endMetadata() - .build()) - .serverSideApply(); - } +class ConsoleReconcilerTest extends ConsoleReconcilerTestBase { @Test void testBasicConsoleReconciliation() { - Console consoleCR = new ConsoleBuilder() - .withMetadata(new ObjectMetaBuilder() - .withName("console-1") - .withNamespace("ns2") - .build()) + Console consoleCR = createConsole(new ConsoleBuilder() .withNewSpec() .withHostname("example.com") .addNewKafkaCluster() @@ -211,71 +60,23 @@ void testBasicConsoleReconciliation() { .withNamespace(kafkaCR.getMetadata().getNamespace()) .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) .endKafkaCluster() - .endSpec() - .build(); - - client.resource(consoleCR).create(); - - await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { - var console = client.resources(Console.class) - .inNamespace(consoleCR.getMetadata().getNamespace()) - .withName(consoleCR.getMetadata().getName()) - .get(); - assertEquals(1, console.getStatus().getConditions().size()); - var condition = console.getStatus().getConditions().get(0); - assertEquals("Ready", condition.getType()); - assertEquals("False", condition.getStatus()); - assertEquals("DependentsNotReady", condition.getReason()); - assertTrue(condition.getMessage().contains("ConsoleIngress")); - assertTrue(condition.getMessage().contains("PrometheusDeployment")); - }); - - client.apps().deployments() - .inNamespace(consoleCR.getMetadata().getNamespace()) - .withName("console-1-prometheus-deployment") - .editStatus(this::setReady); - LOGGER.info("Set ready replicas for Prometheus deployment"); + .endSpec()); + awaitDependentsNotReady(consoleCR, "ConsoleIngress", "PrometheusDeployment"); setConsoleIngressReady(consoleCR); + setDeploymentReady(consoleCR, PrometheusDeployment.NAME); + awaitDependentsNotReady(consoleCR, "ConsoleDeployment"); - await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { - var console = client.resources(Console.class) - .inNamespace(consoleCR.getMetadata().getNamespace()) - .withName(consoleCR.getMetadata().getName()) - .get(); - assertEquals(1, console.getStatus().getConditions().size()); - var condition = console.getStatus().getConditions().get(0); - assertEquals("Ready", condition.getType()); - assertEquals("False", condition.getStatus()); - assertEquals("DependentsNotReady", condition.getReason()); - assertTrue(condition.getMessage().contains("ConsoleDeployment")); - }); - - var consoleDeployment = client.apps().deployments() - .inNamespace(consoleCR.getMetadata().getNamespace()) - .withName("console-1-console-deployment") - .editStatus(this::setReady); - LOGGER.info("Set ready replicas for Console deployment"); - + var consoleDeployment = setDeploymentReady(consoleCR, ConsoleDeployment.NAME); // Images were not set in CR, so assert that the defaults were used var consoleContainers = consoleDeployment.getSpec().getTemplate().getSpec().getContainers(); + assertEquals(config.getValue("console.deployment.default-api-image", String.class), consoleContainers.get(0).getImage()); assertEquals(config.getValue("console.deployment.default-ui-image", String.class), consoleContainers.get(1).getImage()); - await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { - var console = client.resources(Console.class) - .inNamespace(consoleCR.getMetadata().getNamespace()) - .withName(consoleCR.getMetadata().getName()) - .get(); - assertEquals(1, console.getStatus().getConditions().size()); - var condition = console.getStatus().getConditions().get(0); - assertEquals("Ready", condition.getType()); - assertEquals("True", condition.getStatus()); - assertNull(condition.getReason()); - assertEquals("All resources ready", condition.getMessage()); - }); + awaitReady(consoleCR); } @Test @@ -302,15 +103,16 @@ void testConsoleReconciliationWithInvalidListenerName() { .inNamespace(consoleCR.getMetadata().getNamespace()) .withName(consoleCR.getMetadata().getName()) .get(); - assertEquals(2, console.getStatus().getConditions().size()); - var ready = console.getStatus().getConditions().get(0); - assertEquals("Ready", ready.getType()); + List conditions = new ArrayList<>(console.getStatus().getConditions()); + assertEquals(2, conditions.size()); + var ready = conditions.get(0); + assertEquals(Condition.Types.READY, ready.getType()); assertEquals("False", ready.getStatus()); - assertEquals("DependentsNotReady", ready.getReason()); - var warning = console.getStatus().getConditions().get(1); - assertEquals("Warning", warning.getType()); + assertEquals(Condition.Reasons.INVALID_CONFIGURATION, ready.getReason()); + var warning = conditions.get(1); + assertEquals(Condition.Types.ERROR, warning.getType()); assertEquals("True", warning.getStatus()); - assertEquals("ReconcileException", warning.getReason()); + assertEquals(Condition.Reasons.RECONCILIATION_EXCEPTION, warning.getReason()); }); } @@ -343,15 +145,16 @@ void testConsoleReconciliationWithMissingKafkaUser() { .inNamespace(consoleCR.getMetadata().getNamespace()) .withName(consoleCR.getMetadata().getName()) .get(); - assertEquals(2, console.getStatus().getConditions().size()); - var ready = console.getStatus().getConditions().get(0); - assertEquals("Ready", ready.getType()); + List conditions = new ArrayList<>(console.getStatus().getConditions()); + assertEquals(2, conditions.size()); + var ready = conditions.get(0); + assertEquals(Condition.Types.READY, ready.getType()); assertEquals("False", ready.getStatus()); - assertEquals("DependentsNotReady", ready.getReason()); - var warning = console.getStatus().getConditions().get(1); - assertEquals("Warning", warning.getType()); + assertEquals(Condition.Reasons.INVALID_CONFIGURATION, ready.getReason()); + var warning = conditions.get(1); + assertEquals(Condition.Types.ERROR, warning.getType()); assertEquals("True", warning.getStatus()); - assertEquals("ReconcileException", warning.getReason()); + assertEquals(Condition.Reasons.RECONCILIATION_EXCEPTION, warning.getReason()); assertEquals("No such KafkaUser resource: ns1/invalid", warning.getMessage()); }); } @@ -398,15 +201,16 @@ void testConsoleReconciliationWithMissingKafkaUserStatus() { .inNamespace(consoleCR.getMetadata().getNamespace()) .withName(consoleCR.getMetadata().getName()) .get(); - assertEquals(2, console.getStatus().getConditions().size()); - var ready = console.getStatus().getConditions().get(0); - assertEquals("Ready", ready.getType()); + List conditions = new ArrayList<>(console.getStatus().getConditions()); + assertEquals(2, conditions.size()); + var ready = conditions.get(0); + assertEquals(Condition.Types.READY, ready.getType()); assertEquals("False", ready.getStatus()); - assertEquals("DependentsNotReady", ready.getReason()); - var warning = console.getStatus().getConditions().get(1); - assertEquals("Warning", warning.getType()); + assertEquals(Condition.Reasons.INVALID_CONFIGURATION, ready.getReason()); + var warning = conditions.get(1); + assertEquals(Condition.Types.ERROR, warning.getType()); assertEquals("True", warning.getStatus()); - assertEquals("ReconcileException", warning.getReason()); + assertEquals(Condition.Reasons.RECONCILIATION_EXCEPTION, warning.getReason()); assertEquals("KafkaUser ns1/ku1 missing .status.secret", warning.getMessage()); }); } @@ -468,15 +272,16 @@ void testConsoleReconciliationWithMissingJaasConfigKey() { .inNamespace(consoleCR.getMetadata().getNamespace()) .withName(consoleCR.getMetadata().getName()) .get(); - assertEquals(2, console.getStatus().getConditions().size()); - var ready = console.getStatus().getConditions().get(0); - assertEquals("Ready", ready.getType()); + List conditions = new ArrayList<>(console.getStatus().getConditions()); + assertEquals(2, conditions.size()); + var ready = conditions.get(0); + assertEquals(Condition.Types.READY, ready.getType()); assertEquals("False", ready.getStatus()); - assertEquals("DependentsNotReady", ready.getReason()); - var warning = console.getStatus().getConditions().get(1); - assertEquals("Warning", warning.getType()); + assertEquals(Condition.Reasons.INVALID_CONFIGURATION, ready.getReason()); + var warning = conditions.get(1); + assertEquals(Condition.Types.ERROR, warning.getType()); assertEquals("True", warning.getStatus()); - assertEquals("ReconcileException", warning.getReason()); + assertEquals(Condition.Reasons.RECONCILIATION_EXCEPTION, warning.getReason()); assertEquals("Secret ns1/ku1 missing key 'sasl.jaas.config'", warning.getMessage()); }); } @@ -539,10 +344,10 @@ void testConsoleReconciliationWithValidKafkaUser() { .withName(consoleCR.getMetadata().getName()) .get(); assertEquals(1, console.getStatus().getConditions().size()); - var ready = console.getStatus().getConditions().get(0); - assertEquals("Ready", ready.getType()); - assertEquals("False", ready.getStatus()); - assertEquals("DependentsNotReady", ready.getReason()); + var ready = console.getStatus().getConditions().iterator().next(); + assertEquals(Condition.Types.READY, ready.getType(), ready::toString); + assertEquals("False", ready.getStatus(), ready::toString); + assertEquals("DependentsNotReady", ready.getReason(), ready::toString); var consoleSecret = client.secrets().inNamespace("ns2").withName("console-1-" + ConsoleSecret.NAME).get(); assertNotNull(consoleSecret); @@ -918,9 +723,9 @@ void testConsoleReconciliationWithTrustStores() { .inNamespace(consoleCR.getMetadata().getNamespace()) .withName(consoleCR.getMetadata().getName()) .get(); - assertEquals(1, console.getStatus().getConditions().size()); - var condition = console.getStatus().getConditions().get(0); - assertEquals("Ready", condition.getType()); + assertEquals(1, console.getStatus().getConditions().size(), () -> console.getStatus().getConditions().toString()); + var condition = console.getStatus().getConditions().iterator().next(); + assertEquals(Condition.Types.READY, condition.getType()); assertEquals("False", condition.getStatus()); assertEquals("DependentsNotReady", condition.getReason()); assertTrue(condition.getMessage().contains("ConsoleIngress")); @@ -933,9 +738,9 @@ void testConsoleReconciliationWithTrustStores() { .inNamespace(consoleCR.getMetadata().getNamespace()) .withName(consoleCR.getMetadata().getName()) .get(); - assertEquals(1, console.getStatus().getConditions().size()); - var condition = console.getStatus().getConditions().get(0); - assertEquals("Ready", condition.getType()); + assertEquals(1, console.getStatus().getConditions().size(), console.getStatus().getConditions()::toString); + var condition = console.getStatus().getConditions().iterator().next(); + assertEquals(Condition.Types.READY, condition.getType()); assertEquals("False", condition.getStatus()); assertEquals("DependentsNotReady", condition.getReason()); assertTrue(condition.getMessage().contains("ConsoleDeployment")); @@ -964,7 +769,7 @@ void testConsoleReconciliationWithTrustStores() { assertEquals("schema-registry-truststore.example-registry.pem", registryVolume.getSecret().getItems().get(0).getPath()); var mounts = containerSpecAPI.getVolumeMounts().stream().collect(Collectors.toMap(VolumeMount::getName, Function.identity())); - assertEquals(3, mounts.size()); + assertEquals(4, mounts.size(), mounts::toString); var metricsMount = mounts.get(metricsVolName); var metricsMountPath = "/etc/ssl/metrics-source-truststore.example-prometheus.jks"; @@ -991,47 +796,55 @@ void testConsoleReconciliationWithTrustStores() { assertEquals(registryMountPath, registryTrustPath.getValue()); } - // Utility + @Test + void testConsoleReconciliationWithInvalidConfigError() { + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName(CONSOLE_NAME) + .withNamespace(CONSOLE_NS) + .build()) + .withNewSpec() + .withHostname("example.com") + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .withMetricsSource("does-not-exist") + .endKafkaCluster() + .endSpec() + .build(); + + client.resource(consoleCR).create(); - private void assertConsoleConfig(Consumer assertion) { - await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { - var consoleSecret = client.secrets().inNamespace("ns2").withName("console-1-" + ConsoleSecret.NAME).get(); - assertNotNull(consoleSecret); - String configEncoded = consoleSecret.getData().get("console-config.yaml"); - byte[] configDecoded = Base64.getDecoder().decode(configEncoded); - Logger.getLogger(getClass()).infof("config YAML: %s", new String(configDecoded)); - ConsoleConfig consoleConfig = YAML.readValue(configDecoded, ConsoleConfig.class); - assertion.accept(consoleConfig); - }); - } + AtomicReference initialError = new AtomicReference<>(); - private void setConsoleIngressReady(Console consoleCR) { - var consoleIngress = client.network().v1().ingresses() - .inNamespace(consoleCR.getMetadata().getNamespace()) - .withName("console-1-console-ingress") - .get(); + assertInvalidConfiguration(consoleCR, conditions -> { + var errorCondition = conditions.get(0); + assertEquals(Condition.Types.ERROR, errorCondition.getType(), errorCondition::toString); + assertEquals("True", errorCondition.getStatus(), errorCondition::toString); + assertEquals(Condition.Reasons.INVALID_CONFIGURATION, errorCondition.getReason(), errorCondition::toString); + assertEquals("Kafka cluster references an unknown metrics source", errorCondition.getMessage(), errorCondition::toString); + initialError.set(errorCondition); + }); - consoleIngress = consoleIngress.edit() - .editOrNewStatus() - .withNewLoadBalancer() - .addNewIngress() - .withHostname("ingress.example.com") - .endIngress() - .endLoadBalancer() - .endStatus() - .build(); - client.resource(consoleIngress).patchStatus(); - LOGGER.info("Set ingress status for Console ingress"); - } + var updatedConsoleCR = client.resource(consoleCR) + .edit(console -> new ConsoleBuilder(console) + .editSpec() + .withHostname("console.example.com") + .endSpec() + .build()); + var lastGeneration = updatedConsoleCR.getStatus().getObservedGeneration(); - private Deployment setReady(Deployment deployment) { - int desiredReplicas = Optional.ofNullable(deployment.getSpec().getReplicas()).orElse(1); + await().atMost(LIMIT).untilAsserted(() -> { + var generation = client.resource(updatedConsoleCR).get().getStatus().getObservedGeneration(); + assertTrue(generation > lastGeneration); + }); - return deployment.edit() - .editOrNewStatus() - .withReplicas(desiredReplicas) - .withReadyReplicas(desiredReplicas) - .endStatus() - .build(); + assertInvalidConfiguration(consoleCR, conditions -> { + var errorCondition = conditions.get(0); + // the condition should not have changed + assertEquals(initialError.get(), errorCondition); + assertEquals(initialError.get().getLastTransitionTime(), errorCondition.getLastTransitionTime()); + }); } } diff --git a/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTestBase.java b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTestBase.java new file mode 100644 index 000000000..f9b48f9a5 --- /dev/null +++ b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTestBase.java @@ -0,0 +1,317 @@ +package com.github.streamshub.console; + +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.UUID; +import java.util.function.Consumer; + +import jakarta.inject.Inject; + +import org.eclipse.microprofile.config.Config; +import org.jboss.logging.Logger; +import org.junit.jupiter.api.BeforeEach; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; +import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.api.v1alpha1.ConsoleBuilder; +import com.github.streamshub.console.api.v1alpha1.status.Condition; +import com.github.streamshub.console.config.ConsoleConfig; +import com.github.streamshub.console.dependents.ConsoleResource; +import com.github.streamshub.console.dependents.ConsoleSecret; + +import io.fabric8.kubernetes.api.model.ConfigMap; +import io.fabric8.kubernetes.api.model.HasMetadata; +import io.fabric8.kubernetes.api.model.NamespaceBuilder; +import io.fabric8.kubernetes.api.model.ObjectMetaBuilder; +import io.fabric8.kubernetes.api.model.Secret; +import io.fabric8.kubernetes.api.model.apiextensions.v1.CustomResourceDefinitionBuilder; +import io.fabric8.kubernetes.api.model.apps.Deployment; +import io.fabric8.kubernetes.api.model.networking.v1.Ingress; +import io.fabric8.kubernetes.client.KubernetesClient; +import io.javaoperatorsdk.operator.Operator; +import io.strimzi.api.kafka.Crds; +import io.strimzi.api.kafka.model.kafka.Kafka; +import io.strimzi.api.kafka.model.kafka.KafkaBuilder; +import io.strimzi.api.kafka.model.kafka.listener.KafkaListenerAuthenticationScramSha512; +import io.strimzi.api.kafka.model.kafka.listener.KafkaListenerType; +import io.strimzi.api.kafka.model.user.KafkaUser; + +import static org.awaitility.Awaitility.await; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +abstract class ConsoleReconcilerTestBase { + + private static final Logger LOGGER = Logger.getLogger(ConsoleReconcilerTestBase.class); + + protected static final Duration LIMIT = Duration.ofSeconds(10); + protected static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); + + protected static final String KAFKA_NS = "ns1"; + protected static final String KAFKA_NAME = "kafka-1"; + + protected static final String CONSOLE_NS = "ns2"; + protected static final String CONSOLE_NAME = "console-1"; + + @Inject + KubernetesClient client; + + @Inject + Config config; + + @Inject + Operator operator; + + Kafka kafkaCR; + + public static T apply(KubernetesClient client, T resource) { + client.resource(resource).serverSideApply(); + return client.resource(resource).patchStatus(); + } + + @BeforeEach + void setUp() { + client.resource(Crds.kafka()).serverSideApply(); + client.resource(Crds.kafkaUser()).serverSideApply(); + client.resource(new CustomResourceDefinitionBuilder() + .withNewMetadata() + .withName("routes.route.openshift.io") + .endMetadata() + .withNewSpec() + .withScope("Namespaced") + .withGroup("route.openshift.io") + .addNewVersion() + .withName("v1") + .withNewSubresources() + .withNewStatus() + .endStatus() + .endSubresources() + .withNewSchema() + .withNewOpenAPIV3Schema() + .withType("object") + .withXKubernetesPreserveUnknownFields(true) + .endOpenAPIV3Schema() + .endSchema() + .withStorage(true) + .withServed(true) + .endVersion() + .withNewNames() + .withSingular("route") + .withPlural("routes") + .withKind("Route") + .endNames() + .endSpec() + .build()) + .serverSideApply(); + + var allConsoles = client.resources(Console.class).inAnyNamespace(); + var allKafkas = client.resources(Kafka.class).inAnyNamespace(); + var allKafkaUsers = client.resources(KafkaUser.class).inAnyNamespace(); + var allDeployments = client.resources(Deployment.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL); + var allConfigMaps = client.resources(ConfigMap.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL); + var allSecrets = client.resources(Secret.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL); + var allIngresses = client.resources(Ingress.class).inAnyNamespace().withLabels(ConsoleResource.MANAGEMENT_LABEL); + + allConsoles.delete(); + allKafkas.delete(); + allKafkaUsers.delete(); + allDeployments.delete(); + allConfigMaps.delete(); + allSecrets.delete(); + allIngresses.delete(); + + await().atMost(LIMIT).untilAsserted(() -> { + assertTrue(allConsoles.list().getItems().isEmpty()); + assertTrue(allKafkas.list().getItems().isEmpty()); + assertTrue(allKafkaUsers.list().getItems().isEmpty()); + assertTrue(allDeployments.list().getItems().isEmpty()); + assertTrue(allConfigMaps.list().getItems().isEmpty()); + assertTrue(allSecrets.list().getItems().isEmpty()); + assertTrue(allIngresses.list().getItems().isEmpty()); + }); + + operator.start(); + + client.resource(new NamespaceBuilder() + .withNewMetadata() + .withName(KAFKA_NS) + .withLabels(Map.of("streamshub-operator/test", "true")) + .endMetadata() + .build()) + .serverSideApply(); + + kafkaCR = new KafkaBuilder() + .withNewMetadata() + .withName(KAFKA_NAME) + .withNamespace(KAFKA_NS) + .endMetadata() + .withNewSpec() + .withNewKafka() + .addNewListener() + .withName("listener1") + .withType(KafkaListenerType.INGRESS) + .withPort(9093) + .withTls(true) + .withAuth(new KafkaListenerAuthenticationScramSha512()) + .endListener() + .endKafka() + .endSpec() + .withNewStatus() + .withClusterId(UUID.randomUUID().toString()) + .addNewListener() + .withName("listener1") + .addNewAddress() + .withHost("kafka-bootstrap.example.com") + .withPort(9093) + .endAddress() + .endListener() + .endStatus() + .build(); + + kafkaCR = apply(client, kafkaCR); + + client.resource(new NamespaceBuilder() + .withNewMetadata() + .withName(CONSOLE_NS) + .withLabels(Map.of("streamshub-operator/test", "true")) + .endMetadata() + .build()) + .serverSideApply(); + } + + Console createConsole(ConsoleBuilder builder) { + var meta = new ObjectMetaBuilder(builder.getMetadata()) + .withNamespace(CONSOLE_NS) + .withName(CONSOLE_NAME) + .build(); + + builder = builder.withMetadata(meta); + + return client.resource(builder.build()).create(); + } + + void awaitReady(Console resource) { + await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { + var console = client.resources(Console.class) + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getMetadata().getName()) + .get(); + + assertEquals(1, console.getStatus().getConditions().size()); + var condition = console.getStatus().getConditions().iterator().next(); + + assertEquals(Condition.Types.READY, condition.getType(), condition::toString); + assertEquals("True", condition.getStatus(), condition::toString); + assertNull(condition.getReason()); + assertEquals("All resources ready", condition.getMessage(), condition::toString); + }); + } + + void awaitDependentsNotReady(Console resource, String... dependents) { + await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { + var console = client.resources(Console.class) + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getMetadata().getName()) + .get(); + + assertEquals(1, console.getStatus().getConditions().size()); + var condition = console.getStatus().getConditions().iterator().next(); + + assertEquals(Condition.Types.READY, condition.getType(), condition::toString); + assertEquals("False", condition.getStatus(), condition::toString); + assertEquals(Condition.Reasons.DEPENDENTS_NOT_READY, condition.getReason(), condition::toString); + + for (String dependent : dependents) { + assertTrue(condition.getMessage().contains(dependent)); + } + }); + } + + void assertInvalidConfiguration(Console resource, Consumer> assertion) { + await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { + var console = client.resources(Console.class) + .inNamespace(resource.getMetadata().getNamespace()) + .withName(resource.getMetadata().getName()) + .get(); + + var conditions = console.getStatus().getConditions(); + assertTrue(conditions.size() > 1); + + var readyCondition = conditions.iterator().next(); + assertEquals(Condition.Types.READY, readyCondition.getType(), readyCondition::toString); + assertEquals("False", readyCondition.getStatus(), readyCondition::toString); + assertEquals(Condition.Reasons.INVALID_CONFIGURATION, readyCondition.getReason(), readyCondition::toString); + + // Ready is always sorted as the first condition for ease of reference + List errors = List.copyOf(conditions).subList(1, conditions.size()); + + assertion.accept(errors); + }); + } + + void assertConsoleConfig(Consumer assertion) { + await().ignoreException(NullPointerException.class).atMost(LIMIT).untilAsserted(() -> { + var consoleSecret = client.secrets() + .inNamespace(CONSOLE_NS) + .withName(CONSOLE_NAME + "-" + ConsoleSecret.NAME) + .get(); + + assertNotNull(consoleSecret); + + String configEncoded = consoleSecret.getData().get("console-config.yaml"); + byte[] configDecoded = Base64.getDecoder().decode(configEncoded); + + LOGGER.debugf("config YAML: %s", new String(configDecoded)); + + ConsoleConfig consoleConfig = YAML.readValue(configDecoded, ConsoleConfig.class); + assertion.accept(consoleConfig); + }); + } + + void setConsoleIngressReady(Console consoleCR) { + var consoleIngress = client.network().v1().ingresses() + .inNamespace(consoleCR.getMetadata().getNamespace()) + .withName("%s-console-ingress".formatted(consoleCR.getMetadata().getName())) + .get(); + + consoleIngress = consoleIngress.edit() + .editOrNewStatus() + .withNewLoadBalancer() + .addNewIngress() + .withHostname("ingress.example.com") + .endIngress() + .endLoadBalancer() + .endStatus() + .build(); + client.resource(consoleIngress).patchStatus(); + LOGGER.info("Set ingress status for Console ingress"); + } + + Deployment setDeploymentReady(Console consoleCR, String deploymentName) { + var deployment = client.apps().deployments() + .inNamespace(consoleCR.getMetadata().getNamespace()) + .withName("%s-%s".formatted(consoleCR.getMetadata().getName(), deploymentName)) + .editStatus(this::setReady); + LOGGER.infof("Set ready replicas for deployment: %s", deploymentName); + return deployment; + } + + Deployment setReady(Deployment deployment) { + int desiredReplicas = Optional.ofNullable(deployment.getSpec().getReplicas()).orElse(1); + + return deployment.edit() + .editOrNewStatus() + .withReplicas(desiredReplicas) + .withUpdatedReplicas(desiredReplicas) + .withAvailableReplicas(desiredReplicas) + .withReadyReplicas(desiredReplicas) + .endStatus() + .build(); + } +} diff --git a/operator/src/test/resources/com/github/streamshub/console/kube-certs.jks b/operator/src/test/resources/com/github/streamshub/console/kube-certs.jks new file mode 100644 index 000000000..81aa73f66 Binary files /dev/null and b/operator/src/test/resources/com/github/streamshub/console/kube-certs.jks differ diff --git a/operator/src/test/resources/com/github/streamshub/console/kube-certs.pem b/operator/src/test/resources/com/github/streamshub/console/kube-certs.pem new file mode 100644 index 000000000..86ba12ea2 --- /dev/null +++ b/operator/src/test/resources/com/github/streamshub/console/kube-certs.pem @@ -0,0 +1,121 @@ +-----BEGIN CERTIFICATE----- +MIIDMjCCAhqgAwIBAgIIMYTYDdowPTswDQYJKoZIhvcNAQELBQAwNzESMBAGA1UE +CxMJb3BlbnNoaWZ0MSEwHwYDVQQDExhrdWJlLWFwaXNlcnZlci1sYi1zaWduZXIw +HhcNMjQxMDIxMDc1NjU5WhcNMzQxMDE5MDc1NjU5WjA3MRIwEAYDVQQLEwlvcGVu +c2hpZnQxITAfBgNVBAMTGGt1YmUtYXBpc2VydmVyLWxiLXNpZ25lcjCCASIwDQYJ +KoZIhvcNAQEBBQADggEPADCCAQoCggEBALnWRIe7l/94zj624Ax8lGDdfoppPO9i +EtnkBBjAhkfe3ChnL33b+edeGf9lfIxZYbMVng+tEZhq2RHrp40ZSA1BZ74TwTaQ +1FfaSLU1dMNIWvgudNQMcgDNXTxXRamup5/wZ5udKYUBLVPvFEvmJ+je9QCwEGKQ +JrpDX+aKJOLPKyxVox6ZcqBTKJts+/f6fEqrbDwdlQhGAZRSsfXYZgufSSvRO7gN +67tp3KJ9OhuEzvkMoSFvvQxPmxVlWGKdkZFdKNU1vSI0aOBLlCWHOBmk+Wvu/fTi +iuBybWooaQIvu3CSEXGSJaQ+b4Ol/Uj0EO70Q58HzFndst6wxwD+ZGECAwEAAaNC +MEAwDgYDVR0PAQH/BAQDAgKkMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFAj8 +c+01Kl+BDcIolkavVzAU1G41MA0GCSqGSIb3DQEBCwUAA4IBAQBL97dVCrkgo6ca +7/4nZoP1i7owpWV0hfJWU/TKSFBa7Vzbe7xWyT/HBzjPikoUwZEpH7rZAcHwcYQr +tTozW/zDOZS98cnrepY/tAXVi7Hz5wnuaguI3iwFaIVh9OR8FBZ5TAMaXGW1mYEg +q0jNZY5cbFm+3bacRKSF//hS/3nms3o3b6uni2f4rZGED4iW+zK2qXZfz+B/uCwA +1KoHt3TxZsJ8scVXCMJQi7T0cdjR9pGucCRVFoXKxGE0sIL28ajBdOAIalTh/Wh4 +F6n73xM2Ao84Qi1qk6HrWJHQb1DY/L/+tZc5TlEHbAZwHR3Y8nYpxTlvNYTcuft+ +dGv8WhYX +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDQDCCAiigAwIBAgIINykiDqNyBqwwDQYJKoZIhvcNAQELBQAwPjESMBAGA1UE +CxMJb3BlbnNoaWZ0MSgwJgYDVQQDEx9rdWJlLWFwaXNlcnZlci1sb2NhbGhvc3Qt +c2lnbmVyMB4XDTI0MTAyMTA3NTY1OFoXDTM0MTAxOTA3NTY1OFowPjESMBAGA1UE +CxMJb3BlbnNoaWZ0MSgwJgYDVQQDEx9rdWJlLWFwaXNlcnZlci1sb2NhbGhvc3Qt +c2lnbmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAw7zTqYxszydv +LjATLq9ca+rf9URZfiBhKSVWFbM7tRH/gUVsdTPsmq35FfUKFCn6T9UHT+rgOGKA +Z+PqMoDAMOe2QAYPdPUGPHgy5Op2iLfFPagUuOA28avqdryRUXfjMqZpx0EEg6kp +X9O5nOfVKBNYdSlWB5ZGvWl4rUuuUyU+OrzDnyvozRrEvbUt0bLMX4JdYT3u7mlB +MAP+UXKg3qxes3huHP7PSbXRGCV2o5zZzmy0WSxPx2xg0BN3DQnIqxYx8o2w0A8O +Cy1tZAduQZWP33uluFMubRCws3pjRsIUJozhM+POjbEI3e2nmwPRmIuwkJ+u3gYV +58tlkahuBwIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAqQwDwYDVR0TAQH/BAUwAwEB +/zAdBgNVHQ4EFgQUSCtp8dg6T1kO1V7KFkeP6Ioo7RYwDQYJKoZIhvcNAQELBQAD +ggEBACTBDHiGLxRzkT++oWh8n9aYVo9kmq3pCy5xyh0vDGY5YublnrLIX0bf8aL9 +rpGrs9WKqi2z/1HtUm6XNJP9eO+Vt08FUP63RSkPQpU+w4qwbsKqu6frbyyIddpK +caUDWA1ggor3aV7umz2F4n6wVg2HDKDBrR/JHeEpWFOeXX2KeDFkomTot93RtCkd +yo6Y/6PYVFfj+SW9rI+b1WQ9U0BqRjgFzRPzp9wryx6c8n05mTcH3C6vxwrifr8v +NLaxI+xVaMtLALJ6pK1lVAPOkS0J5JaewZqOCXySTf2lKn6KC0xJYC0vHdKf27oi +UU33fkByf2O1XayqoEfCXjZ6uPA= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDTDCCAjSgAwIBAgIIJM+hLK9EGE0wDQYJKoZIhvcNAQELBQAwRDESMBAGA1UE +CxMJb3BlbnNoaWZ0MS4wLAYDVQQDEyVrdWJlLWFwaXNlcnZlci1zZXJ2aWNlLW5l +dHdvcmstc2lnbmVyMB4XDTI0MTAyMTA3NTY1OVoXDTM0MTAxOTA3NTY1OVowRDES +MBAGA1UECxMJb3BlbnNoaWZ0MS4wLAYDVQQDEyVrdWJlLWFwaXNlcnZlci1zZXJ2 +aWNlLW5ldHdvcmstc2lnbmVyMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKC +AQEAoSQzZgP2w02xuh7zlmzAN4hYg2Saz9NMescrUlfCCtqNIVJPCCTx45m2tbhF +jUNv84zjtrJ81BOugyEGQchdjJGoKUdGNcpVUC1Ts/jrRnyuVrmvifjgRl1lFi6u +l5G0jGsgv7Z2W1JT5EpIfaA0qlsUnecdQtm7qienUeWRO9HfYJ08eNaUF+zAB6JA +i8I6AzTl6rJT33EmPymNpXrHFVgr/IDHs2jFakjTauPIGScRxFRze0JTMSvlz/8j +YIu6g59THds1ROm2+NYblcES3zuZeHQ9n4iRalIH8pQ8LQ9lQJ79S4dkYYo++Klu +W60erjZt20zdMgIKnwTtnAY0mQIDAQABo0IwQDAOBgNVHQ8BAf8EBAMCAqQwDwYD +VR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUMWWSZ6Bg1Rs/dGd+Yvl4m05aDowwDQYJ +KoZIhvcNAQELBQADggEBAEc3IMR1duWEuwIqNQC42TX5upKEAM/d065A4kjiLn3k +ACyFoB7gqA55fy23kTDsAPqcRPeSvwJstUOxIq0eP1q+HUFbdPXa2ORiTmPihY/u +oagSkEMQvSi6vTFKl2wTrdpHZzfk1FgYn6kLwX8LYXyHziS6uRxQHPIvUmgCIs6I +MeHWgJh2rKhX37YD6aW7uv2waif5qs6/pUhDqVoafqmvXp4FMNBLbA6JDE8PcVHA +rFHOvhQJ0FtT4VPwll9/VR9aE7pWBZA6fwvbThWB8WILFGsXbF3bcuR0fu1WZte0 +RtkV+Ps4/zb9TZAkdy1TcIRxvSxVrarHUJRNdtCZfUk= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDlzCCAn+gAwIBAgIIK6oSarI03tkwDQYJKoZIhvcNAQELBQAwWTFXMFUGA1UE +AwxOb3BlbnNoaWZ0LWt1YmUtYXBpc2VydmVyLW9wZXJhdG9yX2xvY2FsaG9zdC1y +ZWNvdmVyeS1zZXJ2aW5nLXNpZ25lckAxNzI5NDk4Njk0MB4XDTI0MTAyMTA4MTgx +NFoXDTM0MTAxOTA4MTgxNVowWTFXMFUGA1UEAwxOb3BlbnNoaWZ0LWt1YmUtYXBp +c2VydmVyLW9wZXJhdG9yX2xvY2FsaG9zdC1yZWNvdmVyeS1zZXJ2aW5nLXNpZ25l +ckAxNzI5NDk4Njk0MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAyZPM +58DFXDJfatBHxoxJY2gpSI3rSxF7RHxmIghQxVQQHMD2ny4gkuflyrUYO2VnVZ+v +RfLxl0lhTdx0hLiKwniWqt6rmj+7/0oVbEW2cbWd+OVDos841LxhLZOwrHh+WcQW +spH3NgczP846uPg/yAKEUWX0xAej0lfD1//qr+VdUbhtx7xDl42Jzzt/Me9WS1Lh +J+tHU8Ooa+U2yTX/mGjQgwxfB4qoczoXvpvv1hO35g/wHiKeuOvUCmgzYrPQPCFK +3lx3ETKzC1m+MiRqYYqMDW4DCD43khSTF7XAbFjSMKqk6KrOi4xqhmjyTRZDyX7m +wRun84930lP3/U1ZgQIDAQABo2MwYTAOBgNVHQ8BAf8EBAMCAqQwDwYDVR0TAQH/ +BAUwAwEB/zAdBgNVHQ4EFgQU6ZTgKTS9WAZ8+8OZZvy5Xu9wMnwwHwYDVR0jBBgw +FoAU6ZTgKTS9WAZ8+8OZZvy5Xu9wMnwwDQYJKoZIhvcNAQELBQADggEBAFO6LKg7 +ILwywt/52kbfPRrEvpb5p3T4ANs1c50sU0YewbvT1phhbX0xG63kNm6isuZSLCie +7aNLDuEAjv4HmY4QffGvKHgyIQsII8+/W7JmS+nRgPEI6Yj3tJmy3gvN3X0xrBdt +S96+jCag1aR58zJ9imRaZOBNNlE4aedbvllFZ2k4Gk4BSZjqSJhNZSPaZmWUNsAH +nq/t16ZKs43aLtwqBRTI3ssGmcZjMTNeFVXVV0/WmjIRAnBvJHipzwZvZEyqw+EL +aJIz1fio6X1uGPyIBr4sp/p7Do0eUc+euEi1kLmctsDmntfJzD/WJLDh1qf7t/Ko +5NspdFu8g+UVpsg= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDWzCCAkOgAwIBAgIIIO4v2+5yCt0wDQYJKoZIhvcNAQELBQAwJjEkMCIGA1UE +AwwbaW5ncmVzcy1vcGVyYXRvckAxNzI5NDk4Mjk0MB4XDTI0MTAyMTA4MTEzNVoX +DTI2MTAyMTA4MTEzNlowHTEbMBkGA1UEAwwSKi5hcHBzLWNyYy50ZXN0aW5nMIIB +IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA46vu506RtAKNTOemgaD1kryD +x0QzhjqyaS6XLUhiEpimfaSl1erPJJyjpvPylZpXMPqodQbTVaCOhbmNPFOFygEL +sfB1mpIGKNokTtm2mHE+YhfMLhAOb0OuSAEybk3EqZGxLrUQwVp88owYJHe3bPAG +wG6mMR6Sn/eL3shxpWfgpmVYnATyt2/7qpdTequxyOhasr2KIxD4ScvCwzycgTGS +xqEV2rSczmyZRWNdyw8p+V8394Uow5r7W9s2mUYC/KF6fvAs2RavKtSFD7ZLoBBW +xJmkzrT1uq5duOGgadnKZBo3IMj45zG8PooZ3cDepAVk3+vG3MfTpWHvZFOdywID +AQABo4GVMIGSMA4GA1UdDwEB/wQEAwIFoDATBgNVHSUEDDAKBggrBgEFBQcDATAM +BgNVHRMBAf8EAjAAMB0GA1UdDgQWBBS82jT8YPTXx+3VZ4i5DEfntaJzxzAfBgNV +HSMEGDAWgBTp+7an7Jz7tRjmpnBveqo1hpNIsTAdBgNVHREEFjAUghIqLmFwcHMt +Y3JjLnRlc3RpbmcwDQYJKoZIhvcNAQELBQADggEBAFZ7O3aoPG+OMnVuLlg6S6/I +d1UeyBLdftVWzeB2Y0yzCb6PCPyuj1CVT7k64mqFLCYp/HT7E5QgmsFUDz62Q0Os +bLhKoRcBnPZnk2m58QQf8h4rzvc1oEgrhhYP3KW+RLfmwfAIVLXDzhXhEOejaC+B +mkvYhRsYCW7X9hc/+UrhCWpLFXASXTtJIitbNRHVFfgRMm71mh9NhwFRAQBexwO6 +0ZG0MKapIaYWVknGrNPDTauoXaxyX6WQxM3VqLujCzHKz2OQmsylIcCXG/xtUfhU +JjBImOIsbWvvE3KWfPCA+rXg1MzoUagmECfjkq9uABqooIUxxksCRTb1wqj3DW0= +-----END CERTIFICATE----- +-----BEGIN CERTIFICATE----- +MIIDDDCCAfSgAwIBAgIBATANBgkqhkiG9w0BAQsFADAmMSQwIgYDVQQDDBtpbmdy +ZXNzLW9wZXJhdG9yQDE3Mjk0OTgyOTQwHhcNMjQxMDIxMDgxMTMzWhcNMjYxMDIx +MDgxMTM0WjAmMSQwIgYDVQQDDBtpbmdyZXNzLW9wZXJhdG9yQDE3Mjk0OTgyOTQw +ggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCpCyfKVGuk6af3hAdOqaPi +cEpF3NHMhZDfBHcnCSVz5U7NiBVkDBuYoQbHrFFNnnAO1TWH/ztwLtjH8odUelWa +84Ue7xpDOrCDgNBIEsB7ymoV4oyRw3DVuuC9kfAWx6+YhuP6hEltOVwJvXdC50A7 +SKQTuDUSjF7VZF1RXQW8CBJO+2/cwuXhC+O9z3VHuSTzAUFDsycTnVwC8uB7Ycn/ +7t/UVspP9Es0YMHlmdw6eobGm3xm14UqCKYkySygtXWPTfqPXonfDIMQeu0E0eil +Cg/TSSvm4CJB1u1JrpehzDsVUEOZPnBuO8axY2Rv5MBE3mMJVWzUN6oj5OOp06Gz +AgMBAAGjRTBDMA4GA1UdDwEB/wQEAwICpDASBgNVHRMBAf8ECDAGAQH/AgEAMB0G +A1UdDgQWBBTp+7an7Jz7tRjmpnBveqo1hpNIsTANBgkqhkiG9w0BAQsFAAOCAQEA +DIfuWUfB+lgrOE6qTpF5R+lbBu9oQr7XXLYFnOBjSdTr/V7tJr6GmBO5G9vVm57N +bAGQekVLDMtjvbbHtM3wmOW7O5g0wykMl/uHiHKbtfYEZ89CLxxdYOjQpzgzJHhF +QSfpvdFFG55+/9Gdb1yUJHZ5P54UgVGNtiX3Hnch/FwU5avPD6PAr5a4OrSp++/S +zB4Aw1vhO+4uage+j/TW6uF3YJQT/thVWG8z2vXJuej+i/HBjiviHMEQVdC13LaI +uy7oM4B/BrDebtUD+blCgOYZs24sWu2eiCtqKW5dtxveNcN/Hq1py4xPOqkESfic +29vnSmtl5vitgVMcMNKneQ== +-----END CERTIFICATE----- diff --git a/ui/app/api/auth/[...nextauth]/oidc.ts b/ui/app/api/auth/[...nextauth]/oidc.ts index c8b77c622..b9033ee8e 100644 --- a/ui/app/api/auth/[...nextauth]/oidc.ts +++ b/ui/app/api/auth/[...nextauth]/oidc.ts @@ -3,7 +3,6 @@ import { Session, TokenSet } from "next-auth"; import { JWT } from "next-auth/jwt"; import { OAuthConfig } from "next-auth/providers/index"; import config from '@/utils/config'; -import { redirect } from 'next/navigation' const log = logger.child({ module: "oidc" }); @@ -14,7 +13,8 @@ class OpenIdConnect { constructor( authServerUrl: string | null, clientId: string | null, - clientSecret: string | null + clientSecret: string | null, + truststore: string | null, ) { if (clientId && clientSecret && authServerUrl) { this.provider = { @@ -34,6 +34,9 @@ class OpenIdConnect { image: profile.image, } }, + httpOptions: { + ca: truststore ?? undefined + } } } else { this.provider = null; @@ -51,7 +54,7 @@ class OpenIdConnect { log.trace(`wellKnown endpoint: ${discoveryEndpoint}`); const response = await fetch(discoveryEndpoint); const discovery = await response.json(); - + _tokenEndpoint = discovery.token_endpoint; log.trace(`token endpoint: ${_tokenEndpoint}`); @@ -190,6 +193,7 @@ export default async function oidcSource() { return new OpenIdConnect( oidcConfig?.authServerUrl ?? null, oidcConfig?.clientId ?? null, - oidcConfig?.clientSecret ?? null + oidcConfig?.clientSecret ?? null, + oidcConfig?.truststore ?? null, ); }; diff --git a/ui/environment.d.ts b/ui/environment.d.ts index eee1dff76..0bff17048 100644 --- a/ui/environment.d.ts +++ b/ui/environment.d.ts @@ -6,5 +6,7 @@ namespace NodeJS { NEXT_PUBLIC_PRODUCTIZED_BUILD?: "true" | "false"; LOG_LEVEL?: "fatal" | "error" | "warn" | "info" | "debug" | "trace"; CONSOLE_MODE?: "read-only" | "read-write"; + CONSOLE_CONFIG_PATH: string; + CONSOLE_SECURITY_OIDC_TRUSTSTORE?: string; } } diff --git a/ui/utils/config.ts b/ui/utils/config.ts index 6c299a61f..5df6faba0 100644 --- a/ui/utils/config.ts +++ b/ui/utils/config.ts @@ -10,6 +10,7 @@ export interface OidcConfig { authServerUrl: string | null; clientId: string | null; clientSecret: string | null; + truststore: string | null; } export interface GlobalSecurityConfig { @@ -37,6 +38,7 @@ async function getOrLoadConfig(): Promise { authServerUrl: cfg.security?.oidc?.authServerUrl ?? null, clientId: cfg.security?.oidc?.clientId ?? null, clientSecret: cfg.security?.oidc?.clientSecret ?? null, + truststore: process.env.CONSOLE_SECURITY_OIDC_TRUSTSTORE ?? null, } } };