Rework Cloud Foundry actuator support behind a pluggable abstraction

Deprecate `EndpointExposure.CLOUD_FOUNDRY` and introduce an alternative
implementation based on a pluggable abstraction.

The new `EndpointExposureOutcomeContributor` interface may now be used
to influence `@OnAvailableEndpointCondition` exposure results. Several
infrastructure beans that previously used the condition have been
refactored to always be registered, but tolerate missing endpoints.

A new smoke test application has been added that demonstrates how the
abstraction can be used by a third-party.

Closes gh-41135

Co-authored-by: Phillip Webb <phil.webb@broadcom.com>
This commit is contained in:
Andy Wilkinson
2024-08-20 07:31:01 -07:00
committed by Phillip Webb
parent cbb738338d
commit 73f71d5560
30 changed files with 698 additions and 122 deletions

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-2024 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.
@@ -52,7 +52,7 @@ public class CachesEndpointAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(CachesEndpoint.class)
@ConditionalOnAvailableEndpoint(exposure = { EndpointExposure.WEB, EndpointExposure.CLOUD_FOUNDRY })
@ConditionalOnAvailableEndpoint(exposure = EndpointExposure.WEB)
public CachesEndpointWebExtension cachesEndpointWebExtension(CachesEndpoint cachesEndpoint) {
return new CachesEndpointWebExtension(cachesEndpoint);
}

View File

