diff --git a/spring-boot-actuator-docs/src/restdoc/java/org/springframework/boot/actuate/hypermedia/EndpointDocumentation.java b/spring-boot-actuator-docs/src/restdoc/java/org/springframework/boot/actuate/hypermedia/EndpointDocumentation.java index 84213de856..338b234165 100644 --- a/spring-boot-actuator-docs/src/restdoc/java/org/springframework/boot/actuate/hypermedia/EndpointDocumentation.java +++ b/spring-boot-actuator-docs/src/restdoc/java/org/springframework/boot/actuate/hypermedia/EndpointDocumentation.java @@ -20,12 +20,15 @@ import java.io.File; import java.io.FileOutputStream; import java.io.PrintWriter; import java.util.ArrayList; +import java.util.Arrays; import java.util.Collection; import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.Set; import groovy.text.Template; import groovy.text.TemplateEngine; @@ -70,6 +73,9 @@ public class EndpointDocumentation { static final File LOG_FILE = new File("target/logs/spring.log"); + private static final Set SKIPPED = Collections.unmodifiableSet( + new HashSet(Arrays.asList("/docs", "/logfile", "/heapdump"))); + @Autowired private MvcEndpoints mvcEndpoints; @@ -103,28 +109,27 @@ public class EndpointDocumentation { @Test public void endpoints() throws Exception { - final File docs = new File("src/main/asciidoc"); - final Map model = new LinkedHashMap(); final List endpoints = new ArrayList(); model.put("endpoints", endpoints); for (MvcEndpoint endpoint : getEndpoints()) { - final String endpointPath = StringUtils.hasText(endpoint.getPath()) - ? endpoint.getPath() : "/"; - - if (!endpointPath.equals("/docs") && !endpointPath.equals("/logfile")) { + final String endpointPath = (StringUtils.hasText(endpoint.getPath()) + ? endpoint.getPath() : "/"); + if (!SKIPPED.contains(endpointPath)) { String output = endpointPath.substring(1); output = output.length() > 0 ? output : "./"; this.mockMvc.perform(get(endpointPath).accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()).andDo(document(output)) .andDo(new ResultHandler() { + @Override public void handle(MvcResult mvcResult) throws Exception { EndpointDoc endpoint = new EndpointDoc(docs, endpointPath); endpoints.add(endpoint); } + }); } } diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcManagementContextConfiguration.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcManagementContextConfiguration.java index b3ac138bd1..b12cd3f8de 100644 --- a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcManagementContextConfiguration.java +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcManagementContextConfiguration.java @@ -31,6 +31,7 @@ import org.springframework.boot.actuate.endpoint.mvc.EndpointHandlerMapping; import org.springframework.boot.actuate.endpoint.mvc.EndpointHandlerMappingCustomizer; import org.springframework.boot.actuate.endpoint.mvc.EnvironmentMvcEndpoint; import org.springframework.boot.actuate.endpoint.mvc.HealthMvcEndpoint; +import org.springframework.boot.actuate.endpoint.mvc.HeapdumpMvcEndpoint; import org.springframework.boot.actuate.endpoint.mvc.LogFileMvcEndpoint; import org.springframework.boot.actuate.endpoint.mvc.MetricsMvcEndpoint; import org.springframework.boot.actuate.endpoint.mvc.MvcEndpoint; @@ -132,6 +133,13 @@ public class EndpointWebMvcManagementContextConfiguration { return new EnvironmentMvcEndpoint(delegate); } + @Bean + @ConditionalOnMissingBean + @ConditionalOnEnabledEndpoint("heapdump") + public HeapdumpMvcEndpoint heapdumpMvcEndpoint() { + return new HeapdumpMvcEndpoint(); + } + @Bean @ConditionalOnBean(HealthEndpoint.class) @ConditionalOnEnabledEndpoint("health") diff --git a/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/HeapdumpMvcEndpoint.java b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/HeapdumpMvcEndpoint.java new file mode 100644 index 0000000000..4b631d8485 --- /dev/null +++ b/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/endpoint/mvc/HeapdumpMvcEndpoint.java @@ -0,0 +1,233 @@ +/* + * Copyright 2012-2016 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 + * + * http://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.endpoint.mvc; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.IOException; +import java.io.InputStream; +import java.lang.management.ManagementFactory; +import java.lang.management.PlatformManagedObject; +import java.lang.reflect.Method; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.zip.GZIPOutputStream; + +import javax.servlet.ServletException; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; + +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.lang.UsesJava7; +import org.springframework.util.ClassUtils; +import org.springframework.util.ReflectionUtils; +import org.springframework.util.StreamUtils; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseStatus; + +/** + * {@link MvcEndpoint} to expose heap dumps. + * + * @author Lari Hotari + * @author Phillip Webb + * @since 1.4.0 + */ +@ConfigurationProperties("endpoints.heapdump") +@HypermediaDisabled +public class HeapdumpMvcEndpoint extends AbstractMvcEndpoint implements MvcEndpoint { + + private final long timeout; + + private final Lock lock = new ReentrantLock(); + + private HeapDumper heapDumper; + + public HeapdumpMvcEndpoint() { + this(TimeUnit.SECONDS.toMillis(10)); + } + + protected HeapdumpMvcEndpoint(long timeout) { + super("/heapdump", true); + this.timeout = timeout; + } + + @RequestMapping(method = RequestMethod.GET, produces = MediaType.APPLICATION_OCTET_STREAM_VALUE) + public void invoke(@RequestParam(defaultValue = "true") boolean live, + HttpServletRequest request, HttpServletResponse response) + throws IOException, ServletException { + if (!isEnabled()) { + response.setStatus(HttpStatus.NOT_FOUND.value()); + return; + } + try { + if (this.lock.tryLock(this.timeout, TimeUnit.MILLISECONDS)) { + try { + dumpHeap(live, request, response); + return; + } + finally { + this.lock.unlock(); + } + } + } + catch (InterruptedException ex) { + // Ignore + } + response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); + } + + private void dumpHeap(boolean live, HttpServletRequest request, + HttpServletResponse response) + throws IOException, ServletException, InterruptedException { + if (this.heapDumper == null) { + this.heapDumper = createHeapDumper(); + } + File file = createTempFile(live); + try { + this.heapDumper.dumpHeap(file, live); + handle(file, request, response); + } + finally { + file.delete(); + } + } + + private File createTempFile(boolean live) throws IOException { + String date = new SimpleDateFormat("yyyy-MM-dd-HH-mm").format(new Date()); + File file = File.createTempFile("heapdump" + date + (live ? "-live" : ""), + ".hprof"); + file.delete(); + return file; + } + + /** + * Factory method used to create the {@link HeapDumper}. + * @return the heap dumper to use + * @throws HeapDumperUnavailableException if the heap dumper cannot be created + */ + protected HeapDumper createHeapDumper() throws HeapDumperUnavailableException { + return new HotSpotDiagnosticMXBeanHeapDumper(); + } + + /** + * Handle the heap dump file and respond. By default this method will return the + * response as a GZip stream. + * @param heapDumpFile the generated dump file + * @param request the HTTP request + * @param response the HTTP response + * @throws ServletException on servlet error + * @throws IOException on IO error + */ + protected void handle(File heapDumpFile, HttpServletRequest request, + HttpServletResponse response) throws ServletException, IOException { + response.setContentType("application/octet-stream"); + response.setHeader("Content-Disposition", + "attachment; filename=\"" + (heapDumpFile.getName() + ".gz") + "\""); + try { + InputStream in = new FileInputStream(heapDumpFile); + try { + GZIPOutputStream out = new GZIPOutputStream(response.getOutputStream()); + StreamUtils.copy(in, out); + out.finish(); + } + catch (NullPointerException ex) { + } + finally { + try { + in.close(); + } + catch (Throwable ex) { + } + } + } + catch (FileNotFoundException ex) { + } + } + + /** + * Strategy interface used to dump the heap to a file. + */ + protected interface HeapDumper { + + /** + * Dump the current heap to the specified file. + * @param file the file to dump the heap to + * @param live if only live objects (i.e. objects that are reachable from + * others) should be dumped + * @throws IOException on IO error + * @throws InterruptedException on thread interruption + */ + void dumpHeap(File file, boolean live) throws IOException, InterruptedException; + + } + + /** + * {@link HeapDumper} that uses {@code com.sun.management.HotSpotDiagnosticMXBean} + * available on Oracle and OpenJDK to dump the heap to a file. + */ + @UsesJava7 + protected static class HotSpotDiagnosticMXBeanHeapDumper implements HeapDumper { + + private Object diagnosticMXBean; + + private Method dumpHeapMethod; + + @SuppressWarnings("unchecked") + protected HotSpotDiagnosticMXBeanHeapDumper() { + try { + Class diagnosticMXBeanClass = ClassUtils.resolveClassName( + "com.sun.management.HotSpotDiagnosticMXBean", null); + this.diagnosticMXBean = ManagementFactory.getPlatformMXBean( + (Class) diagnosticMXBeanClass); + this.dumpHeapMethod = ReflectionUtils.findMethod(diagnosticMXBeanClass, + "dumpHeap", String.class, Boolean.TYPE); + } + catch (Throwable ex) { + throw new HeapDumperUnavailableException( + "Unable to locate HotSpotDiagnosticMXBean", ex); + } + } + + @Override + public void dumpHeap(File file, boolean live) { + ReflectionUtils.invokeMethod(this.dumpHeapMethod, this.diagnosticMXBean, + file.getAbsolutePath(), live); + } + + } + + /** + * Exception to be thrown if the {@link HeapDumper} cannot be created. + */ + @ResponseStatus(HttpStatus.SERVICE_UNAVAILABLE) + protected static class HeapDumperUnavailableException extends RuntimeException { + + public HeapDumperUnavailableException(String message, Throwable cause) { + super(message, cause); + } + + } + +} diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfigurationTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfigurationTests.java index f44386c11e..4ce12702fc 100644 --- a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfigurationTests.java +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/autoconfigure/EndpointWebMvcAutoConfigurationTests.java @@ -429,8 +429,8 @@ public class EndpointWebMvcAutoConfigurationTests { this.applicationContext.register(RootConfig.class, BaseConfiguration.class, ServerPortConfig.class, EndpointWebMvcAutoConfiguration.class); this.applicationContext.refresh(); - // /health, /metrics, /env, /actuator (/shutdown is disabled by default) - assertThat(this.applicationContext.getBeansOfType(MvcEndpoint.class)).hasSize(4); + // /health, /metrics, /env, /actuator, /heapdump (/shutdown is disabled by default) + assertThat(this.applicationContext.getBeansOfType(MvcEndpoint.class)).hasSize(5); } @Test diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HalBrowserMvcEndpointDisabledIntegrationTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HalBrowserMvcEndpointDisabledIntegrationTests.java index 0188770198..ab6683b671 100644 --- a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HalBrowserMvcEndpointDisabledIntegrationTests.java +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HalBrowserMvcEndpointDisabledIntegrationTests.java @@ -83,7 +83,7 @@ public class HalBrowserMvcEndpointDisabledIntegrationTests { public void endpointsDoNotHaveLinks() throws Exception { for (MvcEndpoint endpoint : this.mvcEndpoints.getEndpoints()) { String path = endpoint.getPath(); - if ("/actuator".equals(path)) { + if ("/actuator".equals(path) || endpoint instanceof HeapdumpMvcEndpoint) { continue; } path = path.length() > 0 ? path : "/"; diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HalBrowserMvcEndpointVanillaIntegrationTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HalBrowserMvcEndpointVanillaIntegrationTests.java index 184acd81a0..209170a139 100644 --- a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HalBrowserMvcEndpointVanillaIntegrationTests.java +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HalBrowserMvcEndpointVanillaIntegrationTests.java @@ -122,7 +122,7 @@ public class HalBrowserMvcEndpointVanillaIntegrationTests { @Test public void endpointsEachHaveSelf() throws Exception { Set collections = new HashSet( - Arrays.asList("/trace", "/beans", "/dump")); + Arrays.asList("/trace", "/beans", "/dump", "/heapdump")); for (MvcEndpoint endpoint : this.mvcEndpoints.getEndpoints()) { String path = endpoint.getPath(); if (collections.contains(path)) { diff --git a/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HeapdumpMvcEndpointTests.java b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HeapdumpMvcEndpointTests.java new file mode 100644 index 0000000000..b8ed37f3e8 --- /dev/null +++ b/spring-boot-actuator/src/test/java/org/springframework/boot/actuate/endpoint/mvc/HeapdumpMvcEndpointTests.java @@ -0,0 +1,171 @@ +/* + * Copyright 2012-2016 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 + * + * http://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.endpoint.mvc; + +import java.io.File; +import java.io.IOException; +import java.util.concurrent.TimeUnit; +import java.util.zip.GZIPInputStream; + +import org.fusesource.hawtbuf.ByteArrayInputStream; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.actuate.autoconfigure.EndpointWebMvcAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.ManagementServerPropertiesAutoConfiguration; +import org.springframework.boot.autoconfigure.jackson.JacksonAutoConfiguration; +import org.springframework.boot.autoconfigure.web.HttpMessageConvertersAutoConfiguration; +import org.springframework.boot.autoconfigure.web.WebMvcAutoConfiguration; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.junit4.SpringRunner; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.util.FileCopyUtils; +import org.springframework.web.context.WebApplicationContext; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * Tests for {@link HeapdumpMvcEndpoint}. + * + * @author Phillip Webb + */ +@RunWith(SpringRunner.class) +@SpringBootTest +public class HeapdumpMvcEndpointTests { + + @Autowired + private WebApplicationContext context; + + private MockMvc mvc; + + @Autowired + private TestHeapdumpMvcEndpoint endpoint; + + @Before + public void setup() { + this.context.getBean(HeapdumpMvcEndpoint.class).setEnabled(true); + this.mvc = MockMvcBuilders.webAppContextSetup(this.context).build(); + } + + @After + public void reset() { + this.endpoint.reset(); + } + + @Test + public void invokeWhenDisabledShouldReturnNotFoundStatus() throws Exception { + this.endpoint.setEnabled(false); + this.mvc.perform(get("/heapdump")).andExpect(status().isNotFound()); + } + + @Test + public void invokeWhenNotAvailableShouldReturnServiceUnavailableStatus() + throws Exception { + this.endpoint.setAvailable(false); + this.mvc.perform(get("/heapdump")).andExpect(status().isServiceUnavailable()); + } + + @Test + public void invokeWhenLockedShouldReturnTooManyRequestsStatus() throws Exception { + this.endpoint.setLocked(true); + this.mvc.perform(get("/heapdump")).andExpect(status().isTooManyRequests()); + } + + @Test + public void invokeShouldReturnGzipContent() throws Exception { + MvcResult result = this.mvc.perform(get("/heapdump")).andExpect(status().isOk()) + .andReturn(); + byte[] bytes = result.getResponse().getContentAsByteArray(); + GZIPInputStream stream = new GZIPInputStream(new ByteArrayInputStream(bytes)); + byte[] uncompressed = FileCopyUtils.copyToByteArray(stream); + assertThat(uncompressed).isEqualTo("HEAPDUMP".getBytes()); + } + + @Import({ JacksonAutoConfiguration.class, + HttpMessageConvertersAutoConfiguration.class, + EndpointWebMvcAutoConfiguration.class, WebMvcAutoConfiguration.class, + ManagementServerPropertiesAutoConfiguration.class }) + @Configuration + public static class TestConfiguration { + + @Bean + public HeapdumpMvcEndpoint endpoint() { + return new TestHeapdumpMvcEndpoint(); + } + + } + + private static class TestHeapdumpMvcEndpoint extends HeapdumpMvcEndpoint { + + private boolean available; + + private boolean locked; + + private String heapDump; + + TestHeapdumpMvcEndpoint() { + super(TimeUnit.SECONDS.toMillis(1)); + reset(); + } + + public void reset() { + this.available = true; + this.locked = false; + this.heapDump = "HEAPDUMP"; + } + + @Override + protected HeapDumper createHeapDumper() { + return new HeapDumper() { + + @Override + public void dumpHeap(File file, boolean live) + throws IOException, InterruptedException { + if (!TestHeapdumpMvcEndpoint.this.available) { + throw new HeapDumperUnavailableException("Not available", null); + } + if (TestHeapdumpMvcEndpoint.this.locked) { + throw new InterruptedException(); + } + FileCopyUtils.copy(TestHeapdumpMvcEndpoint.this.heapDump.getBytes(), + file); + } + + }; + } + + public void setAvailable(boolean available) { + this.available = available; + } + + public void setLocked(boolean locked) { + this.locked = locked; + } + + } + +}