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

@@ -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);
}
}