Skip to content

Listing Maven dependencies in Spring Boot Actuator Info endpoint

Published on
  • Spring Boot

Spring Boot Actuator endpoints offer a way to get insights from the running Spring Boot application. Out of the box, /actuator/info endpoint exposes information about the operating system the app is running on, the JVM version or Git information.

In this short article you'll learn how you can add a list of the Maven dependencies used in your project to the info endpoint. Actually, you'll learn how to add much more information than just the dependencies.

Similar to getting Git information, we need to hook into the build process to generate the dependency list from pom.xml and put it into target/classes directory so that it gets packaged into application jar. Sounds a bit tedious? For sure it would be, but lucklily we don't need to do it manually as there are ready to use solutions for that. One of them is CycloneDX Maven Plugin.

CycloneDX

CycloneDX in fact is much more than a plugin that gathers list of dependencies. It's a cross platform standard for Software Bill Of Materials. I'll not go deeper into it, as it's not relevant for this article - we're just going to use it to extract the list of the dependencies.

Configure CycloneDX Maven Plugin

In your pom.xml plugin section, add CycloneDX Maven Plugin:

xml
<plugin>
	<groupId>org.cyclonedx</groupId>
	<artifactId>cyclonedx-maven-plugin</artifactId>
	<version>2.7.1</version>
	<executions>
		<execution>
			<phase>validate</phase> (1)
			<goals>
				<goal>makeAggregateBom</goal>
			</goals>
		</execution>
	</executions>
	<configuration>
		<outputFormat>json</outputFormat> (2)
		<outputName>classes/bom</outputName> (3)
	</configuration>
</plugin>
  1. Plugin has to be executed early in the process - especially before tha package phase - so that the result of running this plugin gets into final jar file
  2. By default, it generates both XML and JSON files. We need just one.
  3. By default, generated file is located in target directory. We need to move it to classes so that it gets into the final jar.

For more sophisticated configuration options, check the plugin page.

Now, once you execute ./mvnw package, you'll find in target/classes/bom.json quite a big file containing serious amount of information about dependencies including licenses, checksums. I stripped it out heavily just to give you a feeling how it looks, as the original file even for relatively small project is over 1k lines long:

json
{
  "bomFormat" : "CycloneDX",
  "specVersion" : "1.4",
  "serialNumber" : "urn:uuid:42fafc34-3467-4253-a4f5-41b7a9b26f76",
  "version" : 1,
  "metadata" : {
    "timestamp" : "2022-07-25T10:59:44Z",
    "tools" : [
      ...
    ],
    "component" : {
      ...
    }
  },
  "components" : [
    {
      "publisher" : "Pivotal Software, Inc.",
      "group" : "org.springframework.boot",
      "name" : "spring-boot-starter-actuator",
      "version" : "2.7.2",
      "description" : "Starter for using Spring Boot's Actuator which provides production ready features to help you monitor and manage your application",
      "scope" : "optional",
      "hashes" : [
        {
          "alg" : "MD5",
          "content" : "46f58ea5c19a4248ebbd2126320d1b07"
        },
        {
          "alg" : "SHA-1",
          "content" : "8a87caacd6d4f10c542f3bbafe20828f48b1119f"
        },
        {
          "alg" : "SHA-256",
          "content" : "13ec81781150bf7c68d93a756c715313f0036422bac9eaa06dfa02af9e9661be"
        },
        {
          "alg" : "SHA-512",
          "content" : "a319ebe51846e94a49b3123d00837ed603ad1f42802d584077d4fc2be493d6d8d3838e0419ced58048d4fe629904f5e651705f1d346f6feb6eacbd2934bbeade"
        },
        {
          "alg" : "SHA-384",
          "content" : "befdeaaf049b3c6b43518b15123a36476dd551dfe26144046db47d0aec581c287591461848f48e2c32caba2c6342387b"
        },
        {
          "alg" : "SHA3-384",
          "content" : "d6ad532c861a50f0ddca5e7903ae5cb52fc6749f0c7b91b03fc7b36de703a605150d29e14cf1780ecf353d17febe5e5e"
        },
        {
          "alg" : "SHA3-256",
          "content" : "a9460987319d96a144336ec182d56d1b670c0e1b5fd0baee3f4cc101ced97d2e"
        },
        {
          "alg" : "SHA3-512",
          "content" : "fcf41d875e1e7955005320b521c5e23856bd2809b5a554da03e505ae6b6808efc1a006ced43bd1c8de92c5d7b259131e049f730c6980b474354276cd0884558f"
        }
      ],
      "licenses" : [
        {
          "license" : {
            "id" : "Apache-2.0"
          }
        }
      ],
      "purl" : "pkg:maven/org.springframework.boot/spring-boot-starter-actuator@2.7.2?type=jar",
      "externalReferences" : [
        {
          "type" : "website",
          "url" : "https://spring.io"
        },
        {
          "type" : "issue-tracker",
          "url" : "https://github.com/spring-projects/spring-boot/issues"
        },
        {
          "type" : "vcs",
          "url" : "https://github.com/spring-projects/spring-boot"
        }
      ],
      "type" : "library",
      "bom-ref" : "pkg:maven/org.springframework.boot/spring-boot-starter-actuator@2.7.2?type=jar"
    }
    // ... the rest of the dependencies
  ]
}

