diff --git a/.github/workflows/playwright-tests.yml b/.github/workflows/playwright-tests.yml index b9301df7c..2a6f1e8bd 100644 --- a/.github/workflows/playwright-tests.yml +++ b/.github/workflows/playwright-tests.yml @@ -250,7 +250,7 @@ jobs: kubectl get all,subscriptions,csv,operatorgroups,installplans -n operators -o yaml > ./resources/operators.yaml kubectl logs -n operators -l app.kubernetes.io/name=console-operator --all-containers=true --tail -1 > ./resources/console-operator-logs.txt kubectl get all -n $TARGET_NAMESPACE -o yaml > ./resources/$TARGET_NAMESPACE.yaml - kubectl logs -n ${TARGET_NAMESPACE} -l app.kubernetes.io/instance=example-console-deployment --all-containers=true > ./resources/$TARGET_NAMESPACE-console-logs.txt + kubectl logs -n ${TARGET_NAMESPACE} -l app.kubernetes.io/instance=example-console-deployment --all-containers=true --tail -1 > ./resources/$TARGET_NAMESPACE-console-logs.txt - name: Archive Resource Backup uses: actions/upload-artifact@v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1cc3c01c9..01099b769 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -108,7 +108,6 @@ jobs: run: | npm ci --omit=dev export BACKEND_URL=http://example - export CONSOLE_METRICS_PROMETHEUS_URL=http://example export NEXTAUTH_SECRET=examplesecret export LOG_LEVEL=info export CONSOLE_MODE=read-only diff --git a/.github/workflows/snapshot.yml b/.github/workflows/snapshot.yml index 2e0bf4d8c..499999e7b 100644 --- a/.github/workflows/snapshot.yml +++ b/.github/workflows/snapshot.yml @@ -120,7 +120,6 @@ jobs: run: | npm ci --omit=dev export BACKEND_URL=http://example - export CONSOLE_METRICS_PROMETHEUS_URL=http://example export NEXTAUTH_SECRET=examplesecret export LOG_LEVEL=info export CONSOLE_MODE=read-only diff --git a/Makefile b/Makefile index 770f1b34d..a47145dc1 100644 --- a/Makefile +++ b/Makefile @@ -19,7 +19,6 @@ ARCH ?= linux/amd64 SKIP_RANGE ?= ">=1.0.0 <1.0.3" CONSOLE_UI_NEXTAUTH_SECRET ?= $(shell openssl rand -base64 32) -CONSOLE_METRICS_PROMETHEUS_URL ?= container-image-api: mvn package -am -pl api -Pcontainer-image -DskipTests -Dquarkus.container-image.image=$(CONSOLE_API_IMAGE) @@ -43,7 +42,6 @@ container-image-ui: cd ui && \ npm ci --omit=dev && \ export BACKEND_URL=http://example && \ - export CONSOLE_METRICS_PROMETHEUS_URL=http://example && \ export NEXTAUTH_SECRET=examplesecret && \ export LOG_LEVEL=info && \ export CONSOLE_MODE=read-only && \ @@ -65,7 +63,6 @@ compose-up: echo "CONSOLE_API_KUBERNETES_API_SERVER_URL=$(CONSOLE_API_KUBERNETES_API_SERVER_URL)" >> compose-runtime.env echo "CONSOLE_UI_IMAGE=$(CONSOLE_UI_IMAGE)" >> compose-runtime.env echo "CONSOLE_UI_NEXTAUTH_SECRET=$(CONSOLE_UI_NEXTAUTH_SECRET)" >> compose-runtime.env - echo "CONSOLE_METRICS_PROMETHEUS_URL=$(CONSOLE_METRICS_PROMETHEUS_URL)" >> compose-runtime.env $(CONTAINER_RUNTIME) compose --env-file compose-runtime.env up -d compose-down: diff --git a/README.md b/README.md index d5f965815..d26aa65ce 100644 --- a/README.md +++ b/README.md @@ -50,7 +50,11 @@ To ensure the console has the necessary access to function, a minimum level of a 3. `READ`, `DESCRIBE` for all `GROUP` resources #### Prometheus -Prometheus is an optional dependency of the console if cluster metrics are to be displayed. The operator currently installs a private Prometheus instance for each `Console` instance. However, when installing a single console deployment, Prometheus must be either installed separately or provided via a URL reference. This will be addressed below in the section dealing with creating a console via a `Deployment`. +Prometheus is an optional dependency of the console if cluster metrics are to be displayed. The console supports gathering metrics in several ways. + +- OpenShift-managed Prometheus instances. Monitoring of user-defined projects must be enabled in OpenShift. +- User-supplied Prometheus instances +- Private Prometheus instance for each `Console`. The operator creates a managed Prometheus deployment for use only by the console. ### Deploy the operator with OLM The preferred way to deploy the console is using the Operator Lifecycle Manager, or OLM. The sample install files in `install/operator-olm` will install the operator with cluster-wide scope. This means that `Console` instances may be created in any namespace. If you wish to limit the scope of the operator, the `OperatorGroup` resource may be modified to specify only the namespace that should be watched by the operator. @@ -68,11 +72,18 @@ kind: Console metadata: name: example spec: - hostname: example-console.apps-crc.testing # Hostname where the console will be accessed via HTTPS + hostname: example-console.cloud.example.com # Hostname where the console will be accessed via HTTPS + metricsSources: + # A `standalone` Prometheus instance must already exist and be accessible from the console Pod + - name: custom-prometheus + type: standalone + url: https://custom-prometheus.cloud.example.com + # Prometheus API authentication may also be provided kafkaClusters: - name: console-kafka # Name of the `Kafka` CR representing the cluster namespace: kafka # Namespace of the `Kafka` CR representing the cluster listener: secure # Listener on the `Kafka` CR to connect from the console + metricsSource: custom-prometheus properties: values: [] # Array of name/value for properties to be used for connections # made to this cluster @@ -114,7 +125,6 @@ Running the console locally requires configuration of any Apache Kafka® CONSOLE_API_KUBERNETES_API_SERVER_URL=https://my-kubernetes-api.example.com:6443 - CONSOLE_METRICS_PROMETHEUS_URL=http://console-prometheus. ``` The service account token may be obtained using the `kubectl create token` command. For example, to create a service account named "console-server" with the correct permissions and a token that expires in 1 year ([yq](https://github.com/mikefarah/yq/releases) required): ```shell diff --git a/api/pom.xml b/api/pom.xml index 1bd068127..425f0ce1f 100644 --- a/api/pom.xml +++ b/api/pom.xml @@ -399,10 +399,10 @@ build src/main/docker/Dockerfile true - ${docker.registry} - ${docker.group} - ${docker.tag} - ${docker.push} + ${container-image.registry} + ${container-image.group} + ${container-image.tag} + ${container-image.push} diff --git a/api/src/main/java/com/github/streamshub/console/api/ClientFactory.java b/api/src/main/java/com/github/streamshub/console/api/ClientFactory.java index 87505bfaf..fa6c4cb5e 100644 --- a/api/src/main/java/com/github/streamshub/console/api/ClientFactory.java +++ b/api/src/main/java/com/github/streamshub/console/api/ClientFactory.java @@ -54,6 +54,7 @@ import org.jboss.logging.Logger; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.streamshub.console.api.service.MetricsService; import com.github.streamshub.console.api.support.Holder; import com.github.streamshub.console.api.support.KafkaContext; import com.github.streamshub.console.api.support.TrustAllCertificateManager; @@ -154,6 +155,9 @@ public class ClientFactory { @Named("kafkaAdminFilter") UnaryOperator kafkaAdminFilter = UnaryOperator.identity(); + @Inject + MetricsService metricsService; + @Produces @ApplicationScoped Map produceKafkaContexts(Function, Admin> adminBuilder) { @@ -168,7 +172,6 @@ Map produceKafkaContexts(Function, Adm consoleConfig.getKafka().getClusters() .stream() .filter(c -> cachedKafkaResource(c).isEmpty()) - .filter(Predicate.not(KafkaClusterConfig::hasNamespace)) .forEach(clusterConfig -> putKafkaContext(contexts, clusterConfig, Optional.empty(), @@ -303,6 +306,12 @@ void putKafkaContext(Map contexts, KafkaContext ctx = new KafkaContext(clusterConfig, kafkaResource.orElse(null), clientConfigs, admin); ctx.schemaRegistryClient(registryConfig, mapper); + if (clusterConfig.hasNamespace()) { + ctx.prometheus(metricsService.createClient(consoleConfig, clusterConfig)); + } else if (clusterConfig.getMetricsSource() != null) { + log.infof("Skipping setup of metrics client for cluster %s. Reason: namespace is required for metrics retrieval but none was provided", clusterKey); + } + KafkaContext previous = contexts.put(clusterId, ctx); if (previous == null) { @@ -325,7 +334,7 @@ Optional cachedKafkaResource(KafkaClusterConfig clusterConfig) { String key = clusterConfig.clusterKey(); if (kafkaInformer.isPresent()) { - log.warnf("Configuration references Kubernetes Kafka resource %s, but it was not found", key); + log.infof("Kafka resource %s not found in Kubernetes cluster", key); } else { log.warnf("Configuration references Kubernetes Kafka resource %s, but Kubernetes access is disabled", key); } diff --git a/api/src/main/java/com/github/streamshub/console/api/model/KafkaCluster.java b/api/src/main/java/com/github/streamshub/console/api/model/KafkaCluster.java index 159b1b9d4..f226c66ee 100644 --- a/api/src/main/java/com/github/streamshub/console/api/model/KafkaCluster.java +++ b/api/src/main/java/com/github/streamshub/console/api/model/KafkaCluster.java @@ -185,6 +185,9 @@ static class Attributes { @JsonProperty boolean cruiseControlEnabled; + @JsonProperty + Metrics metrics = new Metrics(); + Attributes(List nodes, Node controller, List authorizedOperations) { this.nodes = nodes; this.controller = controller; @@ -328,4 +331,12 @@ public void nodePools(List nodePools) { public void cruiseControlEnabled(boolean cruiseControlEnabled) { attributes.cruiseControlEnabled = cruiseControlEnabled; } + + public Metrics metrics() { + return attributes.metrics; + } + + public void metrics(Metrics metrics) { + attributes.metrics = metrics; + } } diff --git a/api/src/main/java/com/github/streamshub/console/api/model/Metrics.java b/api/src/main/java/com/github/streamshub/console/api/model/Metrics.java new file mode 100644 index 000000000..3a3fce68a --- /dev/null +++ b/api/src/main/java/com/github/streamshub/console/api/model/Metrics.java @@ -0,0 +1,51 @@ +package com.github.streamshub.console.api.model; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.eclipse.microprofile.openapi.annotations.media.Schema; + +import com.fasterxml.jackson.annotation.JsonAnyGetter; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonPropertyOrder; + +public record Metrics( + @JsonProperty + Map> values, + + @JsonProperty + Map> ranges) { + + public Metrics() { + this(new LinkedHashMap<>(), new LinkedHashMap<>()); + } + + @Schema(additionalProperties = String.class) + public static record ValueMetric( + @JsonProperty + String value, + + @JsonAnyGetter + @Schema(hidden = true) + Map attributes) { + } + + @Schema(additionalProperties = String.class) + public static record RangeMetric( + @JsonProperty + @Schema(implementation = String[][].class) + List range, + + @JsonAnyGetter + @Schema(hidden = true) + Map attributes) { + } + + @JsonFormat(shape = JsonFormat.Shape.ARRAY) + @JsonPropertyOrder({"when", "value"}) + public static record RangeEntry(Instant when, String value) { + } +} diff --git a/api/src/main/java/com/github/streamshub/console/api/service/KafkaClusterService.java b/api/src/main/java/com/github/streamshub/console/api/service/KafkaClusterService.java index 316a14d90..b6963e5d9 100644 --- a/api/src/main/java/com/github/streamshub/console/api/service/KafkaClusterService.java +++ b/api/src/main/java/com/github/streamshub/console/api/service/KafkaClusterService.java @@ -1,5 +1,8 @@ package com.github.streamshub.console.api.service; +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; import java.util.Collection; import java.util.Comparator; import java.util.HashMap; @@ -7,6 +10,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.concurrent.CompletionStage; import java.util.function.Function; import java.util.function.Predicate; @@ -76,6 +80,9 @@ public class KafkaClusterService { @Inject ConsoleConfig consoleConfig; + @Inject + MetricsService metricsService; + @Inject /** * All Kafka contexts known to the application @@ -148,6 +155,7 @@ public CompletionStage describeCluster(List fields) { enumNames(get(result::authorizedOperations)))) .thenApplyAsync(this::addKafkaContextData, threadContext.currentContextExecutor()) .thenApply(this::addKafkaResourceData) + .thenCompose(cluster -> addMetrics(cluster, fields)) .thenApply(this::setManaged); } @@ -313,6 +321,42 @@ KafkaCluster setManaged(KafkaCluster cluster) { return cluster; } + + CompletionStage addMetrics(KafkaCluster cluster, List fields) { + if (!fields.contains(KafkaCluster.Fields.METRICS)) { + return CompletableFuture.completedStage(cluster); + } + + if (kafkaContext.prometheus() == null) { + logger.warnf("Kafka cluster metrics were requested, but Prometheus URL is not configured"); + cluster.metrics(null); + return CompletableFuture.completedStage(cluster); + } + + String namespace = cluster.namespace(); + String name = cluster.name(); + String rangeQuery; + String valueQuery; + + try (var rangesStream = getClass().getResourceAsStream("/metrics/queries/kafkaCluster_ranges.promql"); + var valuesStream = getClass().getResourceAsStream("/metrics/queries/kafkaCluster_values.promql")) { + rangeQuery = new String(rangesStream.readAllBytes(), StandardCharsets.UTF_8) + .formatted(namespace, name); + valueQuery = new String(valuesStream.readAllBytes(), StandardCharsets.UTF_8) + .formatted(namespace, name); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + + var rangeResults = metricsService.queryRanges(rangeQuery).toCompletableFuture(); + var valueResults = metricsService.queryValues(valueQuery).toCompletableFuture(); + + return CompletableFuture.allOf( + rangeResults.thenAccept(cluster.metrics().ranges()::putAll), + valueResults.thenAccept(cluster.metrics().values()::putAll)) + .thenApply(nothing -> cluster); + } + private Optional findCluster(KafkaCluster cluster) { return findCluster(Cache.namespaceKeyFunc(cluster.namespace(), cluster.name())); } diff --git a/api/src/main/java/com/github/streamshub/console/api/service/MetricsService.java b/api/src/main/java/com/github/streamshub/console/api/service/MetricsService.java new file mode 100644 index 000000000..8b80ac4e4 --- /dev/null +++ b/api/src/main/java/com/github/streamshub/console/api/service/MetricsService.java @@ -0,0 +1,192 @@ +package com.github.streamshub.console.api.service; + +import java.net.URI; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Base64; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.CompletionStage; +import java.util.function.BiFunction; +import java.util.function.Predicate; +import java.util.function.Supplier; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.json.JsonArray; +import jakarta.json.JsonObject; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.core.HttpHeaders; + +import org.eclipse.microprofile.rest.client.RestClientBuilder; +import org.jboss.logging.Logger; + +import com.github.streamshub.console.api.model.Metrics; +import com.github.streamshub.console.api.model.Metrics.RangeEntry; +import com.github.streamshub.console.api.support.KafkaContext; +import com.github.streamshub.console.api.support.PrometheusAPI; +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.PrometheusConfig.Type; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.quarkus.tls.TlsConfiguration; +import io.quarkus.tls.TlsConfigurationRegistry; + +import static java.util.stream.Collectors.groupingBy; +import static java.util.stream.Collectors.mapping; +import static java.util.stream.Collectors.toList; +import static java.util.stream.Collectors.toMap; + +@ApplicationScoped +public class MetricsService { + + public static final String METRIC_NAME = "__console_metric_name__"; + + @Inject + Logger logger; + + @Inject + TlsConfigurationRegistry certificates; + + @Inject + KubernetesClient k8s; + + @Inject + KafkaContext kafkaContext; + + Optional additionalFilter = Optional.empty(); + + public /* test */ void setAdditionalFilter(Optional additionalFilter) { + this.additionalFilter = additionalFilter; + } + + ClientRequestFilter createAuthenticationFilter(PrometheusConfig config) { + return requestContext -> { + var authConfig = config.getAuthentication(); + String authHeader = null; + + if (authConfig instanceof PrometheusConfig.Basic basic) { + authHeader = "Basic " + Base64.getEncoder().encodeToString("%s:%s".formatted( + basic.getUsername(), + basic.getPassword()) + .getBytes()); + } else if (authConfig instanceof PrometheusConfig.Bearer bearer) { + authHeader = "Bearer " + bearer.getToken(); + } else if (config.getType() == Type.OPENSHIFT_MONITORING) { + // ServiceAccount needs cluster role `cluster-monitoring-view` + authHeader = "Bearer " + k8s.getConfiguration().getAutoOAuthToken(); + } + + if (authHeader != null) { + requestContext.getHeaders().add(HttpHeaders.AUTHORIZATION, authHeader); + } + }; + } + + public PrometheusAPI createClient(ConsoleConfig consoleConfig, KafkaClusterConfig clusterConfig) { + PrometheusConfig prometheusConfig; + + if (clusterConfig.getMetricsSource() != null) { + prometheusConfig = consoleConfig.getMetricsSources() + .stream() + .filter(source -> source.getName().equals(clusterConfig.getMetricsSource())) + .findFirst() + .orElseThrow(); + + var trustStore = certificates.getDefault().map(TlsConfiguration::getTrustStore).orElse(null); + + RestClientBuilder builder = RestClientBuilder.newBuilder() + .baseUri(URI.create(prometheusConfig.getUrl())) + .trustStore(trustStore) + .register(createAuthenticationFilter(prometheusConfig)); + + additionalFilter.ifPresent(builder::register); + + return builder.build(PrometheusAPI.class); + } + + return null; + } + + CompletionStage>> queryValues(String query) { + PrometheusAPI prometheusAPI = kafkaContext.prometheus(); + + return fetchMetrics( + () -> prometheusAPI.query(query, Instant.now()), + (metric, attributes) -> { + // ignore timestamp in first position + String value = metric.getJsonArray("value").getString(1); + return new Metrics.ValueMetric(value, attributes); + }); + } + + CompletionStage>> queryRanges(String query) { + PrometheusAPI prometheusAPI = kafkaContext.prometheus(); + + return fetchMetrics( + () -> { + Instant now = Instant.now().truncatedTo(ChronoUnit.MILLIS); + Instant start = now.minus(30, ChronoUnit.MINUTES); + Instant end = now; + return prometheusAPI.queryRange(query, start, end, "25"); + }, + (metric, attributes) -> { + List values = metric.getJsonArray("values") + .stream() + .map(JsonArray.class::cast) + .map(e -> new Metrics.RangeEntry( + Instant.ofEpochMilli((long) (e.getJsonNumber(0).doubleValue() * 1000d)), + e.getString(1) + )) + .toList(); + + return new Metrics.RangeMetric(values, attributes); + }); + } + + CompletionStage>> fetchMetrics( + Supplier operation, + BiFunction, M> builder) { + + return CompletableFuture.supplyAsync(() -> { + try { + return extractMetrics(operation.get(), builder); + } catch (WebApplicationException wae) { + logger.warnf("Failed to retrieve Kafka cluster metrics, status %d: %s", + wae.getResponse().getStatus(), + wae.getResponse().getEntity()); + return Collections.emptyMap(); + } catch (Exception e) { + logger.warnf(e, "Failed to retrieve Kafka cluster metrics"); + return Collections.emptyMap(); + } + }); + } + + Map> extractMetrics(JsonObject response, + BiFunction, M> builder) { + + return response.getJsonObject("data").getJsonArray("result") + .stream() + .map(JsonObject.class::cast) + .map(metric -> { + JsonObject meta = metric.getJsonObject("metric"); + String metricName = meta.getString(METRIC_NAME); + + Map attributes = meta.keySet() + .stream() + .filter(Predicate.not(METRIC_NAME::equals)) + .map(key -> Map.entry(key, meta.getString(key))) + .collect(toMap(Map.Entry::getKey, Map.Entry::getValue)); + + return Map.entry(metricName, builder.apply(metric, attributes)); + }) + .collect(groupingBy(Map.Entry::getKey, mapping(Map.Entry::getValue, toList()))); + } +} diff --git a/api/src/main/java/com/github/streamshub/console/api/support/EnabledOperationFilter.java b/api/src/main/java/com/github/streamshub/console/api/support/EnabledOperationFilter.java index 4e4d082d5..7a8d94cae 100644 --- a/api/src/main/java/com/github/streamshub/console/api/support/EnabledOperationFilter.java +++ b/api/src/main/java/com/github/streamshub/console/api/support/EnabledOperationFilter.java @@ -1,6 +1,5 @@ package com.github.streamshub.console.api.support; -import java.io.IOException; import java.lang.reflect.Method; import java.util.List; import java.util.Optional; @@ -28,7 +27,7 @@ public class EnabledOperationFilter extends AbstractOperationFilter implements C ResourceInfo resource; @Override - public void filter(ContainerRequestContext requestContext) throws IOException { + public void filter(ContainerRequestContext requestContext) { if (disabled(requestContext.getMethod(), operationId())) { rejectRequest(requestContext); } diff --git a/api/src/main/java/com/github/streamshub/console/api/support/KafkaContext.java b/api/src/main/java/com/github/streamshub/console/api/support/KafkaContext.java index b577b5e99..df3caf59d 100644 --- a/api/src/main/java/com/github/streamshub/console/api/support/KafkaContext.java +++ b/api/src/main/java/com/github/streamshub/console/api/support/KafkaContext.java @@ -43,6 +43,7 @@ public class KafkaContext implements Closeable { final Admin admin; boolean applicationScoped; SchemaRegistryContext schemaRegistryContext; + PrometheusAPI prometheus; public KafkaContext(KafkaClusterConfig clusterConfig, Kafka resource, Map, Map> configs, Admin admin) { this.clusterConfig = clusterConfig; @@ -56,6 +57,7 @@ public KafkaContext(KafkaContext other, Admin admin) { this(other.clusterConfig, other.resource, other.configs, admin); this.applicationScoped = false; this.schemaRegistryContext = other.schemaRegistryContext; + this.prometheus = other.prometheus; } public static String clusterId(KafkaClusterConfig clusterConfig, Optional kafkaResource) { @@ -137,6 +139,14 @@ public SchemaRegistryContext schemaRegistryContext() { return schemaRegistryContext; } + public void prometheus(PrometheusAPI prometheus) { + this.prometheus = prometheus; + } + + public PrometheusAPI prometheus() { + return prometheus; + } + public String saslMechanism(Class clientType) { return configs(clientType).get(SaslConfigs.SASL_MECHANISM) instanceof String auth ? auth : ""; } diff --git a/api/src/main/java/com/github/streamshub/console/api/support/PrometheusAPI.java b/api/src/main/java/com/github/streamshub/console/api/support/PrometheusAPI.java new file mode 100644 index 000000000..7adf7e0d2 --- /dev/null +++ b/api/src/main/java/com/github/streamshub/console/api/support/PrometheusAPI.java @@ -0,0 +1,49 @@ +package com.github.streamshub.console.api.support; + +import java.time.Instant; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.json.JsonObject; +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.QueryParam; +import jakarta.ws.rs.core.MediaType; + +import org.eclipse.microprofile.rest.client.inject.RegisterRestClient; + +@ApplicationScoped +@RegisterRestClient(configKey = "prometheus") +@Path("/api/v1") +public interface PrometheusAPI { + + /** + * Evaluates an instant query at a single point in time + * + * @see Instant queries + */ + @Path("query") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + JsonObject query( + @QueryParam("query") String query, + @QueryParam("time") Instant time); + + /** + * Evaluates an expression query over a range of time + * + * @see Range queries + */ + @Path("query_range") + @POST + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @Produces(MediaType.APPLICATION_JSON) + JsonObject queryRange( + @QueryParam("query") String query, + @QueryParam("start") Instant start, + @QueryParam("end") Instant end, + @QueryParam("step") String step); + +} diff --git a/api/src/main/java/com/github/streamshub/console/api/support/factories/ConsoleConfigFactory.java b/api/src/main/java/com/github/streamshub/console/api/support/factories/ConsoleConfigFactory.java index 9bd4aa5e9..31c9dd072 100644 --- a/api/src/main/java/com/github/streamshub/console/api/support/factories/ConsoleConfigFactory.java +++ b/api/src/main/java/com/github/streamshub/console/api/support/factories/ConsoleConfigFactory.java @@ -3,10 +3,11 @@ import java.io.IOException; import java.io.InputStream; import java.io.UncheckedIOException; +import java.net.URL; import java.nio.file.Path; -import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.function.Supplier; import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.inject.Produces; @@ -17,7 +18,10 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import org.jboss.logging.Logger; +import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ArrayNode; +import com.fasterxml.jackson.databind.node.ObjectNode; import com.fasterxml.jackson.dataformat.yaml.YAMLFactory; import com.github.streamshub.console.api.support.ValidationProxy; import com.github.streamshub.console.config.ConsoleConfig; @@ -41,43 +45,28 @@ public class ConsoleConfigFactory { @Inject ValidationProxy validationService; + // Note: extract this class and use generally where IOExceptions are simply re-thrown + interface UncheckedIO { + R call() throws IOException; + + static R call(UncheckedIO io, Supplier exceptionMessage) { + try { + return io.call(); + } catch (IOException e) { + throw new UncheckedIOException(exceptionMessage.get(), e); + } + } + } + @Produces @ApplicationScoped public ConsoleConfig produceConsoleConfig() { return configPath.map(Path::of) .map(Path::toUri) - .map(uri -> { - try { - return uri.toURL(); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }) + .map(uri -> UncheckedIO.call(uri::toURL, + () -> "Unable to convert %s to URL".formatted(uri))) .filter(Objects::nonNull) - .map(url -> { - log.infof("Loading console configuration from %s", url); - ObjectMapper yamlMapper = mapper.copyWith(new YAMLFactory()); - - try (InputStream stream = url.openStream()) { - return yamlMapper.readValue(stream, ConsoleConfig.class); - } catch (IOException e) { - throw new UncheckedIOException(e); - } - }) - .map(consoleConfig -> { - consoleConfig.getSchemaRegistries().forEach(registry -> { - registry.setUrl(resolveValue(registry.getUrl())); - }); - - consoleConfig.getKafka().getClusters().forEach(cluster -> { - resolveValues(cluster.getProperties()); - resolveValues(cluster.getAdminProperties()); - resolveValues(cluster.getProducerProperties()); - resolveValues(cluster.getConsumerProperties()); - }); - - return consoleConfig; - }) + .map(this::loadConfiguration) .map(validationService::validate) .orElseGet(() -> { log.warn("Console configuration has not been specified using `console.config-path` property"); @@ -85,9 +74,56 @@ public ConsoleConfig produceConsoleConfig() { }); } - private void resolveValues(Map properties) { - properties.entrySet().forEach(entry -> - entry.setValue(resolveValue(entry.getValue()))); + private ConsoleConfig loadConfiguration(URL url) { + log.infof("Loading console configuration from %s", url); + ObjectMapper yamlMapper = mapper.copyWith(new YAMLFactory()); + + JsonNode tree = UncheckedIO.call(() -> { + try (InputStream stream = url.openStream()) { + return yamlMapper.readTree(stream); + } + }, () -> "Failed to read configuration YAML"); + + // Replace properties specified within string values in the configuration model + processNode(tree); + + return UncheckedIO.call( + () -> mapper.treeToValue(tree, ConsoleConfig.class), + () -> "Failed to load configuration model"); + } + + private void processNode(JsonNode node) { + if (node.isArray()) { + int i = 0; + for (JsonNode entry : node) { + processNode((ArrayNode) node, i++, entry); + } + } else if (node.isObject()) { + for (var cursor = node.fields(); cursor.hasNext();) { + var field = cursor.next(); + processNode((ObjectNode) node, field.getKey(), field.getValue()); + } + } + } + + private void processNode(ObjectNode parent, String key, JsonNode node) { + if (node.isValueNode()) { + if (node.isTextual()) { + parent.put(key, resolveValue(node.asText())); + } + } else { + processNode(node); + } + } + + private void processNode(ArrayNode parent, int position, JsonNode node) { + if (node.isValueNode()) { + if (node.isTextual()) { + parent.set(position, resolveValue(node.asText())); + } + } else { + processNode(node); + } } /** diff --git a/api/src/main/resources/application.properties b/api/src/main/resources/application.properties index dfc2213b5..c2856341b 100644 --- a/api/src/main/resources/application.properties +++ b/api/src/main/resources/application.properties @@ -69,6 +69,7 @@ console.kafka.admin.default.api.timeout.ms=10000 ######## #%dev.quarkus.http.auth.proactive=false #%dev.quarkus.http.auth.permission."oidc".policy=permit +%dev.quarkus.tls.trust-all=true %dev.quarkus.kubernetes-client.trust-certs=true %dev.quarkus.log.category."io.vertx.core.impl.BlockedThreadChecker".level=OFF %dev.quarkus.log.category."com.github.streamshub.console".level=DEBUG diff --git a/api/src/main/resources/metrics/queries/kafkaCluster_ranges.promql b/api/src/main/resources/metrics/queries/kafkaCluster_ranges.promql index 0e83940fe..51a3e0c44 100644 --- a/api/src/main/resources/metrics/queries/kafkaCluster_ranges.promql +++ b/api/src/main/resources/metrics/queries/kafkaCluster_ranges.promql @@ -1,7 +1,7 @@ sum by (nodeId, __console_metric_name__) ( label_replace( label_replace( - rate(container_cpu_usage_seconds_total{namespace="%1$s",pod=~"%2$s-.+-\\d+"}[1m]), + rate(container_cpu_usage_seconds_total{namespace="%1$s",pod=~"%2$s-.+-\\d+"}[5m]), "nodeId", "$1", "pod", diff --git a/api/src/main/resources/metrics/queries/kafkaCluster_values.promql b/api/src/main/resources/metrics/queries/kafkaCluster_values.promql index 0cfe40fcc..8c17d05f7 100644 --- a/api/src/main/resources/metrics/queries/kafkaCluster_values.promql +++ b/api/src/main/resources/metrics/queries/kafkaCluster_values.promql @@ -16,47 +16,6 @@ sum by (__console_metric_name__, nodeId) ( or -sum by (__console_metric_name__) ( - label_replace( - kafka_controller_kafkacontroller_globaltopiccount{namespace="%1$s",pod=~"%2$s-.+-%3$d",strimzi_io_kind="Kafka"} > 0, - "__console_metric_name__", - "total_topics", - "", - "" - ) -) - -or - -sum by (__console_metric_name__) ( - label_replace( - kafka_controller_kafkacontroller_globalpartitioncount{namespace="%1$s",pod=~"%2$s-.+-%3$d",strimzi_io_kind="Kafka"} > 0, - "__console_metric_name__", - "total_partitions", - "", - "" - ) -) - -or - -label_replace( - ( - count( - sum by (topic) ( - kafka_cluster_partition_underreplicated{namespace="%1$s",pod=~"%2$s-.+-\\d+",strimzi_io_kind="Kafka"} > 0 - ) - ) - OR on() vector(0) - ), - "__console_metric_name__", - "underreplicated_topics", - "", - "" -) - -or - sum by (__console_metric_name__, nodeId) ( label_replace( label_replace( diff --git a/api/src/test/java/com/github/streamshub/console/api/KafkaClustersResourceIT.java b/api/src/test/java/com/github/streamshub/console/api/KafkaClustersResourceIT.java index 71d186103..5264979d5 100644 --- a/api/src/test/java/com/github/streamshub/console/api/KafkaClustersResourceIT.java +++ b/api/src/test/java/com/github/streamshub/console/api/KafkaClustersResourceIT.java @@ -37,6 +37,7 @@ import org.junit.jupiter.params.provider.CsvSource; import org.mockito.Mockito; +import com.github.streamshub.console.api.model.KafkaCluster; import com.github.streamshub.console.api.model.ListFetchParams; import com.github.streamshub.console.api.service.KafkaClusterService; import com.github.streamshub.console.api.support.ErrorCategory; @@ -739,6 +740,20 @@ void testDescribeClusterWithScram(boolean tls, String expectedProtocol) { containsString(ScramLoginModule.class.getName())); } + @Test + /* + * Tests with metrics enabled are in KafkaClustersResourceMetricsIT + */ + void testDescribeClusterWithMetricsNotEnabled() { + whenRequesting(req -> req + .param("fields[" + KafkaCluster.API_TYPE + "]", "name,metrics") + .get("{clusterId}", clusterId1)) + .assertThat() + .statusCode(is(Status.OK.getStatusCode())) + .body("data.attributes.name", equalTo("test-kafka1")) + .body("data.attributes", hasEntry(is("metrics"), nullValue())); + } + @ParameterizedTest @CsvSource({ "true", diff --git a/api/src/test/java/com/github/streamshub/console/api/KafkaClustersResourceMetricsIT.java b/api/src/test/java/com/github/streamshub/console/api/KafkaClustersResourceMetricsIT.java new file mode 100644 index 000000000..6577b86c3 --- /dev/null +++ b/api/src/test/java/com/github/streamshub/console/api/KafkaClustersResourceMetricsIT.java @@ -0,0 +1,320 @@ +package com.github.streamshub.console.api; + +import java.io.IOException; +import java.net.URI; +import java.time.Instant; +import java.util.Base64; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Consumer; + +import jakarta.inject.Inject; +import jakarta.json.Json; +import jakarta.json.JsonObject; +import jakarta.ws.rs.WebApplicationException; +import jakarta.ws.rs.client.ClientRequestContext; +import jakarta.ws.rs.client.ClientRequestFilter; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.Response; +import jakarta.ws.rs.core.Response.Status; + +import org.apache.kafka.clients.CommonClientConfigs; +import org.apache.kafka.common.security.auth.SecurityProtocol; +import org.eclipse.microprofile.config.Config; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import com.github.streamshub.console.api.model.KafkaCluster; +import com.github.streamshub.console.api.service.MetricsService; +import com.github.streamshub.console.api.support.KafkaContext; +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.PrometheusConfig.Type; +import com.github.streamshub.console.kafka.systemtest.TestPlainProfile; +import com.github.streamshub.console.kafka.systemtest.deployment.DeploymentManager; +import com.github.streamshub.console.test.AdminClientSpy; +import com.github.streamshub.console.test.TestHelper; + +import io.fabric8.kubernetes.client.KubernetesClient; +import io.fabric8.kubernetes.client.informers.cache.Cache; +import io.quarkus.test.common.http.TestHTTPEndpoint; +import io.quarkus.test.junit.QuarkusTest; +import io.quarkus.test.junit.TestProfile; +import io.strimzi.api.kafka.model.kafka.Kafka; +import io.strimzi.api.kafka.model.kafka.KafkaBuilder; +import io.strimzi.test.container.StrimziKafkaContainer; + +import static com.github.streamshub.console.test.TestHelper.whenRequesting; +import static org.awaitility.Awaitility.await; +import static org.hamcrest.Matchers.aMapWithSize; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anEmptyMap; +import static org.hamcrest.Matchers.contains; +import static org.hamcrest.Matchers.equalTo; +import static org.hamcrest.Matchers.hasEntry; +import static org.hamcrest.Matchers.is; +import static org.junit.jupiter.api.Assertions.assertEquals; + +@QuarkusTest +@TestHTTPEndpoint(KafkaClustersResource.class) +@TestProfile(TestPlainProfile.class) +class KafkaClustersResourceMetricsIT implements ClientRequestFilter { + + static final JsonObject EMPTY_METRICS = Json.createObjectBuilder() + .add("data", Json.createObjectBuilder() + .add("result", Json.createArrayBuilder())) + .build(); + + @Inject + Config config; + + @Inject + KubernetesClient client; + + @Inject + Map configuredContexts; + + @Inject + ConsoleConfig consoleConfig; + + @Inject + MetricsService metricsService; + + @DeploymentManager.InjectDeploymentManager + DeploymentManager deployments; + + TestHelper utils; + + StrimziKafkaContainer kafkaContainer; + String clusterId1; + URI bootstrapServers; + + Consumer filterQuery; + Consumer filterQueryRange; + + @Override + public void filter(ClientRequestContext requestContext) throws IOException { + var requestUri = requestContext.getUri(); + + if (requestUri.getPath().endsWith("query")) { + filterQuery.accept(requestContext); + } else if (requestUri.getPath().endsWith("query_range")) { + filterQueryRange.accept(requestContext); + } + } + + @BeforeEach + void setup() throws IOException { + filterQuery = ctx -> { /* No-op */ }; + filterQueryRange = ctx -> { /* No-op */ }; + metricsService.setAdditionalFilter(Optional.of(this)); + + /* + * Create a mock Prometheus configuration and point test-kafka1 to use it. A client + * will be created when the Kafka CR is discovered. The request filter mock created + * above is our way to intercept outbound requests and abort them with the desired + * response for each test. + */ + var prometheusConfig = new PrometheusConfig(); + prometheusConfig.setName("test"); + prometheusConfig.setType(Type.fromValue("standalone")); + prometheusConfig.setUrl("http://prometheus.example.com"); + + var prometheusAuthN = new PrometheusConfig.Basic(); + prometheusAuthN.setUsername("pr0m3th3u5"); + prometheusAuthN.setPassword("password42"); + prometheusConfig.setAuthentication(prometheusAuthN); + + consoleConfig.setMetricsSources(List.of(prometheusConfig)); + consoleConfig.getKafka().getCluster("default/test-kafka1").get().setMetricsSource("test"); + + kafkaContainer = deployments.getKafkaContainer(); + bootstrapServers = URI.create(kafkaContainer.getBootstrapServers()); + + utils = new TestHelper(bootstrapServers, config, null); + + client.resources(Kafka.class).inAnyNamespace().delete(); + + Kafka kafka1 = new KafkaBuilder(utils.buildKafkaResource("test-kafka1", utils.getClusterId(), bootstrapServers)) + .editOrNewStatus() + .addNewCondition() + .withType("Ready") + .withStatus("True") + .endCondition() + .addNewKafkaNodePool() + .withName("my-node-pool") + .endKafkaNodePool() + .endStatus() + .build(); + + utils.apply(client, kafka1); + + // Wait for the added cluster to be configured in the context map + await().atMost(10, TimeUnit.SECONDS) + .until(() -> configuredContexts.values() + .stream() + .map(KafkaContext::clusterConfig) + .map(KafkaClusterConfig::clusterKey) + .anyMatch(Cache.metaNamespaceKeyFunc(kafka1)::equals)); + + clusterId1 = consoleConfig.getKafka().getCluster("default/test-kafka1").get().getId(); + } + + @AfterEach + void teardown() { + client.resources(Kafka.class).inAnyNamespace().delete(); + } + + @Test + void testDescribeClusterWithMetricsSetsBasicHeader() { + AtomicReference queryAuthHeader = new AtomicReference<>(); + AtomicReference queryRangeAuthHeader = new AtomicReference<>(); + + filterQuery = ctx -> { + queryAuthHeader.set(ctx.getHeaderString(HttpHeaders.AUTHORIZATION)); + ctx.abortWith(Response.ok(EMPTY_METRICS).build()); + }; + + filterQueryRange = ctx -> { + queryRangeAuthHeader.set(ctx.getHeaderString(HttpHeaders.AUTHORIZATION)); + ctx.abortWith(Response.ok(EMPTY_METRICS).build()); + }; + + whenRequesting(req -> req + .param("fields[" + KafkaCluster.API_TYPE + "]", "name,metrics") + .get("{clusterId}", clusterId1)) + .assertThat() + .statusCode(is(Status.OK.getStatusCode())); + + String expected = "Basic " + Base64.getEncoder().encodeToString("pr0m3th3u5:password42".getBytes()); + assertEquals(expected, queryAuthHeader.get()); + assertEquals(expected, queryRangeAuthHeader.get()); + } + + @Test + void testDescribeClusterWithEmptyMetrics() { + filterQuery = ctx -> { + ctx.abortWith(Response.ok(EMPTY_METRICS).build()); + }; + + filterQueryRange = ctx -> { + ctx.abortWith(Response.ok(EMPTY_METRICS).build()); + }; + + whenRequesting(req -> req + .param("fields[" + KafkaCluster.API_TYPE + "]", "name,metrics") + .get("{clusterId}", clusterId1)) + .assertThat() + .statusCode(is(Status.OK.getStatusCode())) + .body("data.attributes.name", equalTo("test-kafka1")) + .body("data.attributes.metrics", allOf( + hasEntry(is("values"), anEmptyMap()), + hasEntry(is("ranges"), anEmptyMap()))); + } + + @Test + void testDescribeClusterWithMetricsErrors() { + filterQuery = ctx -> { + Response error = Response.status(Status.SERVICE_UNAVAILABLE) + .entity("EXPECTED: Prometheus is not available") + .build(); + throw new WebApplicationException(error); + }; + + filterQueryRange = ctx -> { + throw new RuntimeException("EXPECTED"); + }; + + whenRequesting(req -> req + .param("fields[" + KafkaCluster.API_TYPE + "]", "name,metrics") + .get("{clusterId}", clusterId1)) + .assertThat() + .statusCode(is(Status.OK.getStatusCode())) + .body("data.attributes.name", equalTo("test-kafka1")) + .body("data.attributes.metrics", allOf( + hasEntry(is("values"), anEmptyMap()), + hasEntry(is("ranges"), anEmptyMap()))); + } + + @Test + void testDescribeClusterWithMetricsValues() { + Instant t1 = Instant.now().minusSeconds(1); + Instant t2 = Instant.now(); + + filterQuery = ctx -> { + ctx.abortWith(Response.ok(Json.createObjectBuilder() + .add("data", Json.createObjectBuilder() + .add("result", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("metric", Json.createObjectBuilder() + .add(MetricsService.METRIC_NAME, "value-metric1") + .add("custom-attribute", "attribute-value")) + .add("value", Json.createArrayBuilder() + .add(t1.toEpochMilli() / 1000f) + .add("42"))))) + .build()) + .build()); + }; + + filterQueryRange = ctx -> { + ctx.abortWith(Response.ok(Json.createObjectBuilder() + .add("data", Json.createObjectBuilder() + .add("result", Json.createArrayBuilder() + .add(Json.createObjectBuilder() + .add("metric", Json.createObjectBuilder() + .add(MetricsService.METRIC_NAME, "range-metric1") + .add("custom-attribute", "attribute-value")) + .add("values", Json.createArrayBuilder() + .add(Json.createArrayBuilder() + .add((double) t1.toEpochMilli() / 1000f) + .add("2.718")) + .add(Json.createArrayBuilder() + .add((double) t2.toEpochMilli() / 1000f) + .add("3.1415")))))) + .build()) + .build()); + }; + + whenRequesting(req -> req + .param("fields[" + KafkaCluster.API_TYPE + "]", "name,metrics") + .get("{clusterId}", clusterId1)) + .assertThat() + .statusCode(is(Status.OK.getStatusCode())) + .body("data.attributes.name", equalTo("test-kafka1")) + .body("data.attributes.metrics.values.value-metric1", contains(allOf( + aMapWithSize(2), + hasEntry("value", "42"), + hasEntry("custom-attribute", "attribute-value")))) + .body("data.attributes.metrics.ranges.range-metric1", contains(allOf( + aMapWithSize(2), + //hasEntry("range", arrayContaining("", "")), + hasEntry("custom-attribute", "attribute-value") + ))); + } + + // Helper methods + + static Map mockAdminClient() { + return mockAdminClient(Map.of(CommonClientConfigs.SECURITY_PROTOCOL_CONFIG, SecurityProtocol.PLAINTEXT.name)); + } + + static Map mockAdminClient(Map overrides) { + Map clientConfig = new HashMap<>(); + + AdminClientSpy.install(config -> { + clientConfig.putAll(config); + + Map newConfig = new HashMap<>(config); + newConfig.putAll(overrides); + return newConfig; + }, client -> { /* No-op */ }); + + return clientConfig; + } +} diff --git a/api/src/test/java/com/github/streamshub/console/api/RecordsResourceIT.java b/api/src/test/java/com/github/streamshub/console/api/RecordsResourceIT.java index 3b5e31f81..231505a0e 100644 --- a/api/src/test/java/com/github/streamshub/console/api/RecordsResourceIT.java +++ b/api/src/test/java/com/github/streamshub/console/api/RecordsResourceIT.java @@ -605,7 +605,7 @@ void testProduceRecordWithAvroFormat() { final String keyArtifactId = UUID.randomUUID().toString().replace("-", ""); final String keySchema = """ - { + { "namespace": "console.avro", "type": "record", "name": "name_%s", @@ -621,7 +621,7 @@ void testProduceRecordWithAvroFormat() { final String valueArtifactId = UUID.randomUUID().toString().replace("-", ""); final String valueSchema = """ - { + { "namespace": "console.avro", "type": "record", "name": "name_%s", diff --git a/common/src/main/java/com/github/streamshub/console/config/ConsoleConfig.java b/common/src/main/java/com/github/streamshub/console/config/ConsoleConfig.java index 19d7ec94b..75717bc3c 100644 --- a/common/src/main/java/com/github/streamshub/console/config/ConsoleConfig.java +++ b/common/src/main/java/com/github/streamshub/console/config/ConsoleConfig.java @@ -7,9 +7,22 @@ import jakarta.validation.constraints.AssertTrue; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; import io.xlate.validation.constraints.Expression; +@Expression( + message = "Kafka cluster references an unknown metrics source", + value = """ + metricsSources = self.metricsSources.stream() + .map(metrics -> metrics.getName()) + .toList(); + self.kafka.clusters.stream() + .map(cluster -> cluster.getMetricsSource()) + .filter(source -> source != null) + .allMatch(source -> metricsSources.contains(source)) + """) @Expression( message = "Kafka cluster references an unknown schema registry", value = """ @@ -21,20 +34,30 @@ .filter(registry -> registry != null) .allMatch(registry -> registryNames.contains(registry)) """) +@JsonInclude(Include.NON_NULL) public class ConsoleConfig { KubernetesConfig kubernetes = new KubernetesConfig(); + @Valid + List metricsSources = new ArrayList<>(); + @Valid List schemaRegistries = new ArrayList<>(); @Valid KafkaConfig kafka = new KafkaConfig(); + @JsonIgnore + @AssertTrue(message = "Metrics source names must be unique") + public boolean hasUniqueMetricsSourceNames() { + return Named.uniqueNames(metricsSources); + } + @JsonIgnore @AssertTrue(message = "Schema registry names must be unique") public boolean hasUniqueRegistryNames() { - return schemaRegistries.stream().map(SchemaRegistryConfig::getName).distinct().count() == schemaRegistries.size(); + return Named.uniqueNames(schemaRegistries); } public KubernetesConfig getKubernetes() { @@ -45,6 +68,14 @@ public void setKubernetes(KubernetesConfig kubernetes) { this.kubernetes = kubernetes; } + public List getMetricsSources() { + return metricsSources; + } + + public void setMetricsSources(List metricsSources) { + this.metricsSources = metricsSources; + } + public List getSchemaRegistries() { return schemaRegistries; } diff --git a/common/src/main/java/com/github/streamshub/console/config/KafkaClusterConfig.java b/common/src/main/java/com/github/streamshub/console/config/KafkaClusterConfig.java index a676d80c6..7197764ee 100644 --- a/common/src/main/java/com/github/streamshub/console/config/KafkaClusterConfig.java +++ b/common/src/main/java/com/github/streamshub/console/config/KafkaClusterConfig.java @@ -6,14 +6,21 @@ import jakarta.validation.constraints.NotBlank; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; -public class KafkaClusterConfig { +@JsonInclude(Include.NON_NULL) +public class KafkaClusterConfig implements Named { private String id; @NotBlank(message = "Kafka cluster `name` is required") private String name; private String namespace; private String listener; + /** + * Name of a configured metrics source used by this Kafka cluster + */ + private String metricsSource; /** * Name of a configured schema registry that will be used to ser/des configurations * with this Kafka cluster. @@ -42,6 +49,7 @@ public void setId(String id) { this.id = id; } + @Override public String getName() { return name; } @@ -66,6 +74,14 @@ public void setListener(String listener) { this.listener = listener; } + public String getMetricsSource() { + return metricsSource; + } + + public void setMetricsSource(String metricsSource) { + this.metricsSource = metricsSource; + } + public String getSchemaRegistry() { return schemaRegistry; } diff --git a/common/src/main/java/com/github/streamshub/console/config/KafkaConfig.java b/common/src/main/java/com/github/streamshub/console/config/KafkaConfig.java index 429817137..7c16ca926 100644 --- a/common/src/main/java/com/github/streamshub/console/config/KafkaConfig.java +++ b/common/src/main/java/com/github/streamshub/console/config/KafkaConfig.java @@ -8,7 +8,10 @@ import jakarta.validation.constraints.AssertTrue; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; +@JsonInclude(Include.NON_NULL) public class KafkaConfig { @Valid @@ -17,7 +20,7 @@ public class KafkaConfig { @JsonIgnore @AssertTrue(message = "Kafka cluster names must be unique") public boolean hasUniqueClusterNames() { - return clusters.stream().map(KafkaClusterConfig::getName).distinct().count() == clusters.size(); + return Named.uniqueNames(clusters); } @JsonIgnore diff --git a/common/src/main/java/com/github/streamshub/console/config/Named.java b/common/src/main/java/com/github/streamshub/console/config/Named.java new file mode 100644 index 000000000..b541fc928 --- /dev/null +++ b/common/src/main/java/com/github/streamshub/console/config/Named.java @@ -0,0 +1,16 @@ +package com.github.streamshub.console.config; + +import java.util.Collection; + +public interface Named { + + static boolean uniqueNames(Collection items) { + if (items == null) { + return true; + } + return items.stream().map(Named::getName).distinct().count() == items.size(); + } + + String getName(); + +} diff --git a/common/src/main/java/com/github/streamshub/console/config/PrometheusConfig.java b/common/src/main/java/com/github/streamshub/console/config/PrometheusConfig.java new file mode 100644 index 000000000..3dd88cf49 --- /dev/null +++ b/common/src/main/java/com/github/streamshub/console/config/PrometheusConfig.java @@ -0,0 +1,129 @@ +package com.github.streamshub.console.config; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonSubTypes; +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.annotation.JsonTypeInfo.Id; +import com.fasterxml.jackson.annotation.JsonValue; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +@JsonInclude(Include.NON_NULL) +public class PrometheusConfig implements Named { + + @NotBlank(message = "Metrics source `name` is required") + private String name; + private Type type; + @NotBlank(message = "Metrics source `url` is required") + private String url; + @Valid + private Authentication authentication; + + @Override + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Type getType() { + return type; + } + + public void setType(Type type) { + this.type = type; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public Authentication getAuthentication() { + return authentication; + } + + public void setAuthentication(Authentication authentication) { + this.authentication = authentication; + } + + public enum Type { + OPENSHIFT_MONITORING("openshift-monitoring"), + STANDALONE("standalone"); + + private final String value; + + private Type(String value) { + this.value = value; + } + + @JsonValue + public String value() { + return value; + } + + @JsonCreator + public static Type fromValue(String value) { + if (value == null) { + return STANDALONE; + } + + for (var type : values()) { + if (type.value.equals(value.trim())) { + return type; + } + } + + throw new IllegalArgumentException("Invalid Prometheus type: " + value); + } + } + + @JsonTypeInfo(use = Id.DEDUCTION) + @JsonSubTypes({ @JsonSubTypes.Type(Basic.class), @JsonSubTypes.Type(Bearer.class) }) + abstract static class Authentication { + } + + public static class Basic extends Authentication { + @NotBlank + private String username; + @NotBlank + private String password; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + } + + public static class Bearer extends Authentication { + @NotBlank + private String token; + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + } +} diff --git a/common/src/main/java/com/github/streamshub/console/config/SchemaRegistryConfig.java b/common/src/main/java/com/github/streamshub/console/config/SchemaRegistryConfig.java index 0d4dc18ec..999459091 100644 --- a/common/src/main/java/com/github/streamshub/console/config/SchemaRegistryConfig.java +++ b/common/src/main/java/com/github/streamshub/console/config/SchemaRegistryConfig.java @@ -2,7 +2,11 @@ import jakarta.validation.constraints.NotBlank; -public class SchemaRegistryConfig { +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +@JsonInclude(Include.NON_NULL) +public class SchemaRegistryConfig implements Named { @NotBlank(message = "Schema registry `name` is required") String name; @@ -10,6 +14,7 @@ public class SchemaRegistryConfig { @NotBlank(message = "Schema registry `url` is required") String url; + @Override public String getName() { return name; } diff --git a/common/src/test/java/com/github/streamshub/console/config/ConsoleConfigTest.java b/common/src/test/java/com/github/streamshub/console/config/ConsoleConfigTest.java index b8debb754..41c81e829 100644 --- a/common/src/test/java/com/github/streamshub/console/config/ConsoleConfigTest.java +++ b/common/src/test/java/com/github/streamshub/console/config/ConsoleConfigTest.java @@ -93,14 +93,20 @@ void testKafkaNameMissingFailsValidation() { } @Test - void testRegistryNamePassesValidation() { + void testKnownReferenceNamesPassValidation() { SchemaRegistryConfig registry = new SchemaRegistryConfig(); registry.setName("known-registry"); registry.setUrl("http://example.com"); config.getSchemaRegistries().add(registry); + PrometheusConfig metrics = new PrometheusConfig(); + metrics.setName("known-prometheus"); + metrics.setUrl("http://example.com"); + config.getMetricsSources().add(metrics); + KafkaClusterConfig cluster = new KafkaClusterConfig(); cluster.setName("name1"); + cluster.setMetricsSource("known-prometheus"); cluster.setSchemaRegistry("known-registry"); config.getKafka().getClusters().add(cluster); @@ -110,15 +116,47 @@ void testRegistryNamePassesValidation() { } @Test - void testUnknownRegistryNameFailsValidation() { + void testUnknownReferenceNamesFailValidation() { KafkaClusterConfig cluster = new KafkaClusterConfig(); cluster.setName("name1"); + cluster.setMetricsSource("unknown-prometheus"); cluster.setSchemaRegistry("unknown-registry"); config.getKafka().getClusters().add(cluster); var violations = validator.validate(config); + assertEquals(2, violations.size()); + List messages = violations.stream().map(ConstraintViolation::getMessage).toList(); + assertTrue(messages.contains("Kafka cluster references an unknown metrics source")); + assertTrue(messages.contains("Kafka cluster references an unknown schema registry")); + } + + @Test + void testMetricsSourceNamesNotUniqueFailsValidation() { + for (String name : List.of("name1", "name2", "name1")) { + PrometheusConfig metrics = new PrometheusConfig(); + metrics.setName(name); + metrics.setUrl("http://example.com"); + config.getMetricsSources().add(metrics); + } + + var violations = validator.validate(config); + assertEquals(1, violations.size()); - assertEquals("Kafka cluster references an unknown schema registry", violations.iterator().next().getMessage()); + assertEquals("Metrics source names must be unique", violations.iterator().next().getMessage()); + } + + @Test + void testMetricsSourceNamesUniquePassesValidation() { + for (String name : List.of("name1", "name2", "name3")) { + PrometheusConfig metrics = new PrometheusConfig(); + metrics.setName(name); + metrics.setUrl("http://example.com"); + config.getMetricsSources().add(metrics); + } + + var violations = validator.validate(config); + + assertTrue(violations.isEmpty()); } } diff --git a/compose.yaml b/compose.yaml index ca69d1eaa..7ccccf4cc 100644 --- a/compose.yaml +++ b/compose.yaml @@ -21,7 +21,6 @@ services: environment: HOSTNAME: localhost PORT: 3005 - CONSOLE_METRICS_PROMETHEUS_URL: ${CONSOLE_METRICS_PROMETHEUS_URL} NEXTAUTH_SECRET: ${CONSOLE_UI_NEXTAUTH_SECRET} NEXTAUTH_URL: http://localhost:3005 BACKEND_URL: http://localhost:8080/ diff --git a/console-config-example.yaml b/console-config-example.yaml index 61487fcb8..26df27c20 100644 --- a/console-config-example.yaml +++ b/console-config-example.yaml @@ -3,6 +3,17 @@ kubernetes: # Kafka and KafkaTopic custom resources. Enabled by default enabled: true +metricsSources: + # Array of Prometheus API servers that my be referenced by Kafka cluster configurations + # for metrics retrieval to render graphs in the UI and provide other information based + # on the cluster metrics + - name: cluster-monitoring + type: openshift-monitoring + url: https://thanos-querier-openshift-monitoring.cloud.example.com + - name: my-custom-prometheus + type: standalone + url: http://my-custom-prometheus.cloud2.example.com + schemaRegistries: # Array of Apicurio Registries that my be referenced by Kafka cluster configurations # to resolve Avro or Protobuf schemas for topic message browsing @@ -15,6 +26,7 @@ kafka: namespace: my-namespace1 # namespace of the Strimzi Kafka CR (optional) id: my-kafka1-id # value to be used as an identifier for the cluster. Must be specified when namespace is not. listener: "secure" # name of the listener to use for connections from the console + metricsSource: cluster-monitoring schemaRegistry: "my-apicurio-registry" # name of the schema registry to use with this Kafka (optional) # `properties` contains keys/values to use for any Kafka connection properties: @@ -35,6 +47,7 @@ kafka: - name: my-kafka2 namespace: my-namespace2 listener: "secure" + metricsSource: my-custom-prometheus properties: security.protocol: SASL_SSL sasl.mechanism: SCRAM-SHA-512 diff --git a/examples/console/010-Console-example.yaml b/examples/console/010-Console-example.yaml index 793fd1b77..f7f4b987e 100644 --- a/examples/console/010-Console-example.yaml +++ b/examples/console/010-Console-example.yaml @@ -5,6 +5,13 @@ metadata: name: example spec: hostname: example-console.${CLUSTER_DOMAIN} + metricsSources: [ + # array of connected metrics sources (Prometheus servers) with + # name, type, url, and authentication + ] + schemaRegistries: [ + # array of connected registries with name and url. + ] kafkaClusters: # # The values below make use of the example Kafka cluster from examples/kafka. @@ -13,8 +20,8 @@ spec: - name: console-kafka # Name of the `Kafka` CR representing the cluster namespace: ${KAFKA_NAMESPACE} # Namespace of the `Kafka` CR representing the cluster listener: secure # Listener on the `Kafka` CR to connect from the console - schemaRegistry: null # Configuration for a connection to an Apicurio Registry instance - # E.g. : { "url": "http://example.com/apis/registry/v2" } + metricsSource: null # Name of the configured metrics source from the `metricsSources` section + schemaRegistry: null # Name of the configured Apicurio Registry from the `schemaRegistries` section properties: values: [] # Array of name/value for properties to be used for connections # made to this cluster diff --git a/install/console/040-Deployment-console.yaml b/install/console/040-Deployment-console.yaml index d68852190..8e7c13c3f 100644 --- a/install/console/040-Deployment-console.yaml +++ b/install/console/040-Deployment-console.yaml @@ -53,5 +53,3 @@ spec: value: 'https://${CONSOLE_HOSTNAME}' - name: BACKEND_URL value: 'http://127.0.0.1:8080' - - name: CONSOLE_METRICS_PROMETHEUS_URL - value: 'http://prometheus-operated.${NAMESPACE}.svc.cluster.local:9090' diff --git a/operator/pom.xml b/operator/pom.xml index 60ac1daf1..b5c5e7136 100644 --- a/operator/pom.xml +++ b/operator/pom.xml @@ -12,14 +12,6 @@ console-operator jar - - - localhost - streamshub - ${project.version} - false - - com.github.streamshub 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 daca14e5c..57331ef9e 100644 --- a/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java +++ b/operator/src/main/java/com/github/streamshub/console/ConsoleReconciler.java @@ -12,6 +12,7 @@ import com.github.streamshub.console.dependents.ConsoleClusterRoleBinding; import com.github.streamshub.console.dependents.ConsoleDeployment; import com.github.streamshub.console.dependents.ConsoleIngress; +import com.github.streamshub.console.dependents.ConsoleMonitoringClusterRoleBinding; import com.github.streamshub.console.dependents.ConsoleResource; import com.github.streamshub.console.dependents.ConsoleSecret; import com.github.streamshub.console.dependents.ConsoleService; @@ -22,6 +23,7 @@ import com.github.streamshub.console.dependents.PrometheusClusterRoleBinding; import com.github.streamshub.console.dependents.PrometheusConfigMap; import com.github.streamshub.console.dependents.PrometheusDeployment; +import com.github.streamshub.console.dependents.PrometheusPrecondition; import com.github.streamshub.console.dependents.PrometheusService; import com.github.streamshub.console.dependents.PrometheusServiceAccount; @@ -53,23 +55,28 @@ dependents = { @Dependent( name = PrometheusClusterRole.NAME, - type = PrometheusClusterRole.class), + type = PrometheusClusterRole.class, + reconcilePrecondition = PrometheusPrecondition.class), @Dependent( name = PrometheusServiceAccount.NAME, - type = PrometheusServiceAccount.class), + type = PrometheusServiceAccount.class, + reconcilePrecondition = PrometheusPrecondition.class), @Dependent( name = PrometheusClusterRoleBinding.NAME, type = PrometheusClusterRoleBinding.class, + reconcilePrecondition = PrometheusPrecondition.class, dependsOn = { PrometheusClusterRole.NAME, PrometheusServiceAccount.NAME }), @Dependent( name = PrometheusConfigMap.NAME, - type = PrometheusConfigMap.class), + type = PrometheusConfigMap.class, + reconcilePrecondition = PrometheusPrecondition.class), @Dependent( name = PrometheusDeployment.NAME, type = PrometheusDeployment.class, + reconcilePrecondition = PrometheusPrecondition.class, dependsOn = { PrometheusClusterRoleBinding.NAME, PrometheusConfigMap.NAME @@ -78,6 +85,7 @@ @Dependent( name = PrometheusService.NAME, type = PrometheusService.class, + reconcilePrecondition = PrometheusPrecondition.class, dependsOn = { PrometheusDeployment.NAME }), @@ -94,6 +102,13 @@ ConsoleClusterRole.NAME, ConsoleServiceAccount.NAME }), + @Dependent( + name = ConsoleMonitoringClusterRoleBinding.NAME, + type = ConsoleMonitoringClusterRoleBinding.class, + reconcilePrecondition = ConsoleMonitoringClusterRoleBinding.Precondition.class, + dependsOn = { + ConsoleServiceAccount.NAME + }), @Dependent( name = ConsoleSecret.NAME, type = ConsoleSecret.class), @@ -113,8 +128,7 @@ dependsOn = { ConsoleClusterRoleBinding.NAME, ConsoleSecret.NAME, - ConsoleIngress.NAME, - PrometheusService.NAME + ConsoleIngress.NAME }, readyPostcondition = DeploymentReadyCondition.class), }) @@ -136,21 +150,21 @@ }), description = """ The Streamshub Console provides a web-based user interface tool for monitoring Apache Kafka® instances within a Kubernetes based cluster. - - It features a user-friendly way to view Kafka topics and consumer groups, facilitating the searching and filtering of streamed messages. The console also offers insights into Kafka broker disk usage, helping administrators monitor and optimize resource utilization. By simplifying complex Kafka operations, the Streamshub Console enhances the efficiency and effectiveness of data streaming management within Kubernetes environments. - + + It features a user-friendly way to view Kafka topics and consumer groups, facilitating the searching and filtering of streamed messages. The console also offers insights into Kafka broker disk usage, helping administrators monitor and optimize resource utilization. By simplifying complex Kafka operations, the Streamshub Console enhances the efficiency and effectiveness of data streaming management within Kubernetes environments. + ### Documentation Documentation to the current _main_ branch as well as all releases can be found on our [Github](https://github.com/streamshub/console). - + ### Contributing You can contribute to Console by: * Raising any issues you find while using Console * Fixing issues by opening Pull Requests * Improving user documentation * Talking about Console - + The [Contributor Guide](https://github.com/streamshub/console/blob/main/CONTRIBUTING.md) describes how to contribute to Console. - + ### License Console is licensed under the [Apache License, Version 2.0](https://github.com/streamshub/console?tab=Apache-2.0-1-ov-file#readme). For more details, visit the GitHub repository.""", 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 a09f1fd16..077808996 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 @@ -4,6 +4,7 @@ import java.util.List; import com.fasterxml.jackson.annotation.JsonInclude; +import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource; import io.fabric8.generator.annotation.Required; import io.fabric8.kubernetes.api.model.EnvVar; @@ -11,19 +12,29 @@ @Buildable(builderPackage = "io.fabric8.kubernetes.api.builder") @JsonInclude(JsonInclude.Include.NON_NULL) +// Enable validation rules for unique names when array maxItems and string maxLength can be specified +// to influence Kubernetes's estimated rule cost. +// https://github.com/fabric8io/kubernetes-client/pull/6447 +// +// @ValidationRule(value = """ +// !has(self.metricsSources) || +// self.metricsSources.all(s1, self.metricsSources.exists_one(s2, s2.name == s1.name)) +// """, +// message = "Metrics source names must be unique") public class ConsoleSpec { @Required String hostname; - Images images = new Images(); + Images images; + + List metricsSources; List schemaRegistries; List kafkaClusters = new ArrayList<>(); - // TODO: copy EnvVar into console's API to avoid unexpected changes - List env = new ArrayList<>(); + List env; public String getHostname() { return hostname; @@ -41,6 +52,14 @@ public void setImages(Images images) { this.images = images; } + public List getMetricsSources() { + return metricsSources; + } + + public void setMetricsSources(List metricsSources) { + this.metricsSources = metricsSources; + } + public List getSchemaRegistries() { return schemaRegistries; } 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 1f8f13745..fde38d495 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 @@ -51,6 +51,12 @@ public class KafkaCluster { private Credentials credentials; + @JsonPropertyDescription(""" + Name of a configured Prometheus metrics source to use for this Kafka \ + cluster to display resource utilization charts in the console. + """) + private String metricsSource; + @JsonPropertyDescription(""" Name of a configured Apicurio Registry instance to use for serializing \ and de-serializing records written to or read from this Kafka cluster. @@ -105,6 +111,14 @@ public void setCredentials(Credentials credentials) { this.credentials = credentials; } + public String getMetricsSource() { + return metricsSource; + } + + public void setMetricsSource(String metricsSource) { + this.metricsSource = metricsSource; + } + public String getSchemaRegistry() { return schemaRegistry; } diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/metrics/MetricsSource.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/metrics/MetricsSource.java new file mode 100644 index 000000000..d9b1546fa --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/metrics/MetricsSource.java @@ -0,0 +1,84 @@ +package com.github.streamshub.console.api.v1alpha1.spec.metrics; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonValue; + +import io.fabric8.generator.annotation.Required; +import io.sundr.builder.annotations.Buildable; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder") +@JsonInclude(JsonInclude.Include.NON_NULL) +public class MetricsSource { + + @Required + private String name; + @Required + private Type type; + private String url; + private MetricsSourceAuthentication authentication; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public Type getType() { + return type; + } + + public void setType(Type type) { + this.type = type; + } + + public String getUrl() { + return url; + } + + public void setUrl(String url) { + this.url = url; + } + + public MetricsSourceAuthentication getAuthentication() { + return authentication; + } + + public void setAuthentication(MetricsSourceAuthentication authentication) { + this.authentication = authentication; + } + + public enum Type { + EMBEDDED("embedded"), + OPENSHIFT_MONITORING("openshift-monitoring"), + STANDALONE("standalone"); + + private final String value; + + private Type(String value) { + this.value = value; + } + + @JsonValue + public String value() { + return value; + } + + @JsonCreator + public static Type fromValue(String value) { + if (value == null) { + return STANDALONE; + } + + for (var type : values()) { + if (type.value.equals(value.trim())) { + return type; + } + } + + throw new IllegalArgumentException("Invalid Prometheus type: " + value); + } + } +} diff --git a/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/metrics/MetricsSourceAuthentication.java b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/metrics/MetricsSourceAuthentication.java new file mode 100644 index 000000000..ebea0e291 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/api/v1alpha1/spec/metrics/MetricsSourceAuthentication.java @@ -0,0 +1,42 @@ +package com.github.streamshub.console.api.v1alpha1.spec.metrics; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import io.fabric8.generator.annotation.ValidationRule; +import io.sundr.builder.annotations.Buildable; + +@Buildable(builderPackage = "io.fabric8.kubernetes.api.builder") +@JsonInclude(JsonInclude.Include.NON_NULL) +@ValidationRule( + value = "has(self.token) || (has(self.username) && has(self.password))", + message = "One of `token` or `username` + `password` must be provided") +public class MetricsSourceAuthentication { + + private String username; + private String password; + private String token; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } +} \ No newline at end of file diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/BaseClusterRoleBinding.java b/operator/src/main/java/com/github/streamshub/console/dependents/BaseClusterRoleBinding.java index 72b7f5d10..8be70c383 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/BaseClusterRoleBinding.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/BaseClusterRoleBinding.java @@ -43,7 +43,7 @@ protected ClusterRoleBinding desired(Console primary, Context context) .edit() .editMetadata() .withName(instanceName(primary)) - .withLabels(commonLabels(appName)) + .withLabels(commonLabels(appName, resourceName)) .endMetadata() .editRoleRef() .withName(roleName(primary)) diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/BaseLabelDiscriminator.java b/operator/src/main/java/com/github/streamshub/console/dependents/BaseLabelDiscriminator.java index 06db214ae..d2ddd8ef8 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/BaseLabelDiscriminator.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/BaseLabelDiscriminator.java @@ -1,5 +1,6 @@ package com.github.streamshub.console.dependents; +import java.util.Map; import java.util.Optional; import io.fabric8.kubernetes.api.model.HasMetadata; @@ -8,19 +9,27 @@ abstract class BaseLabelDiscriminator implements ResourceDiscriminator { - private final String label; - private final String matchValue; + private final Map matchLabels; protected BaseLabelDiscriminator(String label, String matchValue) { - this.label = label; - this.matchValue = matchValue; + this.matchLabels = Map.of(label, matchValue); + } + + protected BaseLabelDiscriminator(Map matchLabels) { + this.matchLabels = Map.copyOf(matchLabels); } public Optional distinguish(Class resourceType, HasMetadata primary, Context context) { return context.getSecondaryResourcesAsStream(resourceType) - .filter(d -> matchValue.equals(d.getMetadata().getLabels().get(label))) + .filter(this::matches) .findFirst(); } + + private boolean matches(HasMetadata resource) { + return matchLabels.entrySet() + .stream() + .allMatch(label -> label.getValue().equals(resource.getMetadata().getLabels().get(label.getKey()))); + } } diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleClusterRoleBinding.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleClusterRoleBinding.java index 2a125e137..e656fbfc7 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleClusterRoleBinding.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleClusterRoleBinding.java @@ -1,5 +1,7 @@ package com.github.streamshub.console.dependents; +import java.util.Map; + import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -12,11 +14,17 @@ @KubernetesDependent( namespaces = Constants.WATCH_ALL_NAMESPACES, labelSelector = ConsoleResource.MANAGEMENT_SELECTOR, - resourceDiscriminator = ConsoleLabelDiscriminator.class) + resourceDiscriminator = ConsoleClusterRoleBinding.Discriminator.class) public class ConsoleClusterRoleBinding extends BaseClusterRoleBinding { public static final String NAME = "console-clusterrolebinding"; + public static class Discriminator extends ConsoleLabelDiscriminator { + public Discriminator() { + super(Map.of(COMPONENT_LABEL, NAME)); + } + } + @Inject ConsoleClusterRole clusterRole; 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 8b053ce7f..28f9cb6da 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 @@ -1,5 +1,6 @@ package com.github.streamshub.console.dependents; +import java.util.Collections; import java.util.Map; import java.util.Optional; @@ -9,6 +10,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty; import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.api.v1alpha1.spec.Images; import io.fabric8.kubernetes.api.model.apps.Deployment; import io.javaoperatorsdk.operator.api.reconciler.Context; @@ -23,9 +25,6 @@ public class ConsoleDeployment extends CRUDKubernetesDependentResource context) { String name = instanceName(primary); String configSecretName = secret.instanceName(primary); - var imagesSpec = primary.getSpec().getImages(); - String imageAPI = Optional.ofNullable(imagesSpec.getApi()).orElse(defaultAPIImage); - String imageUI = Optional.ofNullable(imagesSpec.getUi()).orElse(defaultUIImage); + var imagesSpec = Optional.ofNullable(primary.getSpec().getImages()); + String imageAPI = imagesSpec.map(Images::getApi).orElse(defaultAPIImage); + String imageUI = imagesSpec.map(Images::getUi).orElse(defaultUIImage); return desired.edit() .editMetadata() @@ -85,13 +84,10 @@ protected Deployment desired(Console primary, Context context) { .endVolume() .editMatchingContainer(c -> "console-api".equals(c.getName())) .withImage(imageAPI) - .addAllToEnv(primary.getSpec().getEnv()) + .addAllToEnv(coalesce(primary.getSpec().getEnv(), Collections::emptyList)) .endContainer() .editMatchingContainer(c -> "console-ui".equals(c.getName())) .withImage(imageUI) - .editMatchingEnv(env -> "CONSOLE_METRICS_PROMETHEUS_URL".equals(env.getName())) - .withValue(getAttribute(context, PrometheusService.NAME + ".url", String.class)) - .endEnv() .editMatchingEnv(env -> "NEXTAUTH_URL".equals(env.getName())) .withValue(getAttribute(context, ConsoleIngress.NAME + ".url", String.class)) .endEnv() diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleLabelDiscriminator.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleLabelDiscriminator.java index 7f7e85218..9bff7f1f1 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleLabelDiscriminator.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleLabelDiscriminator.java @@ -1,5 +1,9 @@ package com.github.streamshub.console.dependents; +import java.util.Map; +import java.util.stream.Collectors; +import java.util.stream.Stream; + import io.fabric8.kubernetes.api.model.rbac.ClusterRole; public class ConsoleLabelDiscriminator extends BaseLabelDiscriminator { @@ -8,4 +12,10 @@ public ConsoleLabelDiscriminator() { super(ConsoleResource.NAME_LABEL, "console"); } + public ConsoleLabelDiscriminator(Map labels) { + super(Stream.concat( + Stream.of(Map.entry(ConsoleResource.NAME_LABEL, "console")), + labels.entrySet().stream()) + .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue))); + } } diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleMonitoringClusterRoleBinding.java b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleMonitoringClusterRoleBinding.java new file mode 100644 index 000000000..7475cd0a8 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/dependents/ConsoleMonitoringClusterRoleBinding.java @@ -0,0 +1,77 @@ +package com.github.streamshub.console.dependents; + +import java.util.Collections; +import java.util.Map; +import java.util.Optional; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource.Type; + +import io.fabric8.kubernetes.api.model.rbac.ClusterRoleBinding; +import io.javaoperatorsdk.operator.api.reconciler.Constants; +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.kubernetes.KubernetesDependent; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +@ApplicationScoped +@KubernetesDependent( + namespaces = Constants.WATCH_ALL_NAMESPACES, + labelSelector = ConsoleResource.MANAGEMENT_SELECTOR, + resourceDiscriminator = ConsoleMonitoringClusterRoleBinding.Discriminator.class) +public class ConsoleMonitoringClusterRoleBinding extends BaseClusterRoleBinding { + + public static final String NAME = "console-monitoring-clusterrolebinding"; + + public static class Discriminator extends ConsoleLabelDiscriminator { + public Discriminator() { + super(Map.of(COMPONENT_LABEL, NAME)); + } + } + + @Inject + ConsoleClusterRole clusterRole; + + @Inject + ConsoleServiceAccount serviceAccount; + + public ConsoleMonitoringClusterRoleBinding() { + super("console", "console-monitoring.clusterrolebinding.yaml", NAME); + } + + @Override + protected String roleName(Console primary) { + // Hard-coded, pre-existing cluster role available in OCP + return "cluster-monitoring-view"; + } + + @Override + protected String subjectName(Console primary) { + return serviceAccount.instanceName(primary); + } + + /** + * The cluster role binding to `cluster-monitoring-view` will only be created + * if one of the metrics sources is OpenShift Monitoring. + */ + public static class Precondition implements Condition { + @Override + public boolean isMet(DependentResource dependentResource, + Console primary, + Context context) { + + var metricsSources = Optional.ofNullable(primary.getSpec().getMetricsSources()) + .orElseGet(Collections::emptyList); + + if (metricsSources.isEmpty()) { + return false; + } + + return metricsSources.stream() + .anyMatch(prometheus -> prometheus.getType() == Type.OPENSHIFT_MONITORING); + } + } +} 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 276aa1e73..ba771b13c 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 @@ -10,9 +10,11 @@ import java.util.Comparator; import java.util.HexFormat; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.NoSuchElementException; import java.util.Optional; +import java.util.function.Supplier; import com.github.streamshub.console.api.v1alpha1.Console; @@ -23,6 +25,7 @@ public interface ConsoleResource { static final String MANAGED_BY_LABEL = "app.kubernetes.io/managed-by"; static final String NAME_LABEL = "app.kubernetes.io/name"; + static final String COMPONENT_LABEL = "app.kubernetes.io/component"; static final String INSTANCE_LABEL = "app.kubernetes.io/instance"; static final String MANAGER = "streamshub-console-operator"; @@ -59,9 +62,16 @@ default void setAttribute(Context context, String key, T value) { } default Map commonLabels(String appName) { + return commonLabels(appName, null); + } + + default Map commonLabels(String appName, String componentName) { Map labels = new LinkedHashMap<>(); labels.putAll(MANAGEMENT_LABEL); labels.put(NAME_LABEL, appName); + if (componentName != null) { + labels.put(COMPONENT_LABEL, componentName); + } return labels; } @@ -101,4 +111,10 @@ default String encodeString(String value) { default String decodeString(String encodedValue) { return new String(Base64.getDecoder().decode(encodedValue), StandardCharsets.UTF_8); } + + 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 fbe2ba3b1..733ef8315 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 @@ -9,14 +9,12 @@ import java.util.Collection; import java.util.Collections; 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 java.util.function.Supplier; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; @@ -27,20 +25,27 @@ 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.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.HasMetadata; import io.fabric8.kubernetes.api.model.Secret; import io.fabric8.kubernetes.api.model.SecretBuilder; +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; @@ -58,11 +63,15 @@ public class ConsoleSecret extends CRUDKubernetesDependentResource implements ConsoleResource { public static final String NAME = "console-secret"; + private static final String EMBEDDED_METRICS_NAME = "streamshub.console.embedded-prometheus"; private static final Random RANDOM = new SecureRandom(); @Inject ObjectMapper objectMapper; + @Inject + PrometheusService prometheusService; + public ConsoleSecret() { super(Secret.class); } @@ -83,7 +92,8 @@ protected Secret desired(Console primary, Context context) { var consoleConfig = buildConfig(primary, context); try { - data.put("console-config.yaml", encodeString(objectMapper.writeValueAsString(consoleConfig))); + var yaml = objectMapper.copyWith(new YAMLFactory()); + data.put("console-config.yaml", encodeString(yaml.writeValueAsString(consoleConfig))); } catch (JsonProcessingException e) { throw new UncheckedIOException(e); } @@ -115,19 +125,11 @@ private static String base64String(int length) { return new String(buffer.toByteArray()).substring(0, length); } - private static List coalesce(List value, Supplier> defaultValue) { - return value != null ? value : defaultValue.get(); - } - private ConsoleConfig buildConfig(Console primary, Context context) { ConsoleConfig config = new ConsoleConfig(); - 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); - } + addMetricsSources(primary, config, context); + addSchemaRegistries(primary, config); for (var kafkaRef : primary.getSpec().getKafkaClusters()) { addConfig(primary, context, config, kafkaRef); @@ -136,6 +138,77 @@ private ConsoleConfig buildConfig(Console primary, Context context) { 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(); @@ -148,6 +221,14 @@ private void addConfig(Console primary, Context context, ConsoleConfig 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); @@ -322,11 +403,18 @@ static T getResource( static T getResource( Context context, Class resourceType, String namespace, String name, boolean optional) { - T resource = context.getClient() - .resources(resourceType) - .inNamespace(namespace) - .withName(name) - .get(); + 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)); diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/PrometheusPrecondition.java b/operator/src/main/java/com/github/streamshub/console/dependents/PrometheusPrecondition.java new file mode 100644 index 000000000..24b889112 --- /dev/null +++ b/operator/src/main/java/com/github/streamshub/console/dependents/PrometheusPrecondition.java @@ -0,0 +1,28 @@ +package com.github.streamshub.console.dependents; + +import java.util.Collections; +import java.util.Optional; + +import com.github.streamshub.console.api.v1alpha1.Console; +import com.github.streamshub.console.api.v1alpha1.spec.metrics.MetricsSource.Type; + +import io.javaoperatorsdk.operator.api.reconciler.Context; +import io.javaoperatorsdk.operator.api.reconciler.dependent.DependentResource; +import io.javaoperatorsdk.operator.processing.dependent.workflow.Condition; + +public class PrometheusPrecondition implements Condition { + + @Override + public boolean isMet(DependentResource dependentResource, Console primary, Context context) { + var metricsSources = Optional.ofNullable(primary.getSpec().getMetricsSources()) + .orElseGet(Collections::emptyList); + + if (metricsSources.isEmpty()) { + return true; + } + + return metricsSources.stream() + .anyMatch(prometheus -> prometheus.getType() == Type.EMBEDDED); + } + +} diff --git a/operator/src/main/java/com/github/streamshub/console/dependents/PrometheusService.java b/operator/src/main/java/com/github/streamshub/console/dependents/PrometheusService.java index 0bcf9658a..e2416f950 100644 --- a/operator/src/main/java/com/github/streamshub/console/dependents/PrometheusService.java +++ b/operator/src/main/java/com/github/streamshub/console/dependents/PrometheusService.java @@ -29,15 +29,12 @@ protected String appName(Console primary) { return deployment.instanceName(primary); } - @Override - protected Service desired(Console primary, Context context) { + String getUrl(Console primary, Context context) { Service desired = super.desired(primary, context); - setAttribute(context, NAME + ".url", "http://%s.%s.svc.cluster.local:%d".formatted( + return "http://%s.%s.svc.cluster.local:%d".formatted( desired.getMetadata().getName(), desired.getMetadata().getNamespace(), - desired.getSpec().getPorts().get(0).getPort())); - - return desired; + desired.getSpec().getPorts().get(0).getPort()); } } diff --git a/operator/src/main/kubernetes/kubernetes.yml b/operator/src/main/kubernetes/kubernetes.yml index 24062082d..2c22807c4 100644 --- a/operator/src/main/kubernetes/kubernetes.yml +++ b/operator/src/main/kubernetes/kubernetes.yml @@ -30,6 +30,16 @@ rules: # temporary until available: https://github.com/operator-framework/java-operator-sdk/pull/2456 - create + # Used by operator to discover the OpenShift Monitoring query endpoint + - apiGroups: + - route.openshift.io + resources: + - routes + resourceNames: + - thanos-querier + verbs: + - get + # Granted to Prometheus instances - apiGroups: [ '' ] resources: @@ -80,3 +90,16 @@ roleRef: subjects: - kind: ServiceAccount name: console-operator +--- +# Required in order to grant to console instances with OpenShift Cluster Monitoring integration +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: consolereconciler-cluster-monitoring-view +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-monitoring-view +subjects: + - kind: ServiceAccount + name: console-operator diff --git a/operator/src/main/resources/com/github/streamshub/console/dependents/console-monitoring.clusterrolebinding.yaml b/operator/src/main/resources/com/github/streamshub/console/dependents/console-monitoring.clusterrolebinding.yaml new file mode 100644 index 000000000..4d88052b3 --- /dev/null +++ b/operator/src/main/resources/com/github/streamshub/console/dependents/console-monitoring.clusterrolebinding.yaml @@ -0,0 +1,12 @@ +kind: ClusterRoleBinding +apiVersion: rbac.authorization.k8s.io/v1 +metadata: + name: console-server-monitoring +roleRef: + apiGroup: rbac.authorization.k8s.io + kind: ClusterRole + name: cluster-monitoring-view +subjects: + - kind: ServiceAccount + name: console-server + namespace: ${NAMESPACE} 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 570f82296..20a2d61d4 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,6 +13,13 @@ spec: spec: serviceAccountName: placeholder volumes: + - name: kubernetes-ca + configMap: + name: kube-root-ca.crt + items: + - key: ca.crt + path: kubernetes-ca.pem + defaultMode: 420 - name: cache emptyDir: {} - name: config @@ -26,10 +33,15 @@ spec: - containerPort: 8080 name: http volumeMounts: + - name: kubernetes-ca + mountPath: /etc/ssl/kubernetes-ca.pem + subPath: kubernetes-ca.pem - name: config mountPath: /deployments/console-config.yaml subPath: console-config.yaml env: + - name: QUARKUS_TLS_TRUST_STORE_PEM_CERTS + value: /etc/ssl/kubernetes-ca.pem - name: CONSOLE_CONFIG_PATH value: /deployments/console-config.yaml startupProbe: @@ -81,8 +93,6 @@ spec: value: 'https://${CONSOLE_HOSTNAME}' - name: BACKEND_URL value: 'http://127.0.0.1:8080' - - name: CONSOLE_METRICS_PROMETHEUS_URL - value: 'http://prometheus-operated.${NAMESPACE}.svc.cluster.local:9090' - name: CONSOLE_MODE value: read-only - name: LOG_LEVEL 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 ce720f337..cf9193c3d 100644 --- a/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTest.java +++ b/operator/src/test/java/com/github/streamshub/console/ConsoleReconcilerTest.java @@ -5,6 +5,7 @@ import java.util.Map; import java.util.Optional; import java.util.UUID; +import java.util.function.Consumer; import jakarta.inject.Inject; @@ -15,21 +16,28 @@ 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.metrics.MetricsSource.Type; import com.github.streamshub.console.config.ConsoleConfig; +import com.github.streamshub.console.config.PrometheusConfig; 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.ConfigMapBuilder; +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.apiextensions.v1.CustomResourceDefinitionBuilder; import io.fabric8.kubernetes.api.model.apps.Deployment; -import io.fabric8.kubernetes.client.CustomResource; 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; @@ -45,14 +53,15 @@ 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; -//@QuarkusTestResource(KubernetesServerTestResource.class) @QuarkusTest class ConsoleReconcilerTest { private static final Logger LOGGER = Logger.getLogger(ConsoleReconcilerTest.class); - private static final Duration LIMIT = Duration.ofSeconds(1_000); + private static final Duration LIMIT = Duration.ofSeconds(10); + private static final ObjectMapper YAML = new ObjectMapper(new YAMLFactory()); @Inject KubernetesClient client; @@ -65,7 +74,7 @@ class ConsoleReconcilerTest { Kafka kafkaCR; - public static > C apply(KubernetesClient client, C resource) { + public static T apply(KubernetesClient client, T resource) { client.resource(resource).serverSideApply(); return client.resource(resource).patchStatus(); } @@ -74,6 +83,36 @@ public static > C apply(KubernetesClient cl 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(); @@ -513,7 +552,7 @@ void testConsoleReconciliationWithValidKafkaUser() { assertNotNull(consoleSecret); String configEncoded = consoleSecret.getData().get("console-config.yaml"); byte[] configDecoded = Base64.getDecoder().decode(configEncoded); - ConsoleConfig consoleConfig = new ObjectMapper().readValue(configDecoded, ConsoleConfig.class); + ConsoleConfig consoleConfig = YAML.readValue(configDecoded, ConsoleConfig.class); assertEquals("jaas-config-value", consoleConfig.getKafka().getClusters().get(0).getProperties().get(SaslConfigs.SASL_JAAS_CONFIG)); }); @@ -589,7 +628,7 @@ void testConsoleReconciliationWithKafkaProperties() { assertNotNull(consoleSecret); String configEncoded = consoleSecret.getData().get("console-config.yaml"); byte[] configDecoded = Base64.getDecoder().decode(configEncoded); - ConsoleConfig consoleConfig = new ObjectMapper().readValue(configDecoded, ConsoleConfig.class); + ConsoleConfig consoleConfig = YAML.readValue(configDecoded, ConsoleConfig.class); var kafkaConfig = consoleConfig.getKafka().getClusters().get(0); assertEquals("x-prop-value", kafkaConfig.getProperties().get("x-prop-name")); assertEquals("x-admin-prop-value", kafkaConfig.getAdminProperties().get("x-admin-prop-name")); @@ -622,24 +661,7 @@ void testConsoleReconciliationWithSchemaRegistryUrl() { 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 ready = console.getStatus().getConditions().get(0); - assertEquals("Ready", ready.getType()); - assertEquals("False", ready.getStatus()); - assertEquals("DependentsNotReady", ready.getReason()); - - 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 = new ObjectMapper().readValue(configDecoded, ConsoleConfig.class); - + assertConsoleConfig(consoleConfig -> { String registryName = consoleConfig.getSchemaRegistries().get(0).getName(); assertEquals("example-registry", registryName); String registryUrl = consoleConfig.getSchemaRegistries().get(0).getUrl(); @@ -650,8 +672,196 @@ void testConsoleReconciliationWithSchemaRegistryUrl() { }); } + @Test + void testConsoleReconciliationWithOpenShiftMonitoring() { + String thanosQueryHost = "thanos.example.com"; + + client.resource(new NamespaceBuilder() + .withNewMetadata() + .withName("openshift-monitoring") + .endMetadata() + .build()) + .serverSideApply(); + + Route thanosQuerier = new RouteBuilder() + .withNewMetadata() + .withNamespace("openshift-monitoring") + .withName("thanos-querier") + .endMetadata() + .withNewSpec() + .endSpec() + .withNewStatus() + .addNewIngress() + .withHost(thanosQueryHost) + .endIngress() + .endStatus() + .build(); + + apply(client, thanosQuerier); + + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName("console-1") + .withNamespace("ns2") + .build()) + .withNewSpec() + .withHostname("example.com") + .addNewMetricsSource() + .withName("ocp-platform-monitoring") + .withType(Type.fromValue("openshift-monitoring")) + .endMetricsSource() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .withMetricsSource("ocp-platform-monitoring") + .endKafkaCluster() + .endSpec() + .build(); + + client.resource(consoleCR).create(); + + assertConsoleConfig(consoleConfig -> { + String metricsName = consoleConfig.getMetricsSources().get(0).getName(); + assertEquals("ocp-platform-monitoring", metricsName); + String metricsUrl = consoleConfig.getMetricsSources().get(0).getUrl(); + assertEquals("https://" + thanosQueryHost, metricsUrl); + + String metricsRef = consoleConfig.getKafka().getClusters().get(0).getMetricsSource(); + assertEquals("ocp-platform-monitoring", metricsRef); + }); + } + + @Test + void testConsoleReconciliationWithPrometheusBasicAuthN() { + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName("console-1") + .withNamespace("ns2") + .build()) + .withNewSpec() + .withHostname("example.com") + .addNewMetricsSource() + .withName("some-prometheus") + .withType(Type.fromValue("standalone")) + .withUrl("https://prometheus.example.com") + .withNewAuthentication() + .withUsername("pr0m3th3u5") + .withPassword("password42") + .endAuthentication() + .endMetricsSource() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .withMetricsSource("some-prometheus") + .endKafkaCluster() + .endSpec() + .build(); + + client.resource(consoleCR).create(); + + assertConsoleConfig(consoleConfig -> { + var prometheusConfig = consoleConfig.getMetricsSources().get(0); + assertEquals("some-prometheus", prometheusConfig.getName()); + assertEquals("https://prometheus.example.com", prometheusConfig.getUrl()); + assertEquals(PrometheusConfig.Type.STANDALONE, prometheusConfig.getType()); + var prometheusAuthN = (PrometheusConfig.Basic) prometheusConfig.getAuthentication(); + assertEquals("pr0m3th3u5", prometheusAuthN.getUsername()); + assertEquals("password42", prometheusAuthN.getPassword()); + + String metricsRef = consoleConfig.getKafka().getClusters().get(0).getMetricsSource(); + assertEquals("some-prometheus", metricsRef); + }); + } + + @Test + void testConsoleReconciliationWithPrometheusBearerAuthN() { + String token = UUID.randomUUID().toString(); + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName("console-1") + .withNamespace("ns2") + .build()) + .withNewSpec() + .withHostname("example.com") + .addNewMetricsSource() + .withName("some-prometheus") + .withType(Type.fromValue("standalone")) + .withUrl("https://prometheus.example.com") + .withNewAuthentication() + .withToken(token) + .endAuthentication() + .endMetricsSource() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .withMetricsSource("some-prometheus") + .endKafkaCluster() + .endSpec() + .build(); + + client.resource(consoleCR).create(); + + assertConsoleConfig(consoleConfig -> { + var prometheusConfig = consoleConfig.getMetricsSources().get(0); + assertEquals("some-prometheus", prometheusConfig.getName()); + assertEquals("https://prometheus.example.com", prometheusConfig.getUrl()); + assertEquals(PrometheusConfig.Type.STANDALONE, prometheusConfig.getType()); + var prometheusAuthN = (PrometheusConfig.Bearer) prometheusConfig.getAuthentication(); + assertEquals(token, prometheusAuthN.getToken()); + + String metricsRef = consoleConfig.getKafka().getClusters().get(0).getMetricsSource(); + assertEquals("some-prometheus", metricsRef); + }); + } + + + @Test + void testConsoleReconciliationWithPrometheusEmptyAuthN() { + Console consoleCR = new ConsoleBuilder() + .withMetadata(new ObjectMetaBuilder() + .withName("console-1") + .withNamespace("ns2") + .build()) + .withNewSpec() + .withHostname("example.com") + .addNewMetricsSource() + .withName("some-prometheus") + .withType(Type.fromValue("standalone")) + .withUrl("https://prometheus.example.com") + .withNewAuthentication() + .endAuthentication() + .endMetricsSource() + .addNewKafkaCluster() + .withName(kafkaCR.getMetadata().getName()) + .withNamespace(kafkaCR.getMetadata().getNamespace()) + .withListener(kafkaCR.getSpec().getKafka().getListeners().get(0).getName()) + .withMetricsSource("some-prometheus") + .endKafkaCluster() + .endSpec() + .build(); + + var resourceClient = client.resource(consoleCR); + // Fails K8s resource validation due to empty `spec.metricsSources[0].authentication object + assertThrows(KubernetesClientException.class, resourceClient::create); + } + // Utility + 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); + }); + } + private Deployment setReady(Deployment deployment) { int desiredReplicas = Optional.ofNullable(deployment.getSpec().getReplicas()).orElse(1); diff --git a/pom.xml b/pom.xml index 42198738c..067fb2a0c 100644 --- a/pom.xml +++ b/pom.xml @@ -50,6 +50,12 @@ java:S110 **/dependents/*.java + + + localhost + streamshub + ${project.version} + false diff --git a/ui/CONTRIBUTING.md b/ui/CONTRIBUTING.md index d85599625..cb9622f83 100644 --- a/ui/CONTRIBUTING.md +++ b/ui/CONTRIBUTING.md @@ -20,7 +20,6 @@ Create a `.env` file containing the details about where to find the API server, ```.dotenv # the actual URLs will depend on how you installed the console BACKEND_URL=http://api.my-cluster -CONSOLE_METRICS_PROMETHEUS_URL=http://prometheus.my-cluster LOG_LEVEL=info ``` diff --git a/ui/api/kafka/actions.ts b/ui/api/kafka/actions.ts index 58319be8b..560d3a5aa 100644 --- a/ui/api/kafka/actions.ts +++ b/ui/api/kafka/actions.ts @@ -2,29 +2,11 @@ import { getHeaders } from "@/api/api"; import { ClusterDetail, - ClusterKpis, - ClusterKpisSchema, ClusterList, ClusterResponse, ClustersResponseSchema, - MetricRange, - MetricRangeSchema, } from "@/api/kafka/schema"; import { logger } from "@/utils/logger"; -import groupBy from "lodash.groupby"; -import { PrometheusDriver } from "prometheus-query"; -import * as clusterPromql from "./cluster.promql"; -import { values } from "./kpi.promql"; -import * as topicPromql from "./topic.promql"; - -export type ClusterMetric = keyof typeof clusterPromql; -export type TopicMetric = keyof typeof topicPromql; - -const prom = process.env.CONSOLE_METRICS_PROMETHEUS_URL - ? new PrometheusDriver({ - endpoint: process.env.CONSOLE_METRICS_PROMETHEUS_URL, - }) - : undefined; const log = logger.child({ module: "kafka-api" }); @@ -56,10 +38,13 @@ export async function getKafkaClusters(): Promise { export async function getKafkaCluster( clusterId: string, + params?: { + fields?: string; + } ): Promise { const sp = new URLSearchParams({ "fields[kafkas]": - "name,namespace,creationTimestamp,status,kafkaVersion,nodes,controller,authorizedOperations,listeners,conditions,nodePools,cruiseControlEnabled", + params?.fields ?? "name,namespace,creationTimestamp,status,kafkaVersion,nodes,controller,authorizedOperations,listeners,conditions,nodePools,cruiseControlEnabled", }); const kafkaClusterQuery = sp.toString(); const url = `${process.env.BACKEND_URL}/api/kafkas/${clusterId}?${kafkaClusterQuery}`; @@ -79,293 +64,6 @@ export async function getKafkaCluster( } } -export async function getKafkaClusterKpis( - clusterId: string, -): Promise<{ cluster: ClusterDetail; kpis: ClusterKpis | null } | null> { - const cluster = await getKafkaCluster(clusterId); - - if (!cluster) { - return null; - } - - if (!prom || !cluster.attributes.namespace) { - log.warn({ clusterId }, "getKafkaClusterKpis: " + - (!cluster.attributes.namespace - ? "Kafka cluster namespace not available" - : "Prometheus not configured or client error")); - return { cluster, kpis: null }; - } - - try { - const valuesRes = await prom.instantQuery( - values( - cluster.attributes.namespace, - cluster.attributes.name, - cluster.attributes.nodePools?.join("|") ?? "", - ), - ); - - log.debug("getKafkaClusterKpis response: " + JSON.stringify(valuesRes)); - - /* - Prometheus returns the data unaggregated. Eg. - - [ - { - "metric": { - "labels": { - "__console_metric_name__": "broker_state", - "nodeId": "2" - } - }, - "value": { - "time": "2023-12-12T16:00:53.381Z", - "value": 3 - } - }, - ... - ] - - We start by flattening the labels, and then group by metric name - */ - const groupedMetrics = groupBy( - valuesRes.result.map((serie) => ({ - metric: serie.metric.labels.__console_metric_name__, - nodeId: serie.metric.labels.nodeId, - time: serie.value.time, - value: serie.value.value, - })), - (v) => v.metric, - ); - - /* - Now we want to transform the data in something easier to work with in the UI. - - Some are totals, in an array form with a single entry; we just need the number. These will look like a metric:value - mapping. - - Some KPIs are provided split by broker id. Of these, some are counts (identified by the string `_count` in the - metric name), and some are other infos. Both will be grouped by nodeId. - The `_count` metrics will have a value with two properties, `byNode` and `total`. `byNode` will hold the grouping. `total` will - have the sum of all the counts. - Other metrics will look like a metric:[node:value] mapping. - - Expected result: - { - "broker_state": { - "0": 3, - "1": 3, - "2": 3 - }, - "replica_count": { - "byNode": { - "0": 57, - "1": 54, - "2": 54 - }, - "total": 165 - }, - "leader_count": { - "byNode": { - "0": 19, - "1": 18, - "2": 18 - }, - "total": 55 - } - } - */ - const kpis = Object.fromEntries( - Object.entries(groupedMetrics).map(([metric, value]) => { - const total = value.reduce((acc, v) => acc + v.value, 0); - if (value.find((v) => v.nodeId)) { - const byNode = Object.fromEntries( - value.map(({ nodeId, value }) => - nodeId ? [nodeId, value] : ["value", value], - ), - ); - return metric.includes("_count") || metric.includes("bytes") - ? [ - metric, - { - byNode, - total, - }, - ] - : [metric, byNode]; - } else { - return [metric, total]; - } - }), - ); - log.debug({ kpis, clusterId }, "getKafkaClusterKpis"); - return { - cluster, - kpis: ClusterKpisSchema.parse(kpis), - }; - } catch (err) { - log.error({ err, clusterId }, "getKafkaClusterKpis"); - return { - cluster, - kpis: null, - }; - } -} - -export async function getKafkaClusterMetrics( - clusterId: string, - metrics: Array, -): Promise<{ - cluster: ClusterDetail; - ranges: Record | null; -} | null> { - async function getRangeByNodeId( - namespace: string, - name: string, - nodePools: string, - metric: ClusterMetric, - ) { - const start = new Date().getTime() - 1 * 60 * 60 * 1000; - const end = new Date(); - const step = 60 * 1; - const seriesRes = await prom!.rangeQuery( - clusterPromql[metric](namespace, name, nodePools), - start, - end, - step, - ); - const serieByNode = Object.fromEntries( - seriesRes.result.map((serie) => [ - serie.metric.labels.nodeId, - Object.fromEntries( - serie.values.map((v: any) => [new Date(v.time).getTime(), v.value]), - ), - ]), - ); - return [metric, MetricRangeSchema.parse(serieByNode)]; - } - - const cluster = await getKafkaCluster(clusterId); - - if (!cluster) { - return null; - } - - if (!prom || !cluster.attributes.namespace) { - log.warn({ clusterId }, "getKafkaClusterMetrics: " + - (!cluster.attributes.namespace - ? "Kafka cluster namespace not available" - : "Prometheus not configured or client error")); - return { cluster, ranges: null }; - } - - try { - const rangesRes = Object.fromEntries( - await Promise.all( - metrics.map((m) => - getRangeByNodeId( - cluster.attributes.namespace!, - cluster.attributes.name, - cluster.attributes.nodePools?.join("|") ?? "", - m, - ), - ), - ), - ); - log.debug( - { ranges: rangesRes, clusterId, metric: metrics }, - "getKafkaClusterMetric", - ); - return { - cluster, - ranges: rangesRes, - }; - } catch (err) { - log.error({ err, clusterId, metric: metrics }, "getKafkaClusterMetric"); - return { - cluster, - ranges: null, - }; - } -} - -export async function getKafkaTopicMetrics( - clusterId: string, - metrics: Array, -): Promise<{ - cluster: ClusterDetail; - ranges: Record | null; -} | null> { - async function getRangeByNodeId( - namespace: string, - name: string, - nodePools: string, - metric: TopicMetric, - ) { - const start = new Date().getTime() - 1 * 60 * 60 * 1000; - const end = new Date(); - const step = 60 * 1; - const seriesRes = await prom!.rangeQuery( - topicPromql[metric](namespace, name, nodePools), - start, - end, - step, - ); - const serieByNode = Object.fromEntries( - seriesRes.result.map((serie) => [ - "all topics", - Object.fromEntries( - serie.values.map((v: any) => [new Date(v.time).getTime(), v.value]), - ), - ]), - ); - return [metric, MetricRangeSchema.parse(serieByNode)]; - } - - const cluster = await getKafkaCluster(clusterId); - - if (!cluster) { - return null; - } - - try { - if (!prom || !cluster.attributes.namespace) { - log.warn({ clusterId }, "getKafkaTopicMetrics: " + - (!cluster.attributes.namespace - ? "Kafka cluster namespace not available" - : "Prometheus not configured or client error")); - return { cluster, ranges: null }; - } - - const rangesRes = Object.fromEntries( - await Promise.all( - metrics.map((m) => - getRangeByNodeId( - cluster.attributes.namespace!, - cluster.attributes.name, - cluster.attributes.nodePools?.join("|") ?? "", - m, - ), - ), - ), - ); - log.debug( - { ranges: rangesRes, clusterId, metric: metrics }, - "getKafkaTopicMetrics", - ); - return { - cluster, - ranges: rangesRes, - }; - } catch (err) { - log.error({ err, clusterId, metric: metrics }, "getKafkaTopicMetrics"); - return { - cluster, - ranges: null, - }; - } -} - export async function updateKafkaCluster( clusterId: string, reconciliationPaused?: boolean, diff --git a/ui/api/kafka/cluster.promql.ts b/ui/api/kafka/cluster.promql.ts deleted file mode 100644 index a03549696..000000000 --- a/ui/api/kafka/cluster.promql.ts +++ /dev/null @@ -1,72 +0,0 @@ -export const cpu = (namespace: string, cluster: string) => ` - sum by (nodeId, __console_metric_name__) ( - label_replace( - label_replace( - rate(container_cpu_usage_seconds_total{namespace="${namespace}",pod=~"${cluster}-.+-\\\\d+",container="kafka"}[1m]), - "nodeId", - "$1", - "pod", - ".+-(\\\\d+)" - ), - "__console_metric_name__", - "cpu_usage_seconds", - "", - "" - ) - ) -`; - -export const memory = (namespace: string, cluster: string) => ` - sum by (nodeId, __console_metric_name__) ( - label_replace( - label_replace( - container_memory_usage_bytes{namespace="${namespace}",pod=~"${cluster}-.+-\\\\d+",container="kafka"}, - "nodeId", - "$1", - "pod", - ".+-(\\\\d+)" - ), - "__console_metric_name__", - "memory_usage_bytes", - "", - "" - ) - ) -`; - -export const volumeCapacity = (namespace: string, cluster: string, nodePools: string) => ` - sum by (nodeId, __console_metric_name__) ( - label_replace( - label_replace( - kubelet_volume_stats_capacity_bytes{namespace="${namespace}",persistentvolumeclaim=~"data(?:-\\\\d+)?-${cluster}-(kafka|${nodePools})-\\\\d+"}, - "nodeId", - "$1", - "persistentvolumeclaim", - ".+-(\\\\d+)" - ), - "__console_metric_name__", - "volume_stats_capacity_bytes", - "", - "" - ) - ) -`; - -export const volumeUsed = (namespace: string, cluster: string, nodePools: string) => ` - sum by (nodeId, __console_metric_name__) ( - label_replace( - label_replace( - kubelet_volume_stats_used_bytes{namespace="${namespace}",persistentvolumeclaim=~"data(?:-\\\\d+)?-${cluster}-(kafka|${nodePools})-\\\\d+"}, - "nodeId", - "$1", - "persistentvolumeclaim", - ".+-(\\\\d+)" - ), - "__console_metric_name__", - "volume_stats_used_bytes", - "", - "" - ) - ) -`; - diff --git a/ui/api/kafka/kpi.promql.ts b/ui/api/kafka/kpi.promql.ts deleted file mode 100644 index b935f1f52..000000000 --- a/ui/api/kafka/kpi.promql.ts +++ /dev/null @@ -1,93 +0,0 @@ -export const values = ( - namespace: string, - cluster: string, - nodePools: string, -) => ` -sum by (__console_metric_name__, nodeId) ( - label_replace( - label_replace( - kafka_server_kafkaserver_brokerstate{namespace="${namespace}",pod=~"${cluster}-.+-\\\\d+",strimzi_io_kind="Kafka"} > 0, - "nodeId", - "$1", - "pod", - ".+-(\\\\d+)" - ), - "__console_metric_name__", - "broker_state", - "", - "" - ) -) - -or - -sum by (__console_metric_name__, nodeId) ( - label_replace( - label_replace( - kafka_server_replicamanager_partitioncount{namespace="${namespace}",pod=~"${cluster}-.+-\\\\d+",strimzi_io_kind="Kafka"} > 0, - "nodeId", - "$1", - "pod", - ".+-(\\\\d+)" - ), - "__console_metric_name__", - "replica_count", - "", - "" - ) -) - -or - -sum by (__console_metric_name__, nodeId) ( - label_replace( - label_replace( - kafka_server_replicamanager_leadercount{namespace="${namespace}",pod=~"${cluster}-.+-\\\\d+",strimzi_io_kind="Kafka"} > 0, - "nodeId", - "$1", - "pod", - ".+-(\\\\d+)" - ), - "__console_metric_name__", - "leader_count", - "", - "" - ) -) - -or - -sum by (__console_metric_name__, nodeId) ( - label_replace( - label_replace( - kubelet_volume_stats_capacity_bytes{namespace="${namespace}",persistentvolumeclaim=~"data(?:-\\\\d+)?-${cluster}-(kafka|${nodePools})-\\\\d+"}, - "nodeId", - "$1", - "persistentvolumeclaim", - ".+-(\\\\d+)" - ), - "__console_metric_name__", - "volume_stats_capacity_bytes", - "", - "" - ) -) - -or - -sum by (__console_metric_name__, nodeId) ( - label_replace( - label_replace( - kubelet_volume_stats_used_bytes{namespace="${namespace}",persistentvolumeclaim=~"data(?:-\\\\d+)?-${cluster}-(kafka|${nodePools})-\\\\d+"}, - "nodeId", - "$1", - "persistentvolumeclaim", - ".+-(\\\\d+)" - ), - "__console_metric_name__", - "volume_stats_used_bytes", - "", - "" - ) -) -`; diff --git a/ui/api/kafka/schema.ts b/ui/api/kafka/schema.ts index c0c0bbe42..1509b8d8f 100644 --- a/ui/api/kafka/schema.ts +++ b/ui/api/kafka/schema.ts @@ -80,44 +80,29 @@ const ClusterDetailSchema = z.object({ .nullable() .optional(), nodePools: z.array(z.string()).optional().nullable(), + metrics: z + .object({ + values: z.record( + z.array(z.object({ + value: z.string(), + nodeId: z.string(), + })), + ), + ranges: z.record( + z.array(z.object({ + range: z.array(z.array( + z.string(), + z.string(), + )), + nodeId: z.string().optional(), + })), + ), + }) + .optional() + .nullable(), }), }); export const ClusterResponse = z.object({ data: ClusterDetailSchema, }); export type ClusterDetail = z.infer; - -export const ClusterKpisSchema = z.object({ - broker_state: z.record(z.number()).optional(), - replica_count: z - .object({ - byNode: z.record(z.number()).optional(), - total: z.number().optional(), - }) - .optional(), - leader_count: z - .object({ - byNode: z.record(z.number()).optional(), - total: z.number().optional(), - }) - .optional(), - volume_stats_capacity_bytes: z - .object({ - byNode: z.record(z.number()).optional(), - total: z.number().optional(), - }) - .optional(), - volume_stats_used_bytes: z - .object({ - byNode: z.record(z.number()).optional(), - total: z.number().optional(), - }) - .optional(), -}); -export type ClusterKpis = z.infer; - -export const MetricRangeSchema = z.record( - z.string(), - z.record(z.number()).optional(), -); -export type MetricRange = z.infer; diff --git a/ui/api/kafka/topic.promql.ts b/ui/api/kafka/topic.promql.ts deleted file mode 100644 index 0f883aed1..000000000 --- a/ui/api/kafka/topic.promql.ts +++ /dev/null @@ -1,31 +0,0 @@ -export const incomingByteRate = ( - namespace: string, - cluster: string, - nodePools: string, -) => ` - sum by (__console_metric_name__) ( - label_replace( - irate(kafka_server_brokertopicmetrics_bytesin_total{topic!="",namespace="${namespace}",pod=~"${cluster}-(kafka|${nodePools})-\\\\d+",strimzi_io_kind="Kafka"}[5m]), - "__console_metric_name__", - "incoming_byte_rate", - "", - "" - ) - ) -`; - -export const outgoingByteRate = ( - namespace: string, - cluster: string, - nodePools: string, -) => ` - sum by (__console_metric_name__) ( - label_replace( - irate(kafka_server_brokertopicmetrics_bytesout_total{topic!="",namespace="${namespace}",pod=~"${cluster}-(kafka|${nodePools})-\\\\d+",strimzi_io_kind="Kafka"}[5m]), - "__console_metric_name__", - "outgoing_byte_rate", - "", - "" - ) - ) -`; diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/layout.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/layout.tsx index 132a132ff..75f24c23c 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/layout.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/layout.tsx @@ -1,5 +1,5 @@ import { ClusterLinks } from "@/app/[locale]/(authorized)/kafka/[kafkaId]/ClusterLinks"; -import { getAuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getAuthOptions } from "@/app/api/auth/[...nextauth]/auth-options"; import { AppLayout } from "@/components/AppLayout"; import { AppLayoutProvider } from "@/components/AppLayoutProvider"; import { diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/nodes/NodesTable.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/nodes/NodesTable.tsx index aa1b4f23a..75d2523ac 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/nodes/NodesTable.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/nodes/NodesTable.tsx @@ -100,7 +100,7 @@ export function NodesTable({ nodes }: { nodes: Node[] }) { ); case "status": - const isStable = row.status == "Stable"; + const isStable = row.status == "Running"; return ( diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/nodes/page.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/nodes/page.tsx index 3efca94d2..a819c9028 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/nodes/page.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/nodes/page.tsx @@ -1,4 +1,4 @@ -import { getKafkaCluster, getKafkaClusterKpis } from "@/api/kafka/actions"; +import { getKafkaCluster } from "@/api/kafka/actions"; import { KafkaParams } from "@/app/[locale]/(authorized)/kafka/[kafkaId]/kafka.params"; import { DistributionChart } from "@/app/[locale]/(authorized)/kafka/[kafkaId]/nodes/DistributionChart"; import { @@ -6,17 +6,25 @@ import { NodesTable, } from "@/app/[locale]/(authorized)/kafka/[kafkaId]/nodes/NodesTable"; import { Alert, PageSection } from "@/libs/patternfly/react-core"; -import { redirect } from "@/i18n/routing"; import { getTranslations } from "next-intl/server"; import { Suspense } from "react"; function nodeMetric( - metrics: Record | undefined, + metrics: { value: string, nodeId: string }[] | undefined, nodeId: number, ): number { - return metrics ? (metrics[nodeId.toString()] ?? 0) : 0; + return parseFloat(metrics?.find(e => e.nodeId == nodeId.toString())?.value ?? "0"); } +function nodeRangeMetric( + metrics: { range: string[][], nodeId?: string }[] | undefined, + nodeId: number, +): number { + let range = metrics?.find(e => e.nodeId == nodeId.toString())?.range; + return parseFloat(range?.[range?.length - 1]?.[1] ?? "0"); +} + + export default function NodesPage({ params }: { params: KafkaParams }) { return ( @@ -27,29 +35,60 @@ export default function NodesPage({ params }: { params: KafkaParams }) { async function ConnectedNodes({ params }: { params: KafkaParams }) { const t = await getTranslations(); - const res = await getKafkaClusterKpis(params.kafkaId); + const cluster = await getKafkaCluster(params.kafkaId, { + fields: 'name,namespace,creationTimestamp,status,kafkaVersion,nodes,controller,authorizedOperations,listeners,conditions,metrics' + }); + const metrics = cluster?.attributes.metrics; - let { cluster, kpis } = res || {}; + const nodes: Node[] = (cluster?.attributes.nodes ?? []).map((node) => { + let brokerState = metrics && nodeMetric(metrics.values?.["broker_state"], node.id); + let status; - const nodes: Node[] = (cluster?.attributes.nodes || []).map((node) => { - const status = kpis - ? nodeMetric(kpis.broker_state, node.id) === 3 - ? "Stable" - : "Unstable" - : "Unknown"; - const leaders = kpis - ? nodeMetric(kpis.leader_count?.byNode, node.id) + /* + * https://github.com/apache/kafka/blob/3.8.0/metadata/src/main/java/org/apache/kafka/metadata/BrokerState.java + */ + switch (brokerState ?? 127) { + case 0: + status = "Not Running"; + break; + case 1: + status = "Starting"; + break; + case 2: + status = "Recovery"; + break; + case 3: + status = "Running"; + break; + case 6: + status = "Pending Controlled Shutdown"; + break; + case 7: + status = "Shutting Down"; + break; + case 127: + default: + status = "Unknown"; + break; + } + + const leaders = metrics + ? nodeMetric(metrics.values?.["leader_count"], node.id) : undefined; + const followers = - kpis && leaders - ? nodeMetric(kpis.replica_count?.byNode, node.id) - leaders + metrics && leaders + ? nodeMetric(metrics.values?.["replica_count"], node.id) - leaders : undefined; - const diskCapacity = kpis - ? nodeMetric(kpis.volume_stats_capacity_bytes?.byNode, node.id) + + const diskCapacity = metrics + ? nodeRangeMetric(metrics.ranges?.["volume_stats_capacity_bytes"], node.id) : undefined; - const diskUsage = kpis - ? nodeMetric(kpis.volume_stats_used_bytes?.byNode, node.id) + + const diskUsage = metrics + ? nodeRangeMetric(metrics.ranges?.["volume_stats_used_bytes"], node.id) : undefined; + return { id: node.id, status, @@ -71,7 +110,7 @@ async function ConnectedNodes({ params }: { params: KafkaParams }) { return ( <> - {!kpis && ( + {!metrics && ( diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterCard.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterCard.tsx index 9f29a655c..bf336bbcd 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterCard.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterCard.tsx @@ -1,46 +1,49 @@ import { ConsumerGroupsResponse } from "@/api/consumerGroups/schema"; -import { ClusterDetail, ClusterKpis } from "@/api/kafka/schema"; +import { ClusterDetail } from "@/api/kafka/schema"; import { ClusterCard } from "@/components/ClusterOverview/ClusterCard"; export async function ConnectedClusterCard({ - data, + cluster, consumerGroups, }: { - data: Promise<{ cluster: ClusterDetail; kpis: ClusterKpis | null } | null>; + cluster: Promise; consumerGroups: Promise; }) { - const res = await data; - if (!res?.kpis) { + const res = await cluster; + + if (!res?.attributes?.metrics) { return ( ); } const groupCount = await consumerGroups.then( (grpResp) => grpResp?.meta.page.total ?? 0, ); - const brokersTotal = Object.keys(res?.kpis.broker_state ?? {}).length; - const brokersOnline = - Object.values(res?.kpis.broker_state ?? {}).filter((s) => s === 3).length || - 0; - const messages = res?.cluster.attributes.conditions + + const brokersTotal = res?.attributes.metrics?.values?.["broker_state"]?.length ?? 0; + const brokersOnline = (res?.attributes.metrics?.values?.["broker_state"] ?? []) + .filter((s) => s.value === "3") + .length; + + const messages = res?.attributes.conditions ?.filter((c) => "Ready" !== c.type) .map((c) => ({ variant: c.type === "Error" ? "danger" : ("warning" as "danger" | "warning"), subject: { type: c.type!, - name: res?.cluster.attributes.name ?? "", - id: res?.cluster.id ?? "", + name: res?.attributes.name ?? "", + id: res?.id ?? "", }, message: c.message ?? "", date: c.lastTransitionTime ?? "", @@ -49,14 +52,14 @@ export async function ConnectedClusterCard({ return ( ); } diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterChartsCard.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterChartsCard.tsx index fc76246ba..5fb0c677f 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterChartsCard.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterChartsCard.tsx @@ -1,32 +1,74 @@ -import { ClusterMetric } from "@/api/kafka/actions"; -import { ClusterDetail, MetricRange } from "@/api/kafka/schema"; +"use client"; + +import { + Alert, + Card, + CardBody, + CardHeader, + CardTitle, + Title, +} from "@/libs/patternfly/react-core"; + +import { useTranslations } from "next-intl"; +import { ClusterDetail } from "@/api/kafka/schema"; import { ClusterChartsCard } from "@/components/ClusterOverview/ClusterChartsCard"; function timeSeriesMetrics( - ranges: Record | null | undefined, - rangeName: ClusterMetric, -): TimeSeriesMetrics[] { - return ranges - ? Object.values(ranges[rangeName] ?? {}).map((val) => val ?? {}) - : []; + ranges: Record | undefined, + rangeName: string, +): Record { + const series: Record = {}; + + if (ranges) { + Object.values(ranges[rangeName] ?? {}).forEach((r) => { + series[r.nodeId!] = r.range.reduce((a, v) => ({ ...a, [v[0]]: parseFloat(v[1]) }), {} as TimeSeriesMetrics); + }); + } + + return series; } export async function ConnectedClusterChartsCard({ - data, + cluster, }: { - data: Promise<{ - cluster: ClusterDetail; - ranges: Record | null; - } | null>; + cluster: Promise; }) { - const res = await data; + const t = useTranslations(); + const res = await cluster; + + if (res?.attributes.metrics === null) { + /* + * metrics being null (rather than undefined or empty) is how the server + * indicates that metrics are not configured for this cluster. + */ + return ( + + + + + {t("ClusterChartsCard.cluster_metrics")} + + + + + + + + ); + } + return ( ); } diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedTopicChartsCard.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedTopicChartsCard.tsx index aa3b412c0..323c253df 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedTopicChartsCard.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedTopicChartsCard.tsx @@ -1,21 +1,33 @@ -import { TopicMetric } from "@/api/kafka/actions"; -import { ClusterDetail, MetricRange } from "@/api/kafka/schema"; +import { ClusterDetail } from "@/api/kafka/schema"; import { TopicChartsCard } from "@/components/ClusterOverview/TopicChartsCard"; +function timeSeriesMetrics( + ranges: Record | undefined, + rangeName: string, +): TimeSeriesMetrics { + let series: TimeSeriesMetrics = {}; + + if (ranges) { + Object.values(ranges[rangeName] ?? {}).forEach((r) => { + series = r.range.reduce((a, v) => ({ ...a, [v[0]]: parseFloat(v[1]) }), series); + }); + } + + return series; +} + export async function ConnectedTopicChartsCard({ - data, + cluster, }: { - data: Promise<{ - cluster: ClusterDetail; - ranges: Record | null; - } | null>; + cluster: Promise; }) { - const res = await data; + const res = await cluster; + return ( ); } diff --git a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/page.tsx b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/page.tsx index 6054ce526..a591bd6b9 100644 --- a/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/page.tsx +++ b/ui/app/[locale]/(authorized)/kafka/[kafkaId]/overview/page.tsx @@ -1,9 +1,5 @@ import { getConsumerGroups } from "@/api/consumerGroups/actions"; -import { - getKafkaClusterKpis, - getKafkaClusterMetrics, - getKafkaTopicMetrics, -} from "@/api/kafka/actions"; +import { getKafkaCluster } from "@/api/kafka/actions"; import { getTopics, getViewedTopics } from "@/api/topics/actions"; import { KafkaParams } from "@/app/[locale]/(authorized)/kafka/[kafkaId]/kafka.params"; import { ConnectedClusterCard } from "@/app/[locale]/(authorized)/kafka/[kafkaId]/overview/ConnectedClusterCard"; @@ -14,17 +10,9 @@ import { PageLayout } from "@/components/ClusterOverview/PageLayout"; import { ConnectedRecentTopics } from "./ConnectedRecentTopics"; export default function OverviewPage({ params }: { params: KafkaParams }) { - const kpi = getKafkaClusterKpis(params.kafkaId); - const cluster = getKafkaClusterMetrics(params.kafkaId, [ - "volumeUsed", - "volumeCapacity", - "memory", - "cpu", - ]); - const topic = getKafkaTopicMetrics(params.kafkaId, [ - "outgoingByteRate", - "incomingByteRate", - ]); + const kafkaCluster = getKafkaCluster(params.kafkaId, { + fields: 'name,namespace,creationTimestamp,status,kafkaVersion,nodes,controller,authorizedOperations,listeners,conditions,metrics' + }); const topics = getTopics(params.kafkaId, { fields: "status", pageSize: 1 }); const consumerGroups = getConsumerGroups(params.kafkaId, { fields: "state" }); const viewedTopics = getViewedTopics().then((topics) => @@ -34,11 +22,11 @@ export default function OverviewPage({ params }: { params: KafkaParams }) { return ( + } topicsPartitions={} - clusterCharts={} - topicCharts={} + clusterCharts={} + topicCharts={} recentTopics={} /> ); diff --git a/ui/app/[locale]/(authorized)/layout.tsx b/ui/app/[locale]/(authorized)/layout.tsx index a7d7e232c..b9a1f5cfc 100644 --- a/ui/app/[locale]/(authorized)/layout.tsx +++ b/ui/app/[locale]/(authorized)/layout.tsx @@ -1,4 +1,4 @@ -import { getAuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getAuthOptions } from "@/app/api/auth/[...nextauth]/auth-options"; import { getServerSession } from "next-auth"; import { ReactNode } from "react"; diff --git a/ui/app/api/auth/[...nextauth]/auth-options.ts b/ui/app/api/auth/[...nextauth]/auth-options.ts new file mode 100644 index 000000000..5c70fcf43 --- /dev/null +++ b/ui/app/api/auth/[...nextauth]/auth-options.ts @@ -0,0 +1,48 @@ +import { getKafkaClusters } from "@/api/kafka/actions"; +import { ClusterList } from "@/api/kafka/schema"; +import { logger } from "@/utils/logger"; +import { AuthOptions } from "next-auth"; +import { Provider } from "next-auth/providers/index"; +import { makeAnonymous } from "./anonymous"; +import { makeOauthTokenProvider } from "./oauth-token"; +import { makeScramShaProvider } from "./scram"; + +const log = logger.child({ module: "auth" }); + +function makeAuthOption(cluster: ClusterList): Provider { + switch (cluster.meta.authentication?.method) { + case "oauth": { + const { tokenUrl } = cluster.meta.authentication; + return makeOauthTokenProvider(tokenUrl ?? "TODO"); + } + case "basic": + return makeScramShaProvider(cluster.id); + case "anonymous": + default: + return makeAnonymous(); + } +} + +export async function getAuthOptions(): Promise { + // retrieve the authentication method required by the default Kafka cluster + const clusters = await getKafkaClusters(); + const providers = clusters.map(makeAuthOption); + log.trace({ providers }, "getAuthOptions"); + return { + providers, + callbacks: { + async jwt({ token, user }) { + if (user) { + token.authorization = user.authorization; + } + return token; + }, + async session({ session, token, user }) { + // Send properties to the client, like an access_token and user id from a provider. + session.authorization = token.authorization; + + return session; + }, + }, + }; +} \ No newline at end of file diff --git a/ui/app/api/auth/[...nextauth]/route.ts b/ui/app/api/auth/[...nextauth]/route.ts index 812db7da4..731ad900c 100644 --- a/ui/app/api/auth/[...nextauth]/route.ts +++ b/ui/app/api/auth/[...nextauth]/route.ts @@ -1,54 +1,7 @@ -import { getKafkaClusters } from "@/api/kafka/actions"; -import { ClusterList } from "@/api/kafka/schema"; -import { logger } from "@/utils/logger"; -import NextAuth, { AuthOptions } from "next-auth"; -import { Provider } from "next-auth/providers/index"; +import NextAuth from "next-auth"; import { NextRequest, NextResponse } from "next/server"; -import { makeAnonymous } from "./anonymous"; -import { makeOauthTokenProvider } from "./oauth-token"; -import { makeScramShaProvider } from "./scram"; +import { getAuthOptions } from "./auth-options"; -const log = logger.child({ module: "auth" }); - -export async function getAuthOptions(): Promise { - // retrieve the authentication method required by the default Kafka cluster - const clusters = await getKafkaClusters(); - const providers = clusters.map(makeAuthOption); - log.trace({ providers }, "getAuthOptions"); - return { - providers, - callbacks: { - async jwt({ token, user }) { - if (user) { - token.authorization = user.authorization; - } - return token; - }, - async session({ session, token, user }) { - // Send properties to the client, like an access_token and user id from a provider. - session.authorization = token.authorization; - - return session; - }, - }, - }; -} - -function makeAuthOption(cluster: ClusterList): Provider { - switch (cluster.meta.authentication?.method) { - case "oauth": { - const { tokenUrl } = cluster.meta.authentication; - return makeOauthTokenProvider(tokenUrl ?? "TODO"); - } - case "basic": - return makeScramShaProvider(cluster.id); - case "anonymous": - default: - return makeAnonymous(); - } -} - -// const handler = NextAuth(authOptions); async function handler(req: NextRequest, res: NextResponse) { const authOptions = await getAuthOptions(); if (authOptions) { diff --git a/ui/components/ClusterOverview/ClusterChartsCard.tsx b/ui/components/ClusterOverview/ClusterChartsCard.tsx index c09e6d91b..aa4a799d5 100644 --- a/ui/components/ClusterOverview/ClusterChartsCard.tsx +++ b/ui/components/ClusterOverview/ClusterChartsCard.tsx @@ -17,10 +17,10 @@ import { HelpIcon } from "@/libs/patternfly/react-icons"; import { useTranslations } from "next-intl"; type ClusterChartsCardProps = { - usedDiskSpace: TimeSeriesMetrics[]; - availableDiskSpace: TimeSeriesMetrics[]; - memoryUsage: TimeSeriesMetrics[]; - cpuUsage: TimeSeriesMetrics[]; + usedDiskSpace: Record; + availableDiskSpace: Record; + memoryUsage: Record; + cpuUsage: Record; }; export function ClusterChartsCard({ diff --git a/ui/components/ClusterOverview/TopicChartsCard.tsx b/ui/components/ClusterOverview/TopicChartsCard.tsx index b33303584..a6d1c2ca7 100644 --- a/ui/components/ClusterOverview/TopicChartsCard.tsx +++ b/ui/components/ClusterOverview/TopicChartsCard.tsx @@ -1,5 +1,4 @@ "use client"; -import { MetricRange } from "@/api/kafka/schema"; import { Card, CardBody, @@ -15,8 +14,8 @@ import { ChartSkeletonLoader } from "./components/ChartSkeletonLoader"; import { useTranslations } from "next-intl"; type TopicChartsCardProps = { - incoming: MetricRange; - outgoing: MetricRange; + incoming: TimeSeriesMetrics; + outgoing: TimeSeriesMetrics; }; export function TopicChartsCard({ diff --git a/ui/components/ClusterOverview/components/ChartCpuUsage.tsx b/ui/components/ClusterOverview/components/ChartCpuUsage.tsx index 32eb9520b..e6ca7618e 100644 --- a/ui/components/ClusterOverview/components/ChartCpuUsage.tsx +++ b/ui/components/ClusterOverview/components/ChartCpuUsage.tsx @@ -15,7 +15,7 @@ import { getHeight, getPadding } from "./chartConsts"; import { useChartWidth } from "./useChartWidth"; type ChartCpuUsageProps = { - usages: TimeSeriesMetrics[]; + usages: Record; }; type Datum = { @@ -29,7 +29,15 @@ export function ChartCpuUsage({ usages }: ChartCpuUsageProps) { const format = useFormatter(); const [containerRef, width] = useChartWidth(); - const itemsPerRow = width > 650 ? 6 : width > 300 ? 3 : 1; + let itemsPerRow; + + if (width > 650) { + itemsPerRow = 6; + } else if (width > 300) { + itemsPerRow = 3; + } else { + itemsPerRow = 1; + } const hasMetrics = Object.keys(usages).length > 0; if (!hasMetrics) { @@ -42,11 +50,11 @@ export function ChartCpuUsage({ usages }: ChartCpuUsageProps) { /> ); } - // const showDate = shouldShowDate(duration); + const CursorVoronoiContainer = createContainer("voronoi", "cursor"); - const legendData = usages.map((_, idx) => ({ - name: `Node ${idx}`, - childName: `node ${idx}`, + const legendData = Object.keys(usages).map((nodeId) => ({ + name: `Node ${nodeId}`, + childName: `node ${nodeId}`, })); const padding = getPadding(legendData.length / itemsPerRow); return ( @@ -112,17 +120,18 @@ export function ChartCpuUsage({ usages }: ChartCpuUsageProps) { }} /> - {usages.map((usage, idx) => { - const usageArray = Object.entries(usage); + {Object.entries(usages).map(([nodeId, series]) => { return ( ({ - name: `Node ${idx + 1}`, - x, - y, - }))} - name={`node ${idx}`} + key={ `cpu-usage-${nodeId}` } + data={ Object.entries(series).map(([k, v]) => { + return ({ + name: `Node ${nodeId}`, + x: Date.parse(k), + y: v, + }) + })} + name={ `node ${nodeId}` } /> ); })} diff --git a/ui/components/ClusterOverview/components/ChartDiskUsage.tsx b/ui/components/ClusterOverview/components/ChartDiskUsage.tsx index 831f142cd..40646e084 100644 --- a/ui/components/ClusterOverview/components/ChartDiskUsage.tsx +++ b/ui/components/ClusterOverview/components/ChartDiskUsage.tsx @@ -17,8 +17,8 @@ import { getHeight, getPadding } from "./chartConsts"; import { useChartWidth } from "./useChartWidth"; type ChartDiskUsageProps = { - usages: TimeSeriesMetrics[]; - available: TimeSeriesMetrics[]; + usages: Record; + available: Record; }; type Datum = { x: number; @@ -46,17 +46,23 @@ export function ChartDiskUsage({ usages, available }: ChartDiskUsageProps) { ); } const CursorVoronoiContainer = createContainer("voronoi", "cursor"); - const legendData = [ - ...usages.map((_, idx) => ({ - name: `Node ${idx}`, - childName: `node ${idx}`, - })), - ...usages.map((_, idx) => ({ - name: `Available storage threshold (node ${idx})`, - childName: `threshold ${idx}`, + const legendData: { name: string, childName: string, symbol?: { type: string } }[] = []; + + Object.entries(usages).forEach(([nodeId, _]) => { + legendData.push({ + name: `Node ${nodeId}`, + childName: `node ${nodeId}`, + }); + }); + + Object.entries(usages).forEach(([nodeId, _]) => { + legendData.push({ + name: `Available storage threshold (node ${nodeId})`, + childName: `threshold ${nodeId}`, symbol: { type: "threshold" }, - })), - ]; + }); + }); + const padding = getPadding(legendData.length / itemsPerRow); return (
@@ -117,36 +123,38 @@ export function ChartDiskUsage({ usages, available }: ChartDiskUsageProps) { dependentAxis showGrid={true} tickFormat={(d) => { - return formatBytes(d, { maximumFractionDigits: 0 }); + return formatBytes(d); }} /> - {usages.map((usage, idx) => { - const usageArray = Object.entries(usage); + {Object.entries(usages).map(([nodeId, series]) => { return ( ({ - name: `Node ${idx + 1}`, - x, - y, - }))} - name={`node ${idx}`} + key={ `usage-area-${nodeId}` } + data={ Object.entries(series).map(([k, v]) => { + return ({ + name: `Node ${nodeId}`, + x: Date.parse(k), + y: v, + }) + })} + name={ `node ${nodeId}` } /> ); })} - {usages.map((usage, idx) => { - const usageArray = Object.entries(usage); - const data = Object.entries(available[idx]); + + {Object.entries(usages).map(([nodeId, _]) => { + const availableSeries = available[nodeId]; + return ( ({ - name: `Available storage threshold (node ${idx + 1})`, - x: usageArray[x][0], - y, + key={ `chart-softlimit-${nodeId}` } + data={ Object.entries(availableSeries).map(([k, v]) => ({ + name: `Available storage threshold (node ${nodeId})`, + x: Date.parse(k), + y: v, }))} - name={`threshold ${idx}`} + name={`threshold ${nodeId}`} /> ); })} diff --git a/ui/components/ClusterOverview/components/ChartIncomingOutgoing.tsx b/ui/components/ClusterOverview/components/ChartIncomingOutgoing.tsx index aec906093..4097cad78 100644 --- a/ui/components/ClusterOverview/components/ChartIncomingOutgoing.tsx +++ b/ui/components/ClusterOverview/components/ChartIncomingOutgoing.tsx @@ -1,6 +1,7 @@ "use client"; import { Chart, + ChartArea, ChartAxis, ChartGroup, ChartLegend, @@ -9,15 +10,14 @@ import { createContainer, } from "@/libs/patternfly/react-charts"; import { useFormatBytes } from "@/utils/useFormatBytes"; -import { ChartArea } from "@/libs/patternfly/react-charts"; import { Alert } from "@patternfly/react-core"; import { useFormatter, useTranslations } from "next-intl"; import { getHeight, getPadding } from "./chartConsts"; import { useChartWidth } from "./useChartWidth"; type ChartIncomingOutgoingProps = { - incoming: Record; - outgoing: Record; + incoming: TimeSeriesMetrics; + outgoing: TimeSeriesMetrics; }; type Datum = { @@ -52,16 +52,14 @@ export function ChartIncomingOutgoing({ } // const showDate = shouldShowDate(duration); const CursorVoronoiContainer = createContainer("voronoi", "cursor"); - const legendData = [ - ...Object.keys(incoming).map((name) => ({ - name: `Incoming bytes (${name})`, - childName: `incoming ${name}`, - })), - ...Object.keys(outgoing).map((name) => ({ - name: `Outgoing bytes (${name})`, - childName: `outgoing ${name}`, - })), - ]; + const legendData = [ { + name: "Incoming bytes (all topics)", + childName: "incoming" + }, { + name: "Outgoing bytes (all topics)", + childName: "outgoing" + } ]; + const padding = getPadding(legendData.length / itemsPerRow); return (
@@ -125,43 +123,36 @@ export function ChartIncomingOutgoing({ { - return formatBytes(Math.abs(d), { maximumFractionDigits: 0 }); + return formatBytes(Math.abs(d)); }} /> - {Object.entries(incoming).map(([name, entries], idx) => { - const entriesArray = Object.entries(entries ?? {}); - return ( - ({ - name: `Incoming (${name})`, - x, - y, - value: y, - }))} - name={`incoming ${name}`} - interpolation={"stepAfter"} - /> - ); - })} - {Object.entries(outgoing).map(([name, entries], idx) => { - const entriesArray = Object.entries(entries ?? {}); - const incomingArray = Object.keys(incoming[name] ?? {}); - return ( - ({ - name: `Outgoing (${name})`, - x: incomingArray[idx], - y: -1 * y, - value: y, - }))} - name={`outgoing ${name}`} - interpolation={"stepAfter"} - /> - ); - })} + { + return ({ + name: `Incoming`, + x: Date.parse(k), + y: v, + value: v, + }) + })} + name={ `incoming` } + interpolation={"stepAfter"} + /> + { + return ({ + name: `Outgoing`, + x: Date.parse(k), + y: v * -1, + value: v, + }) + })} + name={ `outgoing` } + interpolation={"stepAfter"} + />
diff --git a/ui/components/ClusterOverview/components/ChartMemoryUsage.tsx b/ui/components/ClusterOverview/components/ChartMemoryUsage.tsx index fe741647b..77024f7a0 100644 --- a/ui/components/ClusterOverview/components/ChartMemoryUsage.tsx +++ b/ui/components/ClusterOverview/components/ChartMemoryUsage.tsx @@ -16,7 +16,7 @@ import { getHeight, getPadding } from "./chartConsts"; import { useChartWidth } from "./useChartWidth"; type ChartDiskUsageProps = { - usages: TimeSeriesMetrics[]; + usages: Record; }; type Datum = { @@ -46,11 +46,12 @@ export function ChartMemoryUsage({ usages }: ChartDiskUsageProps) { } const CursorVoronoiContainer = createContainer("voronoi", "cursor"); - const legendData = usages.map((_, idx) => ({ - name: `Node ${idx}`, - childName: `node ${idx}`, + const legendData = Object.keys(usages).map((nodeId) => ({ + name: `Node ${nodeId}`, + childName: `node ${nodeId}`, })); const padding = getPadding(legendData.length / itemsPerRow); + return (
{ - return formatBytes(d, { maximumFractionDigits: 0 }); + return formatBytes(d); }} /> - {usages.map((usage, idx) => { - const usageArray = Object.entries(usage); + {Object.entries(usages).map(([nodeId, series]) => { return ( ({ - name: `Node ${idx + 1}`, - x, - y, - }))} - name={`node ${idx}`} + key={ `memory-usage-${nodeId}` } + data={ Object.entries(series).map(([k, v]) => { + return ({ + name: `Node ${nodeId}`, + x: Date.parse(k), + y: v, + }) + })} + name={ `node ${nodeId}` } /> ); })} diff --git a/ui/components/ClusterOverview/components/chartConsts.ts b/ui/components/ClusterOverview/components/chartConsts.ts index 98d7e9891..0998a0afa 100644 --- a/ui/components/ClusterOverview/components/chartConsts.ts +++ b/ui/components/ClusterOverview/components/chartConsts.ts @@ -3,7 +3,7 @@ export const getHeight = (legendEntriesCount: number) => { return 150 + bottom; }; export const getPadding = (legendEntriesCount: number) => ({ - bottom: 35 + 32 * legendEntriesCount, + bottom: 50 + 32 * legendEntriesCount, top: 5, left: 70, right: 30, diff --git a/ui/components/Format/Bytes.stories.tsx b/ui/components/Format/Bytes.stories.tsx index c093d5e9d..7f22bbe8e 100644 --- a/ui/components/Format/Bytes.stories.tsx +++ b/ui/components/Format/Bytes.stories.tsx @@ -26,7 +26,7 @@ type Story = StoryObj; export const Default: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - await waitFor(() => expect(canvas.getByText("1 KiB")).toBeInTheDocument()); + await waitFor(() => expect(canvas.getByText("1.00 KiB")).toBeInTheDocument()); }, }; @@ -47,7 +47,7 @@ export const KilobytesWithDecimal: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await waitFor(() => - expect(canvas.getByText("1.5 KiB")).toBeInTheDocument(), + expect(canvas.getByText("1.50 KiB")).toBeInTheDocument(), ); }, }; @@ -59,7 +59,7 @@ export const MegabytesWithDecimal: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await waitFor(() => - expect(canvas.getByText("1.5 MiB")).toBeInTheDocument(), + expect(canvas.getByText("1.50 MiB")).toBeInTheDocument(), ); }, }; @@ -71,7 +71,7 @@ export const GigabytesWithDecimal: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await waitFor(() => - expect(canvas.getByText("1.5 GiB")).toBeInTheDocument(), + expect(canvas.getByText("1.50 GiB")).toBeInTheDocument(), ); }, }; @@ -83,7 +83,7 @@ export const TerabytesWithDecimal: Story = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); await waitFor(() => - expect(canvas.getByText("1.5 TiB")).toBeInTheDocument(), + expect(canvas.getByText("1.50 TiB")).toBeInTheDocument(), ); }, }; diff --git a/ui/environment.d.ts b/ui/environment.d.ts index 06950d380..af85ca3a9 100644 --- a/ui/environment.d.ts +++ b/ui/environment.d.ts @@ -7,7 +7,6 @@ namespace NodeJS { KEYCLOAK_CLIENTSECRET?: string; NEXT_PUBLIC_KEYCLOAK_URL?: string; NEXT_PUBLIC_PRODUCTIZED_BUILD?: "true" | "false"; - CONSOLE_METRICS_PROMETHEUS_URL?: string; LOG_LEVEL?: "fatal" | "error" | "warn" | "info" | "debug" | "trace"; CONSOLE_MODE?: "read-only" | "read-write"; } diff --git a/ui/messages/en.json b/ui/messages/en.json index 1cb4dd089..0eab68d4b 100644 --- a/ui/messages/en.json +++ b/ui/messages/en.json @@ -279,7 +279,7 @@ "data_unavailable": "Prometheus can't be reached, or is not configured. Please check your configuration." }, "ChartIncomingOutgoing": { - "data_unavailable": "Prometheus can't be reached, or is not configured. Please check your configuration." + "data_unavailable": "Topic metrics are not available for this cluster or no topic activity found. Please check your configuration." }, "ChartMemoryUsage": { "data_unavailable": "Prometheus can't be reached, or is not configured. Please check your configuration." @@ -292,6 +292,7 @@ "no_messages": "No messages" }, "ClusterChartsCard": { + "data_unavailable": "Cluster metrics are not available for this cluster. Please check your configuration.", "cluster_metrics": "Cluster metrics", "used_disk_space": "Used disk space", "used_disk_space_tooltip": "Used and available disk capacity for all brokers over a specified period. Make sure there's enough space for everyday operations.", diff --git a/ui/package.json b/ui/package.json index 6bf462a03..eefb837f5 100644 --- a/ui/package.json +++ b/ui/package.json @@ -26,7 +26,6 @@ "@stdlib/string-truncate": "^0.2.2", "@stdlib/string-truncate-middle": "^0.2.2", "@tanstack/react-virtual": "^3.10.9", - "@types/lodash.groupby": "^4.6.9", "@types/node": "22.9.0", "@types/react": "18.3.12", "@types/react-dom": "18.3.1", @@ -41,13 +40,11 @@ "eslint-import-resolver-typescript": "^3.6.3", "eslint-plugin-storybook": "^0.11.0", "iron-session": "^8.0.4", - "lodash.groupby": "^4.6.0", "next": "^14.2.18", "next-auth": "^4.24.10", "next-intl": "^3.25.1", "next-logger": "^5.0.1", "pino": "^9.5.0", - "prometheus-query": "^3.4.1", "react": "18.3.1", "react-csv-downloader": "^3.1.1", "react-dom": "18.3.1", diff --git a/ui/utils/session.ts b/ui/utils/session.ts index c185cf7bf..3268b3022 100644 --- a/ui/utils/session.ts +++ b/ui/utils/session.ts @@ -1,6 +1,6 @@ "use server"; -import { getAuthOptions } from "@/app/api/auth/[...nextauth]/route"; +import { getAuthOptions } from "@/app/api/auth/[...nextauth]/auth-options"; import { logger } from "@/utils/logger"; import { sealData, unsealData } from "iron-session"; import { getServerSession } from "next-auth"; diff --git a/ui/utils/useFormatBytes.ts b/ui/utils/useFormatBytes.ts index 04e90a273..be6f97548 100644 --- a/ui/utils/useFormatBytes.ts +++ b/ui/utils/useFormatBytes.ts @@ -11,8 +11,34 @@ export function useFormatBytes() { return "0 B"; } const res = convert(bytes, "bytes").to("best", "imperial"); + let minimumFractionDigits = undefined; + + if (maximumFractionDigits === undefined) { + switch (res.unit) { + case "PiB": + case "TiB": + case "GiB": + case "MiB": + case "KiB": + if (res.quantity >= 100) { + maximumFractionDigits = 0; + } else if (res.quantity >= 10) { + minimumFractionDigits = 1; + maximumFractionDigits = 1; + } else { + minimumFractionDigits = 2; + maximumFractionDigits = 2; + } + break; + default: + maximumFractionDigits = 0; + break; + } + } + return `${format.number(res.quantity, { style: "decimal", + minimumFractionDigits, maximumFractionDigits, })} ${res.unit}`; };