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
John Niang 2023-02-28 18:26:18 +08:00 committed by GitHub
parent 039d3f508d
commit 848857fbfd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 635 additions and 284 deletions

1
.gitignore vendored
View File

@ -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/

View File

@ -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) {

View File

@ -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);
}
}

View 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);
}

View File

@ -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);
}
}
}

View File

@ -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)) {

View 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;

View File

@ -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" ]
---

View File

@ -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));
}
}

View File

@ -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()));
}
}
}

View File

@ -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

View File

@ -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.