mirror of https://github.com/halo-dev/halo
Provide an endpoint to get plugin presets (#3394)
#### 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 <https://github.com/halo-sigs/awesome-halo>. 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 提供预设插件功能 ```pull/3390/head^2
parent
039d3f508d
commit
848857fbfd
|
@ -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/
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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<ServerResponse> 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<ServerResponse> listPresets(ServerRequest request) {
|
||||
return ServerResponse.ok().body(pluginService.getPresets(), Plugin.class);
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> 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<ServerResponse> upgrade(ServerRequest request) {
|
||||
var pluginNameInPath = request.pathVariable("name");
|
||||
var tempDirRef = new AtomicReference<Path>();
|
||||
var tempPluginPathRef = new AtomicReference<Path>();
|
||||
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<ServerResponse> 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<ServerResponse> 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<Plugin> deletePluginAndWaitForComplete(String pluginName) {
|
||||
return client.fetch(Plugin.class, pluginName)
|
||||
.flatMap(client::delete)
|
||||
.flatMap(plugin -> waitForDeleted(plugin.getMetadata().getName()).thenReturn(plugin));
|
||||
private Mono<Plugin> installFromFile(Mono<FilePart> filePartMono,
|
||||
Function<Path, Mono<Plugin>> resourceClosure) {
|
||||
var pathMono = filePartMono.flatMap(this::transferToTemp);
|
||||
return Mono.usingWhen(pathMono, resourceClosure, this::deleteFileIfExists);
|
||||
}
|
||||
|
||||
private Mono<Void> 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<Path> createTempDirectory() {
|
||||
return Mono.fromCallable(() -> Files.createTempDirectory("halo-plugin-"))
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
private Mono<Plugin> installFromPreset(Mono<String> presetNameMono,
|
||||
Function<Path, Mono<Plugin>> 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<String, Part> multipartData;
|
||||
|
||||
public InstallRequest(MultiValueMap<String, Part> multipartData) {
|
||||
this.multipartData = multipartData;
|
||||
}
|
||||
|
||||
@Schema(requiredMode = NOT_REQUIRED, description = "Plugin Jar file.")
|
||||
public Mono<FilePart> 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<String> 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<InstallSource> 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<ServerResponse> 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<Void> 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<Path> 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<FilePart> getJarFilePart(MultiValueMap<String, Part> 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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<Plugin> getPresets();
|
||||
|
||||
/**
|
||||
* Gets a plugin information by preset name from plugin presets.
|
||||
*
|
||||
* @param presetName is preset name of plugin.
|
||||
* @return plugin preset information.
|
||||
*/
|
||||
Mono<Plugin> 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<Plugin> install(Path path);
|
||||
|
||||
Mono<Plugin> upgrade(String name, Path path);
|
||||
|
||||
}
|
|
@ -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<Plugin> getPresets() {
|
||||
// list presets from classpath
|
||||
return Flux.defer(() -> getPresetJars()
|
||||
.map(this::toPath)
|
||||
.map(path -> new YamlPluginFinder().find(path)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Plugin> getPreset(String presetName) {
|
||||
return getPresets()
|
||||
.filter(plugin -> Objects.equals(plugin.getMetadata().getName(), presetName))
|
||||
.next();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Plugin> 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.<Plugin>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<Plugin> 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<Path> 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<Plugin> deletePluginAndWaitForComplete(String pluginName) {
|
||||
return client.fetch(Plugin.class, pluginName)
|
||||
.flatMap(client::delete)
|
||||
.flatMap(plugin -> waitForDeleted(pluginName).thenReturn(plugin));
|
||||
}
|
||||
|
||||
private Mono<Void> 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<Resource> 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);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<FilePart> getZipFilePart(MultiValueMap<String, Part> formData) {
|
||||
Part part = formData.getFirst("file");
|
||||
if (!(part instanceof FilePart file)) {
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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" ]
|
||||
---
|
||||
|
|
|
@ -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.<Plugin>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));
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -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).<Plugin>create(argThat(p -> p.getSpec().getEnabled()));
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
Binary file not shown.
Loading…
Reference in New Issue