From 3e455887e76a84c6b7d599d9463f767323ed2102 Mon Sep 17 00:00:00 2001 From: Matthieu MOREL Date: Wed, 15 Apr 2026 07:39:19 +0200 Subject: [PATCH] feat(server): server-side response cache for proxied actuator endpoints Signed-off-by: Matthieu MOREL --- ...AdminServerHazelcastAutoConfiguration.java | 23 +- .../server/config/AdminServerProperties.java | 38 +++ .../config/AdminServerWebConfiguration.java | 49 +++- .../admin/server/web/InstanceWebProxy.java | 235 +++++++++++++++++- .../web/cache/ActuatorResponseCache.java | 102 ++++++++ .../admin/server/web/cache/CacheEntry.java | 116 +++++++++ .../web/cache/CacheInvalidationTrigger.java | 60 +++++ .../server/web/cache/CacheKeyBuilder.java | 79 ++++++ .../cache/HazelcastActuatorResponseCache.java | 117 +++++++++ .../cache/InMemoryActuatorResponseCache.java | 129 ++++++++++ .../reactive/InstancesProxyController.java | 50 ++-- .../web/servlet/InstancesProxyController.java | 73 +++--- ...itional-spring-configuration-metadata.json | 37 ++- ...stancesProxyControllerIntegrationTest.java | 51 ++++ .../cache/CacheInvalidationTriggerTest.java | 114 +++++++++ .../HazelcastActuatorResponseCacheTest.java | 192 ++++++++++++++ .../InMemoryActuatorResponseCacheTest.java | 199 +++++++++++++++ 17 files changed, 1588 insertions(+), 76 deletions(-) create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/ActuatorResponseCache.java create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheEntry.java create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheInvalidationTrigger.java create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheKeyBuilder.java create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/HazelcastActuatorResponseCache.java create mode 100644 spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/InMemoryActuatorResponseCache.java create mode 100644 spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/CacheInvalidationTriggerTest.java create mode 100644 spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/HazelcastActuatorResponseCacheTest.java create mode 100644 spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/InMemoryActuatorResponseCacheTest.java diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java index fb8aedf0499..1037787bb50 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerHazelcastAutoConfiguration.java @@ -28,6 +28,7 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnSingleCandidate; +import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.hazelcast.autoconfigure.HazelcastAutoConfiguration; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -40,6 +41,9 @@ import de.codecentric.boot.admin.server.notify.HazelcastNotificationTrigger; import de.codecentric.boot.admin.server.notify.NotificationTrigger; import de.codecentric.boot.admin.server.notify.Notifier; +import de.codecentric.boot.admin.server.web.cache.ActuatorResponseCache; +import de.codecentric.boot.admin.server.web.cache.CacheEntry; +import de.codecentric.boot.admin.server.web.cache.HazelcastActuatorResponseCache; @Configuration(proxyBeanMethods = false) @ConditionalOnBean(AdminServerMarkerConfiguration.Marker.class) @@ -47,6 +51,7 @@ @ConditionalOnProperty(prefix = "spring.boot.admin.hazelcast", name = "enabled", matchIfMissing = true) @AutoConfigureBefore({ AdminServerAutoConfiguration.class, AdminServerNotifierAutoConfiguration.class }) @AutoConfigureAfter(HazelcastAutoConfiguration.class) +@EnableConfigurationProperties(AdminServerProperties.class) @Lazy(false) public class AdminServerHazelcastAutoConfiguration { @@ -54,8 +59,13 @@ public class AdminServerHazelcastAutoConfiguration { public static final String DEFAULT_NAME_SENT_NOTIFICATIONS_MAP = "spring-boot-admin-sent-notifications"; + public static final String DEFAULT_NAME_RESPONSE_CACHE_MAP = "spring-boot-admin-actuator-response-cache"; + @Value("${spring.boot.admin.hazelcast.event-store:" + DEFAULT_NAME_EVENT_STORE_MAP + "}") - private final String nameEventStoreMap = DEFAULT_NAME_EVENT_STORE_MAP; + private String nameEventStoreMap = DEFAULT_NAME_EVENT_STORE_MAP; + + @Value("${spring.boot.admin.hazelcast.response-cache:" + DEFAULT_NAME_RESPONSE_CACHE_MAP + "}") + private String nameResponseCacheMap = DEFAULT_NAME_RESPONSE_CACHE_MAP; @Bean @ConditionalOnMissingBean(InstanceEventStore.class) @@ -64,12 +74,21 @@ public HazelcastEventStore eventStore(HazelcastInstance hazelcastInstance) { return new HazelcastEventStore(map); } + @Bean + @ConditionalOnMissingBean(ActuatorResponseCache.class) + @ConditionalOnProperty(prefix = "spring.boot.admin.endpoint-cache", name = "enabled", matchIfMissing = true) + public HazelcastActuatorResponseCache actuatorResponseCache(HazelcastInstance hazelcastInstance, + AdminServerProperties properties) { + IMap map = hazelcastInstance.getMap(this.nameResponseCacheMap); + return new HazelcastActuatorResponseCache(map, properties.getEndpointCache()); + } + @Configuration(proxyBeanMethods = false) @ConditionalOnBean(Notifier.class) public static class NotifierTriggerConfiguration { @Value("${spring.boot.admin.hazelcast.sent-notifications:" + DEFAULT_NAME_SENT_NOTIFICATIONS_MAP + "}") - private final String nameSentNotificationsMap = DEFAULT_NAME_SENT_NOTIFICATIONS_MAP; + private String nameSentNotificationsMap = DEFAULT_NAME_SENT_NOTIFICATIONS_MAP; @Bean(initMethod = "start", destroyMethod = "stop") @ConditionalOnMissingBean(NotificationTrigger.class) diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerProperties.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerProperties.java index 59af9226c76..f3eec36ea37 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerProperties.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerProperties.java @@ -49,6 +49,8 @@ public class AdminServerProperties { private InstanceProxyProperties instanceProxy = new InstanceProxyProperties(); + private EndpointCacheProperties endpointCache = new EndpointCacheProperties(); + /** * The metadata keys which should be sanitized when serializing to json */ @@ -199,4 +201,40 @@ public static class InstanceProxyProperties { } + @lombok.Data + public static class EndpointCacheProperties { + + /** + * Whether server-side caching of proxied actuator GET responses is enabled. + */ + private boolean enabled = true; + + /** + * Default TTL for cached responses. + */ + @DurationUnit(ChronoUnit.MILLIS) + private Duration defaultTtl = Duration.ofMinutes(5); + + /** + * TTL per endpoint id. Overrides default-ttl for a specific endpoint. Example: + * {@code spring.boot.admin.endpoint-cache.ttl.mappings=10m} + */ + @DurationUnit(ChronoUnit.MILLIS) + private Map ttl = new HashMap<>(); + + /** + * Endpoint ids whose responses should be cached. Only safe GET requests to these + * endpoints are cached. + */ + private Set endpoints = new HashSet<>( + asList("mappings", "configprops", "beans", "conditions", "sbom", "startup")); + + /** + * Maximum response body size in bytes that will be cached. Responses larger than + * this threshold are forwarded as-is without caching. + */ + private long maxPayloadSize = 10L * 1024 * 1024; + + } + } diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java index 52972b72efd..a873b6a08bb 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/config/AdminServerWebConfiguration.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2024 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,8 +16,12 @@ package de.codecentric.boot.admin.server.config; +import org.reactivestreams.Publisher; +import org.springframework.beans.factory.ObjectProvider; import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; import org.springframework.boot.webmvc.autoconfigure.WebMvcAutoConfiguration; import org.springframework.context.ApplicationEventPublisher; @@ -27,12 +31,18 @@ import org.springframework.web.reactive.accept.RequestedContentTypeResolver; import tools.jackson.databind.module.SimpleModule; +import de.codecentric.boot.admin.server.domain.events.InstanceEvent; import de.codecentric.boot.admin.server.eventstore.InstanceEventStore; import de.codecentric.boot.admin.server.services.ApplicationRegistry; import de.codecentric.boot.admin.server.services.InstanceRegistry; import de.codecentric.boot.admin.server.utils.jackson.AdminServerModule; import de.codecentric.boot.admin.server.web.ApplicationsController; +import de.codecentric.boot.admin.server.web.HttpHeaderFilter; +import de.codecentric.boot.admin.server.web.InstanceWebProxy; import de.codecentric.boot.admin.server.web.InstancesController; +import de.codecentric.boot.admin.server.web.cache.ActuatorResponseCache; +import de.codecentric.boot.admin.server.web.cache.CacheInvalidationTrigger; +import de.codecentric.boot.admin.server.web.cache.InMemoryActuatorResponseCache; import de.codecentric.boot.admin.server.web.client.InstanceWebClient; @Configuration(proxyBeanMethods = false) @@ -62,6 +72,21 @@ public ApplicationsController applicationsController(ApplicationRegistry applica return new ApplicationsController(applicationRegistry, applicationEventPublisher); } + @Bean + @ConditionalOnMissingBean(ActuatorResponseCache.class) + @ConditionalOnProperty(prefix = "spring.boot.admin.endpoint-cache", name = "enabled", matchIfMissing = true) + public InMemoryActuatorResponseCache actuatorResponseCache() { + return new InMemoryActuatorResponseCache(this.adminServerProperties.getEndpointCache()); + } + + @Bean(initMethod = "start", destroyMethod = "stop") + @ConditionalOnBean(ActuatorResponseCache.class) + @ConditionalOnMissingBean(CacheInvalidationTrigger.class) + public CacheInvalidationTrigger cacheInvalidationTrigger(ActuatorResponseCache responseCache, + Publisher events) { + return new CacheInvalidationTrigger(events, responseCache); + } + @Configuration(proxyBeanMethods = false) @ConditionalOnWebApplication(type = ConditionalOnWebApplication.Type.REACTIVE) public static class ReactiveRestApiConfiguration { @@ -75,11 +100,14 @@ public ReactiveRestApiConfiguration(AdminServerProperties adminServerProperties) @Bean @ConditionalOnMissingBean public de.codecentric.boot.admin.server.web.reactive.InstancesProxyController instancesProxyController( - InstanceRegistry instanceRegistry, InstanceWebClient.Builder instanceWebClientBuilder) { + InstanceRegistry instanceRegistry, InstanceWebClient.Builder instanceWebClientBuilder, + ObjectProvider responseCache) { + HttpHeaderFilter headerFilter = new HttpHeaderFilter( + this.adminServerProperties.getInstanceProxy().getIgnoredHeaders()); + InstanceWebProxy instanceWebProxy = new InstanceWebProxy(instanceWebClientBuilder.build(), + responseCache.getIfAvailable(), headerFilter); return new de.codecentric.boot.admin.server.web.reactive.InstancesProxyController( - this.adminServerProperties.getContextPath(), - this.adminServerProperties.getInstanceProxy().getIgnoredHeaders(), instanceRegistry, - instanceWebClientBuilder.build()); + this.adminServerProperties.getContextPath(), headerFilter, instanceRegistry, instanceWebProxy); } @Bean @@ -108,11 +136,14 @@ public ServletRestApiConfiguration(AdminServerProperties adminServerProperties) @Bean @ConditionalOnMissingBean public de.codecentric.boot.admin.server.web.servlet.InstancesProxyController instancesProxyController( - InstanceRegistry instanceRegistry, InstanceWebClient.Builder instanceWebClientBuilder) { + InstanceRegistry instanceRegistry, InstanceWebClient.Builder instanceWebClientBuilder, + ObjectProvider responseCache) { + HttpHeaderFilter headerFilter = new HttpHeaderFilter( + this.adminServerProperties.getInstanceProxy().getIgnoredHeaders()); + InstanceWebProxy instanceWebProxy = new InstanceWebProxy(instanceWebClientBuilder.build(), + responseCache.getIfAvailable(), headerFilter); return new de.codecentric.boot.admin.server.web.servlet.InstancesProxyController( - this.adminServerProperties.getContextPath(), - this.adminServerProperties.getInstanceProxy().getIgnoredHeaders(), instanceRegistry, - instanceWebClientBuilder.build()); + this.adminServerProperties.getContextPath(), headerFilter, instanceRegistry, instanceWebProxy); } @Bean diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstanceWebProxy.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstanceWebProxy.java index 2dd60260801..bbe75160a4a 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstanceWebProxy.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/InstanceWebProxy.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,6 +19,7 @@ import java.io.IOException; import java.net.URI; import java.util.List; +import java.util.Optional; import java.util.concurrent.TimeoutException; import java.util.function.Function; @@ -27,10 +28,16 @@ import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.core.io.buffer.DataBufferFactory; +import org.springframework.core.io.buffer.DataBufferUtils; +import org.springframework.core.io.buffer.DefaultDataBufferFactory; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; +import org.springframework.http.HttpStatusCode; import org.springframework.http.client.reactive.ClientHttpRequest; +import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.BodyInserter; import org.springframework.web.reactive.function.client.ClientResponse; import org.springframework.web.reactive.function.client.ExchangeStrategies; @@ -38,9 +45,12 @@ import org.springframework.web.reactive.function.client.WebClientRequestException; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import de.codecentric.boot.admin.server.domain.entities.Instance; import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.web.cache.ActuatorResponseCache; +import de.codecentric.boot.admin.server.web.cache.CacheEntry; import de.codecentric.boot.admin.server.web.client.InstanceWebClient; import de.codecentric.boot.admin.server.web.client.exception.ResolveEndpointException; @@ -49,9 +59,27 @@ import static org.springframework.http.HttpMethod.PUT; /** - * Forwards a request to a single instances endpoint and will respond with: - 502 (Bad - * Gateway) when any error occurs during the request - 503 (Service unavailable) when the - * instance is not found - 504 (Gateway timeout) when the request exceeds the timeout + * Forwards a request to a single instances endpoint and will respond with: + *
    + *
  • 502 (Bad Gateway) when any error occurs during the request
  • + *
  • 503 (Service unavailable) when the instance is not found
  • + *
  • 504 (Gateway timeout) when the request exceeds the timeout
  • + *
+ * + *

+ * When an optional {@link ActuatorResponseCache} is supplied (together with an + * {@link HttpHeaderFilter}) the proxy transparently: + *

    + *
  1. Returns a stored entry on a GET cache-hit without touching upstream.
  2. + *
  3. Buffers and caches a 2xx GET response body when the response carries a known + * {@code Content-Length} that is within the configured + * {@link ActuatorResponseCache#getMaxPayloadSize() maxPayloadSize}. Responses with an + * unknown {@code Content-Length} (e.g. chunked/streaming) are forwarded as-is and not + * cached.
  4. + *
  5. Invalidates the endpoint's cached entries after a successful mutating request + * (POST/PUT/PATCH/DELETE).
  6. + *
+ * Fan-out calls ({@link #forward(Flux, ForwardRequest)}) are never cached. * * @author Johannes Edmeier */ @@ -65,25 +93,56 @@ public class InstanceWebProxy { private final ExchangeStrategies strategies = ExchangeStrategies.withDefaults(); + @Nullable private final ActuatorResponseCache cache; + + @Nullable private final HttpHeaderFilter headerFilter; + + private final DataBufferFactory bufferFactory = new DefaultDataBufferFactory(); + public InstanceWebProxy(InstanceWebClient instanceWebClient) { + this(instanceWebClient, null, null); + } + + public InstanceWebProxy(InstanceWebClient instanceWebClient, @Nullable ActuatorResponseCache cache, + @Nullable HttpHeaderFilter headerFilter) { + if (cache != null && headerFilter == null) { + throw new IllegalArgumentException("headerFilter must be provided when cache is configured"); + } this.instanceWebClient = instanceWebClient; + this.cache = cache; + this.headerFilter = headerFilter; } + /** + * Forwards a request to a single instance, applying cache semantics when a cache is + * configured. + * @param instanceMono reactive lookup of the target {@link Instance} + * @param forwardRequest the request to proxy + * @param responseHandler consumer of the (possibly cached) {@link ClientResponse} + * @param response type produced by {@code responseHandler} + * @return result of {@code responseHandler} + */ public Mono forward(Mono instanceMono, ForwardRequest forwardRequest, Function> responseHandler) { return instanceMono.defaultIfEmpty(NULL_INSTANCE).flatMap((instance) -> { if (!instance.equals(NULL_INSTANCE)) { - return this.forward(instance, forwardRequest, responseHandler); - } - else { - return Mono.defer(() -> responseHandler - .apply(ClientResponse.create(HttpStatus.SERVICE_UNAVAILABLE, this.strategies).build())); + return (this.cache != null) ? forwardWithCache(instance, forwardRequest, responseHandler) + : forwardUpstream(instance, forwardRequest, responseHandler); } + return Mono.defer(() -> responseHandler + .apply(ClientResponse.create(HttpStatus.SERVICE_UNAVAILABLE, this.strategies).build())); }); } + /** + * Forwards a request to all instances of an application. Caching is never applied to + * fan-out calls. + * @param instances the instances to forward to + * @param forwardRequest the request to proxy + * @return a stream of per-instance responses + */ public Flux forward(Flux instances, ForwardRequest forwardRequest) { - return instances.flatMap((instance) -> this.forward(instance, forwardRequest, (clientResponse) -> { + return instances.flatMap((instance) -> forwardUpstream(instance, forwardRequest, (clientResponse) -> { InstanceResponse.Builder response = InstanceResponse.builder() .instanceId(instance.getId()) .status(clientResponse.statusCode().value()) @@ -95,7 +154,147 @@ public Flux forward(Flux instances, ForwardRequest f })); } - private Mono forward(Instance instance, ForwardRequest forwardRequest, + // ---- cache-aware forwarding ---------------------------------------------- + + private Mono forwardWithCache(Instance instance, ForwardRequest forwardRequest, + Function> responseHandler) { + InstanceId instanceId = instance.getId(); + String endpointPath = forwardRequest.getUri().getPath(); + String rawQuery = forwardRequest.getUri().getRawQuery(); + HttpMethod method = forwardRequest.getMethod(); + String endpointId = extractEndpointId(endpointPath); + + return Mono.fromCallable(() -> lookupCache(instanceId, endpointPath, rawQuery, method, endpointId)) + .subscribeOn(Schedulers.boundedElastic()) + .onErrorResume((ex) -> { + log.warn("Cache lookup failed for instance '{}' endpoint '{}', falling back to upstream", instanceId, + endpointId, ex); + return Mono.just(Optional.empty()); + }) + .flatMap((hit) -> { + if (hit.isPresent()) { + return responseHandler.apply(buildClientResponse(hit.get())); + } + return forwardUpstream(instance, forwardRequest, (cr) -> interceptResponse(cr, instanceId, endpointPath, + rawQuery, endpointId, method, responseHandler)); + }); + } + + private Mono interceptResponse(ClientResponse clientResponse, InstanceId instanceId, String endpointPath, + @Nullable String rawQuery, String endpointId, HttpMethod method, + Function> responseHandler) { + HttpStatusCode statusCode = clientResponse.statusCode(); + + if (isMutatingMethod(method) && statusCode.is2xxSuccessful() + && this.cache.shouldCache(HttpMethod.GET, endpointId)) { + return Mono.fromRunnable(() -> this.cache.invalidateEndpointForInstance(instanceId, endpointId)) + .subscribeOn(Schedulers.boundedElastic()) + .onErrorResume((ex) -> { + log.warn("Failed to invalidate cache for instance '{}' and endpoint '{}'", instanceId, endpointId, + ex); + return Mono.empty(); + }) + .then(Mono.defer(() -> responseHandler.apply(clientResponse))); + } + + if (this.cache.shouldCache(method, endpointId) && statusCode.is2xxSuccessful()) { + return bufferAndCache(clientResponse, instanceId, endpointPath, rawQuery, endpointId, statusCode, + responseHandler); + } + + return responseHandler.apply(clientResponse); + } + + private Mono bufferAndCache(ClientResponse clientResponse, InstanceId instanceId, String endpointPath, + @Nullable String rawQuery, String endpointId, HttpStatusCode statusCode, + Function> responseHandler) { + HttpHeaders originalHeaders = clientResponse.headers().asHttpHeaders(); + long contentLength = originalHeaders.getContentLength(); + if (contentLength < 0) { + log.trace("Skipping cache for endpoint '{}': Content-Length is unknown", endpointId); + return responseHandler.apply(clientResponse); + } + if (contentLength > this.cache.getMaxPayloadSize()) { + log.trace("Skipping cache for endpoint '{}': Content-Length {} exceeds limit {}", endpointId, contentLength, + this.cache.getMaxPayloadSize()); + return responseHandler.apply(clientResponse); + } + // Content-Length is known and within the cache limit, so buffering the body for + // caching is bounded by maxPayloadSize. + return DataBufferUtils.join(clientResponse.body(BodyExtractors.toDataBuffers())) + .switchIfEmpty(Mono.fromSupplier(() -> this.bufferFactory.allocateBuffer(0))) + .flatMap((joined) -> { + byte[] bytes = new byte[joined.readableByteCount()]; + joined.read(bytes); + DataBufferUtils.release(joined); + // Content-Length was verified before joining, so bytes.length is bounded. + HttpHeaders filteredHeaders = this.headerFilter.filterHeaders(originalHeaders); + CacheEntry entry = new CacheEntry(statusCode.value(), filteredHeaders, bytes); + ClientResponse rebuilt = rebuildClientResponse(statusCode, originalHeaders, bytes); + return Mono.fromRunnable(() -> { + this.cache.put(instanceId, endpointPath, rawQuery, entry); + log.trace("Cached response for endpoint '{}' ({} bytes)", endpointId, bytes.length); + }).subscribeOn(Schedulers.boundedElastic()).onErrorResume((ex) -> { + log.warn("Failed to store cache entry for endpoint '{}'", endpointId, ex); + return Mono.empty(); + }).then(Mono.defer(() -> responseHandler.apply(rebuilt))); + }); + } + + // ---- cache helpers ------------------------------------------------------- + + private Optional lookupCache(InstanceId instanceId, String endpointPath, @Nullable String rawQuery, + HttpMethod method, String endpointId) { + if (!this.cache.shouldCache(method, endpointId)) { + return Optional.empty(); + } + Optional entry = this.cache.get(instanceId, endpointPath, rawQuery); + if (entry.isPresent()) { + log.trace("Cache hit for instance {} endpoint '{}'", instanceId, endpointId); + } + else { + log.trace("Cache miss for instance {} endpoint '{}'", instanceId, endpointId); + } + return entry; + } + + /** + * Builds a {@link ClientResponse} from a stored {@link CacheEntry}. The wrapped + * {@link DataBuffer} is a read-only view of the cached byte array (no copy, no pool + * allocation); the response handler (e.g. {@code writeAndFlushWith}) is responsible + * for releasing it. + * @param entry the cached response entry + * @return a {@link ClientResponse} backed by the cached body bytes + */ + private ClientResponse buildClientResponse(CacheEntry entry) { + DataBuffer body = this.bufferFactory.wrap(entry.getBodyRef()); + return ClientResponse.create(HttpStatusCode.valueOf(entry.getStatusCode()), this.strategies) + .headers((h) -> h.addAll(entry.getHttpHeaders())) + .body(Flux.just(body)) + .build(); + } + + /** + * Rebuilds a {@link ClientResponse} after body buffering so the response handler can + * consume the same bytes. The wrapped {@link DataBuffer} is backed directly by the + * buffered byte array (no pool allocation); the response handler is responsible for + * releasing it. + * @param statusCode the upstream response status + * @param originalHeaders the unfiltered upstream response headers + * @param bytes the buffered response body + * @return a {@link ClientResponse} backed by the buffered bytes + */ + private ClientResponse rebuildClientResponse(HttpStatusCode statusCode, HttpHeaders originalHeaders, byte[] bytes) { + DataBuffer body = this.bufferFactory.wrap(bytes); + return ClientResponse.create(statusCode, this.strategies) + .headers((h) -> h.addAll(originalHeaders)) + .body(Flux.just(body)) + .build(); + } + + // ---- upstream forwarding ------------------------------------------------- + + private Mono forwardUpstream(Instance instance, ForwardRequest forwardRequest, Function> responseHandler) { log.trace("Proxy-Request for instance {} with URL '{}'", instance.getId(), forwardRequest.getUri()); WebClient.RequestBodySpec bodySpec = this.instanceWebClient.instance(instance) @@ -132,10 +331,24 @@ private Mono forward(Instance instance, ForwardRequest forwardRequest, }); } + // ---- static helpers ------------------------------------------------------ + + private static String extractEndpointId(String endpointPath) { + int slash = endpointPath.indexOf('/'); + return (slash > 0) ? endpointPath.substring(0, slash) : endpointPath; + } + + private static boolean isMutatingMethod(HttpMethod method) { + return HttpMethod.POST.equals(method) || HttpMethod.PUT.equals(method) || HttpMethod.PATCH.equals(method) + || HttpMethod.DELETE.equals(method); + } + private boolean requiresBody(HttpMethod method) { return List.of(PUT, POST, PATCH).contains(method); } + // ---- nested types -------------------------------------------------------- + @lombok.Data @lombok.Builder(builderClassName = "Builder") public static class InstanceResponse { diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/ActuatorResponseCache.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/ActuatorResponseCache.java new file mode 100644 index 00000000000..c8050908277 --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/ActuatorResponseCache.java @@ -0,0 +1,102 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.web.cache; + +import java.util.Optional; + +import org.jspecify.annotations.Nullable; +import org.springframework.http.HttpMethod; + +import de.codecentric.boot.admin.server.domain.values.InstanceId; + +/** + * Cache for proxied actuator endpoint responses. Implementations must be thread-safe. + * + *

+ * The cache is keyed by instance id, endpoint path (including sub-paths), and query + * string so that different sub-resources of the same endpoint are cached independently. + * + *

+ * Two implementations are provided out of the box: + *

    + *
  • {@link InMemoryActuatorResponseCache} – local, per-node cache (default)
  • + *
  • {@code HazelcastActuatorResponseCache} – distributed cache shared across SBA + * cluster nodes (activated automatically when Hazelcast is on the classpath)
  • + *
+ */ +public interface ActuatorResponseCache { + + /** + * Returns the cached entry for the given key, or {@link Optional#empty()} if the + * entry does not exist or has expired. + * @param instanceId the registered instance + * @param endpointPath path relative to {@code /actuator/} (e.g. {@code "mappings"} or + * {@code "sbom/application"}) + * @param queryString raw query string from the original request, may be {@code null} + * @return the cached entry, or empty if not found or expired + */ + Optional get(InstanceId instanceId, String endpointPath, @Nullable String queryString); + + /** + * Stores an entry. + * @param instanceId the registered instance + * @param endpointPath path relative to {@code /actuator/} + * @param queryString raw query string, may be {@code null} + * @param entry the entry to store + */ + void put(InstanceId instanceId, String endpointPath, @Nullable String queryString, CacheEntry entry); + + /** + * Invalidates all cached entries for the given instance (e.g. when the instance + * deregisters or its registration is updated). + * @param instanceId the registered instance + */ + void invalidateAllForInstance(InstanceId instanceId); + + /** + * Invalidates all cached entries for the given instance and endpoint. Called after a + * successful mutating request (POST/PUT/PATCH/DELETE) so that the next GET returns + * fresh data. + *

+ * The default implementation falls back to {@link #invalidateAllForInstance}, which + * is always safe but overly broad. Implementations are encouraged to override this + * with a targeted eviction. + * @param instanceId the registered instance + * @param endpointId first path segment of the actuator path (e.g. {@code "loggers"}) + */ + default void invalidateEndpointForInstance(InstanceId instanceId, String endpointId) { + invalidateAllForInstance(instanceId); + } + + /** + * Returns {@code true} if a response for the given HTTP method and endpoint id should + * be looked up from / stored in the cache. Implementations use this to enforce the + * configured endpoint inclusion list and restrict caching to safe HTTP methods (GET). + * @param method the HTTP method of the incoming proxy request + * @param endpointId first path segment of the actuator path (e.g. {@code "mappings"}) + * @return {@code true} if the request should use the cache + */ + boolean shouldCache(HttpMethod method, String endpointId); + + /** + * Returns the maximum response body size in bytes that will be stored. Responses + * larger than this threshold are forwarded as-is without caching. + * @return maximum cacheable payload size in bytes + */ + long getMaxPayloadSize(); + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheEntry.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheEntry.java new file mode 100644 index 00000000000..0daaec9b08d --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheEntry.java @@ -0,0 +1,116 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.web.cache; + +import java.io.Serializable; +import java.time.Instant; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.http.HttpHeaders; + +/** + * Immutable snapshot of a proxied actuator endpoint response that can be stored in a + * cache and replayed to clients without hitting the monitored application again. + * + *

+ * The class is {@link Serializable} so that it can be stored in a distributed cache (e.g. + * Hazelcast) without needing a custom serializer. + */ +public final class CacheEntry implements Serializable { + + private static final long serialVersionUID = 1L; + + private final int statusCode; + + /** + * Response headers stored as a plain, serializable map. Security-sensitive headers + * are stripped before the entry is created (see {@code InstanceWebProxy}). + */ + private final Map> headers; + + private final byte[] body; + + private final Instant cachedAt; + + /** + * Creates a new entry with the current timestamp. + * @param statusCode the HTTP status code of the response + * @param headers filtered response headers + * @param body response body bytes + */ + public CacheEntry(int statusCode, HttpHeaders headers, byte[] body) { + this.statusCode = statusCode; + this.headers = toSerializableMap(headers); + this.body = body.clone(); + this.cachedAt = Instant.now(); + } + + public int getStatusCode() { + return this.statusCode; + } + + /** + * Reconstructs an {@link HttpHeaders} instance from the stored map. + * @return reconstructed {@link HttpHeaders} + */ + public HttpHeaders getHttpHeaders() { + HttpHeaders httpHeaders = new HttpHeaders(); + this.headers.forEach((name, values) -> httpHeaders.put(name, new ArrayList<>(values))); + return httpHeaders; + } + + /** + * Returns a defensive copy of the cached body bytes. Callers that only need a + * read-only view of the body should prefer {@link #getBodyRef()} to avoid an + * unnecessary array copy. + * @return defensive copy of the body bytes + */ + public byte[] getBody() { + return this.body.clone(); + } + + /** + * Returns a direct reference to the internal body byte array. The caller must + * not modify the returned array; doing so would corrupt the cached entry. + * @return the internal body byte array (read-only) + */ + public byte[] getBodyRef() { + return this.body; + } + + /** + * Returns the number of bytes in the cached body without copying. + * @return number of body bytes + */ + public int bodyLength() { + return this.body.length; + } + + public Instant getCachedAt() { + return this.cachedAt; + } + + private static Map> toSerializableMap(HttpHeaders headers) { + Map> map = new LinkedHashMap<>(headers.size()); + headers.forEach((name, values) -> map.put(name, new ArrayList<>(values))); + return map; + } + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheInvalidationTrigger.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheInvalidationTrigger.java new file mode 100644 index 00000000000..15373ccc643 --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheInvalidationTrigger.java @@ -0,0 +1,60 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.web.cache; + +import org.reactivestreams.Publisher; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; + +import de.codecentric.boot.admin.server.domain.events.InstanceDeregisteredEvent; +import de.codecentric.boot.admin.server.domain.events.InstanceEndpointsDetectedEvent; +import de.codecentric.boot.admin.server.domain.events.InstanceEvent; +import de.codecentric.boot.admin.server.domain.events.InstanceRegistrationUpdatedEvent; +import de.codecentric.boot.admin.server.services.AbstractEventHandler; + +/** + * Invalidates {@link ActuatorResponseCache} entries when instance lifecycle events occur. + * + *

+ * The following events trigger a full instance cache invalidation: + *

    + *
  • {@link InstanceDeregisteredEvent} – the instance is gone
  • + *
  • {@link InstanceRegistrationUpdatedEvent} – management URL or metadata may have + * changed
  • + *
  • {@link InstanceEndpointsDetectedEvent} – available endpoints may have changed
  • + *
+ */ +public class CacheInvalidationTrigger extends AbstractEventHandler { + + private final ActuatorResponseCache responseCache; + + public CacheInvalidationTrigger(Publisher publisher, ActuatorResponseCache responseCache) { + super(publisher, InstanceEvent.class); + this.responseCache = responseCache; + } + + @Override + protected Publisher handle(Flux publisher) { + return publisher.filter((event) -> event instanceof InstanceDeregisteredEvent + || event instanceof InstanceRegistrationUpdatedEvent || event instanceof InstanceEndpointsDetectedEvent) + .flatMap((event) -> { + this.responseCache.invalidateAllForInstance(event.getInstance()); + return Mono.empty(); + }); + } + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheKeyBuilder.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheKeyBuilder.java new file mode 100644 index 00000000000..6ed51acd0b8 --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/CacheKeyBuilder.java @@ -0,0 +1,79 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.web.cache; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +import org.jspecify.annotations.Nullable; + +import de.codecentric.boot.admin.server.domain.values.InstanceId; + +/** + * Builds unambiguous cache keys for actuator response cache entries. + * + *

+ * The instance id portion of the key is encoded as a SHA-256 hex digest so that instance + * ids containing delimiter characters (e.g. the {@code applicationId:instanceId} format + * produced by + * {@link de.codecentric.boot.admin.server.services.CloudFoundryInstanceIdGenerator}) + * never collide with each other or with the endpoint path segment of the key. + * + *

+ * Key format: {@code SHA256_HEX(instanceId) + ":" + endpointPath [+ "?" + queryString]} + */ +final class CacheKeyBuilder { + + private CacheKeyBuilder() { + } + + /** + * Returns the cache key for the given instance, endpoint path, and optional query + * string. + * @param instanceId the registered instance + * @param endpointPath path relative to {@code /actuator/} + * @param queryString raw query string, may be {@code null} + * @return the cache key + */ + static String buildKey(InstanceId instanceId, String endpointPath, @Nullable String queryString) { + return instancePrefix(instanceId) + endpointPath + ((queryString != null) ? "?" + queryString : ""); + } + + /** + * Returns the instance-scoped key prefix used for invalidating all entries for a + * given instance. Format: {@code SHA256_HEX(instanceId) + ":"} + * @param instanceId the registered instance + * @return the key prefix for this instance + */ + static String instancePrefix(InstanceId instanceId) { + return sha256Hex(instanceId.getValue()) + ":"; + } + + private static String sha256Hex(String value) { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] hash = md.digest(value.getBytes(StandardCharsets.UTF_8)); + return HexFormat.of().formatHex(hash); + } + catch (NoSuchAlgorithmException ex) { + throw new IllegalStateException("SHA-256 algorithm not available", ex); + } + } + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/HazelcastActuatorResponseCache.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/HazelcastActuatorResponseCache.java new file mode 100644 index 00000000000..75dfe101f5f --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/HazelcastActuatorResponseCache.java @@ -0,0 +1,117 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.web.cache; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +import com.hazelcast.map.IMap; +import com.hazelcast.query.Predicate; +import com.hazelcast.query.Predicates; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpMethod; + +import de.codecentric.boot.admin.server.config.AdminServerProperties.EndpointCacheProperties; +import de.codecentric.boot.admin.server.domain.values.InstanceId; + +/** + * Hazelcast-backed implementation of {@link ActuatorResponseCache} using an {@link IMap}. + * + *

+ * TTL is set natively per map entry using + * {@link IMap#put(Object, Object, long, TimeUnit)} so Hazelcast handles eviction + * automatically across all cluster nodes. This makes the cache naturally shared between + * multiple SBA instances without requiring any additional synchronization. + */ +public class HazelcastActuatorResponseCache implements ActuatorResponseCache { + + private static final Logger log = LoggerFactory.getLogger(HazelcastActuatorResponseCache.class); + + private final IMap map; + + private final EndpointCacheProperties properties; + + public HazelcastActuatorResponseCache(IMap map, EndpointCacheProperties properties) { + this.map = map; + this.properties = properties; + } + + @Override + public Optional get(InstanceId instanceId, String endpointPath, @Nullable String queryString) { + if (!this.properties.isEnabled()) { + return Optional.empty(); + } + String key = buildKey(instanceId, endpointPath, queryString); + CacheEntry entry = this.map.get(key); + return Optional.ofNullable(entry); + } + + @Override + public void put(InstanceId instanceId, String endpointPath, @Nullable String queryString, CacheEntry entry) { + if (!this.properties.isEnabled()) { + return; + } + String key = buildKey(instanceId, endpointPath, queryString); + long ttlMs = getTtlMs(extractEndpointId(endpointPath)); + this.map.put(key, entry, ttlMs, TimeUnit.MILLISECONDS); + log.trace("Cached entry for key '{}' (TTL {}ms)", key, ttlMs); + } + + @Override + public void invalidateAllForInstance(InstanceId instanceId) { + String prefix = CacheKeyBuilder.instancePrefix(instanceId); + Predicate predicate = Predicates.like("__key", prefix + "%"); + this.map.removeAll(predicate); + log.debug("Invalidated Hazelcast cache entries for instance {}", instanceId); + } + + @Override + public void invalidateEndpointForInstance(InstanceId instanceId, String endpointId) { + String baseKey = CacheKeyBuilder.buildKey(instanceId, endpointId, null); + Predicate predicate = Predicates.or(Predicates.equal("__key", baseKey), + Predicates.like("__key", baseKey + "/%"), Predicates.like("__key", baseKey + "?%")); + this.map.removeAll(predicate); + log.debug("Invalidated Hazelcast cache entries for instance {} endpoint '{}'", instanceId, endpointId); + } + + @Override + public boolean shouldCache(HttpMethod method, String endpointId) { + return this.properties.isEnabled() && HttpMethod.GET.equals(method) + && this.properties.getEndpoints().contains(endpointId); + } + + @Override + public long getMaxPayloadSize() { + return this.properties.getMaxPayloadSize(); + } + + private long getTtlMs(String endpointId) { + return this.properties.getTtl().getOrDefault(endpointId, this.properties.getDefaultTtl()).toMillis(); + } + + private static String buildKey(InstanceId instanceId, String endpointPath, @Nullable String queryString) { + return CacheKeyBuilder.buildKey(instanceId, endpointPath, queryString); + } + + private static String extractEndpointId(String endpointPath) { + int slash = endpointPath.indexOf('/'); + return (slash > 0) ? endpointPath.substring(0, slash) : endpointPath; + } + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/InMemoryActuatorResponseCache.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/InMemoryActuatorResponseCache.java new file mode 100644 index 00000000000..cca56bb8e25 --- /dev/null +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/cache/InMemoryActuatorResponseCache.java @@ -0,0 +1,129 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.web.cache; + +import java.time.Instant; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpMethod; + +import de.codecentric.boot.admin.server.config.AdminServerProperties.EndpointCacheProperties; +import de.codecentric.boot.admin.server.domain.values.InstanceId; + +/** + * In-memory implementation of {@link ActuatorResponseCache} backed by a + * {@link ConcurrentHashMap}. + * + *

+ * TTL is enforced lazily: expired entries are detected and discarded at read time. No + * background eviction thread is used to keep the implementation simple. For long-running + * deployments with many instances, consider using the Hazelcast-backed implementation + * which performs native TTL eviction. + */ +public class InMemoryActuatorResponseCache implements ActuatorResponseCache { + + private static final Logger log = LoggerFactory.getLogger(InMemoryActuatorResponseCache.class); + + private final ConcurrentMap store = new ConcurrentHashMap<>(); + + private final EndpointCacheProperties properties; + + public InMemoryActuatorResponseCache(EndpointCacheProperties properties) { + this.properties = properties; + } + + @Override + public Optional get(InstanceId instanceId, String endpointPath, @Nullable String queryString) { + if (!this.properties.isEnabled()) { + return Optional.empty(); + } + String key = buildKey(instanceId, endpointPath, queryString); + CacheEntry entry = this.store.get(key); + if (entry == null) { + return Optional.empty(); + } + Instant expiresAt = entry.getCachedAt().plus(getTtl(extractEndpointId(endpointPath))); + if (Instant.now().isAfter(expiresAt)) { + this.store.remove(key, entry); + log.trace("Evicted expired cache entry for key '{}'", key); + return Optional.empty(); + } + return Optional.of(entry); + } + + @Override + public void put(InstanceId instanceId, String endpointPath, @Nullable String queryString, CacheEntry entry) { + if (!this.properties.isEnabled()) { + return; + } + String key = buildKey(instanceId, endpointPath, queryString); + this.store.put(key, entry); + log.trace("Cached entry for key '{}'", key); + } + + @Override + public void invalidateAllForInstance(InstanceId instanceId) { + String prefix = CacheKeyBuilder.instancePrefix(instanceId); + boolean removed = this.store.keySet().removeIf((key) -> key.startsWith(prefix)); + if (removed) { + log.debug("Invalidated cache entries for instance {}", instanceId); + } + } + + @Override + public void invalidateEndpointForInstance(InstanceId instanceId, String endpointId) { + String baseKey = CacheKeyBuilder.buildKey(instanceId, endpointId, null); + String baseKeyWithSlash = baseKey + "/"; + String baseKeyWithQuery = baseKey + "?"; + boolean removed = this.store.keySet() + .removeIf((key) -> key.equals(baseKey) || key.startsWith(baseKeyWithSlash) + || key.startsWith(baseKeyWithQuery)); + if (removed) { + log.debug("Invalidated cache entries for instance {} endpoint '{}'", instanceId, endpointId); + } + } + + @Override + public boolean shouldCache(HttpMethod method, String endpointId) { + return this.properties.isEnabled() && HttpMethod.GET.equals(method) + && this.properties.getEndpoints().contains(endpointId); + } + + @Override + public long getMaxPayloadSize() { + return this.properties.getMaxPayloadSize(); + } + + private java.time.Duration getTtl(String endpointId) { + return this.properties.getTtl().getOrDefault(endpointId, this.properties.getDefaultTtl()); + } + + static String buildKey(InstanceId instanceId, String endpointPath, @Nullable String queryString) { + return CacheKeyBuilder.buildKey(instanceId, endpointPath, queryString); + } + + static String extractEndpointId(String endpointPath) { + int slash = endpointPath.indexOf('/'); + return (slash > 0) ? endpointPath.substring(0, slash) : endpointPath; + } + +} diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/reactive/InstancesProxyController.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/reactive/InstancesProxyController.java index ff4cea7bdaf..2a55032d5a9 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/reactive/InstancesProxyController.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/reactive/InstancesProxyController.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,8 @@ package de.codecentric.boot.admin.server.web.reactive; import java.net.URI; -import java.util.Set; +import org.jspecify.annotations.Nullable; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; @@ -33,6 +33,7 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientResponse; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; @@ -42,7 +43,6 @@ import de.codecentric.boot.admin.server.web.AdminController; import de.codecentric.boot.admin.server.web.HttpHeaderFilter; import de.codecentric.boot.admin.server.web.InstanceWebProxy; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; /** * Http Handler for proxied requests @@ -66,28 +66,26 @@ public class InstancesProxyController { private final HttpHeaderFilter httpHeadersFilter; - public InstancesProxyController(String adminContextPath, Set ignoredHeaders, InstanceRegistry registry, - InstanceWebClient instanceWebClient) { + public InstancesProxyController(String adminContextPath, HttpHeaderFilter httpHeadersFilter, + InstanceRegistry registry, InstanceWebProxy instanceWebProxy) { this.adminContextPath = adminContextPath; + this.httpHeadersFilter = httpHeadersFilter; this.registry = registry; - this.httpHeadersFilter = new HttpHeaderFilter(ignoredHeaders); - this.instanceWebProxy = new InstanceWebProxy(instanceWebClient); + this.instanceWebProxy = instanceWebProxy; } @RequestMapping(path = INSTANCE_MAPPED_PATH, method = { RequestMethod.GET, RequestMethod.HEAD, RequestMethod.POST, RequestMethod.PUT, RequestMethod.PATCH, RequestMethod.DELETE, RequestMethod.OPTIONS }) public Mono endpointProxy(@PathVariable("instanceId") String instanceId, ServerHttpRequest request, ServerHttpResponse response) { - InstanceWebProxy.ForwardRequest fwdRequest = createForwardRequest(request, request.getBody(), - this.adminContextPath + INSTANCE_MAPPED_PATH); - - return this.instanceWebProxy.forward(this.registry.getInstance(InstanceId.of(instanceId)), fwdRequest, - (clientResponse) -> { - response.setStatusCode(clientResponse.statusCode()); - response.getHeaders() - .addAll(this.httpHeadersFilter.filterHeaders(clientResponse.headers().asHttpHeaders())); - return response.writeAndFlushWith(clientResponse.body(BodyExtractors.toDataBuffers()).window(1)); - }); + String localPath = getLocalPath(this.adminContextPath + INSTANCE_MAPPED_PATH, request); + String rawQuery = request.getURI().getRawQuery(); + InstanceId id = InstanceId.of(instanceId); + + InstanceWebProxy.ForwardRequest fwdRequest = createForwardRequest(request, request.getBody(), localPath, + rawQuery); + return this.instanceWebProxy.forward(this.registry.getInstance(id), fwdRequest, + (clientResponse) -> writeProxiedResponse(clientResponse, response)); } @ResponseBody @@ -111,15 +109,25 @@ public Flux endpointProxy( return this.instanceWebProxy.forward(this.registry.getInstances(applicationName), fwdRequest); } - private InstanceWebProxy.ForwardRequest createForwardRequest(ServerHttpRequest request, Flux cachedBody, + private Mono writeProxiedResponse(ClientResponse clientResponse, ServerHttpResponse response) { + response.setStatusCode(clientResponse.statusCode()); + response.getHeaders().addAll(this.httpHeadersFilter.filterHeaders(clientResponse.headers().asHttpHeaders())); + return response.writeAndFlushWith(clientResponse.body(BodyExtractors.toDataBuffers()).window(1)); + } + + private InstanceWebProxy.ForwardRequest createForwardRequest(ServerHttpRequest request, Flux body, String pathPattern) { - String localPath = this.getLocalPath(pathPattern, request); - URI uri = UriComponentsBuilder.fromPath(localPath).query(request.getURI().getRawQuery()).build(true).toUri(); + return createForwardRequest(request, body, getLocalPath(pathPattern, request), request.getURI().getRawQuery()); + } + + private InstanceWebProxy.ForwardRequest createForwardRequest(ServerHttpRequest request, Flux body, + String localPath, @Nullable String rawQuery) { + URI uri = UriComponentsBuilder.fromPath(localPath).query(rawQuery).build(true).toUri(); return InstanceWebProxy.ForwardRequest.builder() .uri(uri) .method(request.getMethod()) .headers(this.httpHeadersFilter.filterHeaders(request.getHeaders())) - .body(BodyInserters.fromDataBuffers(cachedBody)) + .body(BodyInserters.fromDataBuffers(body)) .build(); } diff --git a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/servlet/InstancesProxyController.java b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/servlet/InstancesProxyController.java index 5f3886c498f..3ab33674b72 100644 --- a/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/servlet/InstancesProxyController.java +++ b/spring-boot-admin-server/src/main/java/de/codecentric/boot/admin/server/web/servlet/InstancesProxyController.java @@ -1,5 +1,5 @@ /* - * Copyright 2014-2023 the original author or authors. + * Copyright 2014-2026 the original author or authors. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -19,15 +19,17 @@ import java.io.IOException; import java.io.OutputStream; import java.net.URI; -import java.util.Set; import jakarta.servlet.AsyncContext; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import org.jspecify.annotations.Nullable; import org.springframework.core.io.buffer.DataBuffer; import org.springframework.core.io.buffer.DataBufferFactory; import org.springframework.core.io.buffer.DataBufferUtils; import org.springframework.core.io.buffer.DefaultDataBufferFactory; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatusCode; import org.springframework.http.server.ServerHttpResponse; import org.springframework.http.server.ServletServerHttpRequest; import org.springframework.http.server.ServletServerHttpResponse; @@ -39,6 +41,7 @@ import org.springframework.web.bind.annotation.ResponseBody; import org.springframework.web.reactive.function.BodyExtractors; import org.springframework.web.reactive.function.BodyInserters; +import org.springframework.web.reactive.function.client.ClientResponse; import org.springframework.web.servlet.HandlerMapping; import org.springframework.web.util.UriComponentsBuilder; import reactor.core.publisher.Flux; @@ -49,7 +52,6 @@ import de.codecentric.boot.admin.server.web.AdminController; import de.codecentric.boot.admin.server.web.HttpHeaderFilter; import de.codecentric.boot.admin.server.web.InstanceWebProxy; -import de.codecentric.boot.admin.server.web.client.InstanceWebClient; /** * Http Handler for proxied requests @@ -73,12 +75,12 @@ public class InstancesProxyController { private final String adminContextPath; - public InstancesProxyController(String adminContextPath, Set ignoredHeaders, InstanceRegistry registry, - InstanceWebClient instanceWebClient) { + public InstancesProxyController(String adminContextPath, HttpHeaderFilter httpHeadersFilter, + InstanceRegistry registry, InstanceWebProxy instanceWebProxy) { this.adminContextPath = adminContextPath; + this.httpHeadersFilter = httpHeadersFilter; this.registry = registry; - this.httpHeadersFilter = new HttpHeaderFilter(ignoredHeaders); - this.instanceWebProxy = new InstanceWebProxy(instanceWebClient); + this.instanceWebProxy = instanceWebProxy; } @ResponseBody @@ -96,29 +98,17 @@ public void instanceProxy(@PathVariable("instanceId") String instanceId, HttpSer try { ServletServerHttpRequest request = new ServletServerHttpRequest( (HttpServletRequest) asyncContext.getRequest()); + String localPath = getLocalPath(this.adminContextPath + INSTANCE_MAPPED_PATH, request); + String rawQuery = request.getURI().getRawQuery(); + InstanceId id = InstanceId.of(instanceId); + Flux requestBody = DataBufferUtils.readInputStream(request::getBody, this.bufferFactory, 4096); - InstanceWebProxy.ForwardRequest fwdRequest = createForwardRequest(request, requestBody, - this.adminContextPath + INSTANCE_MAPPED_PATH); + InstanceWebProxy.ForwardRequest fwdRequest = createForwardRequest(request, requestBody, localPath, + rawQuery); this.instanceWebProxy - .forward(this.registry.getInstance(InstanceId.of(instanceId)), fwdRequest, (clientResponse) -> { - ServerHttpResponse response = new ServletServerHttpResponse( - (HttpServletResponse) asyncContext.getResponse()); - response.setStatusCode(clientResponse.statusCode()); - response.getHeaders() - .addAll(this.httpHeadersFilter.filterHeaders(clientResponse.headers().asHttpHeaders())); - try { - OutputStream responseBody = response.getBody(); - response.flush(); - return clientResponse.body(BodyExtractors.toDataBuffers()) - .window(1) - .concatMap((body) -> writeAndFlush(body, responseBody)) - .then(); - } - catch (IOException ex) { - return Mono.error(ex); - } - }) + .forward(this.registry.getInstance(id), fwdRequest, + (clientResponse) -> writeProxiedResponse(clientResponse, asyncContext)) // We need to explicitly block so the headers are received and written // before any async dispatch otherwise the FrameworkServlet will add // wrong @@ -145,14 +135,33 @@ public Flux endpointProxy( return this.instanceWebProxy.forward(this.registry.getInstances(applicationName), fwdRequest); } + private Mono writeProxiedResponse(ClientResponse clientResponse, AsyncContext asyncContext) { + HttpStatusCode statusCode = clientResponse.statusCode(); + HttpHeaders filteredHeaders = this.httpHeadersFilter.filterHeaders(clientResponse.headers().asHttpHeaders()); + ServerHttpResponse response = new ServletServerHttpResponse((HttpServletResponse) asyncContext.getResponse()); + response.setStatusCode(statusCode); + response.getHeaders().addAll(filteredHeaders); + try { + OutputStream responseBody = response.getBody(); + response.flush(); + return clientResponse.body(BodyExtractors.toDataBuffers()) + .window(1) + .concatMap((body) -> writeAndFlush(body, responseBody)) + .then(); + } + catch (IOException ex) { + return Mono.error(ex); + } + } + private InstanceWebProxy.ForwardRequest createForwardRequest(ServletServerHttpRequest request, Flux body, String pathPattern) { - String endpointLocalPath = this.getLocalPath(pathPattern, request); - URI uri = UriComponentsBuilder.fromPath(endpointLocalPath) - .query(request.getURI().getRawQuery()) - .build(true) - .toUri(); + return createForwardRequest(request, body, getLocalPath(pathPattern, request), request.getURI().getRawQuery()); + } + private InstanceWebProxy.ForwardRequest createForwardRequest(ServletServerHttpRequest request, + Flux body, String localPath, @Nullable String rawQuery) { + URI uri = UriComponentsBuilder.fromPath(localPath).query(rawQuery).build(true).toUri(); return InstanceWebProxy.ForwardRequest.builder() .uri(uri) .method(request.getMethod()) diff --git a/spring-boot-admin-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json b/spring-boot-admin-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json index b0fae008493..b2fb633442b 100644 --- a/spring-boot-admin-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json +++ b/spring-boot-admin-server/src/main/resources/META-INF/additional-spring-configuration-metadata.json @@ -12,7 +12,7 @@ "name": "spring.boot.admin.hazelcast.event-store", "type": "java.lang.String", "description": "Name of backing Hazelcast-Map for storing the instance events.", - "defaultValue": "spring-boot-admin-application-store" + "defaultValue": "spring-boot-admin-event-store" }, { "name": "spring.boot.admin.hazelcast.sent-notifications", @@ -20,6 +20,41 @@ "description": "Name of backing Hazelcast-Map for storing the sent notifications.", "defaultValue": "spring-boot-admin-sent-notifications" }, + { + "name": "spring.boot.admin.hazelcast.response-cache", + "type": "java.lang.String", + "description": "Name of the Hazelcast IMap used as the shared actuator response cache across cluster nodes.", + "defaultValue": "spring-boot-admin-actuator-response-cache" + }, + { + "name": "spring.boot.admin.endpoint-cache.enabled", + "type": "java.lang.Boolean", + "description": "Enable server-side caching of proxied actuator endpoint GET responses.", + "defaultValue": "true" + }, + { + "name": "spring.boot.admin.endpoint-cache.default-ttl", + "type": "java.time.Duration", + "description": "Default time-to-live for cached actuator responses.", + "defaultValue": "5m" + }, + { + "name": "spring.boot.admin.endpoint-cache.ttl", + "type": "java.util.Map", + "description": "Per-endpoint-id TTL overrides. Keys are actuator endpoint ids (e.g. 'mappings'). Defaults to endpoint-cache.default-ttl." + }, + { + "name": "spring.boot.admin.endpoint-cache.endpoints", + "type": "java.util.Set", + "description": "Actuator endpoint ids whose GET responses will be cached. Defaults to a conservative set of expensive, mostly-static endpoints.", + "defaultValue": "mappings,configprops,beans,conditions,sbom,startup" + }, + { + "name": "spring.boot.admin.endpoint-cache.max-payload-size", + "type": "java.lang.Long", + "description": "Maximum response body size in bytes that will be stored in the cache. Larger responses are forwarded as-is.", + "defaultValue": "10485760" + }, { "name": "spring.boot.admin.monitor.period", "type": "java.lang.Long", diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/AbstractInstancesProxyControllerIntegrationTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/AbstractInstancesProxyControllerIntegrationTest.java index 1f2f0ff68c6..32abd4768a0 100644 --- a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/AbstractInstancesProxyControllerIntegrationTest.java +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/AbstractInstancesProxyControllerIntegrationTest.java @@ -16,6 +16,7 @@ package de.codecentric.boot.admin.server.web; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.Map; import java.util.concurrent.atomic.AtomicReference; @@ -44,6 +45,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.delete; import static com.github.tomakehurst.wiremock.client.WireMock.deleteRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.equalTo; +import static com.github.tomakehurst.wiremock.client.WireMock.exactly; import static com.github.tomakehurst.wiremock.client.WireMock.get; import static com.github.tomakehurst.wiremock.client.WireMock.getRequestedFor; import static com.github.tomakehurst.wiremock.client.WireMock.ok; @@ -54,6 +56,7 @@ import static com.github.tomakehurst.wiremock.client.WireMock.urlEqualTo; import static org.assertj.core.api.Assertions.assertThat; import static org.springframework.http.HttpHeaders.ALLOW; +import static org.springframework.http.HttpHeaders.CONTENT_LENGTH; import static org.springframework.http.HttpHeaders.CONTENT_TYPE; public abstract class AbstractInstancesProxyControllerIntegrationTest { @@ -249,6 +252,49 @@ public void should_forward_requests_to_multiple_instances() { this.wireMock.verify(deleteRequestedFor(urlEqualTo("/instance2/delete"))); } + @Test + public void should_serve_second_request_from_cache() { + // mappings is in the default cached-endpoints set + this.client.get() + .uri("/instances/{instanceId}/actuator/mappings", this.instanceId) + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("{ \"contexts\": {} }"); + + // Second request must be served from cache + this.client.get() + .uri("/instances/{instanceId}/actuator/mappings", this.instanceId) + .exchange() + .expectStatus() + .isOk() + .expectBody(String.class) + .isEqualTo("{ \"contexts\": {} }"); + + // Upstream should only have been called once + this.wireMock.verify(exactly(1), getRequestedFor(urlEqualTo("/instance1/mappings"))); + } + + @Test + public void should_not_cache_non_configured_endpoint() { + // 'test' is NOT in the default cached-endpoints set + this.client.get() + .uri("/instances/{instanceId}/actuator/test", this.instanceId) + .exchange() + .expectStatus() + .isOk(); + + this.client.get() + .uri("/instances/{instanceId}/actuator/test", this.instanceId) + .exchange() + .expectStatus() + .isOk(); + + // Upstream must have been called both times + this.wireMock.verify(exactly(2), getRequestedFor(urlEqualTo("/instance1/test"))); + } + private void stubForInstance(String managementPath) { String managementUrl = this.wireMock.url(managementPath); @@ -256,6 +302,7 @@ private void stubForInstance(String managementPath) { String actuatorIndex = "{ \"_links\": { " + "\"env\": { \"href\": \"" + managementUrl + "/env\", \"templated\": false }," + "\"test\": { \"href\": \"" + managementUrl + "/test\", \"templated\": false }," + + "\"mappings\": { \"href\": \"" + managementUrl + "/mappings\", \"templated\": false }," + "\"post\": { \"href\": \"" + managementUrl + "/post\", \"templated\": false }," + "\"delete\": { \"href\": \"" + managementUrl + "/delete\", \"templated\": false }," + "\"invalid\": { \"href\": \"" + managementUrl + "/invalid\", \"templated\": false }," + @@ -275,6 +322,10 @@ private void stubForInstance(String managementPath) { this.wireMock.stubFor(get(urlEqualTo(managementPath + "/timeout")).willReturn(ok().withFixedDelay(10000))); this.wireMock.stubFor(get(urlEqualTo(managementPath + "/test")) .willReturn(ok("{ \"foo\" : \"bar\" }").withHeader(CONTENT_TYPE, ACTUATOR_CONTENT_TYPE))); + this.wireMock.stubFor(get(urlEqualTo(managementPath + "/mappings")) + .willReturn(ok("{ \"contexts\": {} }").withHeader(CONTENT_TYPE, ACTUATOR_CONTENT_TYPE) + .withHeader(CONTENT_LENGTH, + String.valueOf("{ \"contexts\": {} }".getBytes(StandardCharsets.UTF_8).length)))); this.wireMock.stubFor(get(urlEqualTo(managementPath + "/test/has%20spaces")) .willReturn(ok("{ \"foo\" : \"bar-with-spaces\" }").withHeader(CONTENT_TYPE, ACTUATOR_CONTENT_TYPE))); this.wireMock.stubFor(post(urlEqualTo(managementPath + "/post")).willReturn(ok())); diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/CacheInvalidationTriggerTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/CacheInvalidationTriggerTest.java new file mode 100644 index 00000000000..4cdace5cc28 --- /dev/null +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/CacheInvalidationTriggerTest.java @@ -0,0 +1,114 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.web.cache; + +import java.time.Duration; + +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import reactor.test.publisher.TestPublisher; + +import de.codecentric.boot.admin.server.config.AdminServerProperties.EndpointCacheProperties; +import de.codecentric.boot.admin.server.domain.events.InstanceDeregisteredEvent; +import de.codecentric.boot.admin.server.domain.events.InstanceEndpointsDetectedEvent; +import de.codecentric.boot.admin.server.domain.events.InstanceEvent; +import de.codecentric.boot.admin.server.domain.events.InstanceRegisteredEvent; +import de.codecentric.boot.admin.server.domain.events.InstanceRegistrationUpdatedEvent; +import de.codecentric.boot.admin.server.domain.values.Endpoints; +import de.codecentric.boot.admin.server.domain.values.InstanceId; +import de.codecentric.boot.admin.server.domain.values.Registration; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; + +class CacheInvalidationTriggerTest { + + private TestPublisher events; + + private InMemoryActuatorResponseCache cache; + + private CacheInvalidationTrigger trigger; + + @BeforeEach + void setup() { + EndpointCacheProperties props = new EndpointCacheProperties(); + props.setDefaultTtl(Duration.ofMinutes(5)); + this.cache = new InMemoryActuatorResponseCache(props); + this.events = TestPublisher.create(); + this.trigger = new CacheInvalidationTrigger(this.events.flux(), this.cache); + this.trigger.start(); + // Wait until AbstractEventHandler has subscribed so emitted events are not + // dropped + await().until(this.events::wasSubscribed); + } + + @AfterEach + void tearDown() { + this.trigger.stop(); + } + + @Test + void should_invalidate_on_deregistration() { + InstanceId id = InstanceId.of("id1"); + this.cache.put(id, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + this.events.next(new InstanceDeregisteredEvent(id, 1L)); + + await().atMost(Duration.ofMillis(500)) + .untilAsserted(() -> assertThat(this.cache.get(id, "mappings", null)).isEmpty()); + } + + @Test + void should_invalidate_on_registration_update() { + InstanceId id = InstanceId.of("id2"); + Registration reg = Registration.create("app", "http://localhost/mgmt").build(); + this.cache.put(id, "beans", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + this.events.next(new InstanceRegistrationUpdatedEvent(id, 1L, reg)); + + await().atMost(Duration.ofMillis(500)) + .untilAsserted(() -> assertThat(this.cache.get(id, "beans", null)).isEmpty()); + } + + @Test + void should_invalidate_on_endpoints_detected() { + InstanceId id = InstanceId.of("id3"); + this.cache.put(id, "configprops", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + this.events.next(new InstanceEndpointsDetectedEvent(id, 1L, Endpoints.empty())); + + await().atMost(Duration.ofMillis(500)) + .untilAsserted(() -> assertThat(this.cache.get(id, "configprops", null)).isEmpty()); + } + + @Test + void should_not_invalidate_on_registered_event() { + InstanceId id = InstanceId.of("id4"); + Registration reg = Registration.create("app", "http://localhost/mgmt").build(); + this.cache.put(id, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + this.events.next(new InstanceRegisteredEvent(id, 1L, reg)); + + // Wait briefly then assert cache entry is still present + await().during(Duration.ofMillis(200)) + .atMost(Duration.ofMillis(500)) + .untilAsserted(() -> assertThat(this.cache.get(id, "mappings", null)).isPresent()); + } + +} diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/HazelcastActuatorResponseCacheTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/HazelcastActuatorResponseCacheTest.java new file mode 100644 index 00000000000..0709b380800 --- /dev/null +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/HazelcastActuatorResponseCacheTest.java @@ -0,0 +1,192 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.web.cache; + +import java.time.Duration; +import java.util.Optional; + +import com.hazelcast.config.Config; +import com.hazelcast.core.Hazelcast; +import com.hazelcast.core.HazelcastInstance; +import com.hazelcast.map.IMap; +import org.awaitility.Awaitility; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; + +import de.codecentric.boot.admin.server.config.AdminServerProperties.EndpointCacheProperties; +import de.codecentric.boot.admin.server.domain.values.InstanceId; + +import static org.assertj.core.api.Assertions.assertThat; + +class HazelcastActuatorResponseCacheTest { + + private HazelcastInstance hazelcast; + + private HazelcastActuatorResponseCache cache; + + private EndpointCacheProperties props; + + @BeforeEach + void setUp() { + Config config = new Config(); + config.getNetworkConfig().getJoin().getMulticastConfig().setEnabled(false); + config.getNetworkConfig().getJoin().getAutoDetectionConfig().setEnabled(false); + this.hazelcast = Hazelcast.newHazelcastInstance(config); + IMap map = this.hazelcast.getMap("test-response-cache-" + System.currentTimeMillis()); + this.props = new EndpointCacheProperties(); + this.props.setDefaultTtl(Duration.ofMinutes(5)); + this.cache = new HazelcastActuatorResponseCache(map, this.props); + } + + @AfterEach + void tearDown() { + if (this.hazelcast != null) { + this.hazelcast.shutdown(); + } + } + + @Test + void should_return_empty_when_no_entry() { + Optional result = this.cache.get(InstanceId.of("id1"), "mappings", null); + assertThat(result).isEmpty(); + } + + @Test + void should_store_and_retrieve_entry() { + InstanceId id = InstanceId.of("id1"); + CacheEntry entry = new CacheEntry(200, new HttpHeaders(), new byte[] { 1, 2, 3 }); + + this.cache.put(id, "mappings", null, entry); + + Optional result = this.cache.get(id, "mappings", null); + assertThat(result).isPresent(); + assertThat(result.get().getStatusCode()).isEqualTo(200); + assertThat(result.get().getBody()).isEqualTo(new byte[] { 1, 2, 3 }); + } + + @Test + void should_treat_query_string_as_part_of_key() { + InstanceId id = InstanceId.of("id1"); + CacheEntry entryA = new CacheEntry(200, new HttpHeaders(), new byte[] { 1 }); + CacheEntry entryB = new CacheEntry(200, new HttpHeaders(), new byte[] { 2 }); + + this.cache.put(id, "beans", "foo=bar", entryA); + this.cache.put(id, "beans", "foo=baz", entryB); + + assertThat(this.cache.get(id, "beans", "foo=bar")).isPresent(); + assertThat(this.cache.get(id, "beans", "foo=baz")).isPresent(); + assertThat(this.cache.get(id, "beans", null)).isEmpty(); + } + + @Test + void should_invalidate_all_entries_for_instance() { + InstanceId id1 = InstanceId.of("id1"); + InstanceId id2 = InstanceId.of("id2"); + this.cache.put(id1, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id1, "beans", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id2, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + this.cache.invalidateAllForInstance(id1); + + assertThat(this.cache.get(id1, "mappings", null)).isEmpty(); + assertThat(this.cache.get(id1, "beans", null)).isEmpty(); + assertThat(this.cache.get(id2, "mappings", null)).isPresent(); + } + + @Test + void should_invalidate_endpoint_exact_sub_path_and_query_variants() { + InstanceId id1 = InstanceId.of("id1"); + InstanceId id2 = InstanceId.of("id2"); + this.cache.put(id1, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id1, "mappings/sub", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id1, "mappings", "page=0", new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id1, "beans", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id2, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + this.cache.invalidateEndpointForInstance(id1, "mappings"); + + // all mappings variants for id1 are evicted + assertThat(this.cache.get(id1, "mappings", null)).isEmpty(); + assertThat(this.cache.get(id1, "mappings/sub", null)).isEmpty(); + assertThat(this.cache.get(id1, "mappings", "page=0")).isEmpty(); + // beans for id1 and mappings for id2 are unaffected + assertThat(this.cache.get(id1, "beans", null)).isPresent(); + assertThat(this.cache.get(id2, "mappings", null)).isPresent(); + } + + @Test + void should_not_cache_when_disabled() { + this.props.setEnabled(false); + InstanceId id = InstanceId.of("id1"); + this.cache.put(id, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + assertThat(this.cache.get(id, "mappings", null)).isEmpty(); + } + + @Test + void should_evict_entry_after_ttl() { + // Use a short but Hazelcast-safe TTL: server-side TTL eviction may fire + // before the first read, so we only assert eventual emptiness. + this.props.setDefaultTtl(Duration.ofMillis(500)); + InstanceId id = InstanceId.of("id1"); + this.cache.put(id, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertThat(this.cache.get(id, "mappings", null)).isEmpty()); + } + + @Test + void should_respect_per_endpoint_ttl() { + this.props.setDefaultTtl(Duration.ofSeconds(60)); + this.props.getTtl().put("mappings", Duration.ofMillis(500)); + InstanceId id = InstanceId.of("id1"); + this.cache.put(id, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id, "beans", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + Awaitility.await() + .atMost(Duration.ofSeconds(10)) + .untilAsserted(() -> assertThat(this.cache.get(id, "mappings", null)).isEmpty()); + assertThat(this.cache.get(id, "beans", null)).isPresent(); + } + + @Test + void shouldCache_returns_true_for_configured_get_endpoint() { + assertThat(this.cache.shouldCache(HttpMethod.GET, "mappings")).isTrue(); + assertThat(this.cache.shouldCache(HttpMethod.GET, "beans")).isTrue(); + } + + @Test + void shouldCache_returns_false_for_unconfigured_endpoint() { + assertThat(this.cache.shouldCache(HttpMethod.GET, "health")).isFalse(); + } + + @Test + void shouldCache_returns_false_for_non_get_method() { + assertThat(this.cache.shouldCache(HttpMethod.POST, "mappings")).isFalse(); + } + + @Test + void shouldCache_returns_false_when_disabled() { + this.props.setEnabled(false); + assertThat(this.cache.shouldCache(HttpMethod.GET, "mappings")).isFalse(); + } + +} diff --git a/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/InMemoryActuatorResponseCacheTest.java b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/InMemoryActuatorResponseCacheTest.java new file mode 100644 index 00000000000..c83c6dfcff2 --- /dev/null +++ b/spring-boot-admin-server/src/test/java/de/codecentric/boot/admin/server/web/cache/InMemoryActuatorResponseCacheTest.java @@ -0,0 +1,199 @@ +/* + * Copyright 2014-2026 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package de.codecentric.boot.admin.server.web.cache; + +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Optional; + +import org.awaitility.Awaitility; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; + +import de.codecentric.boot.admin.server.config.AdminServerProperties.EndpointCacheProperties; +import de.codecentric.boot.admin.server.domain.values.InstanceId; + +import static org.assertj.core.api.Assertions.assertThat; + +class InMemoryActuatorResponseCacheTest { + + private InMemoryActuatorResponseCache cache; + + private EndpointCacheProperties props; + + @BeforeEach + void setup() { + this.props = new EndpointCacheProperties(); + this.props.setDefaultTtl(Duration.ofMinutes(5)); + this.cache = new InMemoryActuatorResponseCache(this.props); + } + + @Test + void should_return_empty_when_no_entry() { + Optional result = this.cache.get(InstanceId.of("id1"), "mappings", null); + assertThat(result).isEmpty(); + } + + @Test + void should_store_and_retrieve_entry() { + InstanceId id = InstanceId.of("id1"); + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + byte[] body = "{\"foo\":\"bar\"}".getBytes(StandardCharsets.UTF_8); + CacheEntry entry = new CacheEntry(200, headers, body); + + this.cache.put(id, "mappings", null, entry); + + Optional result = this.cache.get(id, "mappings", null); + assertThat(result).isPresent(); + assertThat(result.get().getStatusCode()).isEqualTo(200); + assertThat(result.get().getBody()).isEqualTo(body); + assertThat(result.get().getHttpHeaders().getContentType()).isEqualTo(MediaType.APPLICATION_JSON); + } + + @Test + void should_treat_query_string_as_part_of_key() { + InstanceId id = InstanceId.of("id1"); + CacheEntry entry1 = new CacheEntry(200, new HttpHeaders(), "body1".getBytes(StandardCharsets.UTF_8)); + CacheEntry entry2 = new CacheEntry(200, new HttpHeaders(), "body2".getBytes(StandardCharsets.UTF_8)); + + this.cache.put(id, "beans", "foo=bar", entry1); + this.cache.put(id, "beans", "foo=baz", entry2); + + assertThat(this.cache.get(id, "beans", "foo=bar")).isPresent() + .get() + .extracting((e) -> new String(e.getBody(), StandardCharsets.UTF_8)) + .isEqualTo("body1"); + assertThat(this.cache.get(id, "beans", "foo=baz")).isPresent() + .get() + .extracting((e) -> new String(e.getBody(), StandardCharsets.UTF_8)) + .isEqualTo("body2"); + assertThat(this.cache.get(id, "beans", null)).isEmpty(); + } + + @Test + void should_evict_expired_entries_on_read() { + this.props.setDefaultTtl(Duration.ofMillis(50)); + InstanceId id = InstanceId.of("id1"); + this.cache.put(id, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + assertThat(this.cache.get(id, "mappings", null)).isPresent(); + + Awaitility.await() + .atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(this.cache.get(id, "mappings", null)).isEmpty()); + } + + @Test + void should_respect_per_endpoint_ttl() { + this.props.setDefaultTtl(Duration.ofSeconds(60)); + this.props.getTtl().put("mappings", Duration.ofMillis(50)); + InstanceId id = InstanceId.of("id1"); + this.cache.put(id, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id, "beans", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + Awaitility.await() + .atMost(Duration.ofSeconds(5)) + .untilAsserted(() -> assertThat(this.cache.get(id, "mappings", null)).isEmpty()); + assertThat(this.cache.get(id, "beans", null)).isPresent(); + } + + @Test + void should_invalidate_all_entries_for_instance() { + InstanceId id1 = InstanceId.of("id1"); + InstanceId id2 = InstanceId.of("id2"); + this.cache.put(id1, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id1, "beans", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id2, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + this.cache.invalidateAllForInstance(id1); + + assertThat(this.cache.get(id1, "mappings", null)).isEmpty(); + assertThat(this.cache.get(id1, "beans", null)).isEmpty(); + assertThat(this.cache.get(id2, "mappings", null)).isPresent(); + } + + @Test + void should_invalidate_single_endpoint_for_instance() { + InstanceId id1 = InstanceId.of("id1"); + InstanceId id2 = InstanceId.of("id2"); + this.cache.put(id1, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id1, "mappings/sub", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id1, "mappings", "page=0", new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id1, "beans", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + this.cache.put(id2, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + this.cache.invalidateEndpointForInstance(id1, "mappings"); + + // mappings (exact, sub-path and query variant) for id1 are gone + assertThat(this.cache.get(id1, "mappings", null)).isEmpty(); + assertThat(this.cache.get(id1, "mappings/sub", null)).isEmpty(); + assertThat(this.cache.get(id1, "mappings", "page=0")).isEmpty(); + // beans for id1 and mappings for id2 are unaffected + assertThat(this.cache.get(id1, "beans", null)).isPresent(); + assertThat(this.cache.get(id2, "mappings", null)).isPresent(); + } + + @Test + void should_not_cache_when_disabled() { + this.props.setEnabled(false); + InstanceId id = InstanceId.of("id1"); + this.cache.put(id, "mappings", null, new CacheEntry(200, new HttpHeaders(), new byte[0])); + + assertThat(this.cache.get(id, "mappings", null)).isEmpty(); + } + + @Test + void shouldCache_returns_true_for_configured_get_endpoint() { + assertThat(this.cache.shouldCache(HttpMethod.GET, "mappings")).isTrue(); + assertThat(this.cache.shouldCache(HttpMethod.GET, "beans")).isTrue(); + assertThat(this.cache.shouldCache(HttpMethod.GET, "configprops")).isTrue(); + } + + @Test + void shouldCache_returns_false_for_unconfigured_endpoint() { + assertThat(this.cache.shouldCache(HttpMethod.GET, "health")).isFalse(); + assertThat(this.cache.shouldCache(HttpMethod.GET, "info")).isFalse(); + } + + @Test + void shouldCache_returns_false_for_non_get_method() { + assertThat(this.cache.shouldCache(HttpMethod.POST, "mappings")).isFalse(); + assertThat(this.cache.shouldCache(HttpMethod.DELETE, "mappings")).isFalse(); + assertThat(this.cache.shouldCache(HttpMethod.HEAD, "mappings")).isFalse(); + } + + @Test + void shouldCache_returns_false_when_disabled() { + this.props.setEnabled(false); + assertThat(this.cache.shouldCache(HttpMethod.GET, "mappings")).isFalse(); + } + + @Test + void key_builder_encodes_sub_paths_and_query_string() { + String key1 = InMemoryActuatorResponseCache.buildKey(InstanceId.of("abc"), "sbom/application", null); + String key2 = InMemoryActuatorResponseCache.buildKey(InstanceId.of("abc"), "sbom/application", "format=spdx"); + + String expectedPrefix = CacheKeyBuilder.instancePrefix(InstanceId.of("abc")); + assertThat(key1).isEqualTo(expectedPrefix + "sbom/application"); + assertThat(key2).isEqualTo(expectedPrefix + "sbom/application?format=spdx"); + } + +}