diff --git a/README.md b/README.md
index 1c2054f7..97c9e946 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,36 @@ The startup configuration is cluster-safe: only one node applies the file at a t
A configuration that cannot be read, parsed or fully applied stops the application, so an instance never comes up with a configuration it could not reach - in a rolling deployment this blocks the rollout instead of hiding the failure. Since only successful applies are recorded, the configuration is retried when the instance is started again.
+## Plugin installation
+
+The `upm` section installs other plugins declaratively, through the plugin framework itself - no UPM tokens or admin credentials involved. Resolvers and plugins are both keyed maps, so merged YAML documents can override single values. Every plugin references one of the named resolvers explicitly: `marketplace` type resolvers look the artifact up through the Atlassian Marketplace REST API from the plugin key and version alone, `maven` type resolvers derive it from the plugin's Maven coordinates and the standard repository layout.
+
+```yaml
+upm:
+ resolvers:
+ marketplace:
+ type: marketplace
+ baseUrl: https://marketplace.atlassian.com
+ corp:
+ type: maven
+ baseUrl: https://repository.example.com/maven
+ username: reader
+ password: secret
+ plugins:
+ de.griffel.confluence.plugins.plant-uml:
+ version: "2026.103"
+ resolver: marketplace
+ com.example.internal-plugin:
+ version: 1.0.0
+ resolver: corp
+ groupId: com.example
+ artifactId: internal-plugin
+```
+
+A resolver's base URL may point to a proxying repository such as an Artifactory generic remote: all links returned by the Marketplace API are re-resolved against the resolver's base URL, so the API lookup and the binary download flow through the same repository (resolver credentials work for either type). For endpoints only reachable through a forward proxy, each resolver takes an optional `proxy` (`host`, `port` and optional credentials), so an internal repository and a proxied external one can coexist in the same document.
+
+Plugins already installed in the requested version are skipped, `enabled: false` disables a plugin, and responses echo only the plugins map - never the resolver credentials. Like every other section, `upm` works via the REST API (`PUT /upm` or as part of `_all`) and the startup configuration alike.
+
## Installation
Download the plugin for your product from the [releases](https://github.com/deftdevs/bootstrapi/releases) and upload it in the product's administration under *Manage apps* → *Upload app*. The endpoints require a user with system administrator permissions.
diff --git a/commons/pom.xml b/commons/pom.xml
index dfeed78f..9da7ead6 100644
--- a/commons/pom.xml
+++ b/commons/pom.xml
@@ -130,6 +130,12 @@
provided
+
+ com.atlassian.plugins
+ atlassian-plugins-core
+ provided
+
+
com.atlassian.sal
sal-api
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/constants/BootstrAPI.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/constants/BootstrAPI.java
index 676cf4ef..f91abedd 100644
--- a/commons/src/main/java/com/deftdevs/bootstrapi/commons/constants/BootstrAPI.java
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/constants/BootstrAPI.java
@@ -46,6 +46,7 @@ public class BootstrAPI {
public static final String PERMISSION_ANONYMOUS_ACCESS = "anonymous-access";
public static final String PERMISSIONS_GLOBAL = "global";
public static final String PING = "ping";
+ public static final String PLUGIN = "plugin";
public static final String SESSION_CONFIG = "session-config";
public static final String SETTINGS = "settings";
public static final String SETTINGS_BRANDING = "branding";
@@ -56,6 +57,9 @@ public class BootstrAPI {
public static final String SETTINGS_GENERAL = "general";
public static final String SETTINGS_SECURITY = "security";
public static final String TRUSTED_PROXIES = "trusted-proxies";
+ public static final String UPM = "upm";
+ public static final String UPM_PROXY = "proxy";
+ public static final String UPM_RESOLVER = "resolver";
public static final String USER = "user";
public static final String USERS = "users";
public static final String USER_PASSWORD = "password";
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/PluginModel.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/PluginModel.java
new file mode 100644
index 00000000..db0b344e
--- /dev/null
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/PluginModel.java
@@ -0,0 +1,44 @@
+package com.deftdevs.bootstrapi.commons.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import jakarta.xml.bind.annotation.XmlElement;
+import jakarta.xml.bind.annotation.XmlRootElement;
+
+import static com.deftdevs.bootstrapi.commons.constants.BootstrAPI.PLUGIN;
+
+/**
+ * One plugin to install, keyed by its plugin key in the {@code plugins}
+ * map: the version plus a reference to the named resolver the artifact is
+ * fetched from. Marketplace-type resolvers work from the plugin key and
+ * version alone; Maven-type resolvers additionally need the entry's Maven
+ * coordinates.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@XmlRootElement(name = PLUGIN)
+public class PluginModel {
+
+ @XmlElement
+ private String version;
+
+ /** The key of the resolver in the {@code resolvers} map. */
+ @XmlElement
+ private String resolver;
+
+ @XmlElement
+ private String groupId;
+
+ @XmlElement
+ private String artifactId;
+
+ /** Defaults to enabled when absent. */
+ @XmlElement
+ private Boolean enabled;
+
+}
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/PluginProxyModel.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/PluginProxyModel.java
new file mode 100644
index 00000000..4ac2dc2b
--- /dev/null
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/PluginProxyModel.java
@@ -0,0 +1,36 @@
+package com.deftdevs.bootstrapi.commons.model;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import jakarta.xml.bind.annotation.XmlElement;
+import jakarta.xml.bind.annotation.XmlRootElement;
+
+import static com.deftdevs.bootstrapi.commons.constants.BootstrAPI.UPM_PROXY;
+
+/**
+ * An optional web proxy for a plugin resolver, for endpoints that are only
+ * reachable through a forward proxy.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@XmlRootElement(name = UPM_PROXY)
+public class PluginProxyModel {
+
+ @XmlElement
+ private String host;
+
+ @XmlElement
+ private Integer port;
+
+ @XmlElement
+ private String username;
+
+ @XmlElement
+ private String password;
+
+}
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/PluginResolverModel.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/PluginResolverModel.java
new file mode 100644
index 00000000..d10c5809
--- /dev/null
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/PluginResolverModel.java
@@ -0,0 +1,44 @@
+package com.deftdevs.bootstrapi.commons.model;
+
+import com.deftdevs.bootstrapi.commons.model.type.PluginResolverType;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import jakarta.xml.bind.annotation.XmlElement;
+import jakarta.xml.bind.annotation.XmlRootElement;
+
+import static com.deftdevs.bootstrapi.commons.constants.BootstrAPI.UPM_RESOLVER;
+
+/**
+ * A named plugin resolver: its type (the Atlassian Marketplace or a Maven
+ * repository) and the endpoint it works against, either directly or through
+ * a proxying repository (e.g. an Artifactory generic remote). Credentials
+ * authenticate against the endpoint itself; an optional web proxy (with its
+ * own credentials) covers endpoints only reachable through a forward proxy.
+ * Plugins reference a resolver by its key in the {@code resolvers} map.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@XmlRootElement(name = UPM_RESOLVER)
+public class PluginResolverModel {
+
+ @XmlElement
+ private PluginResolverType type;
+
+ @XmlElement
+ private String baseUrl;
+
+ @XmlElement
+ private String username;
+
+ @XmlElement
+ private String password;
+
+ @XmlElement
+ private PluginProxyModel proxy;
+
+}
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/UpmModel.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/UpmModel.java
new file mode 100644
index 00000000..3f00b087
--- /dev/null
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/UpmModel.java
@@ -0,0 +1,37 @@
+package com.deftdevs.bootstrapi.commons.model;
+
+import com.deftdevs.bootstrapi.commons.model.type._AllModelStatus;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import jakarta.xml.bind.annotation.XmlElement;
+import jakarta.xml.bind.annotation.XmlRootElement;
+import java.util.Map;
+
+import static com.deftdevs.bootstrapi.commons.constants.BootstrAPI.UPM;
+
+/**
+ * Declarative plugin installation: the named resolvers and the plugins to
+ * install, both keyed maps so that merged YAML documents can override
+ * single values. Responses echo only the plugins map, never the resolver
+ * credentials.
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+@XmlRootElement(name = UPM)
+public class UpmModel {
+
+ @XmlElement
+ private Map resolvers;
+
+ @XmlElement
+ private Map plugins;
+
+ @XmlElement
+ private Map status;
+
+}
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/_AbstractAllModel.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/_AbstractAllModel.java
index f242088d..03997d0b 100644
--- a/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/_AbstractAllModel.java
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/_AbstractAllModel.java
@@ -36,6 +36,9 @@ public abstract class _AbstractAllModel implements _AllModelAccessor {
@XmlElement
private MailServerModel mailServer;
+ @XmlElement
+ private UpmModel upm;
+
@XmlElement
private Map status;
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/type/PluginResolverType.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/type/PluginResolverType.java
new file mode 100644
index 00000000..b49b2792
--- /dev/null
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/model/type/PluginResolverType.java
@@ -0,0 +1,16 @@
+package com.deftdevs.bootstrapi.commons.model.type;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * The kind of artifact source a named plugin resolver works against.
+ */
+public enum PluginResolverType {
+
+ @JsonProperty("marketplace")
+ MARKETPLACE,
+
+ @JsonProperty("maven")
+ MAVEN,
+
+}
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/plugins/PluginArtifactDownloader.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/plugins/PluginArtifactDownloader.java
new file mode 100644
index 00000000..591e2925
--- /dev/null
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/plugins/PluginArtifactDownloader.java
@@ -0,0 +1,286 @@
+package com.deftdevs.bootstrapi.commons.plugins;
+
+import com.deftdevs.bootstrapi.commons.exception.web.BadRequestException;
+import com.deftdevs.bootstrapi.commons.exception.web.InternalServerErrorException;
+import com.deftdevs.bootstrapi.commons.model.PluginModel;
+import com.deftdevs.bootstrapi.commons.model.PluginProxyModel;
+import com.deftdevs.bootstrapi.commons.model.PluginResolverModel;
+import com.deftdevs.bootstrapi.commons.model.UpmModel;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+
+import java.io.IOException;
+import java.net.Authenticator;
+import java.net.InetSocketAddress;
+import java.net.PasswordAuthentication;
+import java.net.ProxySelector;
+import java.net.URI;
+import java.net.URLEncoder;
+import java.net.http.HttpClient;
+import java.net.http.HttpRequest;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.time.Duration;
+
+/**
+ * Resolves and downloads plugin artifacts through the named resolver a
+ * plugin references, according to the resolver's type:
+ *
+ * - {@code marketplace}: the version is looked up through the Atlassian
+ * Marketplace REST API from the plugin key and version name, and the binary
+ * link of the embedded artifact is downloaded.
+ * - {@code maven}: the artifact location is derived from the Maven
+ * coordinates and the standard repository layout, without any API call.
+ *
+ * All links returned by the Marketplace API are re-resolved against the
+ * resolver's base URL, so a proxying repository (e.g. an Artifactory
+ * generic remote) serves the API lookup and the binary download alike
+ * instead of being bypassed by the absolute URLs in the API response.
+ */
+public class PluginArtifactDownloader {
+
+ private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(10);
+ private static final Duration REQUEST_TIMEOUT = Duration.ofMinutes(5);
+
+ private static final String STAGING_DIR_PREFIX = "bootstrapi-plugins-";
+
+ private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
+
+ /**
+ * Downloads the artifact for the given plugin to a temporary file. The
+ * caller is responsible for deleting the file.
+ */
+ public Path download(
+ final UpmModel upmModel,
+ final String pluginKey,
+ final PluginModel pluginModel) {
+
+ final PluginResolverModel resolverModel = requireResolver(upmModel, pluginModel.getResolver());
+ if (resolverModel.getType() == null) {
+ throw new BadRequestException("Resolver '" + pluginModel.getResolver()
+ + "' must declare a type ('marketplace' or 'maven')");
+ }
+
+ switch (resolverModel.getType()) {
+ case MARKETPLACE:
+ return downloadFromMarketplace(resolverModel, pluginKey, pluginModel);
+ case MAVEN:
+ return downloadFromMavenRepository(resolverModel, pluginKey, pluginModel);
+ default:
+ throw new BadRequestException("Unsupported plugin resolver type: " + resolverModel.getType());
+ }
+ }
+
+ private Path downloadFromMarketplace(
+ final PluginResolverModel resolverModel,
+ final String pluginKey,
+ final PluginModel pluginModel) {
+
+ final HttpClient client = newHttpClient(resolverModel);
+
+ final URI versionUri = URI.create(baseUrl(resolverModel)
+ + "/rest/2/addons/" + encode(pluginKey)
+ + "/versions/name/" + encode(pluginModel.getVersion()));
+ final HttpResponse versionResponse = send(client, versionUri, HttpResponse.BodyHandlers.ofString());
+ if (versionResponse.statusCode() == 404) {
+ throw new BadRequestException("Plugin '" + pluginKey + "' version '"
+ + pluginModel.getVersion() + "' was not found in the marketplace");
+ }
+ requireSuccess(versionUri, versionResponse.statusCode());
+
+ final JsonNode versionNode;
+ try {
+ versionNode = OBJECT_MAPPER.readTree(versionResponse.body());
+ } catch (IOException e) {
+ throw new InternalServerErrorException("Failed to parse the marketplace response for " + versionUri);
+ }
+
+ final String binaryHref = versionNode.path("_embedded").path("artifact")
+ .path("_links").path("binary").path("href").asText();
+ if (binaryHref.isEmpty()) {
+ throw new BadRequestException("The marketplace version of plugin '" + pluginKey
+ + "' does not embed an artifact binary link");
+ }
+
+ // the API returns absolute links; keeping only their path and query
+ // routes the binary download through the resolver's base URL as well
+ final URI binaryLink = URI.create(binaryHref);
+ final URI binaryUri = URI.create(baseUrl(resolverModel) + binaryLink.getRawPath()
+ + (binaryLink.getRawQuery() != null ? "?" + binaryLink.getRawQuery() : ""));
+
+ return downloadArtifact(client, binaryUri);
+ }
+
+ private Path downloadFromMavenRepository(
+ final PluginResolverModel resolverModel,
+ final String pluginKey,
+ final PluginModel pluginModel) {
+
+ if (isBlank(pluginModel.getGroupId()) || isBlank(pluginModel.getArtifactId())) {
+ throw new BadRequestException("Plugin '" + pluginKey
+ + "' uses a maven resolver and must provide a groupId and an artifactId");
+ }
+
+ final HttpClient client = newHttpClient(resolverModel);
+ final URI artifactUri = URI.create(baseUrl(resolverModel)
+ + "/" + pluginModel.getGroupId().replace('.', '/')
+ + "/" + pluginModel.getArtifactId()
+ + "/" + pluginModel.getVersion()
+ + "/" + pluginModel.getArtifactId() + "-" + pluginModel.getVersion() + ".jar");
+
+ return downloadArtifact(client, artifactUri);
+ }
+
+ private Path downloadArtifact(
+ final HttpClient client,
+ final URI artifactUri) {
+
+ final Path artifactFile;
+ try {
+ // the artifact is staged in a fresh private directory (created
+ // owner-only where the filesystem supports it), so no other local
+ // user can read or replace it between download and installation
+ artifactFile = Files.createTempDirectory(STAGING_DIR_PREFIX).resolve("plugin.jar");
+ } catch (IOException e) {
+ throw new InternalServerErrorException("Failed to create a staging directory for the plugin artifact");
+ }
+
+ try {
+ final HttpResponse response = send(client, artifactUri, HttpResponse.BodyHandlers.ofFile(artifactFile));
+ requireSuccess(artifactUri, response.statusCode());
+ return artifactFile;
+ } catch (RuntimeException e) {
+ deleteArtifact(artifactFile);
+ throw e;
+ }
+ }
+
+ /**
+ * Deletes a downloaded artifact together with its staging directory.
+ */
+ public static void deleteArtifact(
+ final Path artifactFile) {
+
+ deleteQuietly(artifactFile);
+ final Path stagingDirectory = artifactFile.getParent();
+ if (stagingDirectory != null
+ && stagingDirectory.getFileName().toString().startsWith(STAGING_DIR_PREFIX)) {
+ deleteQuietly(stagingDirectory);
+ }
+ }
+
+ private static PluginResolverModel requireResolver(
+ final UpmModel upmModel,
+ final String resolverKey) {
+
+ if (isBlank(resolverKey)) {
+ throw new BadRequestException("A plugin must name its resolver explicitly");
+ }
+
+ final PluginResolverModel resolverModel = upmModel.getResolvers() != null
+ ? upmModel.getResolvers().get(resolverKey)
+ : null;
+ if (resolverModel == null) {
+ throw new BadRequestException("Resolver '" + resolverKey + "' is not declared in the resolvers map");
+ }
+ if (isBlank(resolverModel.getBaseUrl())) {
+ throw new BadRequestException("Resolver '" + resolverKey + "' does not configure a base URL");
+ }
+ return resolverModel;
+ }
+
+ private static HttpClient newHttpClient(
+ final PluginResolverModel resolverModel) {
+
+ final HttpClient.Builder builder = HttpClient.newBuilder()
+ .followRedirects(HttpClient.Redirect.NORMAL)
+ .connectTimeout(CONNECT_TIMEOUT);
+
+ final PluginProxyModel proxyModel = resolverModel.getProxy();
+ if (proxyModel != null) {
+ if (isBlank(proxyModel.getHost()) || proxyModel.getPort() == null) {
+ throw new BadRequestException("A resolver proxy must provide a host and a port");
+ }
+ builder.proxy(ProxySelector.of(new InetSocketAddress(proxyModel.getHost(), proxyModel.getPort())));
+ }
+
+ builder.authenticator(new Authenticator() {
+ @Override
+ protected PasswordAuthentication getPasswordAuthentication() {
+ if (getRequestorType() == RequestorType.PROXY && proxyModel != null
+ && !isBlank(proxyModel.getUsername())) {
+ return new PasswordAuthentication(proxyModel.getUsername(),
+ proxyModel.getPassword() != null ? proxyModel.getPassword().toCharArray() : new char[0]);
+ }
+ if (getRequestorType() == RequestorType.SERVER && !isBlank(resolverModel.getUsername())) {
+ return new PasswordAuthentication(resolverModel.getUsername(),
+ resolverModel.getPassword() != null ? resolverModel.getPassword().toCharArray() : new char[0]);
+ }
+ return null;
+ }
+ });
+
+ return builder.build();
+ }
+
+ private HttpResponse send(
+ final HttpClient client,
+ final URI uri,
+ final HttpResponse.BodyHandler bodyHandler) {
+
+ final HttpRequest request = HttpRequest.newBuilder(uri)
+ .timeout(REQUEST_TIMEOUT)
+ .header("Accept", "application/json, */*")
+ .GET()
+ .build();
+
+ try {
+ return client.send(request, bodyHandler);
+ } catch (IOException e) {
+ throw new InternalServerErrorException("Request to " + uri + " failed: " + e.getMessage());
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new InternalServerErrorException("Request to " + uri + " was interrupted");
+ }
+ }
+
+ private static void requireSuccess(
+ final URI uri,
+ final int statusCode) {
+
+ if (statusCode >= 300) {
+ throw new InternalServerErrorException("Request to " + uri + " failed with status " + statusCode);
+ }
+ }
+
+ private static String baseUrl(
+ final PluginResolverModel resolverModel) {
+
+ final String baseUrl = resolverModel.getBaseUrl();
+ return baseUrl.endsWith("/") ? baseUrl.substring(0, baseUrl.length() - 1) : baseUrl;
+ }
+
+ private static String encode(
+ final String value) {
+
+ return URLEncoder.encode(value, StandardCharsets.UTF_8);
+ }
+
+ private static boolean isBlank(
+ final String value) {
+
+ return value == null || value.isBlank();
+ }
+
+ private static void deleteQuietly(
+ final Path file) {
+
+ try {
+ Files.deleteIfExists(file);
+ } catch (IOException e) {
+ // the temporary file is left behind, nothing more to do
+ }
+ }
+}
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/rest/AbstractUpmResourceImpl.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/rest/AbstractUpmResourceImpl.java
new file mode 100644
index 00000000..a97e0726
--- /dev/null
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/rest/AbstractUpmResourceImpl.java
@@ -0,0 +1,43 @@
+package com.deftdevs.bootstrapi.commons.rest;
+
+import com.deftdevs.bootstrapi.commons.exception.web.BadRequestException;
+import com.deftdevs.bootstrapi.commons.model.PluginModel;
+import com.deftdevs.bootstrapi.commons.model.UpmModel;
+import com.deftdevs.bootstrapi.commons.model.type.ServiceResult;
+import com.deftdevs.bootstrapi.commons.rest.api.UpmResource;
+import com.deftdevs.bootstrapi.commons.service.api.UpmService;
+
+import jakarta.ws.rs.core.Response;
+import java.util.Map;
+
+public abstract class AbstractUpmResourceImpl implements UpmResource {
+
+ private final UpmService upmService;
+
+ public AbstractUpmResourceImpl(
+ final UpmService upmService) {
+
+ this.upmService = upmService;
+ }
+
+ @Override
+ public Response getPlugins() {
+ final Map pluginModels = upmService.getPlugins();
+ return Response.ok(pluginModels).build();
+ }
+
+ @Override
+ public Response setUpm(
+ final UpmModel upmModel) {
+
+ if (upmModel == null) {
+ throw new BadRequestException("A UPM configuration must be provided in the request body");
+ }
+
+ final ServiceResult serviceResult = upmService.setUpm(upmModel);
+ final UpmModel result = serviceResult.getModel();
+ result.setStatus(serviceResult.getStatus());
+ final int overallStatus = _AbstractAllResourceImpl.computeOverallStatus(serviceResult.getStatus());
+ return Response.status(overallStatus).entity(result).build();
+ }
+}
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/rest/api/UpmResource.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/rest/api/UpmResource.java
new file mode 100644
index 00000000..22b75c70
--- /dev/null
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/rest/api/UpmResource.java
@@ -0,0 +1,78 @@
+package com.deftdevs.bootstrapi.commons.rest.api;
+
+import com.deftdevs.bootstrapi.commons.constants.BootstrAPI;
+import com.deftdevs.bootstrapi.commons.model.ErrorCollection;
+import com.deftdevs.bootstrapi.commons.model.PluginModel;
+import com.deftdevs.bootstrapi.commons.model.UpmModel;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+
+import jakarta.ws.rs.Consumes;
+import jakarta.ws.rs.GET;
+import jakarta.ws.rs.PUT;
+import jakarta.ws.rs.Produces;
+import jakarta.ws.rs.core.MediaType;
+import jakarta.ws.rs.core.Response;
+
+public interface UpmResource {
+
+ @GET
+ @Produces({MediaType.APPLICATION_JSON, BootstrAPI.MEDIA_TYPE_YAML, BootstrAPI.MEDIA_TYPE_YAML_LEGACY, BootstrAPI.MEDIA_TYPE_YAML_TEXT})
+ @Operation(
+ tags = { BootstrAPI.UPM },
+ summary = "Get all installed plugins",
+ description = "Returns every installed plugin (bundled and user-installed) with its version and enabled state, keyed by plugin key",
+ responses = {
+ @ApiResponse(
+ responseCode = "200", content = @Content(schema = @Schema(implementation = PluginModel.class)),
+ description = "Returns a map of all installed plugins, keyed by plugin key"
+ ),
+ @ApiResponse(
+ responseCode = "default", content = @Content(schema = @Schema(implementation = ErrorCollection.class)),
+ description = BootstrAPI.ERROR_COLLECTION_RESPONSE_DESCRIPTION
+ ),
+ }
+ )
+ Response getPlugins();
+
+ @PUT
+ @Consumes({MediaType.APPLICATION_JSON, BootstrAPI.MEDIA_TYPE_YAML, BootstrAPI.MEDIA_TYPE_YAML_LEGACY, BootstrAPI.MEDIA_TYPE_YAML_TEXT})
+ @Produces({MediaType.APPLICATION_JSON, BootstrAPI.MEDIA_TYPE_YAML, BootstrAPI.MEDIA_TYPE_YAML_LEGACY, BootstrAPI.MEDIA_TYPE_YAML_TEXT})
+ @Operation(
+ tags = { BootstrAPI.UPM },
+ summary = "Apply a UPM configuration",
+ description = "Resolves, installs and enables (or disables) the given plugins. Every plugin references one of"
+ + " the named resolvers by key: 'marketplace' type resolvers look the artifact up through the"
+ + " Marketplace REST API from the plugin key and version, 'maven' type resolvers derive it from the"
+ + " plugin's Maven coordinates and the standard repository layout. A resolver's base URL may point to"
+ + " a proxying repository (e.g. an Artifactory generic remote), and each resolver supports basic-auth"
+ + " credentials and an optional web proxy. Plugins already installed in the requested version are"
+ + " skipped, so re-applying the same configuration is safe.",
+ responses = {
+ @ApiResponse(
+ responseCode = "200", content = @Content(schema = @Schema(implementation = UpmModel.class)),
+ description = "Returns the applied plugins. The per-plugin outcome is reported in the"
+ + " 'status' map, keyed by plugin key. The resolvers are not echoed."
+ ),
+ @ApiResponse(
+ responseCode = "4XX", content = @Content(schema = @Schema(implementation = UpmModel.class)),
+ description = "One or more plugins failed to apply. The response code is the highest per-plugin"
+ + " status code; inspect the 'status' map in the response body."
+ ),
+ @ApiResponse(
+ responseCode = "5XX", content = @Content(schema = @Schema(implementation = UpmModel.class)),
+ description = "One or more plugins failed to apply. The response code is the highest per-plugin"
+ + " status code; inspect the 'status' map in the response body."
+ ),
+ @ApiResponse(
+ responseCode = "default", content = @Content(schema = @Schema(implementation = ErrorCollection.class)),
+ description = BootstrAPI.ERROR_COLLECTION_RESPONSE_DESCRIPTION
+ ),
+ }
+ )
+ Response setUpm(
+ final UpmModel upmModel);
+
+}
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/service/DefaultUpmServiceImpl.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/service/DefaultUpmServiceImpl.java
new file mode 100644
index 00000000..6c1f3e6a
--- /dev/null
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/service/DefaultUpmServiceImpl.java
@@ -0,0 +1,158 @@
+package com.deftdevs.bootstrapi.commons.service;
+
+import com.atlassian.plugin.JarPluginArtifact;
+import com.atlassian.plugin.Plugin;
+import com.atlassian.plugin.PluginAccessor;
+import com.atlassian.plugin.PluginController;
+import com.deftdevs.bootstrapi.commons.exception.web.BadRequestException;
+import com.deftdevs.bootstrapi.commons.model.PluginModel;
+import com.deftdevs.bootstrapi.commons.model.UpmModel;
+import com.deftdevs.bootstrapi.commons.model.type.ServiceResult;
+import com.deftdevs.bootstrapi.commons.model.type._AllModelStatus;
+import com.deftdevs.bootstrapi.commons.plugins.PluginArtifactDownloader;
+import com.deftdevs.bootstrapi.commons.service.api.UpmService;
+import com.deftdevs.bootstrapi.commons.util.ServiceResultUtil;
+
+import jakarta.ws.rs.core.Response;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+import java.util.TreeMap;
+
+/**
+ * Installs plugins through the plugin framework itself
+ * ({@link PluginController}), the same layer the UPM builds on, so no HTTP
+ * round-trip, UPM token or admin credentials are involved. The
+ * implementation is product-independent because the plugin framework API is
+ * shared by all products.
+ */
+public class DefaultUpmServiceImpl implements UpmService {
+
+ private final PluginAccessor pluginAccessor;
+ private final PluginController pluginController;
+ private final PluginArtifactDownloader pluginArtifactDownloader;
+
+ public DefaultUpmServiceImpl(
+ final PluginAccessor pluginAccessor,
+ final PluginController pluginController) {
+
+ this(pluginAccessor, pluginController, new PluginArtifactDownloader());
+ }
+
+ DefaultUpmServiceImpl(
+ final PluginAccessor pluginAccessor,
+ final PluginController pluginController,
+ final PluginArtifactDownloader pluginArtifactDownloader) {
+
+ this.pluginAccessor = pluginAccessor;
+ this.pluginController = pluginController;
+ this.pluginArtifactDownloader = pluginArtifactDownloader;
+ }
+
+ @Override
+ public Map getPlugins() {
+ final Map pluginModels = new TreeMap<>();
+ for (final Plugin plugin : pluginAccessor.getPlugins()) {
+ pluginModels.put(plugin.getKey(), PluginModel.builder()
+ .version(plugin.getPluginInformation().getVersion())
+ .enabled(pluginAccessor.isPluginEnabled(plugin.getKey()))
+ .build());
+ }
+ return pluginModels;
+ }
+
+ @Override
+ public ServiceResult setUpm(
+ final UpmModel upmModel) {
+
+ final Map results = new LinkedHashMap<>();
+ final Map statusMap = new LinkedHashMap<>();
+
+ final Map pluginModels = upmModel.getPlugins() != null
+ ? upmModel.getPlugins()
+ : Map.of();
+ for (final Map.Entry entry : pluginModels.entrySet()) {
+ final String pluginKey = entry.getKey();
+ if (entry.getValue() == null) {
+ // setEntity would silently skip a null input, but an empty
+ // plugin entry is a mistake and must be reported
+ statusMap.put(pluginKey, _AllModelStatus.error(Response.Status.BAD_REQUEST,
+ "Failed to apply " + pluginKey + " configuration",
+ "The plugin entry must provide a version and a resolver"));
+ continue;
+ }
+ ServiceResultUtil.setEntity(statusMap, pluginKey, entry.getValue(),
+ pluginModel -> applyPlugin(upmModel, pluginKey, pluginModel),
+ pluginModel -> results.put(pluginKey, pluginModel));
+ }
+
+ // only the plugins map is echoed, never the resolver credentials
+ return new ServiceResult<>(UpmModel.builder().plugins(results).build(), statusMap);
+ }
+
+ private PluginModel applyPlugin(
+ final UpmModel upmModel,
+ final String pluginKey,
+ final PluginModel pluginModel) {
+
+ validate(pluginKey, pluginModel);
+
+ final Plugin existingPlugin = pluginAccessor.getPlugin(pluginKey);
+ if (existingPlugin == null
+ || !pluginModel.getVersion().equals(existingPlugin.getPluginInformation().getVersion())) {
+ installPlugin(upmModel, pluginKey, pluginModel);
+ }
+
+ // absent means enabled, so a minimal entry yields an active plugin
+ if (pluginModel.getEnabled() == null || pluginModel.getEnabled()) {
+ pluginController.enablePlugins(pluginKey);
+ } else {
+ pluginController.disablePlugin(pluginKey);
+ }
+
+ final Plugin plugin = pluginAccessor.getPlugin(pluginKey);
+ return PluginModel.builder()
+ .version(plugin.getPluginInformation().getVersion())
+ .resolver(pluginModel.getResolver())
+ .enabled(pluginAccessor.isPluginEnabled(pluginKey))
+ .build();
+ }
+
+ private void installPlugin(
+ final UpmModel upmModel,
+ final String pluginKey,
+ final PluginModel pluginModel) {
+
+ final Path artifactFile = pluginArtifactDownloader.download(upmModel, pluginKey, pluginModel);
+ try {
+ final Set installedKeys =
+ pluginController.installPlugins(new JarPluginArtifact(artifactFile.toFile()));
+ if (!installedKeys.contains(pluginKey)) {
+ throw new BadRequestException("The resolved artifact installed the plugin keys "
+ + installedKeys + " instead of '" + pluginKey + "'");
+ }
+ } finally {
+ PluginArtifactDownloader.deleteArtifact(artifactFile);
+ }
+ }
+
+ private static void validate(
+ final String pluginKey,
+ final PluginModel pluginModel) {
+
+ if (isBlank(pluginModel.getVersion())) {
+ throw new BadRequestException("Plugin '" + pluginKey + "' must provide a version");
+ }
+ if (isBlank(pluginModel.getResolver())) {
+ throw new BadRequestException("Plugin '" + pluginKey
+ + "' must name its resolver explicitly");
+ }
+ }
+
+ private static boolean isBlank(
+ final String value) {
+
+ return value == null || value.isBlank();
+ }
+}
diff --git a/commons/src/main/java/com/deftdevs/bootstrapi/commons/service/api/UpmService.java b/commons/src/main/java/com/deftdevs/bootstrapi/commons/service/api/UpmService.java
new file mode 100644
index 00000000..b3a16742
--- /dev/null
+++ b/commons/src/main/java/com/deftdevs/bootstrapi/commons/service/api/UpmService.java
@@ -0,0 +1,32 @@
+package com.deftdevs.bootstrapi.commons.service.api;
+
+import com.deftdevs.bootstrapi.commons.model.PluginModel;
+import com.deftdevs.bootstrapi.commons.model.UpmModel;
+import com.deftdevs.bootstrapi.commons.model.type.ServiceResult;
+
+import java.util.Map;
+
+public interface UpmService {
+
+ /**
+ * Gets all installed plugins with their version and enabled state,
+ * keyed by plugin key.
+ *
+ * @return the installed plugins
+ */
+ Map getPlugins();
+
+ /**
+ * Applies a UPM configuration: resolves, installs and enables (or
+ * disables) every plugin of the {@code plugins} map. Plugins already
+ * installed in the requested version are not installed again, so
+ * re-applying the same configuration is safe.
+ *
+ * @param upmModel the UPM configuration
+ * @return the applied plugins and a per-plugin-key status map; the
+ * resolvers (and their credentials) are not echoed
+ */
+ ServiceResult setUpm(
+ UpmModel upmModel);
+
+}
diff --git a/commons/src/test/java/com/deftdevs/bootstrapi/commons/model/UpmModelTest.java b/commons/src/test/java/com/deftdevs/bootstrapi/commons/model/UpmModelTest.java
new file mode 100644
index 00000000..c6e5c802
--- /dev/null
+++ b/commons/src/test/java/com/deftdevs/bootstrapi/commons/model/UpmModelTest.java
@@ -0,0 +1,60 @@
+package com.deftdevs.bootstrapi.commons.model;
+
+import com.deftdevs.bootstrapi.commons.model.type.PluginResolverType;
+import com.deftdevs.bootstrapi.commons.rest.provider.YamlObjectMapperHolder;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class UpmModelTest {
+
+ @Test
+ void testYamlBindsTheKeyedMapsAndLowercaseTypes() throws IOException {
+ final String yaml = "resolvers:\n"
+ + " central:\n"
+ + " type: marketplace\n"
+ + " baseUrl: https://marketplace.example.com\n"
+ + " corp:\n"
+ + " type: maven\n"
+ + " baseUrl: https://repository.example.com/maven\n"
+ + " proxy:\n"
+ + " host: proxy.example.com\n"
+ + " port: 3128\n"
+ + "plugins:\n"
+ + " com.example.plugin:\n"
+ + " version: 1.2.3\n"
+ + " resolver: central\n"
+ + " com.example.other:\n"
+ + " version: 2.0.0\n"
+ + " resolver: corp\n"
+ + " groupId: com.example\n"
+ + " artifactId: other-plugin\n";
+
+ final UpmModel model = YamlObjectMapperHolder.YAML_OBJECT_MAPPER.readValue(yaml, UpmModel.class);
+
+ assertEquals(PluginResolverType.MARKETPLACE, model.getResolvers().get("central").getType());
+ assertEquals(PluginResolverType.MAVEN, model.getResolvers().get("corp").getType());
+ assertEquals("proxy.example.com", model.getResolvers().get("corp").getProxy().getHost());
+ assertEquals("central", model.getPlugins().get("com.example.plugin").getResolver());
+ assertEquals("2.0.0", model.getPlugins().get("com.example.other").getVersion());
+ }
+
+ @Test
+ void testYamlWritesTheLowercaseTypeNames() throws IOException {
+ final UpmModel model = UpmModel.builder()
+ .resolvers(Map.of("central", PluginResolverModel.builder()
+ .type(PluginResolverType.MARKETPLACE)
+ .baseUrl("https://marketplace.example.com")
+ .build()))
+ .build();
+
+ final String yaml = YamlObjectMapperHolder.YAML_OBJECT_MAPPER.writeValueAsString(model);
+
+ assertTrue(yaml.contains("type: \"marketplace\"") || yaml.contains("type: marketplace"),
+ "the resolver type must serialize as its lowercase name, got:\n" + yaml);
+ }
+}
diff --git a/commons/src/test/java/com/deftdevs/bootstrapi/commons/plugins/PluginArtifactDownloaderTest.java b/commons/src/test/java/com/deftdevs/bootstrapi/commons/plugins/PluginArtifactDownloaderTest.java
new file mode 100644
index 00000000..001f960b
--- /dev/null
+++ b/commons/src/test/java/com/deftdevs/bootstrapi/commons/plugins/PluginArtifactDownloaderTest.java
@@ -0,0 +1,259 @@
+package com.deftdevs.bootstrapi.commons.plugins;
+
+import com.deftdevs.bootstrapi.commons.exception.web.BadRequestException;
+import com.deftdevs.bootstrapi.commons.exception.web.InternalServerErrorException;
+import com.deftdevs.bootstrapi.commons.model.PluginModel;
+import com.deftdevs.bootstrapi.commons.model.PluginResolverModel;
+import com.deftdevs.bootstrapi.commons.model.UpmModel;
+import com.deftdevs.bootstrapi.commons.model.type.PluginResolverType;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PluginArtifactDownloaderTest {
+
+ private static final String PLUGIN_KEY = "com.example.plugin";
+ private static final byte[] JAR_BYTES = "fake jar content".getBytes(StandardCharsets.UTF_8);
+
+ private final PluginArtifactDownloader downloader = new PluginArtifactDownloader();
+ private final List requestedPaths = new ArrayList<>();
+
+ private HttpServer server;
+ private String baseUrl;
+
+ @BeforeEach
+ void setup() throws IOException {
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ baseUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/repository";
+ }
+
+ @AfterEach
+ void teardown() {
+ server.stop(0);
+ }
+
+ @Test
+ void testMarketplaceDownloadRebasesTheBinaryLink() throws IOException {
+ // the binary link is absolute to marketplace.atlassian.com, like in the real API
+ stub("/repository/rest/2/addons/com.example.plugin/versions/name/1.2.3", 200,
+ ("{\"_embedded\":{\"artifact\":{\"_links\":{\"binary\":"
+ + "{\"href\":\"https://marketplace.atlassian.com/download/apps/123/version/456\"}}}}}")
+ .getBytes(StandardCharsets.UTF_8));
+ stub("/repository/download/apps/123/version/456", 200, JAR_BYTES);
+ server.start();
+
+ final Path artifact = downloader.download(upmModel(), PLUGIN_KEY, marketplacePlugin());
+
+ assertArrayEquals(JAR_BYTES, Files.readAllBytes(artifact));
+ assertTrue(requestedPaths.contains("/repository/download/apps/123/version/456"),
+ "the binary must be fetched through the resolver's base URL, not the absolute link");
+
+ PluginArtifactDownloader.deleteArtifact(artifact);
+ assertFalse(Files.exists(artifact));
+ assertFalse(Files.exists(artifact.getParent()), "the private staging directory must be deleted as well");
+ }
+
+ @Test
+ void testMarketplaceUnknownVersionFailsWithBadRequest() {
+ stub("/repository/rest/2/addons/com.example.plugin/versions/name/1.2.3", 404, new byte[0]);
+ server.start();
+
+ assertThrows(BadRequestException.class,
+ () -> downloader.download(upmModel(), PLUGIN_KEY, marketplacePlugin()));
+ }
+
+ @Test
+ void testMarketplaceVersionWithoutBinaryLinkFailsWithBadRequest() {
+ stub("/repository/rest/2/addons/com.example.plugin/versions/name/1.2.3", 200,
+ "{\"_embedded\":{}}".getBytes(StandardCharsets.UTF_8));
+ server.start();
+
+ assertThrows(BadRequestException.class,
+ () -> downloader.download(upmModel(), PLUGIN_KEY, marketplacePlugin()));
+ }
+
+ @Test
+ void testMarketplaceDownloadSendsBasicAuthWhenChallenged() throws IOException {
+ // a marketplace resolver may point at a protected mirror, e.g. an
+ // Artifactory generic remote, so it authenticates like any endpoint
+ final List authorizationHeaders = new ArrayList<>();
+ server.createContext("/repository", exchange -> {
+ final String authorization = exchange.getRequestHeaders().getFirst("Authorization");
+ if (authorization == null) {
+ exchange.getResponseHeaders().add("WWW-Authenticate", "Basic realm=\"repository\"");
+ respond(exchange, 401, new byte[0]);
+ return;
+ }
+ authorizationHeaders.add(authorization);
+ if (exchange.getRequestURI().getPath().startsWith("/repository/rest/2/addons/")) {
+ respond(exchange, 200, ("{\"_embedded\":{\"artifact\":{\"_links\":{\"binary\":"
+ + "{\"href\":\"https://marketplace.atlassian.com/download/apps/123/version/456\"}}}}}")
+ .getBytes(StandardCharsets.UTF_8));
+ } else {
+ respond(exchange, 200, JAR_BYTES);
+ }
+ });
+ server.start();
+
+ final UpmModel upmModel = upmModel();
+ upmModel.getResolvers().get("central").setUsername("reader");
+ upmModel.getResolvers().get("central").setPassword("secret");
+
+ final Path artifact = downloader.download(upmModel, PLUGIN_KEY, marketplacePlugin());
+
+ assertArrayEquals(JAR_BYTES, Files.readAllBytes(artifact));
+ assertTrue(authorizationHeaders.stream().anyMatch(header -> header.startsWith("Basic ")));
+ PluginArtifactDownloader.deleteArtifact(artifact);
+ }
+
+ @Test
+ void testUndeclaredResolverFailsWithBadRequest() {
+ server.start();
+
+ final PluginModel pluginModel = marketplacePlugin();
+ pluginModel.setResolver("unknown");
+ assertThrows(BadRequestException.class,
+ () -> downloader.download(upmModel(), PLUGIN_KEY, pluginModel));
+ }
+
+ @Test
+ void testResolverWithoutTypeFailsWithBadRequest() {
+ server.start();
+
+ final UpmModel upmModel = UpmModel.builder()
+ .resolvers(Map.of("central", PluginResolverModel.builder().baseUrl(baseUrl).build()))
+ .build();
+ assertThrows(BadRequestException.class,
+ () -> downloader.download(upmModel, PLUGIN_KEY, marketplacePlugin()));
+ }
+
+ @Test
+ void testMavenDownloadUsesTheRepositoryLayout() throws IOException {
+ stub("/repository/com/example/example-plugin/1.2.3/example-plugin-1.2.3.jar", 200, JAR_BYTES);
+ server.start();
+
+ final Path artifact = downloader.download(upmModel(), PLUGIN_KEY, mavenPlugin());
+
+ assertArrayEquals(JAR_BYTES, Files.readAllBytes(artifact));
+ PluginArtifactDownloader.deleteArtifact(artifact);
+ }
+
+ @Test
+ void testMavenDownloadSendsBasicAuthWhenChallenged() throws IOException {
+ final List authorizationHeaders = new ArrayList<>();
+ server.createContext("/repository", exchange -> {
+ final String authorization = exchange.getRequestHeaders().getFirst("Authorization");
+ if (authorization == null) {
+ exchange.getResponseHeaders().add("WWW-Authenticate", "Basic realm=\"repository\"");
+ respond(exchange, 401, new byte[0]);
+ } else {
+ authorizationHeaders.add(authorization);
+ respond(exchange, 200, JAR_BYTES);
+ }
+ });
+ server.start();
+
+ final UpmModel upmModel = upmModel();
+ upmModel.getResolvers().get("corp").setUsername("deployer");
+ upmModel.getResolvers().get("corp").setPassword("secret");
+
+ final Path artifact = downloader.download(upmModel, PLUGIN_KEY, mavenPlugin());
+
+ assertArrayEquals(JAR_BYTES, Files.readAllBytes(artifact));
+ assertTrue(authorizationHeaders.stream().anyMatch(header -> header.startsWith("Basic ")));
+ PluginArtifactDownloader.deleteArtifact(artifact);
+ }
+
+ @Test
+ void testMavenWithoutCoordinatesFailsWithBadRequest() {
+ server.start();
+
+ final PluginModel pluginModel = mavenPlugin();
+ pluginModel.setGroupId(null);
+ assertThrows(BadRequestException.class,
+ () -> downloader.download(upmModel(), PLUGIN_KEY, pluginModel));
+ }
+
+ @Test
+ void testFailedDownloadFailsWithServerError() {
+ stub("/repository/com/example/example-plugin/1.2.3/example-plugin-1.2.3.jar", 500, new byte[0]);
+ server.start();
+
+ assertThrows(InternalServerErrorException.class,
+ () -> downloader.download(upmModel(), PLUGIN_KEY, mavenPlugin()));
+ }
+
+ private UpmModel upmModel() {
+ return UpmModel.builder()
+ .resolvers(Map.of(
+ "central", PluginResolverModel.builder()
+ .type(PluginResolverType.MARKETPLACE)
+ .baseUrl(baseUrl)
+ .build(),
+ "corp", PluginResolverModel.builder()
+ .type(PluginResolverType.MAVEN)
+ .baseUrl(baseUrl + "/")
+ .build()))
+ .build();
+ }
+
+ private static PluginModel marketplacePlugin() {
+ return PluginModel.builder()
+ .version("1.2.3")
+ .resolver("central")
+ .build();
+ }
+
+ private static PluginModel mavenPlugin() {
+ return PluginModel.builder()
+ .version("1.2.3")
+ .resolver("corp")
+ .groupId("com.example")
+ .artifactId("example-plugin")
+ .build();
+ }
+
+ private void stub(
+ final String path,
+ final int status,
+ final byte[] body) {
+
+ server.createContext(path, exchange -> {
+ respond(exchange, status, body);
+ });
+ }
+
+ private void respond(
+ final HttpExchange exchange,
+ final int status,
+ final byte[] body) throws IOException {
+
+ requestedPaths.add(exchange.getRequestURI().getPath());
+ if (body.length == 0) {
+ exchange.sendResponseHeaders(status, -1);
+ exchange.close();
+ return;
+ }
+ exchange.sendResponseHeaders(status, body.length);
+ try (OutputStream out = exchange.getResponseBody()) {
+ out.write(body);
+ }
+ }
+}
diff --git a/commons/src/test/java/com/deftdevs/bootstrapi/commons/service/DefaultUpmServiceImplTest.java b/commons/src/test/java/com/deftdevs/bootstrapi/commons/service/DefaultUpmServiceImplTest.java
new file mode 100644
index 00000000..9c8b13ae
--- /dev/null
+++ b/commons/src/test/java/com/deftdevs/bootstrapi/commons/service/DefaultUpmServiceImplTest.java
@@ -0,0 +1,215 @@
+package com.deftdevs.bootstrapi.commons.service;
+
+import com.atlassian.plugin.Plugin;
+import com.atlassian.plugin.PluginAccessor;
+import com.atlassian.plugin.PluginArtifact;
+import com.atlassian.plugin.PluginController;
+import com.atlassian.plugin.PluginInformation;
+import com.deftdevs.bootstrapi.commons.model.PluginModel;
+import com.deftdevs.bootstrapi.commons.model.UpmModel;
+import com.deftdevs.bootstrapi.commons.model.type.ServiceResult;
+import com.deftdevs.bootstrapi.commons.plugins.PluginArtifactDownloader;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyNoInteractions;
+
+@ExtendWith(MockitoExtension.class)
+class DefaultUpmServiceImplTest {
+
+ private static final String PLUGIN_KEY = "com.example.plugin";
+
+ @Mock
+ private PluginAccessor pluginAccessor;
+
+ @Mock
+ private PluginController pluginController;
+
+ @Mock
+ private PluginArtifactDownloader pluginArtifactDownloader;
+
+ @InjectMocks
+ private DefaultUpmServiceImpl upmService;
+
+ @Test
+ void testGetPluginsReturnsSortedPlugins() {
+ doReturn(List.of(plugin("com.example.b", "2.0.0"), plugin("com.example.a", "1.0.0")))
+ .when(pluginAccessor).getPlugins();
+ doReturn(true).when(pluginAccessor).isPluginEnabled("com.example.a");
+ doReturn(false).when(pluginAccessor).isPluginEnabled("com.example.b");
+
+ final Map plugins = upmService.getPlugins();
+
+ assertEquals(List.of("com.example.a", "com.example.b"), List.copyOf(plugins.keySet()));
+ assertEquals("1.0.0", plugins.get("com.example.a").getVersion());
+ assertTrue(plugins.get("com.example.a").getEnabled());
+ assertFalse(plugins.get("com.example.b").getEnabled());
+ }
+
+ @Test
+ void testSetUpmInstallsAndEnablesAbsentPlugin() throws IOException {
+ final Path artifact = Files.createTempFile("bootstrapi-plugin-test-", ".jar");
+ final UpmModel upmModel = upmModel(pluginModel());
+ doReturn(null, plugin(PLUGIN_KEY, "1.2.3")).when(pluginAccessor).getPlugin(PLUGIN_KEY);
+ doReturn(artifact).when(pluginArtifactDownloader)
+ .download(upmModel, PLUGIN_KEY, upmModel.getPlugins().get(PLUGIN_KEY));
+ doReturn(Set.of(PLUGIN_KEY)).when(pluginController).installPlugins(any(PluginArtifact.class));
+ doReturn(true).when(pluginAccessor).isPluginEnabled(PLUGIN_KEY);
+
+ final ServiceResult result = upmService.setUpm(upmModel);
+
+ verify(pluginController).installPlugins(any(PluginArtifact.class));
+ verify(pluginController).enablePlugins(PLUGIN_KEY);
+ assertEquals(200, result.getStatus().get(PLUGIN_KEY).getStatus());
+ assertEquals("1.2.3", result.getModel().getPlugins().get(PLUGIN_KEY).getVersion());
+ assertTrue(result.getModel().getPlugins().get(PLUGIN_KEY).getEnabled());
+ // the resolver endpoints and their credentials are not echoed
+ assertNull(result.getModel().getResolvers());
+ assertFalse(Files.exists(artifact), "the downloaded artifact must be deleted after the install");
+ }
+
+ @Test
+ void testSetUpmSkipsInstallWhenVersionMatches() {
+ final UpmModel upmModel = upmModel(pluginModel());
+ doReturn(plugin(PLUGIN_KEY, "1.2.3")).when(pluginAccessor).getPlugin(PLUGIN_KEY);
+ doReturn(true).when(pluginAccessor).isPluginEnabled(PLUGIN_KEY);
+
+ final ServiceResult result = upmService.setUpm(upmModel);
+
+ verifyNoInteractions(pluginArtifactDownloader);
+ verify(pluginController, never()).installPlugins(any(PluginArtifact.class));
+ verify(pluginController).enablePlugins(PLUGIN_KEY);
+ assertEquals(200, result.getStatus().get(PLUGIN_KEY).getStatus());
+ }
+
+ @Test
+ void testSetUpmDisablesPluginWhenRequested() {
+ final PluginModel pluginModel = pluginModel();
+ pluginModel.setEnabled(false);
+ final UpmModel upmModel = upmModel(pluginModel);
+ doReturn(plugin(PLUGIN_KEY, "1.2.3")).when(pluginAccessor).getPlugin(PLUGIN_KEY);
+ doReturn(false).when(pluginAccessor).isPluginEnabled(PLUGIN_KEY);
+
+ final ServiceResult result = upmService.setUpm(upmModel);
+
+ verify(pluginController).disablePlugin(PLUGIN_KEY);
+ verify(pluginController, never()).enablePlugins(PLUGIN_KEY);
+ assertFalse(result.getModel().getPlugins().get(PLUGIN_KEY).getEnabled());
+ }
+
+ @Test
+ void testSetUpmWithoutResolverFailsThePlugin() {
+ final PluginModel pluginModel = pluginModel();
+ pluginModel.setResolver(null);
+
+ final ServiceResult result = upmService.setUpm(upmModel(pluginModel));
+
+ assertEquals(400, result.getStatus().get(PLUGIN_KEY).getStatus());
+ assertTrue(result.getModel().getPlugins().isEmpty());
+ verifyNoInteractions(pluginController, pluginArtifactDownloader);
+ }
+
+ @Test
+ void testSetUpmWithEmptyPluginEntryFailsThePlugin() {
+ final UpmModel upmModel = new UpmModel();
+ final Map plugins = new LinkedHashMap<>();
+ plugins.put(PLUGIN_KEY, null);
+ upmModel.setPlugins(plugins);
+
+ final ServiceResult result = upmService.setUpm(upmModel);
+
+ assertEquals(400, result.getStatus().get(PLUGIN_KEY).getStatus());
+ verifyNoInteractions(pluginAccessor, pluginController, pluginArtifactDownloader);
+ }
+
+ @Test
+ void testSetUpmWithKeyMismatchFailsThePlugin() throws IOException {
+ final Path artifact = Files.createTempFile("bootstrapi-plugin-test-", ".jar");
+ final UpmModel upmModel = upmModel(pluginModel());
+ doReturn(null).when(pluginAccessor).getPlugin(PLUGIN_KEY);
+ doReturn(artifact).when(pluginArtifactDownloader)
+ .download(upmModel, PLUGIN_KEY, upmModel.getPlugins().get(PLUGIN_KEY));
+ doReturn(Set.of("com.example.other")).when(pluginController).installPlugins(any(PluginArtifact.class));
+
+ final ServiceResult result = upmService.setUpm(upmModel);
+
+ assertEquals(400, result.getStatus().get(PLUGIN_KEY).getStatus());
+ assertFalse(Files.exists(artifact), "the downloaded artifact must be deleted after a failed install");
+ }
+
+ @Test
+ void testSetUpmContinuesAfterAFailedPlugin() {
+ final PluginModel invalidModel = PluginModel.builder()
+ .resolver("central")
+ .build();
+ final UpmModel upmModel = new UpmModel();
+ final Map plugins = new LinkedHashMap<>();
+ plugins.put("com.example.invalid", invalidModel);
+ plugins.put(PLUGIN_KEY, pluginModel());
+ upmModel.setPlugins(plugins);
+ doReturn(plugin(PLUGIN_KEY, "1.2.3")).when(pluginAccessor).getPlugin(PLUGIN_KEY);
+ doReturn(true).when(pluginAccessor).isPluginEnabled(PLUGIN_KEY);
+
+ final ServiceResult result = upmService.setUpm(upmModel);
+
+ assertEquals(400, result.getStatus().get("com.example.invalid").getStatus());
+ assertEquals(200, result.getStatus().get(PLUGIN_KEY).getStatus());
+ assertEquals(1, result.getModel().getPlugins().size());
+ }
+
+ @Test
+ void testSetUpmWithoutPluginsDoesNothing() {
+ final ServiceResult result = upmService.setUpm(new UpmModel());
+
+ assertTrue(result.getStatus().isEmpty());
+ verifyNoInteractions(pluginAccessor, pluginController, pluginArtifactDownloader);
+ }
+
+ private static UpmModel upmModel(
+ final PluginModel pluginModel) {
+
+ return UpmModel.builder()
+ .plugins(Map.of(PLUGIN_KEY, pluginModel))
+ .build();
+ }
+
+ private static PluginModel pluginModel() {
+ return PluginModel.builder()
+ .version("1.2.3")
+ .resolver("central")
+ .build();
+ }
+
+ private static Plugin plugin(
+ final String key,
+ final String version) {
+
+ final PluginInformation pluginInformation = mock(PluginInformation.class);
+ lenient().doReturn(version).when(pluginInformation).getVersion();
+ final Plugin plugin = mock(Plugin.class);
+ lenient().doReturn(key).when(plugin).getKey();
+ lenient().doReturn(pluginInformation).when(plugin).getPluginInformation();
+ return plugin;
+ }
+}
diff --git a/commons/src/test/java/it/com/deftdevs/bootstrapi/commons/rest/AbstractUpmResourceFuncTest.java b/commons/src/test/java/it/com/deftdevs/bootstrapi/commons/rest/AbstractUpmResourceFuncTest.java
new file mode 100644
index 00000000..7bb7cf2a
--- /dev/null
+++ b/commons/src/test/java/it/com/deftdevs/bootstrapi/commons/rest/AbstractUpmResourceFuncTest.java
@@ -0,0 +1,271 @@
+package it.com.deftdevs.bootstrapi.commons.rest;
+
+import com.deftdevs.bootstrapi.commons.constants.BootstrAPI;
+import com.deftdevs.bootstrapi.commons.model.PluginModel;
+import com.deftdevs.bootstrapi.commons.model.PluginResolverModel;
+import com.deftdevs.bootstrapi.commons.model.UpmModel;
+import com.deftdevs.bootstrapi.commons.model.type.PluginResolverType;
+import com.fasterxml.jackson.core.type.TypeReference;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpExchange;
+import com.sun.net.httpserver.HttpServer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+import jakarta.ws.rs.HttpMethod;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.OutputStream;
+import java.net.InetSocketAddress;
+import java.net.http.HttpResponse;
+import java.nio.charset.StandardCharsets;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.CopyOnWriteArrayList;
+import java.util.jar.JarEntry;
+import java.util.jar.JarOutputStream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Drives the UPM resource of a running product against a stub repository
+ * served by the test itself: a Maven layout and a Marketplace API answering
+ * with absolute links, plus a minimal throwaway plugin generated on the
+ * fly. This exercises the real download, link rebasing, installation and
+ * enablement path through the product's plugin framework.
+ */
+public abstract class AbstractUpmResourceFuncTest {
+
+ private static final String PLUGIN_KEY = "it.com.deftdevs.bootstrapi.it-plugin";
+ private static final String MAVEN_GROUP_PATH = "it/com/deftdevs/bootstrapi";
+ private static final String MAVEN_ARTIFACT_ID = "bootstrapi-it-plugin";
+
+ private final ObjectMapper objectMapper = new ObjectMapper();
+ private final List requestedPaths = new CopyOnWriteArrayList<>();
+
+ private HttpServer server;
+ private String repositoryUrl;
+
+ @BeforeEach
+ void setup() throws IOException {
+ // the products run on this host too, so the stub repository is
+ // reachable for the in-product downloader via the loopback address
+ server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
+ repositoryUrl = "http://127.0.0.1:" + server.getAddress().getPort() + "/repository";
+
+ stubMavenArtifact("1.0.0");
+ stubMarketplaceVersion("2.0.0");
+ server.start();
+ }
+
+ @AfterEach
+ void teardown() {
+ server.stop(0);
+ }
+
+ @Test
+ void testInstallUpgradeAndDisableLifecycle() throws Exception {
+ // install via the maven resolver
+ final HttpResponse installResponse = putUpm(pluginModel("1.0.0", "maven-repo", true));
+ assertEquals(200, installResponse.statusCode(), installResponse.body());
+
+ final UpmModel installResult = objectMapper.readValue(installResponse.body(), UpmModel.class);
+ assertEquals(200, installResult.getStatus().get(PLUGIN_KEY).getStatus());
+ assertEquals("1.0.0", installResult.getPlugins().get(PLUGIN_KEY).getVersion());
+ assertTrue(installResult.getPlugins().get(PLUGIN_KEY).getEnabled());
+
+ // the installed plugin shows up in the plugins map
+ final HttpResponse getResponse = HttpRequestHelper.builder(BootstrAPI.UPM).request();
+ assertEquals(200, getResponse.statusCode());
+ final Map installedPlugins = objectMapper.readValue(getResponse.body(),
+ new TypeReference