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>() {}); + assertTrue(installedPlugins.containsKey(PLUGIN_KEY)); + assertEquals("1.0.0", installedPlugins.get(PLUGIN_KEY).getVersion()); + + // re-applying the same version must not download or install again + final long downloadsBefore = countArtifactDownloads(); + final HttpResponse rerunResponse = putUpm(pluginModel("1.0.0", "maven-repo", true)); + assertEquals(200, rerunResponse.statusCode(), rerunResponse.body()); + assertEquals(downloadsBefore, countArtifactDownloads(), + "an already installed version must not be downloaded again"); + + // upgrade via the marketplace resolver, proving the API lookup and + // the rebased binary download inside the product + final HttpResponse upgradeResponse = putUpm(pluginModel("2.0.0", "marketplace-repo", true)); + assertEquals(200, upgradeResponse.statusCode(), upgradeResponse.body()); + + final UpmModel upgradeResult = objectMapper.readValue(upgradeResponse.body(), UpmModel.class); + assertEquals("2.0.0", upgradeResult.getPlugins().get(PLUGIN_KEY).getVersion()); + assertTrue(requestedPaths.contains("/repository/rest/2/addons/" + PLUGIN_KEY + "/versions/name/2.0.0"), + "the marketplace version lookup must go through the resolver's base URL"); + assertTrue(requestedPaths.contains("/repository/download/apps/42/version/4200"), + "the binary download must be rebased onto the resolver's base URL"); + + // disable the plugin declaratively + final HttpResponse disableResponse = putUpm(pluginModel("2.0.0", "marketplace-repo", false)); + assertEquals(200, disableResponse.statusCode(), disableResponse.body()); + + final UpmModel disableResult = objectMapper.readValue(disableResponse.body(), UpmModel.class); + assertFalse(disableResult.getPlugins().get(PLUGIN_KEY).getEnabled()); + } + + @Test + void testSetUpmUnknownResolverReportsFailedPlugin() throws Exception { + final UpmModel upmModel = UpmModel.builder() + .plugins(Map.of(PLUGIN_KEY, PluginModel.builder() + .version("1.0.0") + .resolver("unknown") + .build())) + .build(); + + final HttpResponse response = HttpRequestHelper.builder(BootstrAPI.UPM) + .request(HttpMethod.PUT, upmModel); + assertEquals(400, response.statusCode()); + + final UpmModel result = objectMapper.readValue(response.body(), UpmModel.class); + assertEquals(400, result.getStatus().get(PLUGIN_KEY).getStatus()); + } + + @Test + void testGetUpmUnauthenticated() throws Exception { + final HttpResponse response = HttpRequestHelper.builder(BootstrAPI.UPM) + .username("wrong") + .password("password") + .request(); + assertEquals(401, response.statusCode()); + } + + @Test + void testSetUpmUnauthenticated() throws Exception { + final HttpResponse response = HttpRequestHelper.builder(BootstrAPI.UPM) + .username("wrong") + .password("password") + .request(HttpMethod.PUT, new UpmModel()); + assertEquals(401, response.statusCode()); + } + + @Test + void testGetUpmUnauthorized() throws Exception { + final HttpResponse response = HttpRequestHelper.builder(BootstrAPI.UPM) + .username("user") + .password("user") + .request(); + assertEquals(403, response.statusCode()); + } + + @Test + void testSetUpmUnauthorized() throws Exception { + final HttpResponse response = HttpRequestHelper.builder(BootstrAPI.UPM) + .username("user") + .password("user") + .request(HttpMethod.PUT, new UpmModel()); + assertEquals(403, response.statusCode()); + } + + private HttpResponse putUpm( + final PluginModel pluginModel) throws IOException, InterruptedException { + + final UpmModel upmModel = UpmModel.builder() + .resolvers(Map.of( + "maven-repo", PluginResolverModel.builder() + .type(PluginResolverType.MAVEN) + .baseUrl(repositoryUrl) + .build(), + "marketplace-repo", PluginResolverModel.builder() + .type(PluginResolverType.MARKETPLACE) + .baseUrl(repositoryUrl) + .build())) + .plugins(Map.of(PLUGIN_KEY, pluginModel)) + .build(); + + return HttpRequestHelper.builder(BootstrAPI.UPM).request(HttpMethod.PUT, upmModel); + } + + private static PluginModel pluginModel( + final String version, + final String resolver, + final boolean enabled) { + + return PluginModel.builder() + .version(version) + .resolver(resolver) + .groupId(MAVEN_GROUP_PATH.replace('/', '.')) + .artifactId(MAVEN_ARTIFACT_ID) + .enabled(enabled) + .build(); + } + + private void stubMavenArtifact( + final String version) throws IOException { + + final byte[] jar = pluginJar(version); + stub("/repository/" + MAVEN_GROUP_PATH + "/" + MAVEN_ARTIFACT_ID + "/" + version + + "/" + MAVEN_ARTIFACT_ID + "-" + version + ".jar", jar); + } + + private void stubMarketplaceVersion( + final String version) throws IOException { + + // the binary link is absolute like in the real Marketplace API, so a + // download succeeds only when the product rebases it onto the base URL + stub("/repository/rest/2/addons/" + PLUGIN_KEY + "/versions/name/" + version, + ("{\"_embedded\":{\"artifact\":{\"_links\":{\"binary\":" + + "{\"href\":\"https://marketplace.atlassian.com/download/apps/42/version/4200\"}}}}}") + .getBytes(StandardCharsets.UTF_8)); + stub("/repository/download/apps/42/version/4200", pluginJar(version)); + } + + /** + * Builds a minimal descriptor-only plugin on the fly; the products' + * OSGi transformation turns it into an installable bundle. + */ + private static byte[] pluginJar( + final String version) throws IOException { + + final String descriptor = "\n" + + " \n" + + " Throwaway plugin installed by the UPM func test\n" + + " " + version + "\n" + + " \n" + + " \n" + + "\n"; + + final ByteArrayOutputStream out = new ByteArrayOutputStream(); + try (JarOutputStream jar = new JarOutputStream(out)) { + jar.putNextEntry(new JarEntry("atlassian-plugin.xml")); + jar.write(descriptor.getBytes(StandardCharsets.UTF_8)); + jar.closeEntry(); + } + return out.toByteArray(); + } + + private void stub( + final String path, + final byte[] body) { + + server.createContext(path, exchange -> { + requestedPaths.add(exchange.getRequestURI().getPath()); + respond(exchange, body); + }); + } + + private long countArtifactDownloads() { + return requestedPaths.stream() + .filter(path -> path.endsWith(".jar") || path.startsWith("/repository/download/")) + .count(); + } + + private static void respond( + final HttpExchange exchange, + final byte[] body) throws IOException { + + exchange.sendResponseHeaders(200, body.length); + try (OutputStream out = exchange.getResponseBody()) { + out.write(body); + } + } +} diff --git a/confluence/Apis/UpmApi.md b/confluence/Apis/UpmApi.md new file mode 100644 index 00000000..089d8f64 --- /dev/null +++ b/confluence/Apis/UpmApi.md @@ -0,0 +1,61 @@ +# UpmApi + +All URIs are relative to *https://CONFLUENCE_URL/rest/bootstrapi/1* + +| Method | HTTP request | Description | +|------------- | ------------- | -------------| +| [**getPlugins**](UpmApi.md#getPlugins) | **GET** /upm | Get all installed plugins | +| [**setUpm**](UpmApi.md#setUpm) | **PUT** /upm | Apply a UPM configuration | + + + +# **getPlugins** +> PluginModel getPlugins() + +Get all installed plugins + + Returns every installed plugin (bundled and user-installed) with its version and enabled state, keyed by plugin key + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**PluginModel**](../Models/PluginModel.md) + +### Authorization + +[basicAuth](../README.md#basicAuth) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json, application/yaml, application/x-yaml, text/yaml + + +# **setUpm** +> UpmModel setUpm(UpmModel) + +Apply a UPM configuration + + 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. + +### Parameters + +|Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **UpmModel** | [**UpmModel**](../Models/UpmModel.md)| | [optional] | + +### Return type + +[**UpmModel**](../Models/UpmModel.md) + +### Authorization + +[basicAuth](../README.md#basicAuth) + +### HTTP request headers + +- **Content-Type**: application/json, application/yaml, application/x-yaml, text/yaml +- **Accept**: application/json, application/yaml, application/x-yaml, text/yaml + diff --git a/confluence/Models/PluginModel.md b/confluence/Models/PluginModel.md new file mode 100644 index 00000000..800cf046 --- /dev/null +++ b/confluence/Models/PluginModel.md @@ -0,0 +1,13 @@ +# PluginModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **version** | **String** | | [optional] [default to null] | +| **resolver** | **String** | | [optional] [default to null] | +| **groupId** | **String** | | [optional] [default to null] | +| **artifactId** | **String** | | [optional] [default to null] | +| **enabled** | **Boolean** | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/confluence/Models/PluginProxyModel.md b/confluence/Models/PluginProxyModel.md new file mode 100644 index 00000000..eab6c1cb --- /dev/null +++ b/confluence/Models/PluginProxyModel.md @@ -0,0 +1,12 @@ +# PluginProxyModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **host** | **String** | | [optional] [default to null] | +| **port** | **Integer** | | [optional] [default to null] | +| **username** | **String** | | [optional] [default to null] | +| **password** | **String** | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/confluence/Models/PluginResolverModel.md b/confluence/Models/PluginResolverModel.md new file mode 100644 index 00000000..cee1702e --- /dev/null +++ b/confluence/Models/PluginResolverModel.md @@ -0,0 +1,13 @@ +# PluginResolverModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **type** | **String** | | [optional] [default to null] | +| **baseUrl** | **String** | | [optional] [default to null] | +| **username** | **String** | | [optional] [default to null] | +| **password** | **String** | | [optional] [default to null] | +| **proxy** | [**PluginProxyModel**](PluginProxyModel.md) | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/confluence/Models/UpmModel.md b/confluence/Models/UpmModel.md new file mode 100644 index 00000000..5f4bf438 --- /dev/null +++ b/confluence/Models/UpmModel.md @@ -0,0 +1,11 @@ +# UpmModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **resolvers** | [**Map**](PluginResolverModel.md) | | [optional] [default to null] | +| **plugins** | [**Map**](PluginModel.md) | | [optional] [default to null] | +| **status** | [**Map**](_AllModelStatus.md) | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/confluence/Models/_AllModel.md b/confluence/Models/_AllModel.md index 7911cc07..ffecc53a 100644 --- a/confluence/Models/_AllModel.md +++ b/confluence/Models/_AllModel.md @@ -8,6 +8,7 @@ | **applicationLinks** | [**Map**](ApplicationLinkModel.md) | | [optional] [default to null] | | **licenses** | [**Map**](LicenseModel.md) | | [optional] [default to null] | | **mailServer** | [**MailServerModel**](MailServerModel.md) | | [optional] [default to null] | +| **upm** | [**UpmModel**](UpmModel.md) | | [optional] [default to null] | | **status** | [**Map**](_AllModelStatus.md) | | [optional] [default to null] | | **authentication** | [**AuthenticationModel**](AuthenticationModel.md) | | [optional] [default to null] | | **permissions** | [**PermissionsModel**](PermissionsModel.md) | | [optional] [default to null] | diff --git a/confluence/README.md b/confluence/README.md index 69ad242f..7ec0a926 100644 --- a/confluence/README.md +++ b/confluence/README.md @@ -56,6 +56,8 @@ All URIs are relative to *https://CONFLUENCE_URL/rest/bootstrapi/1* *SettingsApi* | [**setSettingsBrandingLogo**](Apis/SettingsApi.md#setSettingsBrandingLogo) | **PUT** /settings/branding/logo | Set the logo | *SettingsApi* | [**setSettingsGeneral**](Apis/SettingsApi.md#setSettingsGeneral) | **PUT** /settings/general | Set the general settings | *SettingsApi* | [**setSettingsSecurity**](Apis/SettingsApi.md#setSettingsSecurity) | **PUT** /settings/security | Set the security settings | +| *UpmApi* | [**getPlugins**](Apis/UpmApi.md#getPlugins) | **GET** /upm | Get all installed plugins | +*UpmApi* | [**setUpm**](Apis/UpmApi.md#setUpm) | **PUT** /upm | Apply a UPM configuration | | *UserApi* | [**getUser**](Apis/UserApi.md#getUser) | **GET** /user | Get a user | *UserApi* | [**setUser**](Apis/UserApi.md#setUser) | **PUT** /user | Update an user | *UserApi* | [**setUserPassword**](Apis/UserApi.md#setUserPassword) | **PUT** /user/password | Update a user password | @@ -97,12 +99,16 @@ All URIs are relative to *https://CONFLUENCE_URL/rest/bootstrapi/1* - [MailServerSmtpModel](./Models/MailServerSmtpModel.md) - [PermissionsGlobalModel](./Models/PermissionsGlobalModel.md) - [PermissionsModel](./Models/PermissionsModel.md) + - [PluginModel](./Models/PluginModel.md) + - [PluginProxyModel](./Models/PluginProxyModel.md) + - [PluginResolverModel](./Models/PluginResolverModel.md) - [SettingsBrandingColorSchemeModel](./Models/SettingsBrandingColorSchemeModel.md) - [SettingsBrandingCustomHtmlModel](./Models/SettingsBrandingCustomHtmlModel.md) - [SettingsBrandingModel](./Models/SettingsBrandingModel.md) - [SettingsGeneralModel](./Models/SettingsGeneralModel.md) - [SettingsModel](./Models/SettingsModel.md) - [SettingsSecurityModel](./Models/SettingsSecurityModel.md) + - [UpmModel](./Models/UpmModel.md) - [UserModel](./Models/UserModel.md) - [_AllModel](./Models/_AllModel.md) - [_AllModelStatus](./Models/_AllModelStatus.md) diff --git a/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/config/AtlassianConfig.java b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/config/AtlassianConfig.java index 6bbb75ab..e79ca49a 100644 --- a/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/config/AtlassianConfig.java +++ b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/config/AtlassianConfig.java @@ -19,6 +19,8 @@ import com.atlassian.oauth.consumer.ConsumerTokenStore; import com.atlassian.oauth.serviceprovider.ServiceProviderConsumerStore; import com.atlassian.oauth.serviceprovider.ServiceProviderTokenStore; +import com.atlassian.plugin.PluginAccessor; +import com.atlassian.plugin.PluginController; import com.atlassian.plugins.authentication.api.config.IdpConfigService; import com.atlassian.plugins.authentication.api.config.SsoConfigService; import com.atlassian.sal.api.ApplicationProperties; @@ -113,6 +115,16 @@ public PermissionManager permissionManager() { return importOsgiService(PermissionManager.class); } + @Bean + public PluginAccessor pluginAccessor() { + return importOsgiService(PluginAccessor.class); + } + + @Bean + public PluginController pluginController() { + return importOsgiService(PluginController.class); + } + @Bean public PluginSettingsFactory pluginSettingsFactory() { return importOsgiService(PluginSettingsFactory.class); diff --git a/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/config/ServiceConfig.java b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/config/ServiceConfig.java index 7bb8765e..0fa52129 100644 --- a/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/config/ServiceConfig.java +++ b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/config/ServiceConfig.java @@ -1,5 +1,6 @@ package com.deftdevs.bootstrapi.confluence.config; +import com.deftdevs.bootstrapi.commons.service.DefaultUpmServiceImpl; import com.deftdevs.bootstrapi.commons.service.api.*; import com.deftdevs.bootstrapi.confluence.model._AllModel; import com.deftdevs.bootstrapi.confluence.service.*; @@ -28,7 +29,8 @@ public _AllService<_AllModel> _allService() { confluenceAuthenticationService(), licensesService(), mailServerService(), - permissionsService()); + permissionsService(), + upmService()); } @Bean @@ -89,6 +91,13 @@ public PermissionsService permissionsService() { atlassianConfig.spacePermissionManager()); } + @Bean + public UpmService upmService() { + return new DefaultUpmServiceImpl( + atlassianConfig.pluginAccessor(), + atlassianConfig.pluginController()); + } + @Bean public UsersService usersService() { return new UsersServiceImpl( diff --git a/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/rest/UpmResourceImpl.java b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/rest/UpmResourceImpl.java new file mode 100644 index 00000000..63c8e537 --- /dev/null +++ b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/rest/UpmResourceImpl.java @@ -0,0 +1,23 @@ +package com.deftdevs.bootstrapi.confluence.rest; + +import com.atlassian.plugins.rest.api.security.annotation.SystemAdminOnly; +import com.deftdevs.bootstrapi.commons.constants.BootstrAPI; +import com.deftdevs.bootstrapi.commons.rest.AbstractUpmResourceImpl; +import com.deftdevs.bootstrapi.commons.service.api.UpmService; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Path; + +@Path(BootstrAPI.UPM) +@SystemAdminOnly +public class UpmResourceImpl extends AbstractUpmResourceImpl { + + @Inject + public UpmResourceImpl( + final UpmService upmService) { + + super(upmService); + } + + // Completely inheriting the implementation of AbstractUpmResourceImpl +} diff --git a/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/service/_AllServiceImpl.java b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/service/_AllServiceImpl.java index 95ddcf16..3e7c9044 100644 --- a/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/service/_AllServiceImpl.java +++ b/confluence/src/main/java/com/deftdevs/bootstrapi/confluence/service/_AllServiceImpl.java @@ -6,6 +6,7 @@ import com.deftdevs.bootstrapi.commons.model.LicenseModel; import com.deftdevs.bootstrapi.commons.model.MailServerModel; import com.deftdevs.bootstrapi.commons.model.PermissionsModel; +import com.deftdevs.bootstrapi.commons.model.UpmModel; import com.deftdevs.bootstrapi.commons.model.type._AllModelStatus; import com.deftdevs.bootstrapi.commons.service._AbstractAllServiceImpl; import com.deftdevs.bootstrapi.commons.service.api.ApplicationLinksService; @@ -13,6 +14,7 @@ import com.deftdevs.bootstrapi.commons.service.api.LicensesService; import com.deftdevs.bootstrapi.commons.service.api.MailServerService; import com.deftdevs.bootstrapi.commons.service.api.PermissionsService; +import com.deftdevs.bootstrapi.commons.service.api.UpmService; import com.deftdevs.bootstrapi.confluence.model.SettingsModel; import com.deftdevs.bootstrapi.confluence.model._AllModel; import com.deftdevs.bootstrapi.confluence.service.api.ConfluenceAuthenticationService; @@ -31,6 +33,7 @@ public class _AllServiceImpl extends _AbstractAllServiceImpl<_AllModel> { private final LicensesService licensesService; private final MailServerService mailServerService; private final PermissionsService permissionsService; + private final UpmService upmService; public _AllServiceImpl( final ConfluenceSettingsService settingsService, @@ -39,7 +42,8 @@ public _AllServiceImpl( final ConfluenceAuthenticationService authenticationService, final LicensesService licensesService, final MailServerService mailServerService, - final PermissionsService permissionsService) { + final PermissionsService permissionsService, + final UpmService upmService) { this.settingsService = settingsService; this.directoriesService = directoriesService; @@ -48,6 +52,7 @@ public _AllServiceImpl( this.licensesService = licensesService; this.mailServerService = mailServerService; this.permissionsService = permissionsService; + this.upmService = upmService; } @Override @@ -57,6 +62,10 @@ public _AllModel setAll( final _AllModel result = new _AllModel(); final Map statusMap = new LinkedHashMap<>(); + // plugins are applied first so the sections below can configure them + setEntityWithStatus(UpmModel.class, allModel.getUpm(), + upmService::setUpm, result::setUpm, statusMap); + setEntityWithStatus(SettingsModel.class, allModel.getSettings(), settingsService::setSettings, result::setSettings, statusMap); diff --git a/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/service/_AllServiceImplTest.java b/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/service/_AllServiceImplTest.java index a09b43bb..b563e49c 100644 --- a/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/service/_AllServiceImplTest.java +++ b/confluence/src/test/java/com/deftdevs/bootstrapi/confluence/service/_AllServiceImplTest.java @@ -8,6 +8,7 @@ import com.deftdevs.bootstrapi.commons.model.MailServerSmtpModel; import com.deftdevs.bootstrapi.commons.model.PermissionsGlobalModel; import com.deftdevs.bootstrapi.commons.model.PermissionsModel; +import com.deftdevs.bootstrapi.commons.model.UpmModel; import com.deftdevs.bootstrapi.commons.model.AuthenticationSsoModel; import com.deftdevs.bootstrapi.commons.model.SettingsGeneralModel; import com.deftdevs.bootstrapi.commons.model.type.ServiceResult; @@ -19,6 +20,7 @@ import com.deftdevs.bootstrapi.commons.service.api.LicensesService; import com.deftdevs.bootstrapi.commons.service.api.MailServerService; import com.deftdevs.bootstrapi.commons.service.api.PermissionsService; +import com.deftdevs.bootstrapi.commons.service.api.UpmService; import com.deftdevs.bootstrapi.confluence.model.SettingsModel; import com.deftdevs.bootstrapi.confluence.model._AllModel; import com.deftdevs.bootstrapi.confluence.service.api.ConfluenceAuthenticationService; @@ -66,6 +68,9 @@ class _AllServiceImplTest { @Mock private PermissionsService permissionsService; + @Mock + private UpmService upmService; + private _AllServiceImpl allService; @BeforeEach @@ -77,7 +82,8 @@ void setup() { authenticationService, licensesService, mailServerService, - permissionsService); + permissionsService, + upmService); } @Test @@ -86,7 +92,8 @@ void testSetAllEmptyModelYieldsEmptyStatus() { assertTrue(result.getStatus().isEmpty()); verifyNoInteractions(settingsService, directoriesService, applicationLinksService, - authenticationService, licensesService, mailServerService, permissionsService); + authenticationService, licensesService, mailServerService, permissionsService, + upmService); } @Test @@ -104,6 +111,7 @@ void testSetAllAppliesAllFields() { Collections.singletonMap(LicenseKeyRedactor.redact("licenseKey"), LicenseModel.EXAMPLE_1); final MailServerModel mailServer = new MailServerModel(MailServerSmtpModel.EXAMPLE_1, null); final PermissionsModel permissions = new PermissionsModel(); + final UpmModel upm = new UpmModel(); doReturn(new ServiceResult<>(settings, Collections.singletonMap(FieldNames.of(SettingsModel.class, SettingsGeneralModel.class), _AllModelStatus.success()))) @@ -120,6 +128,9 @@ void testSetAllAppliesAllFields() { doReturn(new ServiceResult<>(permissions, Collections.singletonMap(FieldNames.of(PermissionsModel.class, PermissionsGlobalModel.class), _AllModelStatus.success()))) .when(permissionsService).setPermissions(permissions); + doReturn(new ServiceResult<>(upm, + Collections.singletonMap("com.example.plugin", _AllModelStatus.success()))) + .when(upmService).setUpm(upm); final _AllModel allModel = new _AllModel(); allModel.setSettings(settings); @@ -129,6 +140,7 @@ void testSetAllAppliesAllFields() { allModel.setLicenses(licenses); allModel.setMailServer(mailServer); allModel.setPermissions(permissions); + allModel.setUpm(upm); final _AllModel result = allService.setAll(allModel); @@ -139,9 +151,10 @@ void testSetAllAppliesAllFields() { assertEquals(redactedLicenses, result.getLicenses()); assertEquals(mailServer, result.getMailServer()); assertEquals(permissions, result.getPermissions()); + assertEquals(upm, result.getUpm()); final Map status = result.getStatus(); - assertEquals(7, status.size()); + assertEquals(8, status.size()); assertEquals(200, status.get(FieldNames.pathOf(_AllModel.class, SettingsGeneralModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.of(_AllModel.class, AbstractDirectoryModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.of(_AllModel.class, ApplicationLinkModel.class)).getStatus()); @@ -149,6 +162,7 @@ void testSetAllAppliesAllFields() { assertEquals(200, status.get(FieldNames.of(_AllModel.class, LicenseModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.pathOf(_AllModel.class, MailServerSmtpModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.pathOf(_AllModel.class, PermissionsGlobalModel.class)).getStatus()); + assertEquals(200, status.get(FieldNames.of(_AllModel.class, UpmModel.class) + "/com.example.plugin").getStatus()); } @Test diff --git a/confluence/src/test/java/it/com/deftdevs/bootstrapi/confluence/rest/UpmResourceFuncTest.java b/confluence/src/test/java/it/com/deftdevs/bootstrapi/confluence/rest/UpmResourceFuncTest.java new file mode 100644 index 00000000..afaf1547 --- /dev/null +++ b/confluence/src/test/java/it/com/deftdevs/bootstrapi/confluence/rest/UpmResourceFuncTest.java @@ -0,0 +1,8 @@ +package it.com.deftdevs.bootstrapi.confluence.rest; + +import it.com.deftdevs.bootstrapi.commons.rest.AbstractUpmResourceFuncTest; + +public class UpmResourceFuncTest extends AbstractUpmResourceFuncTest { + + // Completely inheriting the implementation of AbstractUpmResourceFuncTest +} diff --git a/crowd/Apis/UpmApi.md b/crowd/Apis/UpmApi.md new file mode 100644 index 00000000..4704f3c5 --- /dev/null +++ b/crowd/Apis/UpmApi.md @@ -0,0 +1,61 @@ +# UpmApi + +All URIs are relative to *https://CROWD_URL/rest/bootstrapi/1* + +| Method | HTTP request | Description | +|------------- | ------------- | -------------| +| [**getPlugins**](UpmApi.md#getPlugins) | **GET** /upm | Get all installed plugins | +| [**setUpm**](UpmApi.md#setUpm) | **PUT** /upm | Apply a UPM configuration | + + + +# **getPlugins** +> PluginModel getPlugins() + +Get all installed plugins + + Returns every installed plugin (bundled and user-installed) with its version and enabled state, keyed by plugin key + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**PluginModel**](../Models/PluginModel.md) + +### Authorization + +[basicAuth](../README.md#basicAuth) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json, application/yaml, application/x-yaml, text/yaml + + +# **setUpm** +> UpmModel setUpm(UpmModel) + +Apply a UPM configuration + + 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. + +### Parameters + +|Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **UpmModel** | [**UpmModel**](../Models/UpmModel.md)| | [optional] | + +### Return type + +[**UpmModel**](../Models/UpmModel.md) + +### Authorization + +[basicAuth](../README.md#basicAuth) + +### HTTP request headers + +- **Content-Type**: application/json, application/yaml, application/x-yaml, text/yaml +- **Accept**: application/json, application/yaml, application/x-yaml, text/yaml + diff --git a/crowd/Models/PluginModel.md b/crowd/Models/PluginModel.md new file mode 100644 index 00000000..800cf046 --- /dev/null +++ b/crowd/Models/PluginModel.md @@ -0,0 +1,13 @@ +# PluginModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **version** | **String** | | [optional] [default to null] | +| **resolver** | **String** | | [optional] [default to null] | +| **groupId** | **String** | | [optional] [default to null] | +| **artifactId** | **String** | | [optional] [default to null] | +| **enabled** | **Boolean** | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/crowd/Models/PluginProxyModel.md b/crowd/Models/PluginProxyModel.md new file mode 100644 index 00000000..eab6c1cb --- /dev/null +++ b/crowd/Models/PluginProxyModel.md @@ -0,0 +1,12 @@ +# PluginProxyModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **host** | **String** | | [optional] [default to null] | +| **port** | **Integer** | | [optional] [default to null] | +| **username** | **String** | | [optional] [default to null] | +| **password** | **String** | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/crowd/Models/PluginResolverModel.md b/crowd/Models/PluginResolverModel.md new file mode 100644 index 00000000..cee1702e --- /dev/null +++ b/crowd/Models/PluginResolverModel.md @@ -0,0 +1,13 @@ +# PluginResolverModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **type** | **String** | | [optional] [default to null] | +| **baseUrl** | **String** | | [optional] [default to null] | +| **username** | **String** | | [optional] [default to null] | +| **password** | **String** | | [optional] [default to null] | +| **proxy** | [**PluginProxyModel**](PluginProxyModel.md) | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/crowd/Models/UpmModel.md b/crowd/Models/UpmModel.md new file mode 100644 index 00000000..5f4bf438 --- /dev/null +++ b/crowd/Models/UpmModel.md @@ -0,0 +1,11 @@ +# UpmModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **resolvers** | [**Map**](PluginResolverModel.md) | | [optional] [default to null] | +| **plugins** | [**Map**](PluginModel.md) | | [optional] [default to null] | +| **status** | [**Map**](_AllModelStatus.md) | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/crowd/Models/_AllModel.md b/crowd/Models/_AllModel.md index 6f85b170..f7d9313d 100644 --- a/crowd/Models/_AllModel.md +++ b/crowd/Models/_AllModel.md @@ -8,6 +8,7 @@ | **applicationLinks** | [**Map**](ApplicationLinkModel.md) | | [optional] [default to null] | | **licenses** | [**Map**](LicenseModel.md) | | [optional] [default to null] | | **mailServer** | [**MailServerModel**](MailServerModel.md) | | [optional] [default to null] | +| **upm** | [**UpmModel**](UpmModel.md) | | [optional] [default to null] | | **status** | [**Map**](_AllModelStatus.md) | | [optional] [default to null] | | **applications** | [**Map**](ApplicationModel.md) | | [optional] [default to null] | | **mailTemplates** | [**MailTemplatesModel**](MailTemplatesModel.md) | | [optional] [default to null] | diff --git a/crowd/README.md b/crowd/README.md index 63442561..296bf7f1 100644 --- a/crowd/README.md +++ b/crowd/README.md @@ -55,6 +55,8 @@ All URIs are relative to *https://CROWD_URL/rest/bootstrapi/1* *TrustedProxiesApi* | [**getTrustedProxies**](Apis/TrustedProxiesApi.md#getTrustedProxies) | **GET** /trusted-proxies | Get the trusted proxies | *TrustedProxiesApi* | [**removeTrustedProxy**](Apis/TrustedProxiesApi.md#removeTrustedProxy) | **DELETE** /trusted-proxies | Remove a trusted proxy | *TrustedProxiesApi* | [**setTrustedProxies**](Apis/TrustedProxiesApi.md#setTrustedProxies) | **PUT** /trusted-proxies | Set the trusted proxies | +| *UpmApi* | [**getPlugins**](Apis/UpmApi.md#getPlugins) | **GET** /upm | Get all installed plugins | +*UpmApi* | [**setUpm**](Apis/UpmApi.md#setUpm) | **PUT** /upm | Apply a UPM configuration | | *UserApi* | [**getUser**](Apis/UserApi.md#getUser) | **GET** /user | Get a user | *UserApi* | [**setUser**](Apis/UserApi.md#setUser) | **PUT** /user | Update an user | *UserApi* | [**setUserPassword**](Apis/UserApi.md#setUserPassword) | **PUT** /user/password | Update a user password | @@ -91,12 +93,16 @@ All URIs are relative to *https://CROWD_URL/rest/bootstrapi/1* - [MailServerPopModel](./Models/MailServerPopModel.md) - [MailServerSmtpModel](./Models/MailServerSmtpModel.md) - [MailTemplatesModel](./Models/MailTemplatesModel.md) + - [PluginModel](./Models/PluginModel.md) + - [PluginProxyModel](./Models/PluginProxyModel.md) + - [PluginResolverModel](./Models/PluginResolverModel.md) - [SessionConfigModel](./Models/SessionConfigModel.md) - [SettingsBrandingLoginPageModel](./Models/SettingsBrandingLoginPageModel.md) - [SettingsBrandingModel](./Models/SettingsBrandingModel.md) - [SettingsGeneralModel](./Models/SettingsGeneralModel.md) - [SettingsModel](./Models/SettingsModel.md) - [SettingsSecurityModel](./Models/SettingsSecurityModel.md) + - [UpmModel](./Models/UpmModel.md) - [UserModel](./Models/UserModel.md) - [_AllModel](./Models/_AllModel.md) - [_AllModelStatus](./Models/_AllModelStatus.md) diff --git a/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/config/AtlassianConfig.java b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/config/AtlassianConfig.java index ba2eefa3..99abd695 100644 --- a/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/config/AtlassianConfig.java +++ b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/config/AtlassianConfig.java @@ -16,6 +16,8 @@ import com.atlassian.oauth.consumer.ConsumerTokenStore; import com.atlassian.oauth.serviceprovider.ServiceProviderConsumerStore; import com.atlassian.oauth.serviceprovider.ServiceProviderTokenStore; +import com.atlassian.plugin.PluginAccessor; +import com.atlassian.plugin.PluginController; import com.atlassian.sal.api.ApplicationProperties; import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory; import org.springframework.context.annotation.Bean; @@ -91,6 +93,16 @@ public MutatingApplicationLinkService mutatingApplicationLinkService() { return importOsgiService(MutatingApplicationLinkService.class); } + @Bean + public PluginAccessor pluginAccessor() { + return importOsgiService(PluginAccessor.class); + } + + @Bean + public PluginController pluginController() { + return importOsgiService(PluginController.class); + } + @Bean public PluginSettingsFactory pluginSettingsFactory() { return importOsgiService(PluginSettingsFactory.class); diff --git a/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/config/ServiceConfig.java b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/config/ServiceConfig.java index cedb0db1..37a6d3e5 100644 --- a/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/config/ServiceConfig.java +++ b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/config/ServiceConfig.java @@ -1,5 +1,6 @@ package com.deftdevs.bootstrapi.crowd.config; +import com.deftdevs.bootstrapi.commons.service.DefaultUpmServiceImpl; import com.deftdevs.bootstrapi.commons.service.api.*; import com.deftdevs.bootstrapi.crowd.model._AllModel; import com.deftdevs.bootstrapi.crowd.service.*; @@ -28,7 +29,8 @@ public _AllService<_AllModel> _allService() { mailServerService(), mailTemplatesService(), sessionConfigService(), - trustedProxiesService()); + trustedProxiesService(), + upmService()); } @Bean @@ -92,6 +94,13 @@ public MailTemplatesService mailTemplatesService() { atlassianConfig.propertyManager()); } + @Bean + public UpmService upmService() { + return new DefaultUpmServiceImpl( + atlassianConfig.pluginAccessor(), + atlassianConfig.pluginController()); + } + @Bean public SessionConfigService sessionConfigService() { return new SessionConfigServiceImpl( diff --git a/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/rest/UpmResourceImpl.java b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/rest/UpmResourceImpl.java new file mode 100644 index 00000000..3d81d459 --- /dev/null +++ b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/rest/UpmResourceImpl.java @@ -0,0 +1,23 @@ +package com.deftdevs.bootstrapi.crowd.rest; + +import com.atlassian.plugins.rest.api.security.annotation.SystemAdminOnly; +import com.deftdevs.bootstrapi.commons.constants.BootstrAPI; +import com.deftdevs.bootstrapi.commons.rest.AbstractUpmResourceImpl; +import com.deftdevs.bootstrapi.commons.service.api.UpmService; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Path; + +@Path(BootstrAPI.UPM) +@SystemAdminOnly +public class UpmResourceImpl extends AbstractUpmResourceImpl { + + @Inject + public UpmResourceImpl( + final UpmService upmService) { + + super(upmService); + } + + // Completely inheriting the implementation of AbstractUpmResourceImpl +} diff --git a/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/service/_AllServiceImpl.java b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/service/_AllServiceImpl.java index 746c31a9..2f19389b 100644 --- a/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/service/_AllServiceImpl.java +++ b/crowd/src/main/java/com/deftdevs/bootstrapi/crowd/service/_AllServiceImpl.java @@ -4,12 +4,14 @@ import com.deftdevs.bootstrapi.commons.model.ApplicationLinkModel; import com.deftdevs.bootstrapi.commons.model.LicenseModel; import com.deftdevs.bootstrapi.commons.model.MailServerModel; +import com.deftdevs.bootstrapi.commons.model.UpmModel; import com.deftdevs.bootstrapi.commons.model.type._AllModelStatus; import com.deftdevs.bootstrapi.commons.service._AbstractAllServiceImpl; import com.deftdevs.bootstrapi.commons.service.api.ApplicationLinksService; import com.deftdevs.bootstrapi.commons.service.api.DirectoriesService; import com.deftdevs.bootstrapi.commons.service.api.LicensesService; import com.deftdevs.bootstrapi.commons.service.api.MailServerService; +import com.deftdevs.bootstrapi.commons.service.api.UpmService; import com.deftdevs.bootstrapi.crowd.model.ApplicationModel; import com.deftdevs.bootstrapi.crowd.model.MailTemplatesModel; import com.deftdevs.bootstrapi.crowd.model.SessionConfigModel; @@ -36,6 +38,7 @@ public class _AllServiceImpl extends _AbstractAllServiceImpl<_AllModel> { private final MailTemplatesService mailTemplatesService; private final SessionConfigService sessionConfigService; private final TrustedProxiesService trustedProxiesService; + private final UpmService upmService; public _AllServiceImpl( final CrowdSettingsService settingsService, @@ -46,7 +49,8 @@ public _AllServiceImpl( final MailServerService mailServerService, final MailTemplatesService mailTemplatesService, final SessionConfigService sessionConfigService, - final TrustedProxiesService trustedProxiesService) { + final TrustedProxiesService trustedProxiesService, + final UpmService upmService) { this.settingsService = settingsService; this.directoriesService = directoriesService; @@ -57,6 +61,7 @@ public _AllServiceImpl( this.mailTemplatesService = mailTemplatesService; this.sessionConfigService = sessionConfigService; this.trustedProxiesService = trustedProxiesService; + this.upmService = upmService; } @Override @@ -66,6 +71,10 @@ public _AllModel setAll( final _AllModel result = new _AllModel(); final Map statusMap = new LinkedHashMap<>(); + // plugins are applied first so the sections below can configure them + setEntityWithStatus(UpmModel.class, allModel.getUpm(), + upmService::setUpm, result::setUpm, statusMap); + setEntityWithStatus(SettingsModel.class, allModel.getSettings(), settingsService::setSettings, result::setSettings, statusMap); diff --git a/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/service/_AllServiceImplTest.java b/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/service/_AllServiceImplTest.java index 744fb3e8..04179271 100644 --- a/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/service/_AllServiceImplTest.java +++ b/crowd/src/test/java/com/deftdevs/bootstrapi/crowd/service/_AllServiceImplTest.java @@ -5,6 +5,7 @@ import com.deftdevs.bootstrapi.commons.model.LicenseModel; import com.deftdevs.bootstrapi.commons.model.MailServerModel; import com.deftdevs.bootstrapi.commons.model.MailServerSmtpModel; +import com.deftdevs.bootstrapi.commons.model.UpmModel; import com.deftdevs.bootstrapi.commons.model.SettingsGeneralModel; import com.deftdevs.bootstrapi.commons.model.type.ServiceResult; import com.deftdevs.bootstrapi.commons.util.FieldNames; @@ -14,6 +15,7 @@ import com.deftdevs.bootstrapi.commons.service.api.DirectoriesService; import com.deftdevs.bootstrapi.commons.service.api.LicensesService; import com.deftdevs.bootstrapi.commons.service.api.MailServerService; +import com.deftdevs.bootstrapi.commons.service.api.UpmService; import com.deftdevs.bootstrapi.crowd.model.ApplicationModel; import com.deftdevs.bootstrapi.crowd.model.MailTemplatesModel; import com.deftdevs.bootstrapi.crowd.model.SessionConfigModel; @@ -74,6 +76,9 @@ class _AllServiceImplTest { @Mock private TrustedProxiesService trustedProxiesService; + @Mock + private UpmService upmService; + private _AllServiceImpl allService; @BeforeEach @@ -87,7 +92,8 @@ void setup() { mailServerService, mailTemplatesService, sessionConfigService, - trustedProxiesService); + trustedProxiesService, + upmService); } @Test @@ -97,7 +103,8 @@ void testSetAllEmptyModelYieldsEmptyStatus() { assertTrue(result.getStatus().isEmpty()); verifyNoInteractions(settingsService, directoriesService, applicationsService, applicationLinksService, licensesService, mailServerService, - mailTemplatesService, sessionConfigService, trustedProxiesService); + mailTemplatesService, sessionConfigService, trustedProxiesService, + upmService); } @Test @@ -118,6 +125,7 @@ void testSetAllAppliesAllFields() { final MailTemplatesModel mailTemplates = MailTemplatesModel.EXAMPLE_1; final SessionConfigModel sessionConfig = new SessionConfigModel(); final List trustedProxies = Collections.singletonList("192.168.0.1"); + final UpmModel upm = new UpmModel(); doReturn(new ServiceResult<>(settings, Collections.singletonMap(FieldNames.of(SettingsModel.class, SettingsGeneralModel.class), _AllModelStatus.success()))) @@ -132,6 +140,9 @@ void testSetAllAppliesAllFields() { doReturn(mailTemplates).when(mailTemplatesService).setMailTemplates(mailTemplates); doReturn(sessionConfig).when(sessionConfigService).setSessionConfig(sessionConfig); doReturn(trustedProxies).when(trustedProxiesService).setTrustedProxies(trustedProxies); + doReturn(new ServiceResult<>(upm, + Collections.singletonMap("com.example.plugin", _AllModelStatus.success()))) + .when(upmService).setUpm(upm); final _AllModel allModel = new _AllModel(); allModel.setSettings(settings); @@ -143,6 +154,7 @@ void testSetAllAppliesAllFields() { allModel.setMailTemplates(mailTemplates); allModel.setSessionConfig(sessionConfig); allModel.setTrustedProxies(trustedProxies); + allModel.setUpm(upm); final _AllModel result = allService.setAll(allModel); @@ -155,9 +167,10 @@ void testSetAllAppliesAllFields() { assertEquals(mailTemplates, result.getMailTemplates()); assertEquals(sessionConfig, result.getSessionConfig()); assertEquals(trustedProxies, result.getTrustedProxies()); + assertEquals(upm, result.getUpm()); final Map status = result.getStatus(); - assertEquals(9, status.size()); + assertEquals(10, status.size()); assertEquals(200, status.get(FieldNames.pathOf(_AllModel.class, SettingsGeneralModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.of(_AllModel.class, AbstractDirectoryModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.of(_AllModel.class, ApplicationModel.class)).getStatus()); @@ -167,6 +180,7 @@ void testSetAllAppliesAllFields() { assertEquals(200, status.get(FieldNames.of(_AllModel.class, MailTemplatesModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.of(_AllModel.class, SessionConfigModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.of(_AllModel.class, String.class)).getStatus()); + assertEquals(200, status.get(FieldNames.of(_AllModel.class, UpmModel.class) + "/com.example.plugin").getStatus()); } @Test diff --git a/crowd/src/test/java/it/com/deftdevs/bootstrapi/crowd/rest/UpmResourceFuncTest.java b/crowd/src/test/java/it/com/deftdevs/bootstrapi/crowd/rest/UpmResourceFuncTest.java new file mode 100644 index 00000000..670492ef --- /dev/null +++ b/crowd/src/test/java/it/com/deftdevs/bootstrapi/crowd/rest/UpmResourceFuncTest.java @@ -0,0 +1,8 @@ +package it.com.deftdevs.bootstrapi.crowd.rest; + +import it.com.deftdevs.bootstrapi.commons.rest.AbstractUpmResourceFuncTest; + +public class UpmResourceFuncTest extends AbstractUpmResourceFuncTest { + + // Completely inheriting the implementation of AbstractUpmResourceFuncTest +} diff --git a/jira/Apis/UpmApi.md b/jira/Apis/UpmApi.md new file mode 100644 index 00000000..55fb8abc --- /dev/null +++ b/jira/Apis/UpmApi.md @@ -0,0 +1,61 @@ +# UpmApi + +All URIs are relative to *https://JIRA_URL/rest/bootstrapi/1* + +| Method | HTTP request | Description | +|------------- | ------------- | -------------| +| [**getPlugins**](UpmApi.md#getPlugins) | **GET** /upm | Get all installed plugins | +| [**setUpm**](UpmApi.md#setUpm) | **PUT** /upm | Apply a UPM configuration | + + + +# **getPlugins** +> PluginModel getPlugins() + +Get all installed plugins + + Returns every installed plugin (bundled and user-installed) with its version and enabled state, keyed by plugin key + +### Parameters +This endpoint does not need any parameter. + +### Return type + +[**PluginModel**](../Models/PluginModel.md) + +### Authorization + +[basicAuth](../README.md#basicAuth) + +### HTTP request headers + +- **Content-Type**: Not defined +- **Accept**: application/json, application/yaml, application/x-yaml, text/yaml + + +# **setUpm** +> UpmModel setUpm(UpmModel) + +Apply a UPM configuration + + 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. + +### Parameters + +|Name | Type | Description | Notes | +|------------- | ------------- | ------------- | -------------| +| **UpmModel** | [**UpmModel**](../Models/UpmModel.md)| | [optional] | + +### Return type + +[**UpmModel**](../Models/UpmModel.md) + +### Authorization + +[basicAuth](../README.md#basicAuth) + +### HTTP request headers + +- **Content-Type**: application/json, application/yaml, application/x-yaml, text/yaml +- **Accept**: application/json, application/yaml, application/x-yaml, text/yaml + diff --git a/jira/Models/PluginModel.md b/jira/Models/PluginModel.md new file mode 100644 index 00000000..800cf046 --- /dev/null +++ b/jira/Models/PluginModel.md @@ -0,0 +1,13 @@ +# PluginModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **version** | **String** | | [optional] [default to null] | +| **resolver** | **String** | | [optional] [default to null] | +| **groupId** | **String** | | [optional] [default to null] | +| **artifactId** | **String** | | [optional] [default to null] | +| **enabled** | **Boolean** | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/jira/Models/PluginProxyModel.md b/jira/Models/PluginProxyModel.md new file mode 100644 index 00000000..eab6c1cb --- /dev/null +++ b/jira/Models/PluginProxyModel.md @@ -0,0 +1,12 @@ +# PluginProxyModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **host** | **String** | | [optional] [default to null] | +| **port** | **Integer** | | [optional] [default to null] | +| **username** | **String** | | [optional] [default to null] | +| **password** | **String** | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/jira/Models/PluginResolverModel.md b/jira/Models/PluginResolverModel.md new file mode 100644 index 00000000..cee1702e --- /dev/null +++ b/jira/Models/PluginResolverModel.md @@ -0,0 +1,13 @@ +# PluginResolverModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **type** | **String** | | [optional] [default to null] | +| **baseUrl** | **String** | | [optional] [default to null] | +| **username** | **String** | | [optional] [default to null] | +| **password** | **String** | | [optional] [default to null] | +| **proxy** | [**PluginProxyModel**](PluginProxyModel.md) | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/jira/Models/UpmModel.md b/jira/Models/UpmModel.md new file mode 100644 index 00000000..5f4bf438 --- /dev/null +++ b/jira/Models/UpmModel.md @@ -0,0 +1,11 @@ +# UpmModel +## Properties + +| Name | Type | Description | Notes | +|------------ | ------------- | ------------- | -------------| +| **resolvers** | [**Map**](PluginResolverModel.md) | | [optional] [default to null] | +| **plugins** | [**Map**](PluginModel.md) | | [optional] [default to null] | +| **status** | [**Map**](_AllModelStatus.md) | | [optional] [default to null] | + +[[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) + diff --git a/jira/Models/_AllModel.md b/jira/Models/_AllModel.md index 7911cc07..ffecc53a 100644 --- a/jira/Models/_AllModel.md +++ b/jira/Models/_AllModel.md @@ -8,6 +8,7 @@ | **applicationLinks** | [**Map**](ApplicationLinkModel.md) | | [optional] [default to null] | | **licenses** | [**Map**](LicenseModel.md) | | [optional] [default to null] | | **mailServer** | [**MailServerModel**](MailServerModel.md) | | [optional] [default to null] | +| **upm** | [**UpmModel**](UpmModel.md) | | [optional] [default to null] | | **status** | [**Map**](_AllModelStatus.md) | | [optional] [default to null] | | **authentication** | [**AuthenticationModel**](AuthenticationModel.md) | | [optional] [default to null] | | **permissions** | [**PermissionsModel**](PermissionsModel.md) | | [optional] [default to null] | diff --git a/jira/README.md b/jira/README.md index 377b82f1..aa229b00 100644 --- a/jira/README.md +++ b/jira/README.md @@ -46,6 +46,8 @@ All URIs are relative to *https://JIRA_URL/rest/bootstrapi/1* *SettingsApi* | [**setSettingsBrandingBanner**](Apis/SettingsApi.md#setSettingsBrandingBanner) | **PUT** /settings/branding/banner | Set the banner | *SettingsApi* | [**setSettingsGeneral**](Apis/SettingsApi.md#setSettingsGeneral) | **PUT** /settings/general | Set the general settings | *SettingsApi* | [**setSettingsSecurity**](Apis/SettingsApi.md#setSettingsSecurity) | **PUT** /settings/security | Set the security settings | +| *UpmApi* | [**getPlugins**](Apis/UpmApi.md#getPlugins) | **GET** /upm | Get all installed plugins | +*UpmApi* | [**setUpm**](Apis/UpmApi.md#setUpm) | **PUT** /upm | Apply a UPM configuration | @@ -83,11 +85,15 @@ All URIs are relative to *https://JIRA_URL/rest/bootstrapi/1* - [MailServerSmtpModel](./Models/MailServerSmtpModel.md) - [PermissionsGlobalModel](./Models/PermissionsGlobalModel.md) - [PermissionsModel](./Models/PermissionsModel.md) + - [PluginModel](./Models/PluginModel.md) + - [PluginProxyModel](./Models/PluginProxyModel.md) + - [PluginResolverModel](./Models/PluginResolverModel.md) - [SettingsBrandingBannerModel](./Models/SettingsBrandingBannerModel.md) - [SettingsBrandingModel](./Models/SettingsBrandingModel.md) - [SettingsGeneralModel](./Models/SettingsGeneralModel.md) - [SettingsModel](./Models/SettingsModel.md) - [SettingsSecurityModel](./Models/SettingsSecurityModel.md) + - [UpmModel](./Models/UpmModel.md) - [UserModel](./Models/UserModel.md) - [_AllModel](./Models/_AllModel.md) - [_AllModelStatus](./Models/_AllModelStatus.md) diff --git a/jira/src/main/java/com/deftdevs/bootstrapi/jira/config/AtlassianConfig.java b/jira/src/main/java/com/deftdevs/bootstrapi/jira/config/AtlassianConfig.java index dfda31c9..cd19b260 100644 --- a/jira/src/main/java/com/deftdevs/bootstrapi/jira/config/AtlassianConfig.java +++ b/jira/src/main/java/com/deftdevs/bootstrapi/jira/config/AtlassianConfig.java @@ -14,6 +14,8 @@ import com.atlassian.oauth.consumer.ConsumerTokenStore; import com.atlassian.oauth.serviceprovider.ServiceProviderConsumerStore; import com.atlassian.oauth.serviceprovider.ServiceProviderTokenStore; +import com.atlassian.plugin.PluginAccessor; +import com.atlassian.plugin.PluginController; import com.atlassian.plugins.authentication.api.config.IdpConfigService; import com.atlassian.plugins.authentication.api.config.SsoConfigService; import com.atlassian.sal.api.pluginsettings.PluginSettingsFactory; @@ -85,6 +87,16 @@ public MutatingApplicationLinkService mutatingApplicationLinkService() { return importOsgiService(MutatingApplicationLinkService.class); } + @Bean + public PluginAccessor pluginAccessor() { + return importOsgiService(PluginAccessor.class); + } + + @Bean + public PluginController pluginController() { + return importOsgiService(PluginController.class); + } + @Bean public PluginSettingsFactory pluginSettingsFactory() { return importOsgiService(PluginSettingsFactory.class); diff --git a/jira/src/main/java/com/deftdevs/bootstrapi/jira/config/ServiceConfig.java b/jira/src/main/java/com/deftdevs/bootstrapi/jira/config/ServiceConfig.java index 0671d8c7..2eb78b6e 100644 --- a/jira/src/main/java/com/deftdevs/bootstrapi/jira/config/ServiceConfig.java +++ b/jira/src/main/java/com/deftdevs/bootstrapi/jira/config/ServiceConfig.java @@ -1,5 +1,6 @@ package com.deftdevs.bootstrapi.jira.config; +import com.deftdevs.bootstrapi.commons.service.DefaultUpmServiceImpl; import com.deftdevs.bootstrapi.commons.service.api.*; import com.deftdevs.bootstrapi.jira.model._AllModel; import com.deftdevs.bootstrapi.jira.service.*; @@ -27,7 +28,8 @@ public _AllService<_AllModel> _allService() { jiraAuthenticationService(), licensesService(), mailServerService(), - permissionsService()); + permissionsService(), + upmService()); } @Bean @@ -76,4 +78,11 @@ public PermissionsService permissionsService() { atlassianConfig.globalPermissionManager()); } + @Bean + public UpmService upmService() { + return new DefaultUpmServiceImpl( + atlassianConfig.pluginAccessor(), + atlassianConfig.pluginController()); + } + } diff --git a/jira/src/main/java/com/deftdevs/bootstrapi/jira/rest/UpmResourceImpl.java b/jira/src/main/java/com/deftdevs/bootstrapi/jira/rest/UpmResourceImpl.java new file mode 100644 index 00000000..70926214 --- /dev/null +++ b/jira/src/main/java/com/deftdevs/bootstrapi/jira/rest/UpmResourceImpl.java @@ -0,0 +1,23 @@ +package com.deftdevs.bootstrapi.jira.rest; + +import com.atlassian.plugins.rest.api.security.annotation.SystemAdminOnly; +import com.deftdevs.bootstrapi.commons.constants.BootstrAPI; +import com.deftdevs.bootstrapi.commons.rest.AbstractUpmResourceImpl; +import com.deftdevs.bootstrapi.commons.service.api.UpmService; + +import jakarta.inject.Inject; +import jakarta.ws.rs.Path; + +@Path(BootstrAPI.UPM) +@SystemAdminOnly +public class UpmResourceImpl extends AbstractUpmResourceImpl { + + @Inject + public UpmResourceImpl( + final UpmService upmService) { + + super(upmService); + } + + // Completely inheriting the implementation of AbstractUpmResourceImpl +} diff --git a/jira/src/main/java/com/deftdevs/bootstrapi/jira/service/_AllServiceImpl.java b/jira/src/main/java/com/deftdevs/bootstrapi/jira/service/_AllServiceImpl.java index 17b3c923..1374c255 100644 --- a/jira/src/main/java/com/deftdevs/bootstrapi/jira/service/_AllServiceImpl.java +++ b/jira/src/main/java/com/deftdevs/bootstrapi/jira/service/_AllServiceImpl.java @@ -6,6 +6,7 @@ import com.deftdevs.bootstrapi.commons.model.LicenseModel; import com.deftdevs.bootstrapi.commons.model.MailServerModel; import com.deftdevs.bootstrapi.commons.model.PermissionsModel; +import com.deftdevs.bootstrapi.commons.model.UpmModel; import com.deftdevs.bootstrapi.commons.model.type._AllModelStatus; import com.deftdevs.bootstrapi.commons.service._AbstractAllServiceImpl; import com.deftdevs.bootstrapi.commons.service.api.ApplicationLinksService; @@ -13,6 +14,7 @@ import com.deftdevs.bootstrapi.commons.service.api.LicensesService; import com.deftdevs.bootstrapi.commons.service.api.MailServerService; import com.deftdevs.bootstrapi.commons.service.api.PermissionsService; +import com.deftdevs.bootstrapi.commons.service.api.UpmService; import com.deftdevs.bootstrapi.jira.model.SettingsModel; import com.deftdevs.bootstrapi.jira.model._AllModel; import com.deftdevs.bootstrapi.jira.service.api.JiraAuthenticationService; @@ -31,6 +33,7 @@ public class _AllServiceImpl extends _AbstractAllServiceImpl<_AllModel> { private final LicensesService licensesService; private final MailServerService mailServerService; private final PermissionsService permissionsService; + private final UpmService upmService; public _AllServiceImpl( final JiraSettingsService settingsService, @@ -39,7 +42,8 @@ public _AllServiceImpl( final JiraAuthenticationService authenticationService, final LicensesService licensesService, final MailServerService mailServerService, - final PermissionsService permissionsService) { + final PermissionsService permissionsService, + final UpmService upmService) { this.settingsService = settingsService; this.directoriesService = directoriesService; @@ -48,6 +52,7 @@ public _AllServiceImpl( this.licensesService = licensesService; this.mailServerService = mailServerService; this.permissionsService = permissionsService; + this.upmService = upmService; } @Override @@ -57,6 +62,10 @@ public _AllModel setAll( final _AllModel result = new _AllModel(); final Map statusMap = new LinkedHashMap<>(); + // plugins are applied first so the sections below can configure them + setEntityWithStatus(UpmModel.class, allModel.getUpm(), + upmService::setUpm, result::setUpm, statusMap); + setEntityWithStatus(SettingsModel.class, allModel.getSettings(), settingsService::setSettings, result::setSettings, statusMap); diff --git a/jira/src/test/java/com/deftdevs/bootstrapi/jira/service/_AllServiceImplTest.java b/jira/src/test/java/com/deftdevs/bootstrapi/jira/service/_AllServiceImplTest.java index 44831248..87ca4d7c 100644 --- a/jira/src/test/java/com/deftdevs/bootstrapi/jira/service/_AllServiceImplTest.java +++ b/jira/src/test/java/com/deftdevs/bootstrapi/jira/service/_AllServiceImplTest.java @@ -8,6 +8,7 @@ import com.deftdevs.bootstrapi.commons.model.MailServerSmtpModel; import com.deftdevs.bootstrapi.commons.model.PermissionsGlobalModel; import com.deftdevs.bootstrapi.commons.model.PermissionsModel; +import com.deftdevs.bootstrapi.commons.model.UpmModel; import com.deftdevs.bootstrapi.commons.model.AuthenticationSsoModel; import com.deftdevs.bootstrapi.commons.model.SettingsGeneralModel; import com.deftdevs.bootstrapi.commons.model.type.ServiceResult; @@ -19,6 +20,7 @@ import com.deftdevs.bootstrapi.commons.service.api.LicensesService; import com.deftdevs.bootstrapi.commons.service.api.MailServerService; import com.deftdevs.bootstrapi.commons.service.api.PermissionsService; +import com.deftdevs.bootstrapi.commons.service.api.UpmService; import com.deftdevs.bootstrapi.jira.model.SettingsModel; import com.deftdevs.bootstrapi.jira.model._AllModel; import com.deftdevs.bootstrapi.jira.service.api.JiraAuthenticationService; @@ -66,6 +68,9 @@ class _AllServiceImplTest { @Mock private PermissionsService permissionsService; + @Mock + private UpmService upmService; + private _AllServiceImpl allService; @BeforeEach @@ -77,7 +82,8 @@ void setup() { authenticationService, licensesService, mailServerService, - permissionsService); + permissionsService, + upmService); } @Test @@ -86,7 +92,8 @@ void testSetAllEmptyModelYieldsEmptyStatus() { assertTrue(result.getStatus().isEmpty()); verifyNoInteractions(settingsService, directoriesService, applicationLinksService, - authenticationService, licensesService, mailServerService, permissionsService); + authenticationService, licensesService, mailServerService, permissionsService, + upmService); } @Test @@ -104,6 +111,7 @@ void testSetAllAppliesAllFields() { Collections.singletonMap(LicenseKeyRedactor.redact("licenseKey"), LicenseModel.EXAMPLE_1); final MailServerModel mailServer = new MailServerModel(MailServerSmtpModel.EXAMPLE_1, null); final PermissionsModel permissions = new PermissionsModel(); + final UpmModel upm = new UpmModel(); doReturn(new ServiceResult<>(settings, Collections.singletonMap(FieldNames.of(SettingsModel.class, SettingsGeneralModel.class), _AllModelStatus.success()))) @@ -120,6 +128,9 @@ void testSetAllAppliesAllFields() { doReturn(new ServiceResult<>(permissions, Collections.singletonMap(FieldNames.of(PermissionsModel.class, PermissionsGlobalModel.class), _AllModelStatus.success()))) .when(permissionsService).setPermissions(permissions); + doReturn(new ServiceResult<>(upm, + Collections.singletonMap("com.example.plugin", _AllModelStatus.success()))) + .when(upmService).setUpm(upm); final _AllModel allModel = new _AllModel(); allModel.setSettings(settings); @@ -129,6 +140,7 @@ void testSetAllAppliesAllFields() { allModel.setLicenses(licenses); allModel.setMailServer(mailServer); allModel.setPermissions(permissions); + allModel.setUpm(upm); final _AllModel result = allService.setAll(allModel); @@ -139,9 +151,10 @@ void testSetAllAppliesAllFields() { assertEquals(redactedLicenses, result.getLicenses()); assertEquals(mailServer, result.getMailServer()); assertEquals(permissions, result.getPermissions()); + assertEquals(upm, result.getUpm()); final Map status = result.getStatus(); - assertEquals(7, status.size()); + assertEquals(8, status.size()); assertEquals(200, status.get(FieldNames.pathOf(_AllModel.class, SettingsGeneralModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.of(_AllModel.class, AbstractDirectoryModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.of(_AllModel.class, ApplicationLinkModel.class)).getStatus()); @@ -149,6 +162,7 @@ void testSetAllAppliesAllFields() { assertEquals(200, status.get(FieldNames.of(_AllModel.class, LicenseModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.pathOf(_AllModel.class, MailServerSmtpModel.class)).getStatus()); assertEquals(200, status.get(FieldNames.pathOf(_AllModel.class, PermissionsGlobalModel.class)).getStatus()); + assertEquals(200, status.get(FieldNames.of(_AllModel.class, UpmModel.class) + "/com.example.plugin").getStatus()); } @Test diff --git a/jira/src/test/java/it/com/deftdevs/bootstrapi/jira/rest/UpmResourceFuncTest.java b/jira/src/test/java/it/com/deftdevs/bootstrapi/jira/rest/UpmResourceFuncTest.java new file mode 100644 index 00000000..96447b25 --- /dev/null +++ b/jira/src/test/java/it/com/deftdevs/bootstrapi/jira/rest/UpmResourceFuncTest.java @@ -0,0 +1,8 @@ +package it.com.deftdevs.bootstrapi.jira.rest; + +import it.com.deftdevs.bootstrapi.commons.rest.AbstractUpmResourceFuncTest; + +public class UpmResourceFuncTest extends AbstractUpmResourceFuncTest { + + // Completely inheriting the implementation of AbstractUpmResourceFuncTest +}