From 848857fbfd7f750347c28fba94a9c0349a5e3a3b Mon Sep 17 00:00:00 2001 From: John Niang Date: Tue, 28 Feb 2023 18:26:18 +0800 Subject: [PATCH] Provide an endpoint to get plugin presets (#3394) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /area core /milestone 2.3.x #### What this PR does / why we need it: This PR provide a new endpoint to get plugins presets. Please see the result: ```bash ❯ curl -s -u admin:admin 'http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/plugin-presets' ``` ```json [ { "spec": { "displayName": "评论组件", "version": "1.2.0", "author": { "name": "Halo OSS Team", "website": "https://github.com/halo-dev" }, "logo": "https://halo.run/logo", "pluginDependencies": {}, "homepage": "https://github.com/halo-sigs/plugin-comment-widget", "description": "为用户前台提供完整的评论解决方案", "license": [ { "name": "MIT" } ], "requires": ">=2.2.0", "enabled": true }, "status": { "phase": "RESOLVED" }, "apiVersion": "plugin.halo.run/v1alpha1", "kind": "Plugin", "metadata": { "name": "PluginCommentWidget" } }, { "spec": { "displayName": "搜索组件", "version": "1.0.0", "author": { "name": "Halo OSS Team", "website": "https://github.com/halo-dev" }, "logo": "https://halo.run/logo", "pluginDependencies": {}, "homepage": "https://github.com/halo-sigs/plugin-search-widget", "description": "为 Halo 2.0 提供统一的搜索组件,方便主题端使用。", "license": [ { "name": "MIT" } ], "requires": ">=2.0.0", "enabled": true }, "status": { "phase": "RESOLVED" }, "apiVersion": "plugin.halo.run/v1alpha1", "kind": "Plugin", "metadata": { "name": "PluginSearchWidget" } } ] ``` #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/3120 #### Special notes for your reviewer: 1. Try to download some plugins from . 2. Put those jars into folder `src/main/resources/presets/plugins/`. 3. Get all plugins presets using the command below: ```bash # You might change your own username and password by yourself. curl -s -u admin:admin 'http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/plugin-presets' ``` #### Does this PR introduce a user-facing change? ```release-note 提供预设插件功能 ``` --- .gitignore | 1 + .../run/halo/app/core/extension/Plugin.java | 4 + .../extension/endpoint/PluginEndpoint.java | 302 ++++++++---------- .../core/extension/service/PluginService.java | 30 ++ .../service/impl/PluginServiceImpl.java | 206 ++++++++++++ .../core/extension/theme/ThemeEndpoint.java | 19 +- .../run/halo/app/plugin/YamlPluginFinder.java | 1 + .../extensions/role-template-plugin.yaml | 3 + .../endpoint/PluginEndpointTest.java | 90 +----- .../service/impl/PluginServiceImplTest.java | 207 ++++++++++++ .../YamlPluginDescriptorFinderTest.java | 28 +- .../halo/app/plugin/YamlPluginFinderTest.java | 28 +- .../resources/presets/plugins/fake-plugin.jar | Bin 0 -> 698 bytes 13 files changed, 635 insertions(+), 284 deletions(-) create mode 100644 src/main/java/run/halo/app/core/extension/service/PluginService.java create mode 100644 src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java create mode 100644 src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java create mode 100644 src/test/resources/presets/plugins/fake-plugin.jar diff --git a/.gitignore b/.gitignore index b7e9268cb..fd2a99521 100755 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ application-local.properties !src/test/resources/themes/*.zip !src/main/resources/themes/*.zip src/main/resources/console/ +src/main/resources/presets/ diff --git a/src/main/java/run/halo/app/core/extension/Plugin.java b/src/main/java/run/halo/app/core/extension/Plugin.java index bdbb37aed..0917329e5 100644 --- a/src/main/java/run/halo/app/core/extension/Plugin.java +++ b/src/main/java/run/halo/app/core/extension/Plugin.java @@ -4,6 +4,7 @@ import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; +import java.net.URI; import java.time.Instant; import java.util.HashMap; import java.util.List; @@ -114,6 +115,9 @@ public class Plugin extends AbstractExtension { private String logo; + @Schema(description = "Load location of the plugin, often a path.") + private URI loadLocation; + public static ConditionList nullSafeConditions(@NonNull PluginStatus status) { Assert.notNull(status, "The status must not be null."); if (status.getConditions() == null) { diff --git a/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java b/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java index f2c366e64..9cfb07909 100644 --- a/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java @@ -1,6 +1,6 @@ package run.halo.app.core.extension.endpoint; -import static java.nio.file.Files.copy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; import static java.util.Comparator.comparing; import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; import static org.springdoc.core.fn.builders.content.Builder.contentBuilder; @@ -12,9 +12,7 @@ import static org.springframework.web.reactive.function.server.RequestPredicates import static run.halo.app.extension.ListResult.generateGenericClass; import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType; import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; -import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently; -import com.github.zafarkhaja.semver.Version; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.ArraySchema; import io.swagger.v3.oas.annotations.media.Schema; @@ -22,14 +20,13 @@ import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.nio.file.StandardCopyOption; import java.time.Duration; import java.util.ArrayList; import java.util.Comparator; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; import java.util.function.Predicate; import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -38,47 +35,37 @@ import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.data.domain.Sort; import org.springframework.http.MediaType; import org.springframework.http.codec.multipart.FilePart; +import org.springframework.http.codec.multipart.FormFieldPart; import org.springframework.http.codec.multipart.Part; -import org.springframework.retry.RetryException; import org.springframework.stereotype.Component; -import org.springframework.util.Assert; import org.springframework.util.MultiValueMap; import org.springframework.util.StringUtils; import org.springframework.web.reactive.function.server.RouterFunction; import org.springframework.web.reactive.function.server.ServerRequest; import org.springframework.web.reactive.function.server.ServerResponse; -import org.springframework.web.server.ServerErrorException; import org.springframework.web.server.ServerWebExchange; import org.springframework.web.server.ServerWebInputException; -import reactor.core.Exceptions; import reactor.core.publisher.Mono; import reactor.core.scheduler.Schedulers; import reactor.util.retry.Retry; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Setting; +import run.halo.app.core.extension.service.PluginService; import run.halo.app.core.extension.theme.SettingUtils; import run.halo.app.extension.Comparators; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.extension.router.IListRequest.QueryListRequest; -import run.halo.app.infra.SystemVersionSupplier; -import run.halo.app.infra.exception.PluginAlreadyExistsException; -import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; -import run.halo.app.infra.utils.FileUtils; -import run.halo.app.infra.utils.VersionUtils; -import run.halo.app.plugin.PluginProperties; -import run.halo.app.plugin.YamlPluginFinder; +import run.halo.app.plugin.PluginNotFoundException; @Slf4j @Component @AllArgsConstructor public class PluginEndpoint implements CustomEndpoint { - private final PluginProperties pluginProperties; - private final ReactiveExtensionClient client; - private final SystemVersionSupplier systemVersionSupplier; + private final PluginService pluginService; @Override public RouterFunction endpoint() { @@ -169,9 +156,18 @@ public class PluginEndpoint implements CustomEndpoint { .response(responseBuilder() .implementation(ConfigMap.class)) ) + .GET("plugin-presets", this::listPresets, + builder -> builder.operationId("ListPluginPresets") + .description("List all plugin presets in the system.") + .tag(tag) + .response(responseBuilder().implementationArray(Plugin.class))) .build(); } + private Mono listPresets(ServerRequest request) { + return ServerResponse.ok().body(pluginService.getPresets(), Plugin.class); + } + private Mono fetchPluginConfig(ServerRequest request) { final var name = request.pathVariable("name"); return client.fetch(Plugin.class, name) @@ -206,7 +202,7 @@ public class PluginEndpoint implements CustomEndpoint { if (!configMapName.equals(configMapNameToUpdate)) { throw new ServerWebInputException( "The name from the request body does not match the plugin " - + "configMapName name."); + + "configMapName name."); } }) .flatMap(configMapToUpdate -> client.fetch(ConfigMap.class, configMapName) @@ -251,94 +247,63 @@ public class PluginEndpoint implements CustomEndpoint { .filter(t -> t instanceof OptimisticLockingFailureException)); } - private Mono upgrade(ServerRequest request) { - var pluginNameInPath = request.pathVariable("name"); - var tempDirRef = new AtomicReference(); - var tempPluginPathRef = new AtomicReference(); - return client.fetch(Plugin.class, pluginNameInPath) - .switchIfEmpty(Mono.error(() -> new ServerWebInputException( - "The given plugin with name " + pluginNameInPath + " does not exit"))) - .then(request.multipartData()) - .flatMap(this::getJarFilePart) - .flatMap(jarFilePart -> createTempDirectory() - .doOnNext(tempDirRef::set) - .flatMap(tempDirectory -> { - var pluginPath = tempDirectory.resolve(jarFilePart.filename()); - return jarFilePart.transferTo(pluginPath).thenReturn(pluginPath); + + private Mono install(ServerRequest request) { + return request.multipartData() + .map(InstallRequest::new) + .flatMap(installRequest -> installRequest.getSource() + .flatMap(source -> { + if (InstallSource.FILE.equals(source)) { + return installFromFile(installRequest.getFile(), pluginService::install); + } + if (InstallSource.PRESET.equals(source)) { + return installFromPreset(installRequest.getPresetName(), + pluginService::install); + } + return Mono.error( + new UnsupportedOperationException("Unsupported install source " + source)); })) - .doOnNext(tempPluginPathRef::set) - .map(pluginPath -> new YamlPluginFinder().find(pluginPath)) - .doOnNext(newPlugin -> { - // validate the plugin name - if (!Objects.equals(pluginNameInPath, newPlugin.getMetadata().getName())) { - throw new ServerWebInputException( - "The uploaded plugin doesn't match the given plugin name"); - } - - satisfiesRequiresVersion(newPlugin); - }) - .flatMap(newPlugin -> deletePluginAndWaitForComplete(newPlugin.getMetadata().getName()) - .map(oldPlugin -> { - var enabled = oldPlugin.getSpec().getEnabled(); - newPlugin.getSpec().setEnabled(enabled); - return newPlugin; - }) - ) - .publishOn(Schedulers.boundedElastic()) - .doOnNext(newPlugin -> { - // copy the Jar file into plugin root - try { - var pluginRoot = Paths.get(pluginProperties.getPluginsRoot()); - createDirectoriesIfNotExists(pluginRoot); - var tempPluginPath = tempPluginPathRef.get(); - copy(tempPluginPath, pluginRoot.resolve(newPlugin.generateFileName())); - } catch (IOException e) { - throw Exceptions.propagate(e); - } - }) - .flatMap(client::create) - .flatMap(newPlugin -> ServerResponse.ok().bodyValue(newPlugin)) - .doFinally(signalType -> deleteRecursivelyAndSilently(tempDirRef.get())); + .flatMap(plugin -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(plugin)); } - private void satisfiesRequiresVersion(Plugin newPlugin) { - Assert.notNull(newPlugin, "The plugin must not be null."); - Version version = systemVersionSupplier.get(); - // validate the plugin version - // only use the nominal system version to compare, the format is like MAJOR.MINOR.PATCH - String systemVersion = version.getNormalVersion(); - String requires = newPlugin.getSpec().getRequires(); - if (!VersionUtils.satisfiesRequires(systemVersion, requires)) { - throw new UnsatisfiedAttributeValueException(String.format( - "Plugin requires a minimum system version of [%s], but the current version is " - + "[%s].", - requires, systemVersion), - "problemDetail.plugin.version.unsatisfied.requires", - new String[] {requires, systemVersion}); - } + private Mono upgrade(ServerRequest request) { + var pluginName = request.pathVariable("name"); + return request.multipartData() + .map(InstallRequest::new) + .flatMap(installRequest -> installRequest.getSource() + .flatMap(source -> { + if (InstallSource.FILE.equals(source)) { + return installFromFile(installRequest.getFile(), + path -> pluginService.upgrade(pluginName, path)); + } + if (InstallSource.PRESET.equals(source)) { + return installFromPreset(installRequest.getPresetName(), + path -> pluginService.upgrade(pluginName, path)); + } + return Mono.error( + new UnsupportedOperationException("Unsupported install source " + source)); + })) + .flatMap(upgradedPlugin -> ServerResponse.ok() + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(upgradedPlugin)); } - private Mono deletePluginAndWaitForComplete(String pluginName) { - return client.fetch(Plugin.class, pluginName) - .flatMap(client::delete) - .flatMap(plugin -> waitForDeleted(plugin.getMetadata().getName()).thenReturn(plugin)); + private Mono installFromFile(Mono filePartMono, + Function> resourceClosure) { + var pathMono = filePartMono.flatMap(this::transferToTemp); + return Mono.usingWhen(pathMono, resourceClosure, this::deleteFileIfExists); } - private Mono waitForDeleted(String pluginName) { - return client.fetch(Plugin.class, pluginName) - .flatMap(plugin -> Mono.error( - new RetryException("Re-check if the plugin is deleted successfully"))) - .retryWhen(Retry.fixedDelay(20, Duration.ofMillis(100)) - .filter(t -> t instanceof RetryException) - ) - .onErrorMap(Exceptions::isRetryExhausted, - t -> new ServerErrorException("Wait timeout for plugin deleted", t)) - .then(); - } - - private Mono createTempDirectory() { - return Mono.fromCallable(() -> Files.createTempDirectory("halo-plugin-")) - .subscribeOn(Schedulers.boundedElastic()); + private Mono installFromPreset(Mono presetNameMono, + Function> resourceClosure) { + return presetNameMono.flatMap(pluginService::getPreset) + .switchIfEmpty( + Mono.error(() -> new PluginNotFoundException("Plugin preset was not found."))) + .map(pluginPreset -> pluginPreset.getStatus().getLoadLocation()) + .map(Path::of) + .flatMap(resourceClosure); } public static class ListRequest extends QueryListRequest { @@ -364,7 +329,7 @@ public class PluginEndpoint implements CustomEndpoint { @ArraySchema(uniqueItems = true, arraySchema = @Schema(name = "sort", description = "Sort property and direction of the list result. Supported fields: " - + "creationTimestamp"), + + "creationTimestamp"), schema = @Schema(description = "like field,asc or field,desc", implementation = String.class, example = "creationTimestamp,desc")) @@ -442,47 +407,79 @@ public class PluginEndpoint implements CustomEndpoint { .flatMap(listResult -> ServerResponse.ok().bodyValue(listResult)); } - public record InstallRequest( - @Schema(required = true, description = "Plugin Jar file.") FilePart file) { + @Schema(name = "PluginInstallRequest") + public static class InstallRequest { + + private final MultiValueMap multipartData; + + public InstallRequest(MultiValueMap multipartData) { + this.multipartData = multipartData; + } + + @Schema(requiredMode = NOT_REQUIRED, description = "Plugin Jar file.") + public Mono getFile() { + var part = multipartData.getFirst("file"); + if (part == null) { + return Mono.error(new ServerWebInputException("Form field file is required")); + } + if (!(part instanceof FilePart file)) { + return Mono.error(new ServerWebInputException("Invalid parameter of file")); + } + if (!Paths.get(file.filename()).toString().endsWith(".jar")) { + return Mono.error( + new ServerWebInputException("Invalid file type, only jar is supported")); + } + return Mono.just(file); + } + + @Schema(requiredMode = NOT_REQUIRED, + description = "Plugin preset name. We will find the plugin from plugin presets") + public Mono getPresetName() { + var part = multipartData.getFirst("presetName"); + if (part == null) { + return Mono.error(new ServerWebInputException( + "Form field presetName is required.")); + } + if (!(part instanceof FormFieldPart presetName)) { + return Mono.error(new ServerWebInputException( + "Invalid format of presetName field, string required")); + } + if (!StringUtils.hasText(presetName.value())) { + return Mono.error(new ServerWebInputException("presetName must not be blank")); + } + return Mono.just(presetName.value()); + } + + @Schema(requiredMode = NOT_REQUIRED, description = "Install source. Default is file.") + public Mono getSource() { + var part = multipartData.getFirst("source"); + if (part == null) { + return Mono.just(InstallSource.FILE); + } + if (!(part instanceof FormFieldPart sourcePart)) { + return Mono.error(new ServerWebInputException( + "Invalid format of source field, string required.")); + } + var installSource = InstallSource.valueOf(sourcePart.value().toUpperCase()); + return Mono.just(installSource); + } } - Mono install(ServerRequest request) { - return request.multipartData() - .flatMap(this::getJarFilePart) - .flatMap(this::transferToTemp) - .flatMap(tempJarFilePath -> { - var plugin = new YamlPluginFinder().find(tempJarFilePath); - // validate the plugin version - satisfiesRequiresVersion(plugin); - // Disable auto enable during installation - plugin.getSpec().setEnabled(false); - return client.fetch(Plugin.class, plugin.getMetadata().getName()) - .doOnNext(oldPlugin -> { - throw new PluginAlreadyExistsException(oldPlugin.getMetadata().getName()); - }) - .then(client.create(plugin)) - .publishOn(Schedulers.boundedElastic()) - .doOnNext(created -> { - String fileName = created.generateFileName(); - var pluginRoot = Paths.get(pluginProperties.getPluginsRoot()); - createDirectoriesIfNotExists(pluginRoot); - Path pluginFilePath = pluginRoot.resolve(fileName); - // move the plugin jar file to the plugin root - // replace the old plugin jar file if exists - FileUtils.copy(tempJarFilePath, pluginFilePath, - StandardCopyOption.REPLACE_EXISTING); - }) - .doFinally(signalType -> { - try { - Files.deleteIfExists(tempJarFilePath); - } catch (IOException e) { - log.error("Failed to delete temp file: {}", tempJarFilePath, e); - } - }); - }) - .flatMap(plugin -> ServerResponse.ok() - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(plugin)); + public enum InstallSource { + FILE, + PRESET, + URL + } + + Mono deleteFileIfExists(Path path) { + return Mono.fromRunnable(() -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + // ignore this error + log.warn("Failed to delete temporary jar file: {}", path, e); + } + }).subscribeOn(Schedulers.boundedElastic()).then(); } private Mono transferToTemp(FilePart filePart) { @@ -493,27 +490,4 @@ public class PluginEndpoint implements CustomEndpoint { ); } - void createDirectoriesIfNotExists(Path directory) { - if (Files.exists(directory)) { - return; - } - try { - Files.createDirectories(directory); - } catch (IOException e) { - throw new RuntimeException("Failed to create directory " + directory, e); - } - } - - Mono getJarFilePart(MultiValueMap formData) { - Part part = formData.getFirst("file"); - if (!(part instanceof FilePart file)) { - return Mono.error(new ServerWebInputException( - "Invalid parameter of file, binary data is required")); - } - if (!Paths.get(file.filename()).toString().endsWith(".jar")) { - return Mono.error(new ServerWebInputException( - "Invalid file type, only jar is supported")); - } - return Mono.just(file); - } } diff --git a/src/main/java/run/halo/app/core/extension/service/PluginService.java b/src/main/java/run/halo/app/core/extension/service/PluginService.java new file mode 100644 index 000000000..9aa5a23dd --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/service/PluginService.java @@ -0,0 +1,30 @@ +package run.halo.app.core.extension.service; + +import java.nio.file.Path; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.Plugin; + +public interface PluginService { + + Flux getPresets(); + + /** + * Gets a plugin information by preset name from plugin presets. + * + * @param presetName is preset name of plugin. + * @return plugin preset information. + */ + Mono getPreset(String presetName); + + /** + * Installs a plugin from a temporary Jar path. + * + * @param path is temporary jar path. Do not set the plugin home at here. + * @return created plugin. + */ + Mono install(Path path); + + Mono upgrade(String name, Path path); + +} diff --git a/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java b/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java new file mode 100644 index 000000000..c821c173c --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/service/impl/PluginServiceImpl.java @@ -0,0 +1,206 @@ +package run.halo.app.core.extension.service.impl; + +import static java.nio.file.StandardCopyOption.REPLACE_EXISTING; + +import com.github.zafarkhaja.semver.Version; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.time.Duration; +import java.util.Objects; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.io.Resource; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; +import org.springframework.retry.RetryException; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; +import org.springframework.web.server.ServerErrorException; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.Exceptions; +import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; +import reactor.util.retry.Retry; +import run.halo.app.core.extension.Plugin; +import run.halo.app.core.extension.service.PluginService; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.SystemVersionSupplier; +import run.halo.app.infra.exception.PluginAlreadyExistsException; +import run.halo.app.infra.exception.UnsatisfiedAttributeValueException; +import run.halo.app.infra.utils.FileUtils; +import run.halo.app.infra.utils.VersionUtils; +import run.halo.app.plugin.PluginProperties; +import run.halo.app.plugin.YamlPluginFinder; + +@Slf4j +@Component +public class PluginServiceImpl implements PluginService { + + private static final String PRESET_LOCATION_PREFIX = "classpath:/presets/plugins/"; + private static final String PRESETS_LOCATION_PATTERN = PRESET_LOCATION_PREFIX + "*.jar"; + + private final ReactiveExtensionClient client; + + private final SystemVersionSupplier systemVersion; + + private final PluginProperties pluginProperties; + + public PluginServiceImpl(ReactiveExtensionClient client, + SystemVersionSupplier systemVersion, PluginProperties pluginProperties) { + this.client = client; + this.systemVersion = systemVersion; + this.pluginProperties = pluginProperties; + } + + @Override + public Flux getPresets() { + // list presets from classpath + return Flux.defer(() -> getPresetJars() + .map(this::toPath) + .map(path -> new YamlPluginFinder().find(path))); + } + + @Override + public Mono getPreset(String presetName) { + return getPresets() + .filter(plugin -> Objects.equals(plugin.getMetadata().getName(), presetName)) + .next(); + } + + @Override + public Mono install(Path path) { + return Mono.defer(() -> { + final var pluginFinder = new YamlPluginFinder(); + final var pluginInPath = pluginFinder.find(path); + // validate the plugin version + satisfiesRequiresVersion(pluginInPath); + + return client.fetch(Plugin.class, pluginInPath.getMetadata().getName()) + .flatMap(oldPlugin -> Mono.error( + new PluginAlreadyExistsException(oldPlugin.getMetadata().getName()))) + .switchIfEmpty(Mono.defer( + () -> copyToPluginHome(pluginInPath) + .map(pluginFinder::find) + .doOnNext(p -> { + // Disable auto enable after installation + p.getSpec().setEnabled(false); + }) + .flatMap(client::create))); + + }); + } + + @Override + public Mono upgrade(String name, Path path) { + return Mono.defer(() -> { + // pre-check the plugin in the path + final var pluginFinder = new YamlPluginFinder(); + final var pluginInPath = pluginFinder.find(path); + satisfiesRequiresVersion(pluginInPath); + if (!Objects.equals(name, pluginInPath.getMetadata().getName())) { + return Mono.error(new ServerWebInputException( + "The provided plugin " + pluginInPath.getMetadata().getName() + + " didn't match the given plugin " + name)); + } + + // check if the plugin exists + return client.fetch(Plugin.class, name) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException( + "The given plugin with name " + name + " was not found."))) + // delete the plugin and wait for the deletion + .then(Mono.defer(() -> deletePluginAndWaitForComplete(name))) + // copy plugin into plugin home + .flatMap(prevPlugin -> copyToPluginHome(pluginInPath) + .map(pluginFinder::find) + // reset enabled spec + .doOnNext(pluginToCreate -> { + var enabled = prevPlugin.getSpec().getEnabled(); + pluginToCreate.getSpec().setEnabled(enabled); + })) + // create the plugin + .flatMap(client::create); + }); + } + + /** + * Copy plugin into plugin home. + * + * @param plugin is a staging plugin. + * @return new path in plugin home. + */ + private Mono copyToPluginHome(Plugin plugin) { + return Mono.fromCallable( + () -> { + var fileName = plugin.generateFileName(); + var pluginRoot = Paths.get(pluginProperties.getPluginsRoot()); + try { + Files.createDirectories(pluginRoot); + } catch (IOException e) { + throw Exceptions.propagate(e); + } + var pluginFilePath = pluginRoot.resolve(fileName); + FileUtils.checkDirectoryTraversal(pluginRoot, pluginFilePath); + // move the plugin jar file to the plugin root + // replace the old plugin jar file if exists + var path = Path.of(plugin.getStatus().getLoadLocation()); + FileUtils.copy(path, pluginFilePath, REPLACE_EXISTING); + return pluginFilePath; + }) + .subscribeOn(Schedulers.boundedElastic()); + } + + private Mono deletePluginAndWaitForComplete(String pluginName) { + return client.fetch(Plugin.class, pluginName) + .flatMap(client::delete) + .flatMap(plugin -> waitForDeleted(pluginName).thenReturn(plugin)); + } + + private Mono waitForDeleted(String pluginName) { + return Mono.defer(() -> client.fetch(Plugin.class, pluginName) + .flatMap(plugin -> Mono.error( + new RetryException("Re-check if the plugin is deleted successfully")))) + .retryWhen(Retry.fixedDelay(20, Duration.ofMillis(100)) + .filter(t -> t instanceof RetryException) + ) + .onErrorMap(Exceptions::isRetryExhausted, + t -> new ServerErrorException("Wait timeout for plugin deleted", t)) + .then(); + } + + + private void satisfiesRequiresVersion(Plugin newPlugin) { + Assert.notNull(newPlugin, "The plugin must not be null."); + Version version = systemVersion.get(); + // validate the plugin version + // only use the nominal system version to compare, the format is like MAJOR.MINOR.PATCH + String systemVersion = version.getNormalVersion(); + String requires = newPlugin.getSpec().getRequires(); + if (!VersionUtils.satisfiesRequires(systemVersion, requires)) { + throw new UnsatisfiedAttributeValueException(String.format( + "Plugin requires a minimum system version of [%s], but the current version is " + + "[%s].", + requires, systemVersion), + "problemDetail.plugin.version.unsatisfied.requires", + new String[] {requires, systemVersion}); + } + } + + private Flux getPresetJars() { + var resolver = new PathMatchingResourcePatternResolver(); + try { + var resources = resolver.getResources(PRESETS_LOCATION_PATTERN); + return Flux.fromArray(resources); + } catch (IOException e) { + return Flux.error(e); + } + } + + private Path toPath(Resource resource) { + try { + return Path.of(resource.getURI()); + } catch (IOException e) { + throw Exceptions.propagate(e); + } + } +} diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java b/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java index 98e9f4fdd..223d66375 100644 --- a/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeEndpoint.java @@ -11,7 +11,6 @@ import static run.halo.app.infra.utils.DataBufferUtils.toInputStream; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.media.Schema; import java.io.IOException; -import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; @@ -414,6 +413,7 @@ public class ThemeEndpoint implements CustomEndpoint { .bodyValue(theme)); } + @Schema(name = "ThemeInstallRequest") public record InstallRequest( @Schema(required = true, description = "Theme zip file.") FilePart file) { } @@ -433,23 +433,6 @@ public class ThemeEndpoint implements CustomEndpoint { .bodyValue(theme)); } - private Path getThemePath(Theme theme) { - return getThemeWorkDir().resolve(theme.getMetadata().getName()); - } - - private Path getThemeWorkDir() { - Path themePath = themeRoot.get(); - if (Files.notExists(themePath)) { - try { - Files.createDirectories(themePath); - } catch (IOException e) { - throw new UnsupportedOperationException( - "Failed to create directory " + themePath, e); - } - } - return themePath; - } - Mono getZipFilePart(MultiValueMap formData) { Part part = formData.getFirst("file"); if (!(part instanceof FilePart file)) { diff --git a/src/main/java/run/halo/app/plugin/YamlPluginFinder.java b/src/main/java/run/halo/app/plugin/YamlPluginFinder.java index 1e462149b..22b124767 100644 --- a/src/main/java/run/halo/app/plugin/YamlPluginFinder.java +++ b/src/main/java/run/halo/app/plugin/YamlPluginFinder.java @@ -65,6 +65,7 @@ public class YamlPluginFinder { if (plugin.getStatus() == null) { Plugin.PluginStatus pluginStatus = new Plugin.PluginStatus(); pluginStatus.setPhase(PluginState.RESOLVED); + pluginStatus.setLoadLocation(pluginPath.toUri()); plugin.setStatus(pluginStatus); } return plugin; diff --git a/src/main/resources/extensions/role-template-plugin.yaml b/src/main/resources/extensions/role-template-plugin.yaml index 6e433d8df..1cb06bc2e 100644 --- a/src/main/resources/extensions/role-template-plugin.yaml +++ b/src/main/resources/extensions/role-template-plugin.yaml @@ -18,6 +18,9 @@ rules: - apiGroups: [ "api.console.halo.run" ] resources: [ "plugins/upgrade", "plugins/resetconfig", "plugins/config" ] verbs: [ "*" ] + - apiGroups: [ "api.console.halo.run" ] + resources: [ "plugin-presets" ] + verbs: [ "list" ] - nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/plugins/*" ] verbs: [ "create" ] --- diff --git a/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java b/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java index 102213a57..c11e2c778 100644 --- a/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java +++ b/src/test/java/run/halo/app/core/extension/endpoint/PluginEndpointTest.java @@ -5,10 +5,9 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isA; import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.lenient; -import static org.mockito.Mockito.never; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction; @@ -37,9 +36,11 @@ import org.springframework.core.io.FileSystemResource; import org.springframework.http.MediaType; import org.springframework.http.client.MultipartBodyBuilder; import org.springframework.test.web.reactive.server.WebTestClient; +import org.springframework.web.server.ServerWebInputException; import reactor.core.publisher.Mono; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.Setting; +import run.halo.app.core.extension.service.PluginService; import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ListResult; import run.halo.app.extension.Metadata; @@ -61,6 +62,9 @@ class PluginEndpointTest { @Mock SystemVersionSupplier systemVersionSupplier; + @Mock + PluginService pluginService; + @InjectMocks PluginEndpoint endpoint; @@ -126,8 +130,8 @@ class PluginEndpointTest { verify(client).list(same(Plugin.class), argThat( predicate -> predicate.test(expectPlugin) - && !predicate.test(unexpectedPlugin1) - && !predicate.test(unexpectedPlugin2)), + && !predicate.test(unexpectedPlugin1) + && !predicate.test(unexpectedPlugin2)), any(), anyInt(), anyInt()); } @@ -154,8 +158,8 @@ class PluginEndpointTest { verify(client).list(same(Plugin.class), argThat( predicate -> predicate.test(expectPlugin) - && !predicate.test(unexpectedPlugin1) - && !predicate.test(unexpectedPlugin2)), + && !predicate.test(unexpectedPlugin1) + && !predicate.test(unexpectedPlugin2)), any(), anyInt(), anyInt()); } @@ -163,10 +167,6 @@ class PluginEndpointTest { void shouldSortPluginsWhenCreationTimestampSet() { var expectPlugin = createPlugin("fake-plugin-2", "expected display name", "", true); - var unexpectedPlugin1 = - createPlugin("fake-plugin-1", "first fake display name", "", false); - var unexpectedPlugin2 = - createPlugin("fake-plugin-3", "second fake display name", "", false); var expectResult = new ListResult<>(List.of(expectPlugin)); when(client.list(same(Plugin.class), any(), any(), anyInt(), anyInt())) .thenReturn(Mono.just(expectResult)); @@ -231,7 +231,8 @@ class PluginEndpointTest { bodyBuilder.part("file", new FileSystemResource(plugin002)) .contentType(MediaType.MULTIPART_FORM_DATA); - when(client.fetch(Plugin.class, "fake-plugin")).thenReturn(Mono.empty()); + when(pluginService.upgrade(eq("fake-plugin"), isA(Path.class))) + .thenReturn(Mono.error(new ServerWebInputException("plugin not found"))); webClient.post().uri("/plugins/fake-plugin/upgrade") .contentType(MediaType.MULTIPART_FORM_DATA) @@ -239,72 +240,7 @@ class PluginEndpointTest { .exchange() .expectStatus().isBadRequest(); - verify(client).fetch(Plugin.class, "fake-plugin"); - verify(client, never()).delete(any(Plugin.class)); - verify(client, never()).create(any(Plugin.class)); - } - - @Test - void shouldWaitTimeoutIfOldPluginCannotBeDeleted() { - var bodyBuilder = new MultipartBodyBuilder(); - bodyBuilder.part("file", new FileSystemResource(plugin002)) - .contentType(MediaType.MULTIPART_FORM_DATA); - - var oldPlugin = createPlugin("fake-plugin"); - when(client.fetch(Plugin.class, "fake-plugin")) - // for first check - .thenReturn(Mono.just(oldPlugin)) - // for deleting check - .thenReturn(Mono.just(oldPlugin)) - // for waiting - .thenReturn(Mono.just(oldPlugin)); - - when(client.delete(oldPlugin)).thenReturn(Mono.just(oldPlugin)); - - webClient.post().uri("/plugins/fake-plugin/upgrade") - .contentType(MediaType.MULTIPART_FORM_DATA) - .body(fromMultipartData(bodyBuilder.build())) - .exchange() - .expectStatus().is5xxServerError(); - - verify(client, times(3)).fetch(Plugin.class, "fake-plugin"); - verify(client).delete(oldPlugin); - verify(client, never()).create(any(Plugin.class)); - } - - @Test - void shouldBeOkIfPluginInstalledBefore() { - var bodyBuilder = new MultipartBodyBuilder(); - bodyBuilder.part("file", new FileSystemResource(plugin002)) - .contentType(MediaType.MULTIPART_FORM_DATA); - - var oldPlugin = createPlugin("fake-plugin"); - when(client.fetch(Plugin.class, "fake-plugin")) - // for first check - .thenReturn(Mono.just(oldPlugin)) - // for deleting check - .thenReturn(Mono.just(oldPlugin)) - // for waiting - .thenReturn(Mono.empty()); - - when(client.delete(oldPlugin)).thenReturn(Mono.just(oldPlugin)); - - Plugin newPlugin = createPlugin("fake-plugin", Instant.now()); - when(client.create( - argThat(plugin -> "0.0.2".equals(plugin.getSpec().getVersion())))) - .thenReturn(Mono.just(newPlugin)); - - webClient.post().uri("/plugins/fake-plugin/upgrade") - .contentType(MediaType.MULTIPART_FORM_DATA) - .body(fromMultipartData(bodyBuilder.build())) - .exchange() - .expectStatus().isOk() - .expectBody(Plugin.class) - .isEqualTo(newPlugin); - - verify(client, times(3)).fetch(Plugin.class, "fake-plugin"); - verify(client).delete(oldPlugin); - verify(client).create(any(Plugin.class)); + verify(pluginService).upgrade(eq("fake-plugin"), isA(Path.class)); } } diff --git a/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java b/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java new file mode 100644 index 000000000..d0e4f786b --- /dev/null +++ b/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java @@ -0,0 +1,207 @@ +package run.halo.app.core.extension.service.impl; + +import static java.util.Objects.requireNonNull; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.isA; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.github.zafarkhaja.semver.Version; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +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 org.pf4j.PluginState; +import org.springframework.web.server.ServerErrorException; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import reactor.test.StepVerifier; +import run.halo.app.core.extension.Plugin; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.SystemVersionSupplier; +import run.halo.app.infra.exception.PluginAlreadyExistsException; +import run.halo.app.infra.utils.FileUtils; +import run.halo.app.plugin.PluginProperties; +import run.halo.app.plugin.YamlPluginFinder; + +@ExtendWith(MockitoExtension.class) +class PluginServiceImplTest { + + @Mock + SystemVersionSupplier systemVersionSupplier; + + @Mock + ReactiveExtensionClient client; + + @Mock + PluginProperties pluginProperties; + + @InjectMocks + PluginServiceImpl pluginService; + + @Test + void getPresetsTest() { + var presets = pluginService.getPresets(); + StepVerifier.create(presets) + .assertNext(plugin -> { + assertEquals("fake-plugin", plugin.getMetadata().getName()); + assertEquals("0.0.2", plugin.getSpec().getVersion()); + assertEquals(PluginState.RESOLVED, plugin.getStatus().getPhase()); + }) + .verifyComplete(); + } + + @Test + void getPresetIfNotFound() { + var plugin = pluginService.getPreset("not-found-plugin"); + StepVerifier.create(plugin) + .verifyComplete(); + } + + @Test + void getPresetIfFound() { + var plugin = pluginService.getPreset("fake-plugin"); + StepVerifier.create(plugin) + .expectNextCount(1) + .verifyComplete(); + } + + @Nested + class InstallOrUpdateTest { + + Path fakePluginPath; + + Path tempDirectory; + + @BeforeEach + void setUp() throws URISyntaxException, IOException { + tempDirectory = Files.createTempDirectory("halo-ut-plugin-service-impl-"); + fakePluginPath = tempDirectory.resolve("plugin-0.0.2.jar"); + var fakePluingUri = requireNonNull( + getClass().getClassLoader().getResource("plugin/plugin-0.0.2")).toURI(); + FileUtils.jar(Paths.get(fakePluingUri), tempDirectory.resolve("plugin-0.0.2.jar")); + + lenient().when(pluginProperties.getPluginsRoot()) + .thenReturn(tempDirectory.resolve("plugins").toString()); + + lenient().when(systemVersionSupplier.get()).thenReturn(Version.valueOf("0.0.0")); + } + + @AfterEach + void cleanUp() { + FileUtils.deleteRecursivelyAndSilently(tempDirectory); + } + + @Test + void installWhenPluginExists() { + var existingPlugin = new YamlPluginFinder().find(fakePluginPath); + when(client.fetch(Plugin.class, "fake-plugin")).thenReturn(Mono.just(existingPlugin)); + var plugin = pluginService.install(fakePluginPath); + StepVerifier.create(plugin) + .expectError(PluginAlreadyExistsException.class) + .verify(); + + verify(client).fetch(Plugin.class, "fake-plugin"); + verify(systemVersionSupplier).get(); + } + + @Test + void installWhenPluginNotExist() { + when(client.fetch(Plugin.class, "fake-plugin")).thenReturn(Mono.empty()); + var createdPlugin = mock(Plugin.class); + when(client.create(isA(Plugin.class))).thenReturn(Mono.just(createdPlugin)); + var plugin = pluginService.install(fakePluginPath); + StepVerifier.create(plugin) + .expectNext(createdPlugin) + .verifyComplete(); + + verify(client).fetch(Plugin.class, "fake-plugin"); + verify(systemVersionSupplier).get(); + verify(pluginProperties).getPluginsRoot(); + verify(client).create(isA(Plugin.class)); + } + + @Test + void upgradeWhenPluginNameMismatch() { + var plugin = pluginService.upgrade("non-fake-plugin", fakePluginPath); + StepVerifier.create(plugin) + .expectError(ServerWebInputException.class) + .verify(); + + verify(client, never()).fetch(Plugin.class, "fake-plugin"); + } + + @Test + void upgradeWhenPluginNotFound() { + when(client.fetch(Plugin.class, "fake-plugin")).thenReturn(Mono.empty()); + var plugin = pluginService.upgrade("fake-plugin", fakePluginPath); + StepVerifier.create(plugin) + .expectError(ServerWebInputException.class) + .verify(); + + verify(client).fetch(Plugin.class, "fake-plugin"); + } + + @Test + void upgradeWhenWaitingTimeoutForPluginDeletion() { + var prevPlugin = mock(Plugin.class); + when(client.fetch(Plugin.class, "fake-plugin")) + .thenReturn(Mono.just(prevPlugin)); + when(client.delete(isA(Plugin.class))).thenReturn(Mono.just(prevPlugin)); + + var plugin = pluginService.upgrade("fake-plugin", fakePluginPath); + StepVerifier.create(plugin) + .consumeErrorWith(error -> { + assertTrue(error instanceof ServerErrorException); + assertEquals("Wait timeout for plugin deleted", + ((ServerErrorException) error).getReason()); + }) + .verify(); + + verify(client, times(23)).fetch(Plugin.class, "fake-plugin"); + verify(client).delete(isA(Plugin.class)); + } + + @Test + void upgradeNormally() { + var prevPlugin = mock(Plugin.class); + var spec = mock(Plugin.PluginSpec.class); + when(prevPlugin.getSpec()).thenReturn(spec); + when(spec.getEnabled()).thenReturn(true); + + when(client.fetch(Plugin.class, "fake-plugin")) + .thenReturn(Mono.just(prevPlugin)) + .thenReturn(Mono.just(prevPlugin)) + .thenReturn(Mono.empty()); + when(client.delete(isA(Plugin.class))).thenReturn(Mono.just(prevPlugin)); + + var createdPlugin = mock(Plugin.class); + when(client.create(isA(Plugin.class))).thenReturn(Mono.just(createdPlugin)); + + var plugin = pluginService.upgrade("fake-plugin", fakePluginPath); + + StepVerifier.create(plugin) + .expectNext(createdPlugin) + .verifyComplete(); + + verify(client, times(3)).fetch(Plugin.class, "fake-plugin"); + verify(client).delete(isA(Plugin.class)); + verify(client).create(argThat(p -> p.getSpec().getEnabled())); + } + } +} \ No newline at end of file diff --git a/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java b/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java index be8159566..e47eabe54 100644 --- a/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java +++ b/src/test/java/run/halo/app/plugin/YamlPluginDescriptorFinderTest.java @@ -58,20 +58,24 @@ class YamlPluginDescriptorFinderTest { // jar file is applicable Path tempJarFile = Files.createTempFile("test", ".jar"); - applicable = - yamlPluginDescriptorFinder.isApplicable(tempJarFile); - assertThat(applicable).isTrue(); - - // zip file is not applicable Path tempZipFile = Files.createTempFile("test", ".zip"); - applicable = - yamlPluginDescriptorFinder.isApplicable(tempZipFile); - assertThat(applicable).isFalse(); + try { + applicable = + yamlPluginDescriptorFinder.isApplicable(tempJarFile); + assertThat(applicable).isTrue(); + // zip file is not applicable + applicable = + yamlPluginDescriptorFinder.isApplicable(tempZipFile); + assertThat(applicable).isFalse(); - // directory is applicable - applicable = - yamlPluginDescriptorFinder.isApplicable(tempJarFile.getParent()); - assertThat(applicable).isTrue(); + // directory is applicable + applicable = + yamlPluginDescriptorFinder.isApplicable(tempJarFile.getParent()); + assertThat(applicable).isTrue(); + } finally { + FileUtils.deleteRecursivelyAndSilently(tempJarFile); + FileUtils.deleteRecursivelyAndSilently(tempZipFile); + } } @Test diff --git a/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java b/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java index 111e49663..d01208590 100644 --- a/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java +++ b/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java @@ -3,6 +3,7 @@ package run.halo.app.plugin; import static java.util.Objects.requireNonNull; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.junit.jupiter.api.Assertions.assertEquals; import com.fasterxml.jackson.core.JsonProcessingException; import java.io.File; @@ -16,6 +17,7 @@ import org.json.JSONException; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.pf4j.PluginRuntimeException; +import org.pf4j.PluginState; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -47,20 +49,20 @@ class YamlPluginFinderTest { @Test void find() throws IOException, JSONException { - Path tempDirectory = Files.createTempDirectory("halo-test-plugin"); + var tempDirectory = Files.createTempDirectory("halo-test-plugin"); + try { + var directories = + Files.createDirectories(tempDirectory.resolve("build/resources/main")); + FileCopyUtils.copy(testFile, directories.resolve("plugin.yaml").toFile()); - Path directories = Files.createDirectories(tempDirectory.resolve("build/resources/main")); - FileCopyUtils.copy(testFile, directories.resolve("plugin.yaml").toFile()); - - Plugin plugin = pluginFinder.find(tempDirectory); - assertThat(plugin).isNotNull(); - JSONAssert.assertEquals(""" - { - "phase": "RESOLVED" - } - """, - JsonUtils.objectToJson(plugin.getStatus()), - true); + var plugin = pluginFinder.find(tempDirectory); + assertThat(plugin).isNotNull(); + var status = plugin.getStatus(); + assertEquals(PluginState.RESOLVED, status.getPhase()); + assertEquals(tempDirectory.toUri(), status.getLoadLocation()); + } finally { + FileUtils.deleteRecursivelyAndSilently(tempDirectory); + } } @Test diff --git a/src/test/resources/presets/plugins/fake-plugin.jar b/src/test/resources/presets/plugins/fake-plugin.jar new file mode 100644 index 0000000000000000000000000000000000000000..0acfb0723d44fd8f6599ce9d7d1293a9b86dd566 GIT binary patch literal 698 zcmWIWW@Zs#;Nak3SkMp|#()Gk8CV#6T|*poJ^kGD|D9rBU}gyLX6FE@V1gk+5d)sq@Bg@7OYpTV+ne0YBHYBfaP|$~Y@y;@p+{SU{?A*o zQ#j}1_qaFTdm8U+a#b%+v^7aDTikYei$vL3A5M3l719jt#+g5k7;jy;N+?6)6z`ks zR*C#N4I3UEQRX=NLWsXW&@m__{Xtkq*NlyyF5X#bleAauMdr*A)JXf^rfliuqI!CZ@WpL_HqG$y7!J5OM9-d`U^-oKSR>bq-J z96UdJuc%p2{MMxE6ZhSw89Y3Hdt3hZIs6R%qK=8G*FB6}_%c@q=>88V^s$*Byz1g@ z?;q1IzplFfhY1v6bM<0(e+7m`BO@rZ7@0&EP~#gGnV|Sa1@H(2#Xh=L32Y%FfafBO32Zw literal 0 HcmV?d00001