@@ -0,0 +1,57 @@
/*
* Copyright 2012-2024 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 org.springframework.boot.actuate.autoconfigure.cloudfoundry;
import java.util.Set;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.EndpointExposureOutcomeContributor;
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure;
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.IncludeExcludeEndpointFilter;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.autoconfigure.condition.ConditionMessage.Builder;
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.boot.cloud.CloudPlatform;
import org.springframework.core.env.Environment;
/**
* {@link EndpointExposureOutcomeContributor} to expose {@link EndpointExposure#WEB web}
* endpoints for Cloud Foundry.
*
* @author Phillip Webb
*/
class CloudFoundryEndpointExposureOutcomeContributor implements EndpointExposureOutcomeContributor {
private static final String PROPERTY = "management.endpoints.cloud-foundry.exposure";
private final IncludeExcludeEndpointFilter<?> filter;
CloudFoundryEndpointExposureOutcomeContributor(Environment environment) {
this.filter = (!CloudPlatform.CLOUD_FOUNDRY.isActive(environment)) ? null
: new IncludeExcludeEndpointFilter<>(ExposableEndpoint.class, environment, PROPERTY, "*");
}
@Override
public ConditionOutcome getExposureOutcome(EndpointId endpointId, Set<EndpointExposure> exposures,
Builder message) {
if (exposures.contains(EndpointExposure.WEB) && this.filter != null && this.filter.match(endpointId)) {
return ConditionOutcome.match(message.because("marked as exposed by a '" + PROPERTY + "' property"));
}
return null;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-2024 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.
@@ -55,7 +55,7 @@ public class ConfigurationPropertiesReportEndpointAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(ConfigurationPropertiesReportEndpoint.class)
@ConditionalOnAvailableEndpoint(exposure = { EndpointExposure.WEB, EndpointExposure.CLOUD_FOUNDRY })
@ConditionalOnAvailableEndpoint(exposure = EndpointExposure.WEB)
public ConfigurationPropertiesReportEndpointWebExtension configurationPropertiesReportEndpointWebExtension(
ConfigurationPropertiesReportEndpoint configurationPropertiesReportEndpoint,
ConfigurationPropertiesReportEndpointProperties properties) {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2024 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.
@@ -31,15 +31,18 @@ import org.springframework.core.env.Environment;
/**
* {@link Conditional @Conditional} that checks whether an endpoint is available. An
* endpoint is considered available if it is both enabled and exposed on the specified
* technologies. Matches enablement according to the endpoints specific
* {@link Environment} property, falling back to
* {@code management.endpoints.enabled-by-default} or failing that
* {@link Endpoint#enableByDefault()}. Matches exposure according to any of the
* {@code management.endpoints.web.exposure.<id>} or
* {@code management.endpoints.jmx.exposure.<id>} specific properties or failing that to
* whether the application runs on
* {@link org.springframework.boot.cloud.CloudPlatform#CLOUD_FOUNDRY}. Both those
* conditions should match for the endpoint to be considered available.
* technologies.
* <p>
* Matches enablement according to the endpoints specific {@link Environment} property,
* falling back to {@code management.endpoints.enabled-by-default} or failing that
* {@link Endpoint#enableByDefault()}.
* <p>
* Matches exposure according to any of the {@code management.endpoints.web.exposure.<id>}
* or {@code management.endpoints.jmx.exposure.<id>} specific properties or failing that
* to whether any {@link EndpointExposureOutcomeContributor} exposes the endpoint.
* <p>
* Both enablement and exposure conditions should match for the endpoint to be considered
* available.
* <p>
* When placed on a {@code @Bean} method, the endpoint defaults to the return type of the
* factory method:
@@ -97,6 +100,8 @@ import org.springframework.core.env.Environment;
*
* @author Brian Clozel
* @author Stephane Nicoll
* @author Andy Wilkinson
* @author Phillip Webb
* @since 2.2.0
* @see Endpoint
*/

View File

@@ -0,0 +1,52 @@
/*
* Copyright 2012-2024 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 org.springframework.boot.actuate.autoconfigure.endpoint.condition;
import java.util.Set;
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.autoconfigure.condition.ConditionMessage;
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.core.env.Environment;
/**
* Contributor loaded from the {@code spring.factories} file and used by
* {@link ConditionalOnAvailableEndpoint @ConditionalOnAvailableEndpoint} to determine if
* an endpoint is exposed. If any contributor returns a {@link ConditionOutcome#isMatch()
* matching} {@link ConditionOutcome} then the endpoint is considered exposed.
* <p>
* Implementations may declare a constructor that accepts an {@link Environment} argument.
*
* @author Andy Wilkinson
* @author Phillip Webb
* @since 3.4.0
*/
public interface EndpointExposureOutcomeContributor {
/**
* Return if the given endpoint is exposed for the given set of exposure technologies.
* @param endpointId the endpoint ID
* @param exposures the exposure technologies to check
* @param message the condition message builder
* @return a {@link ConditionOutcome#isMatch() matching} {@link ConditionOutcome} if
* the endpoint is exposed or {@code null} if the contributor should not apply
*/
ConditionOutcome getExposureOutcome(EndpointId endpointId, Set<EndpointExposure> exposures,
ConditionMessage.Builder message);
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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,9 +17,10 @@
package org.springframework.boot.actuate.autoconfigure.endpoint.condition;
import java.util.Arrays;
import java.util.Collection;
import java.util.EnumSet;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -31,15 +32,17 @@ import org.springframework.boot.actuate.endpoint.ExposableEndpoint;
import org.springframework.boot.actuate.endpoint.annotation.Endpoint;
import org.springframework.boot.actuate.endpoint.annotation.EndpointExtension;
import org.springframework.boot.autoconfigure.condition.ConditionMessage;
import org.springframework.boot.autoconfigure.condition.ConditionMessage.Builder;
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
import org.springframework.boot.cloud.CloudPlatform;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.annotation.MergedAnnotation;
import org.springframework.core.annotation.MergedAnnotations;
import org.springframework.core.annotation.MergedAnnotations.SearchStrategy;
import org.springframework.core.env.Environment;
import org.springframework.core.io.support.SpringFactoriesLoader;
import org.springframework.core.io.support.SpringFactoriesLoader.ArgumentResolver;
import org.springframework.core.type.AnnotatedTypeMetadata;
import org.springframework.core.type.MethodMetadata;
import org.springframework.util.Assert;
@@ -52,6 +55,7 @@ import org.springframework.util.ConcurrentReferenceHashMap;
* @author Brian Clozel
* @author Stephane Nicoll
* @author Phillip Webb
* @author Andy Wilkinson
* @see ConditionalOnAvailableEndpoint
*/
class OnAvailableEndpointCondition extends SpringBootCondition {
@@ -60,7 +64,7 @@ class OnAvailableEndpointCondition extends SpringBootCondition {
private static final String ENABLED_BY_DEFAULT_KEY = "management.endpoints.enabled-by-default";
private static final Map<Environment, Set<ExposureFilter>> exposureFiltersCache = new ConcurrentReferenceHashMap<>();
private static final Map<Environment, Set<EndpointExposureOutcomeContributor>> exposureOutcomeContributorsCache = new ConcurrentReferenceHashMap<>();
private static final ConcurrentReferenceHashMap<Environment, Optional<Boolean>> enabledByDefaultCache = new ConcurrentReferenceHashMap<>();
@@ -110,18 +114,10 @@ class OnAvailableEndpointCondition extends SpringBootCondition {
ConditionMessage.Builder message = ConditionMessage.forCondition(ConditionalOnAvailableEndpoint.class);
EndpointId endpointId = EndpointId.of(environment, endpointAnnotation.getString("id"));
ConditionOutcome enablementOutcome = getEnablementOutcome(environment, endpointAnnotation, endpointId, message);
if (!enablementOutcome.isMatch()) {
return enablementOutcome;
}
Set<EndpointExposure> exposuresToCheck = getExposuresToCheck(conditionAnnotation);
Set<ExposureFilter> exposureFilters = getExposureFilters(environment);
for (ExposureFilter exposureFilter : exposureFilters) {
if (exposuresToCheck.contains(exposureFilter.getExposure()) && exposureFilter.isExposed(endpointId)) {
return ConditionOutcome.match(message.because("marked as exposed by a 'management.endpoints."
+ exposureFilter.getExposure().name().toLowerCase() + ".exposure' property"));
}
}
return ConditionOutcome.noMatch(message.because("no 'management.endpoints' property marked it as exposed"));
ConditionOutcome exposureOutcome = (!enablementOutcome.isMatch()) ? null
: getExposureOutcome(environment, conditionAnnotation, endpointAnnotation, endpointId, message);
return (exposureOutcome != null) ? exposureOutcome
: ConditionOutcome.noMatch(message.because("not enabled or exposed"));
}
private ConditionOutcome getEnablementOutcome(Environment environment,
@@ -148,54 +144,83 @@ class OnAvailableEndpointCondition extends SpringBootCondition {
return enabledByDefault.orElse(null);
}
private Set<EndpointExposure> getExposuresToCheck(
MergedAnnotation<ConditionalOnAvailableEndpoint> conditionAnnotation) {
EndpointExposure[] exposure = conditionAnnotation.getEnumArray("exposure", EndpointExposure.class);
return (exposure.length == 0) ? EnumSet.allOf(EndpointExposure.class)
: new LinkedHashSet<>(Arrays.asList(exposure));
}
private Set<ExposureFilter> getExposureFilters(Environment environment) {
Set<ExposureFilter> exposureFilters = exposureFiltersCache.get(environment);
if (exposureFilters == null) {
exposureFilters = new HashSet<>(2);
if (environment.getProperty(JMX_ENABLED_KEY, Boolean.class, false)) {
exposureFilters.add(new ExposureFilter(environment, EndpointExposure.JMX));
private ConditionOutcome getExposureOutcome(Environment environment,
MergedAnnotation<ConditionalOnAvailableEndpoint> conditionAnnotation,
MergedAnnotation<Endpoint> endpointAnnotation, EndpointId endpointId, Builder message) {
Set<EndpointExposure> exposures = getExposures(conditionAnnotation);
Set<EndpointExposureOutcomeContributor> outcomeContributors = getExposureOutcomeContributors(environment);
for (EndpointExposureOutcomeContributor outcomeContributor : outcomeContributors) {
ConditionOutcome outcome = outcomeContributor.getExposureOutcome(endpointId, exposures, message);
if (outcome != null && outcome.isMatch()) {
return outcome;
}
if (CloudPlatform.CLOUD_FOUNDRY.isActive(environment)) {
exposureFilters.add(new ExposureFilter(environment, EndpointExposure.CLOUD_FOUNDRY));
}
exposureFilters.add(new ExposureFilter(environment, EndpointExposure.WEB));
exposureFiltersCache.put(environment, exposureFilters);
}
return exposureFilters;
return null;
}
static final class ExposureFilter extends IncludeExcludeEndpointFilter<ExposableEndpoint<?>> {
private Set<EndpointExposure> getExposures(MergedAnnotation<ConditionalOnAvailableEndpoint> conditionAnnotation) {
EndpointExposure[] exposures = conditionAnnotation.getEnumArray("exposure", EndpointExposure.class);
return replaceCloudFoundryExposure(
(exposures.length == 0) ? EnumSet.allOf(EndpointExposure.class) : Arrays.asList(exposures));
}
@SuppressWarnings("removal")
private Set<EndpointExposure> replaceCloudFoundryExposure(Collection<EndpointExposure> exposures) {
Set<EndpointExposure> result = EnumSet.copyOf(exposures);
if (result.remove(EndpointExposure.CLOUD_FOUNDRY)) {
result.add(EndpointExposure.WEB);
}
return result;
}
private Set<EndpointExposureOutcomeContributor> getExposureOutcomeContributors(Environment environment) {
Set<EndpointExposureOutcomeContributor> contributors = exposureOutcomeContributorsCache.get(environment);
if (contributors == null) {
contributors = new LinkedHashSet<>();
contributors.add(new StandardExposureOutcomeContributor(environment, EndpointExposure.WEB));
if (environment.getProperty(JMX_ENABLED_KEY, Boolean.class, false)) {
contributors.add(new StandardExposureOutcomeContributor(environment, EndpointExposure.JMX));
}
contributors.addAll(loadExposureOutcomeContributors(environment));
exposureOutcomeContributorsCache.put(environment, contributors);
}
return contributors;
}
private List<EndpointExposureOutcomeContributor> loadExposureOutcomeContributors(Environment environment) {
ArgumentResolver argumentResolver = ArgumentResolver.of(Environment.class, environment);
return SpringFactoriesLoader.forDefaultResourceLocation()
.load(EndpointExposureOutcomeContributor.class, argumentResolver);
}
/**
* Standard {@link EndpointExposureOutcomeContributor}.
*/
private static class StandardExposureOutcomeContributor implements EndpointExposureOutcomeContributor {
private final EndpointExposure exposure;
@SuppressWarnings({ "unchecked", "rawtypes" })
private ExposureFilter(Environment environment, EndpointExposure exposure) {
super((Class) ExposableEndpoint.class, environment,
"management.endpoints." + getCanonicalName(exposure) + ".exposure", exposure.getDefaultIncludes());
private final String property;
private final IncludeExcludeEndpointFilter<?> filter;
StandardExposureOutcomeContributor(Environment environment, EndpointExposure exposure) {
this.exposure = exposure;
String name = exposure.name().toLowerCase().replace('_', '-');
this.property = "management.endpoints." + name + ".exposure";
this.filter = new IncludeExcludeEndpointFilter<>(ExposableEndpoint.class, environment, this.property,
exposure.getDefaultIncludes());
}
private static String getCanonicalName(EndpointExposure exposure) {
if (EndpointExposure.CLOUD_FOUNDRY.equals(exposure)) {
return "cloud-foundry";
@Override
public ConditionOutcome getExposureOutcome(EndpointId endpointId, Set<EndpointExposure> exposures,
ConditionMessage.Builder message) {
if (exposures.contains(this.exposure) && this.filter.match(endpointId)) {
return ConditionOutcome
.match(message.because("marked as exposed by a '" + this.property + "' property"));
}
return exposure.name().toLowerCase();
}
EndpointExposure getExposure() {
return this.exposure;
}
boolean isExposed(EndpointId id) {
return super.match(id);
return null;
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@@ -37,7 +37,10 @@ public enum EndpointExposure {
/**
* Exposed on Cloud Foundry over `/cloudfoundryapplication`.
* @since 2.6.4
* @deprecated since 3.4.0 for removal in 3.6.0 in favor of using
* {@link EndpointExposure#WEB}
*/
@Deprecated(since = "3.4.0", forRemoval = true)
CLOUD_FOUNDRY("*");
private final String[] defaultIncludes;

View File

@@ -100,12 +100,12 @@ class JerseyWebEndpointManagementContextConfiguration {
JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar jerseyDifferentPortAdditionalHealthEndpointPathsResourcesRegistrar(
WebEndpointsSupplier webEndpointsSupplier, HealthEndpointGroups healthEndpointGroups) {
Collection<ExposableWebEndpoint> webEndpoints = webEndpointsSupplier.getEndpoints();
ExposableWebEndpoint health = webEndpoints.stream()
ExposableWebEndpoint healthEndpoint = webEndpoints.stream()
.filter((endpoint) -> endpoint.getEndpointId().equals(HEALTH_ENDPOINT_ID))
.findFirst()
.orElseThrow(
() -> new IllegalStateException("No endpoint with id '%s' found".formatted(HEALTH_ENDPOINT_ID)));
return new JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar(health, healthEndpointGroups);
.orElse(null);
return new JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar(healthEndpoint,
healthEndpointGroups);
}
@Bean
@@ -180,19 +180,21 @@ class JerseyWebEndpointManagementContextConfiguration {
class JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar
implements ManagementContextResourceConfigCustomizer {
private final ExposableWebEndpoint endpoint;
private final ExposableWebEndpoint healthEndpoint;
private final HealthEndpointGroups groups;
JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar(ExposableWebEndpoint endpoint,
JerseyAdditionalHealthEndpointPathsManagementResourcesRegistrar(ExposableWebEndpoint healthEndpoint,
HealthEndpointGroups groups) {
this.endpoint = endpoint;
this.healthEndpoint = healthEndpoint;
this.groups = groups;
}
@Override
public void customize(ResourceConfig config) {
register(config);
if (this.healthEndpoint != null) {
register(config);
}
}
private void register(ResourceConfig config) {
@@ -200,7 +202,7 @@ class JerseyWebEndpointManagementContextConfiguration {
JerseyHealthEndpointAdditionalPathResourceFactory resourceFactory = new JerseyHealthEndpointAdditionalPathResourceFactory(
WebServerNamespace.MANAGEMENT, this.groups);
Collection<Resource> endpointResources = resourceFactory
.createEndpointResources(mapping, Collections.singletonList(this.endpoint))
.createEndpointResources(mapping, Collections.singletonList(this.healthEndpoint))
.stream()
.filter(Objects::nonNull)
.toList();

View File

@@ -117,12 +117,11 @@ public class WebFluxEndpointManagementContextConfiguration {
public AdditionalHealthEndpointPathsWebFluxHandlerMapping managementHealthEndpointWebFluxHandlerMapping(
WebEndpointsSupplier webEndpointsSupplier, HealthEndpointGroups groups) {
Collection<ExposableWebEndpoint> webEndpoints = webEndpointsSupplier.getEndpoints();
ExposableWebEndpoint health = webEndpoints.stream()
ExposableWebEndpoint healthEndpoint = webEndpoints.stream()
.filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID))
.findFirst()
.orElseThrow(
() -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID)));
return new AdditionalHealthEndpointPathsWebFluxHandlerMapping(new EndpointMapping(""), health,
.orElse(null);
return new AdditionalHealthEndpointPathsWebFluxHandlerMapping(new EndpointMapping(""), healthEndpoint,
groups.getAllWithAdditionalPath(WebServerNamespace.MANAGEMENT));
}

View File

@@ -112,15 +112,18 @@ public class WebMvcEndpointManagementContextConfiguration {
public AdditionalHealthEndpointPathsWebMvcHandlerMapping managementHealthEndpointWebMvcHandlerMapping(
WebEndpointsSupplier webEndpointsSupplier, HealthEndpointGroups groups) {
Collection<ExposableWebEndpoint> webEndpoints = webEndpointsSupplier.getEndpoints();
ExposableWebEndpoint health = webEndpoints.stream()
.filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID))
ExposableWebEndpoint healthEndpoint = webEndpoints.stream()
.filter(this::isHealthEndpoint)
.findFirst()
.orElseThrow(
() -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID)));
return new AdditionalHealthEndpointPathsWebMvcHandlerMapping(health,
.orElse(null);
return new AdditionalHealthEndpointPathsWebMvcHandlerMapping(healthEndpoint,
groups.getAllWithAdditionalPath(WebServerNamespace.MANAGEMENT));
}
private boolean isHealthEndpoint(ExposableWebEndpoint endpoint) {
return endpoint.getEndpointId().equals(HealthEndpoint.ID);
}
@Bean
@ConditionalOnMissingBean
@SuppressWarnings("removal")

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-2024 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.
@@ -53,7 +53,7 @@ public class EnvironmentEndpointAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(EnvironmentEndpoint.class)
@ConditionalOnAvailableEndpoint(exposure = { EndpointExposure.WEB, EndpointExposure.CLOUD_FOUNDRY })
@ConditionalOnAvailableEndpoint(exposure = EndpointExposure.WEB)
public EnvironmentEndpointWebExtension environmentEndpointWebExtension(EnvironmentEndpoint environmentEndpoint,
EnvironmentEndpointProperties properties) {
return new EnvironmentEndpointWebExtension(environmentEndpoint, properties.getShowValues(),

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@@ -45,8 +45,7 @@ import org.springframework.context.annotation.Configuration;
*/
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.REACTIVE)
@ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class,
exposure = { EndpointExposure.WEB, EndpointExposure.CLOUD_FOUNDRY })
@ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class, exposure = EndpointExposure.WEB)
class HealthEndpointReactiveWebExtensionConfiguration {
@Bean
@@ -60,7 +59,6 @@ class HealthEndpointReactiveWebExtensionConfiguration {
}
@Configuration(proxyBeanMethods = false)
@ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class, exposure = EndpointExposure.WEB)
static class WebFluxAdditionalHealthEndpointPathsConfiguration {
@Bean
@@ -70,8 +68,7 @@ class HealthEndpointReactiveWebExtensionConfiguration {
ExposableWebEndpoint health = webEndpoints.stream()
.filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID))
.findFirst()
.orElseThrow(
() -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID)));
.orElse(null);
return new AdditionalHealthEndpointPathsWebFluxHandlerMapping(new EndpointMapping(""), health,
groups.getAllWithAdditionalPath(WebServerNamespace.SERVER));
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@@ -64,8 +64,7 @@ import org.springframework.web.servlet.DispatcherServlet;
@Configuration(proxyBeanMethods = false)
@ConditionalOnWebApplication(type = Type.SERVLET)
@ConditionalOnBean(HealthEndpoint.class)
@ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class,
exposure = { EndpointExposure.WEB, EndpointExposure.CLOUD_FOUNDRY })
@ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class, exposure = EndpointExposure.WEB)
class HealthEndpointWebExtensionConfiguration {
@Bean
@@ -81,12 +80,10 @@ class HealthEndpointWebExtensionConfiguration {
return webEndpoints.stream()
.filter((endpoint) -> endpoint.getEndpointId().equals(HealthEndpoint.ID))
.findFirst()
.orElseThrow(
() -> new IllegalStateException("No endpoint with id '%s' found".formatted(HealthEndpoint.ID)));
.orElse(null);
}
@ConditionalOnBean(DispatcherServlet.class)
@ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class, exposure = EndpointExposure.WEB)
static class MvcAdditionalHealthEndpointPathsConfiguration {
@Bean
@@ -102,7 +99,6 @@ class HealthEndpointWebExtensionConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnClass(ResourceConfig.class)
@ConditionalOnMissingClass("org.springframework.web.servlet.DispatcherServlet")
@ConditionalOnAvailableEndpoint(endpoint = HealthEndpoint.class, exposure = EndpointExposure.WEB)
static class JerseyAdditionalHealthEndpointPathsConfiguration {
@Bean
@@ -163,7 +159,8 @@ class HealthEndpointWebExtensionConfiguration {
JerseyHealthEndpointAdditionalPathResourceFactory resourceFactory = new JerseyHealthEndpointAdditionalPathResourceFactory(
WebServerNamespace.SERVER, this.groups);
Collection<Resource> endpointResources = resourceFactory
.createEndpointResources(mapping, Collections.singletonList(this.endpoint))
.createEndpointResources(mapping,
(this.endpoint != null) ? Collections.singletonList(this.endpoint) : Collections.emptyList())
.stream()
.filter(Objects::nonNull)
.toList();

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2022 the original author or authors.
* Copyright 2012-2024 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.
@@ -56,7 +56,7 @@ public class QuartzEndpointAutoConfiguration {
@Bean
@ConditionalOnBean(QuartzEndpoint.class)
@ConditionalOnMissingBean
@ConditionalOnAvailableEndpoint(exposure = { EndpointExposure.WEB, EndpointExposure.CLOUD_FOUNDRY })
@ConditionalOnAvailableEndpoint(exposure = EndpointExposure.WEB)
public QuartzEndpointWebExtension quartzEndpointWebExtension(QuartzEndpoint endpoint,
QuartzEndpointProperties properties) {
return new QuartzEndpointWebExtension(endpoint, properties.getShowValues(), properties.getRoles());

View File

@@ -55,7 +55,7 @@ public class SbomEndpointAutoConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnBean(SbomEndpoint.class)
@ConditionalOnAvailableEndpoint(exposure = { EndpointExposure.WEB, EndpointExposure.CLOUD_FOUNDRY })
@ConditionalOnAvailableEndpoint(exposure = EndpointExposure.WEB)
SbomEndpointWebExtension sbomEndpointWebExtension(SbomEndpoint sbomEndpoint) {
return new SbomEndpointWebExtension(sbomEndpoint, this.properties);
}

View File

@@ -1,3 +1,7 @@
# Endpoint Exposure Outcome Contributors
org.springframework.boot.actuate.autoconfigure.endpoint.condition.EndpointExposureOutcomeContributor=\
org.springframework.boot.actuate.autoconfigure.cloudfoundry.CloudFoundryEndpointExposureOutcomeContributor
# Failure Analyzers
org.springframework.boot.diagnostics.FailureAnalyzer=\
org.springframework.boot.actuate.autoconfigure.health.NoSuchHealthContributorFailureAnalyzer,\

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@@ -374,8 +374,7 @@ class ConditionalOnAvailableEndpointTests {
static class ExposureEndpointConfiguration {
@Bean
@ConditionalOnAvailableEndpoint(endpoint = TestEndpoint.class,
exposure = { EndpointExposure.WEB, EndpointExposure.CLOUD_FOUNDRY })
@ConditionalOnAvailableEndpoint(endpoint = TestEndpoint.class, exposure = EndpointExposure.WEB)
String unexposed() {
return "unexposed";
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2023 the original author or authors.
* Copyright 2012-2024 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.
@@ -26,6 +26,9 @@ import reactor.core.publisher.Mono;
import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointConfiguration.HealthEndpointGroupMembershipValidator.NoSuchHealthContributorException;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointReactiveWebExtensionConfiguration.WebFluxAdditionalHealthEndpointPathsConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointWebExtensionConfiguration.JerseyAdditionalHealthEndpointPathsConfiguration;
import org.springframework.boot.actuate.autoconfigure.health.HealthEndpointWebExtensionConfiguration.MvcAdditionalHealthEndpointPathsConfiguration;
import org.springframework.boot.actuate.endpoint.ApiVersion;
import org.springframework.boot.actuate.endpoint.SecurityContext;
import org.springframework.boot.actuate.endpoint.web.WebEndpointResponse;
@@ -50,12 +53,16 @@ import org.springframework.boot.actuate.health.Status;
import org.springframework.boot.actuate.health.StatusAggregator;
import org.springframework.boot.actuate.health.SystemHealth;
import org.springframework.boot.autoconfigure.AutoConfigurations;
import org.springframework.boot.autoconfigure.logging.ConditionEvaluationReportLoggingListener;
import org.springframework.boot.autoconfigure.web.servlet.DispatcherServletAutoConfiguration;
import org.springframework.boot.logging.LogLevel;
import org.springframework.boot.test.context.FilteredClassLoader;
import org.springframework.boot.test.context.runner.ApplicationContextRunner;
import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner;
import org.springframework.boot.test.context.runner.WebApplicationContextRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
@@ -337,6 +344,47 @@ class HealthEndpointAutoConfigurationTests {
}));
}
@Test
void additionalHealthEndpointsPathsTolerateHealthEndpointThatIsNotWebExposed() {
this.contextRunner
.withConfiguration(AutoConfigurations.of(DispatcherServletAutoConfiguration.class,
EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class))
.withPropertyValues("management.endpoints.web.exposure.exclude=*",
"management.endpoints.cloudfoundry.exposure.include=*", "spring.main.cloud-platform=cloud_foundry")
.run((context) -> {
assertThat(context).hasSingleBean(MvcAdditionalHealthEndpointPathsConfiguration.class);
assertThat(context).hasNotFailed();
});
}
@Test
void additionalJerseyHealthEndpointsPathsTolerateHealthEndpointThatIsNotWebExposed() {
this.contextRunner
.withConfiguration(
AutoConfigurations.of(EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class))
.withClassLoader(new FilteredClassLoader(DispatcherServlet.class))
.withPropertyValues("management.endpoints.web.exposure.exclude=*",
"management.endpoints.cloudfoundry.exposure.include=*", "spring.main.cloud-platform=cloud_foundry")
.withInitializer(ConditionEvaluationReportLoggingListener.forLogLevel(LogLevel.INFO))
.run((context) -> {
assertThat(context).hasSingleBean(JerseyAdditionalHealthEndpointPathsConfiguration.class);
assertThat(context).hasNotFailed();
});
}
@Test
void additionalReactiveHealthEndpointsPathsTolerateHealthEndpointThatIsNotWebExposed() {
this.reactiveContextRunner
.withConfiguration(
AutoConfigurations.of(EndpointAutoConfiguration.class, WebEndpointAutoConfiguration.class))
.withPropertyValues("management.endpoints.web.exposure.exclude=*",
"management.endpoints.cloudfoundry.exposure.include=*", "spring.main.cloud-platform=cloud_foundry")
.run((context) -> {
assertThat(context).hasSingleBean(WebFluxAdditionalHealthEndpointPathsConfiguration.class);
assertThat(context).hasNotFailed();
});
}
@Configuration(proxyBeanMethods = false)
static class HealthIndicatorsConfiguration {

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2024 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,6 +16,7 @@
package org.springframework.boot.actuate.endpoint.web.reactive;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;
@@ -41,21 +42,28 @@ public class AdditionalHealthEndpointPathsWebFluxHandlerMapping extends Abstract
private final EndpointMapping endpointMapping;
private final ExposableWebEndpoint endpoint;
private final ExposableWebEndpoint healthEndpoint;
private final Set<HealthEndpointGroup> groups;
public AdditionalHealthEndpointPathsWebFluxHandlerMapping(EndpointMapping endpointMapping,
ExposableWebEndpoint endpoint, Set<HealthEndpointGroup> groups) {
super(endpointMapping, Collections.singletonList(endpoint), null, null, false);
ExposableWebEndpoint healthEndpoint, Set<HealthEndpointGroup> groups) {
super(endpointMapping, asList(healthEndpoint), null, null, false);
this.endpointMapping = endpointMapping;
this.groups = groups;
this.endpoint = endpoint;
this.healthEndpoint = healthEndpoint;
}
private static Collection<ExposableWebEndpoint> asList(ExposableWebEndpoint healthEndpoint) {
return (healthEndpoint != null) ? Collections.singletonList(healthEndpoint) : Collections.emptyList();
}
@Override
protected void initHandlerMethods() {
for (WebOperation operation : this.endpoint.getOperations()) {
if (this.healthEndpoint == null) {
return;
}
for (WebOperation operation : this.healthEndpoint.getOperations()) {
WebOperationRequestPredicate predicate = operation.getRequestPredicate();
String matchAllRemainingPathSegmentsVariable = predicate.getMatchAllRemainingPathSegmentsVariable();
if (matchAllRemainingPathSegmentsVariable != null) {
@@ -64,7 +72,7 @@ public class AdditionalHealthEndpointPathsWebFluxHandlerMapping extends Abstract
if (additionalPath != null) {
RequestMappingInfo requestMappingInfo = getRequestMappingInfo(operation,
additionalPath.getValue());
registerReadMapping(requestMappingInfo, this.endpoint, operation);
registerReadMapping(requestMappingInfo, this.healthEndpoint, operation);
}
}
}

View File

@@ -1,5 +1,5 @@
/*
* Copyright 2012-2021 the original author or authors.
* Copyright 2012-2024 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,6 +16,7 @@
package org.springframework.boot.actuate.endpoint.web.servlet;
import java.util.Collection;
import java.util.Collections;
import java.util.Set;
@@ -36,27 +37,34 @@ import org.springframework.web.servlet.HandlerMapping;
*/
public class AdditionalHealthEndpointPathsWebMvcHandlerMapping extends AbstractWebMvcEndpointHandlerMapping {
private final ExposableWebEndpoint endpoint;
private final ExposableWebEndpoint healthEndpoint;
private final Set<HealthEndpointGroup> groups;
public AdditionalHealthEndpointPathsWebMvcHandlerMapping(ExposableWebEndpoint endpoint,
public AdditionalHealthEndpointPathsWebMvcHandlerMapping(ExposableWebEndpoint healthEndpoint,
Set<HealthEndpointGroup> groups) {
super(new EndpointMapping(""), Collections.singletonList(endpoint), null, false);
this.endpoint = endpoint;
super(new EndpointMapping(""), asList(healthEndpoint), null, false);
this.healthEndpoint = healthEndpoint;
this.groups = groups;
}
private static Collection<ExposableWebEndpoint> asList(ExposableWebEndpoint healthEndpoint) {
return (healthEndpoint != null) ? Collections.singletonList(healthEndpoint) : Collections.emptyList();
}
@Override
protected void initHandlerMethods() {
for (WebOperation operation : this.endpoint.getOperations()) {
if (this.healthEndpoint == null) {
return;
}
for (WebOperation operation : this.healthEndpoint.getOperations()) {
WebOperationRequestPredicate predicate = operation.getRequestPredicate();
String matchAllRemainingPathSegmentsVariable = predicate.getMatchAllRemainingPathSegmentsVariable();
if (matchAllRemainingPathSegmentsVariable != null) {
for (HealthEndpointGroup group : this.groups) {
AdditionalHealthEndpointPath additionalPath = group.getAdditionalPath();
if (additionalPath != null) {
registerMapping(this.endpoint, predicate, operation, additionalPath.getValue());
registerMapping(this.healthEndpoint, predicate, operation, additionalPath.getValue());
}
}
}

View File

@@ -0,0 +1,13 @@
plugins {
id "java"
id "org.springframework.boot.conventions"
}
description = "Spring Boot Actuator Extension smoke test"
dependencies {
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-actuator"))
implementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-web"))
testImplementation(project(":spring-boot-project:spring-boot-starters:spring-boot-starter-test"))
}

View File

@@ -0,0 +1,58 @@
/*
* Copyright 2012-2024 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 smoketest.actuator.extension;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import org.springframework.beans.factory.ObjectProvider;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.CorsEndpointProperties;
import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties;
import org.springframework.boot.actuate.endpoint.EndpointFilter;
import org.springframework.boot.actuate.endpoint.invoke.OperationInvokerAdvisor;
import org.springframework.boot.actuate.endpoint.invoke.ParameterValueMapper;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.WebEndpointsSupplier;
import org.springframework.boot.actuate.endpoint.web.annotation.WebEndpointDiscoverer;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.web.cors.CorsConfiguration;
@Configuration(proxyBeanMethods = false)
public class MyExtensionConfiguration {
@Bean
public MyExtensionWebMvcEndpointHandlerMapping myWebMvcEndpointHandlerMapping(
WebEndpointsSupplier webEndpointsSupplier, EndpointMediaTypes endpointMediaTypes,
ObjectProvider<CorsEndpointProperties> corsPropertiesProvider, WebEndpointProperties webEndpointProperties,
Environment environment, ApplicationContext applicationContext, ParameterValueMapper parameterMapper) {
CorsEndpointProperties corsProperties = corsPropertiesProvider.getIfAvailable();
CorsConfiguration corsConfiguration = (corsProperties != null) ? corsProperties.toCorsConfiguration() : null;
List<OperationInvokerAdvisor> invokerAdvisors = Collections.emptyList();
List<EndpointFilter<ExposableWebEndpoint>> filters = Collections
.singletonList(new MyExtensionEndpointFilter(environment));
WebEndpointDiscoverer discoverer = new WebEndpointDiscoverer(applicationContext, parameterMapper,
endpointMediaTypes, null, invokerAdvisors, filters);
Collection<ExposableWebEndpoint> endpoints = discoverer.getEndpoints();
return new MyExtensionWebMvcEndpointHandlerMapping(endpoints, endpointMediaTypes, corsConfiguration);
}
}

View File

@@ -0,0 +1,47 @@
/*
* Copyright 2012-2024 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 smoketest.actuator.extension;
import java.util.Set;
import org.springframework.boot.actuate.autoconfigure.endpoint.condition.EndpointExposureOutcomeContributor;
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.EndpointExposure;
import org.springframework.boot.actuate.endpoint.EndpointId;
import org.springframework.boot.autoconfigure.condition.ConditionMessage.Builder;
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.core.env.Environment;
class MyExtensionEndpointExposureOutcomeContributor implements EndpointExposureOutcomeContributor {
private final MyExtensionEndpointFilter filter;
MyExtensionEndpointExposureOutcomeContributor(Environment environment) {
this.filter = new MyExtensionEndpointFilter(environment);
}
@Override
public ConditionOutcome getExposureOutcome(EndpointId endpointId, Set<EndpointExposure> exposures,
Builder message) {
if (exposures.contains(EndpointExposure.WEB) && this.filter.match(endpointId)) {
return ConditionOutcome.match(message.because("marked as exposed by a my extension '"
+ MyExtensionEndpointFilter.PROPERTY_PREFIX + "' property"));
}
return null;
}
}

View File

@@ -0,0 +1,31 @@
/*
* Copyright 2012-2024 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 smoketest.actuator.extension;
import org.springframework.boot.actuate.autoconfigure.endpoint.expose.IncludeExcludeEndpointFilter;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.core.env.Environment;
class MyExtensionEndpointFilter extends IncludeExcludeEndpointFilter<ExposableWebEndpoint> {
static final String PROPERTY_PREFIX = "management.endpoints.myextension.exposure";
MyExtensionEndpointFilter(Environment environment) {
super(ExposableWebEndpoint.class, environment, PROPERTY_PREFIX, "*");
}
}

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2012-2024 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 smoketest.actuator.extension;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpStatus;
import org.springframework.web.servlet.HandlerInterceptor;
class MyExtensionSecurityInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
throws Exception {
String auth = request.getHeader("Authorization");
if (!"Bearer secret".equals(auth)) {
response.sendError(HttpStatus.UNAUTHORIZED.value());
return false;
}
return true;
}
}

View File

@@ -0,0 +1,76 @@
/*
* Copyright 2012-2024 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 smoketest.actuator.extension;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.actuate.endpoint.web.EndpointLinksResolver;
import org.springframework.boot.actuate.endpoint.web.EndpointMapping;
import org.springframework.boot.actuate.endpoint.web.EndpointMediaTypes;
import org.springframework.boot.actuate.endpoint.web.ExposableWebEndpoint;
import org.springframework.boot.actuate.endpoint.web.Link;
import org.springframework.boot.actuate.endpoint.web.servlet.AbstractWebMvcEndpointHandlerMapping;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.cors.CorsConfiguration;
class MyExtensionWebMvcEndpointHandlerMapping extends AbstractWebMvcEndpointHandlerMapping {
private static final String PATH = "/myextension";
private final EndpointLinksResolver linksResolver;
MyExtensionWebMvcEndpointHandlerMapping(Collection<ExposableWebEndpoint> endpoints,
EndpointMediaTypes endpointMediaTypes, CorsConfiguration corsConfiguration) {
super(new EndpointMapping(PATH), endpoints, endpointMediaTypes, corsConfiguration, true);
this.linksResolver = new EndpointLinksResolver(endpoints, PATH);
setOrder(-100);
}
@Override
protected LinksHandler getLinksHandler() {
return new WebMvcLinksHandler();
}
@Override
protected void extendInterceptors(List<Object> interceptors) {
super.extendInterceptors(interceptors);
interceptors.add(0, new MyExtensionSecurityInterceptor());
}
class WebMvcLinksHandler implements LinksHandler {
@Override
@ResponseBody
public Map<String, Map<String, Link>> links(HttpServletRequest request, HttpServletResponse response) {
return Collections.singletonMap("_links", MyExtensionWebMvcEndpointHandlerMapping.this.linksResolver
.resolveLinks(request.getRequestURL().toString()));
}
@Override
public String toString() {
return "Actuator extension root web endpoint";
}
}
}

View File

@@ -0,0 +1,29 @@
/*
* Copyright 2012-2024 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 smoketest.actuator.extension;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication(proxyBeanMethods = false)
public class SampleActuatorExtensionApplication {
public static void main(String[] args) {
SpringApplication.run(SampleActuatorExtensionApplication.class, args);
}
}

View File

@@ -0,0 +1,2 @@
org.springframework.boot.actuate.autoconfigure.endpoint.condition.EndpointExposureOutcomeContributor=\
smoketest.actuator.extension.MyExtensionEndpointExposureOutcomeContributor

View File

@@ -0,0 +1,4 @@
spring.jmx.enabled=false
management.endpoints.web.exposure.exclude=*
management.endpoints.myextension.exposure.include=health,beans,configprops

View File

@@ -0,0 +1,71 @@
/*
* Copyright 2012-2024 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 smoketest.actuator.extension;
import java.util.Map;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.boot.test.context.SpringBootTest.WebEnvironment;
import org.springframework.boot.test.web.client.LocalHostUriTemplateHandler;
import org.springframework.boot.test.web.client.TestRestTemplate;
import org.springframework.boot.web.client.RestTemplateBuilder;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import static org.assertj.core.api.Assertions.assertThat;
@SpringBootTest(webEnvironment = WebEnvironment.RANDOM_PORT, properties = { "server.error.include-message=always" })
class SampleActuatorExtensionApplicationTests {
@Autowired
private Environment environment;
@Autowired
private TestRestTemplate restTemplate;
@Autowired
private RestTemplateBuilder restTemplateBuilder;
@Test
@SuppressWarnings("rawtypes")
void healthActuatorIsNotExposed() {
ResponseEntity<Map> entity = this.restTemplate.getForEntity("/actuator/health", Map.class);
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.NOT_FOUND);
}
@Test
@SuppressWarnings("rawtypes")
void healthExtensionWithAuthHeaderIsDenied() {
ResponseEntity<Map> entity = this.restTemplate.getForEntity("/myextension/health", Map.class);
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.UNAUTHORIZED);
}
@Test
@SuppressWarnings("rawtypes")
void healthExtensionWithAuthHeader() {
TestRestTemplate restTemplate = new TestRestTemplate(
this.restTemplateBuilder.defaultHeader("Authorization", "Bearer secret"));
restTemplate.setUriTemplateHandler(new LocalHostUriTemplateHandler(this.environment));
ResponseEntity<Map> entity = restTemplate.getForEntity("/myextension/health", Map.class);
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
}
}