Info Contributor

Once file gets generated, we need a piece of Java code that will take information from this file and put it into what's returned in /actuator/info endpoint.

INFO

Do not forget to expose info endpoint in application.properties file:

management.endpoints.web.exposure.include=info

Spring Boot offers a simple way to do it by creating bean implementing InfoContributor interface.

java
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.actuate.info.Info;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.core.io.Resource;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;

@Component
class CycloneDxInfoContributor implements InfoContributor, InitializingBean {
    private final Resource bomFile;
    private final ObjectMapper objectMapper = new ObjectMapper();
    private @Nullable JsonNode bom;

    SimpleCycloneDxInfoContributor(@Value("classpath:bom.json") Resource bomFile) {
        this.bomFile = bomFile;
    }

    @Override 
    public void contribute(Info.Builder builder) {
        if (bom != null) {
            builder.withDetail("bom", bom);
        }
    }

    @Override 
    public void afterPropertiesSet() throws Exception {
        if (bomFile.exists()) {
            try (var is = bomFile.getInputStream()) {
                this.bom = objectMapper.readTree(is);
            }
        }
    }
}

This piece of code will expose everything that plugin generated in the /actuator/info endpoint.

If that's what you need - great - but it's likely more than you actually need. Assuming we need just the list of structures like group id, artifact id and version, we can filter greater part of what's currently returned.

Listing the dependencies

To filter out the noise, we need to deserialize JSON to something more meaningful and then filter and map to object structures we are interested in. While we could create classes that represent the JSON structure, we can save ourselves time by adding the dependency containing the CycloneDX model (but note that it does contain few transitive dependencies).

xml
<dependency>
	<groupId>org.cyclonedx</groupId>
	<artifactId>cyclonedx-core-java</artifactId>
	<version>7.2.0</version>
</dependency>

And then update the CycloneDxInfoContributor class with following:

java
import java.util.List;
import java.util.stream.Collectors;

import org.cyclonedx.parsers.JsonParser;

import org.springframework.beans.factory.InitializingBean;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.actuate.info.Info;
import org.springframework.boot.actuate.info.InfoContributor;
import org.springframework.core.io.Resource;
import org.springframework.lang.Nullable;
import org.springframework.stereotype.Component;

@Component
class CycloneDxInfoContributor implements InfoContributor, InitializingBean {
    private final Resource bomFile;
    private final JsonParser jsonParser = new JsonParser();
    private @Nullable List<Dependency> dependencies;

    CycloneDxInfoContributor(@Value("classpath:bom.json") Resource bomFile) {
        this.bomFile = bomFile;
    }

    @Override 
    public void contribute(Info.Builder builder) {
        if (dependencies != null) {
            builder.withDetail("dependencies", dependencies);
        }
    }

    @Override 
    public void afterPropertiesSet() throws Exception {
        if (bomFile.exists()) {
            try (var is = bomFile.getInputStream()) {
                var bom = jsonParser.parse(is);
                this.dependencies = bom.getComponents()
                        .stream()
                        .map(Dependency::new)
                        .collect(Collectors.toList());
            }
        }
    }

    record Dependency(String groupId, String artifactId, String version){
        Dependency(org.cyclonedx.model.Component component) {
            this(component.getGroup(), component.getName(), component.getVersion());
        }
    }
}

One important thing to note here is that we should not use Spring Boot managed ObjectMapper for deserialization because its configuration may not reflect how the CycloneDX JSON file was generated. Instead, it's safer to use Cyclone's JsonParser class.

Once you hit now the /actuator/info endpoint, you'll get very concise list of the dependencies (including transitive dependencies):

json
{
  "dependencies": [
    {
      "groupId": "org.springframework.boot",
      "artifactId": "spring-boot-starter-actuator",
      "version": "2.7.2"
    },
    {
      "groupId": "org.springframework.boot",
      "artifactId": "spring-boot-starter",
      "version": "2.7.2"
    },
    {
      "groupId": "org.springframework.boot",
      "artifactId": "spring-boot",
      "version": "2.7.2"
    },
    {
      "groupId": "org.springframework.boot",
      "artifactId": "spring-boot-autoconfigure",
      "version": "2.7.2"
    },
    {
      "groupId": "org.springframework.boot",
      "artifactId": "spring-boot-starter-logging",
      "version": "2.7.2"
    },
    // ... the rest of the dependencies
  ]
}

Conclusion

CycloneDX is a standardized way to describe Software Bill Of Materials, and we can use its Maven plugin to generate the list of the dependencies exposed by Spring Boot Actuator in the info endpoint. In addition to the dependencies coordinates, we can also list licenses, checksums and links to project websites.

If you need something like that but you use Gradle, instead of using Maven plugin go with CycloneDX Gradle Plugin.

Sources for this tutorial are available at: https://github.com/maciejwalkowiak/cyclonedx-spring-boot-sample

Let's stay in touch and follow me on Twitter: @maciejwalkowiak

Subscribe to RSS feed