mirror of https://github.com/halo-dev/halo
feat: support validate requires version for plugin (#3114)
#### What type of PR is this? /kind feature /area core /milestone 2.2.x #### What this PR does / why we need it: 插件安装和升级支持版本校验 BTW: 此 PR 中 PluginReconciler 有一些异常提示是没有加 i18n 的,主要是考虑 Reconciler 与请求不挂钩,无法获取到 request 上下文的 Locale,如果用 Locale.getDefault() 那么后续用户切换语言时也更改不到已经持久化到数据库中的错误信息,可能得靠客户端翻译异常。 参考文档: - [semver-expressions-api-ranges](https://github.com/zafarkhaja/jsemver#semver-expressions-api-ranges) - [integrating-with-actuator.build-info](https://docs.spring.io/spring-boot/docs/current/gradle-plugin/reference/htmlsingle/#integrating-with-actuator.build-info) - [BuildInfoContributor](https://github.com/spring-projects/spring-boot/blob/v3.0.1/spring-boot-project/spring-boot-actuator/src/main/java/org/springframework/boot/actuate/info/BuildInfoContributor.java) #### Which issue(s) this PR fixes: Fixes #3089 #### Special notes for your reviewer: how to test it? - 开发模式下不会校验插件填写的 requires,但通过接口安装和升级都会统一校验。 - 在 deployment 模式下安装插件和升级插件会根据 halo 的版本校验插件的 spec.requires 是否符合要求,参考 [semver-expressions-api-ranges](https://github.com/zafarkhaja/jsemver#semver-expressions-api-ranges)。 - 如果 spec.requires 为 `*` 则表示允许所有,如果填写为具体的版本号,例如 requires: "2.2.0" 则隐式表示为 `>=2.2.0`。 可以测试这几种情况是否符合期望。 /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note 插件安装和升级支持版本校验 ```pull/3148/head^2
parent
2a70d59350
commit
5c29ab5750
|
@ -27,6 +27,10 @@ configurations {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
springBoot {
|
||||||
|
buildInfo()
|
||||||
|
}
|
||||||
|
|
||||||
bootJar {
|
bootJar {
|
||||||
manifest {
|
manifest {
|
||||||
attributes "Implementation-Title": "Halo Application",
|
attributes "Implementation-Title": "Halo Application",
|
||||||
|
|
|
@ -14,6 +14,7 @@ import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersF
|
||||||
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
|
import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate;
|
||||||
import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently;
|
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.enums.ParameterIn;
|
||||||
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
import io.swagger.v3.oas.annotations.media.ArraySchema;
|
||||||
import io.swagger.v3.oas.annotations.media.Schema;
|
import io.swagger.v3.oas.annotations.media.Schema;
|
||||||
|
@ -29,6 +30,7 @@ import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.function.Predicate;
|
import java.util.function.Predicate;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
|
||||||
import org.springframework.dao.OptimisticLockingFailureException;
|
import org.springframework.dao.OptimisticLockingFailureException;
|
||||||
|
@ -38,6 +40,7 @@ import org.springframework.http.codec.multipart.FilePart;
|
||||||
import org.springframework.http.codec.multipart.Part;
|
import org.springframework.http.codec.multipart.Part;
|
||||||
import org.springframework.retry.RetryException;
|
import org.springframework.retry.RetryException;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
import org.springframework.util.MultiValueMap;
|
import org.springframework.util.MultiValueMap;
|
||||||
import org.springframework.util.StringUtils;
|
import org.springframework.util.StringUtils;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
|
@ -57,22 +60,23 @@ import run.halo.app.extension.Comparators;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.router.IListRequest.QueryListRequest;
|
import run.halo.app.extension.router.IListRequest.QueryListRequest;
|
||||||
|
import run.halo.app.infra.SystemVersionSupplier;
|
||||||
|
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
|
||||||
import run.halo.app.infra.utils.FileUtils;
|
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.PluginProperties;
|
||||||
import run.halo.app.plugin.YamlPluginFinder;
|
import run.halo.app.plugin.YamlPluginFinder;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
|
@AllArgsConstructor
|
||||||
public class PluginEndpoint implements CustomEndpoint {
|
public class PluginEndpoint implements CustomEndpoint {
|
||||||
|
|
||||||
private final PluginProperties pluginProperties;
|
private final PluginProperties pluginProperties;
|
||||||
|
|
||||||
private final ReactiveExtensionClient client;
|
private final ReactiveExtensionClient client;
|
||||||
|
|
||||||
public PluginEndpoint(PluginProperties pluginProperties, ReactiveExtensionClient client) {
|
private final SystemVersionSupplier systemVersionSupplier;
|
||||||
this.pluginProperties = pluginProperties;
|
|
||||||
this.client = client;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public RouterFunction<ServerResponse> endpoint() {
|
public RouterFunction<ServerResponse> endpoint() {
|
||||||
|
@ -172,6 +176,8 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
throw new ServerWebInputException(
|
throw new ServerWebInputException(
|
||||||
"The uploaded plugin doesn't match the given plugin name");
|
"The uploaded plugin doesn't match the given plugin name");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
satisfiesRequiresVersion(newPlugin);
|
||||||
})
|
})
|
||||||
.flatMap(newPlugin -> deletePluginAndWaitForComplete(newPlugin.getMetadata().getName())
|
.flatMap(newPlugin -> deletePluginAndWaitForComplete(newPlugin.getMetadata().getName())
|
||||||
.map(oldPlugin -> {
|
.map(oldPlugin -> {
|
||||||
|
@ -187,7 +193,6 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
var pluginRoot = Paths.get(pluginProperties.getPluginsRoot());
|
var pluginRoot = Paths.get(pluginProperties.getPluginsRoot());
|
||||||
createDirectoriesIfNotExists(pluginRoot);
|
createDirectoriesIfNotExists(pluginRoot);
|
||||||
var tempPluginPath = tempPluginPathRef.get();
|
var tempPluginPath = tempPluginPathRef.get();
|
||||||
var filename = tempPluginPath.getFileName().toString();
|
|
||||||
copy(tempPluginPath, pluginRoot.resolve(newPlugin.generateFileName()));
|
copy(tempPluginPath, pluginRoot.resolve(newPlugin.generateFileName()));
|
||||||
} catch (IOException e) {
|
} catch (IOException e) {
|
||||||
throw Exceptions.propagate(e);
|
throw Exceptions.propagate(e);
|
||||||
|
@ -198,6 +203,23 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
.doFinally(signalType -> deleteRecursivelyAndSilently(tempDirRef.get()));
|
.doFinally(signalType -> deleteRecursivelyAndSilently(tempDirRef.get()));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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<Plugin> deletePluginAndWaitForComplete(String pluginName) {
|
private Mono<Plugin> deletePluginAndWaitForComplete(String pluginName) {
|
||||||
return client.fetch(Plugin.class, pluginName)
|
return client.fetch(Plugin.class, pluginName)
|
||||||
.flatMap(client::delete)
|
.flatMap(client::delete)
|
||||||
|
@ -332,6 +354,8 @@ public class PluginEndpoint implements CustomEndpoint {
|
||||||
.flatMap(this::transferToTemp)
|
.flatMap(this::transferToTemp)
|
||||||
.flatMap(tempJarFilePath -> {
|
.flatMap(tempJarFilePath -> {
|
||||||
var plugin = new YamlPluginFinder().find(tempJarFilePath);
|
var plugin = new YamlPluginFinder().find(tempJarFilePath);
|
||||||
|
// validate the plugin version
|
||||||
|
satisfiesRequiresVersion(plugin);
|
||||||
// Disable auto enable during installation
|
// Disable auto enable during installation
|
||||||
plugin.getSpec().setEnabled(false);
|
plugin.getSpec().setEnabled(false);
|
||||||
return client.fetch(Plugin.class, plugin.getMetadata().getName())
|
return client.fetch(Plugin.class, plugin.getMetadata().getName())
|
||||||
|
|
|
@ -11,6 +11,7 @@ import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.pf4j.PluginRuntimeException;
|
import org.pf4j.PluginRuntimeException;
|
||||||
|
@ -41,17 +42,12 @@ import run.halo.app.plugin.resources.BundleResourceUtils;
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
|
@AllArgsConstructor
|
||||||
public class PluginReconciler implements Reconciler<Request> {
|
public class PluginReconciler implements Reconciler<Request> {
|
||||||
private static final String FINALIZER_NAME = "plugin-protection";
|
private static final String FINALIZER_NAME = "plugin-protection";
|
||||||
private final ExtensionClient client;
|
private final ExtensionClient client;
|
||||||
private final HaloPluginManager haloPluginManager;
|
private final HaloPluginManager haloPluginManager;
|
||||||
|
|
||||||
public PluginReconciler(ExtensionClient client,
|
|
||||||
HaloPluginManager haloPluginManager) {
|
|
||||||
this.client = client;
|
|
||||||
this.haloPluginManager = haloPluginManager;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result reconcile(Request request) {
|
public Result reconcile(Request request) {
|
||||||
client.fetch(Plugin.class, request.name())
|
client.fetch(Plugin.class, request.name())
|
||||||
|
@ -138,6 +134,12 @@ public class PluginReconciler implements Reconciler<Request> {
|
||||||
private void startPlugin(String pluginName) {
|
private void startPlugin(String pluginName) {
|
||||||
client.fetch(Plugin.class, pluginName).ifPresent(plugin -> {
|
client.fetch(Plugin.class, pluginName).ifPresent(plugin -> {
|
||||||
final Plugin oldPlugin = JsonUtils.deepCopy(plugin);
|
final Plugin oldPlugin = JsonUtils.deepCopy(plugin);
|
||||||
|
|
||||||
|
// verify plugin meets the preconditions for startup
|
||||||
|
if (!verifyStartCondition(pluginName)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (shouldReconcileStartState(plugin)) {
|
if (shouldReconcileStartState(plugin)) {
|
||||||
PluginState currentState = haloPluginManager.startPlugin(pluginName);
|
PluginState currentState = haloPluginManager.startPlugin(pluginName);
|
||||||
handleStatus(plugin, currentState, PluginState.STARTED);
|
handleStatus(plugin, currentState, PluginState.STARTED);
|
||||||
|
@ -159,6 +161,47 @@ public class PluginReconciler implements Reconciler<Request> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private boolean verifyStartCondition(String pluginName) {
|
||||||
|
PluginWrapper pluginWrapper = haloPluginManager.getPlugin(pluginName);
|
||||||
|
return client.fetch(Plugin.class, pluginName).map(plugin -> {
|
||||||
|
Plugin.PluginStatus oldStatus = JsonUtils.deepCopy(plugin.statusNonNull());
|
||||||
|
|
||||||
|
Plugin.PluginStatus status = plugin.statusNonNull();
|
||||||
|
status.setLastTransitionTime(Instant.now());
|
||||||
|
if (pluginWrapper == null) {
|
||||||
|
status.setPhase(PluginState.FAILED);
|
||||||
|
status.setReason("PluginNotFound");
|
||||||
|
status.setMessage("Plugin [" + pluginName + "] not found in plugin manager");
|
||||||
|
if (!oldStatus.equals(status)) {
|
||||||
|
client.update(plugin);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this plugin version is match requires param.
|
||||||
|
if (!haloPluginManager.validatePluginVersion(pluginWrapper)) {
|
||||||
|
status.setPhase(PluginState.FAILED);
|
||||||
|
status.setReason("PluginVersionNotMatch");
|
||||||
|
String message = String.format(
|
||||||
|
"Plugin requires a minimum system version of [%s], and you have [%s].",
|
||||||
|
plugin.getSpec().getRequires(), haloPluginManager.getSystemVersion());
|
||||||
|
status.setMessage(message);
|
||||||
|
if (!oldStatus.equals(status)) {
|
||||||
|
client.update(plugin);
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginState pluginState = pluginWrapper.getPluginState();
|
||||||
|
if (PluginState.DISABLED.equals(pluginState)) {
|
||||||
|
status.setPhase(pluginState);
|
||||||
|
status.setReason("PluginDisabled");
|
||||||
|
status.setMessage("The plugin is disabled for some reason and cannot be started.");
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}).orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
private boolean shouldReconcileStopState(Plugin plugin) {
|
private boolean shouldReconcileStopState(Plugin plugin) {
|
||||||
return !plugin.getSpec().getEnabled()
|
return !plugin.getSpec().getEnabled()
|
||||||
&& plugin.statusNonNull().getPhase() == PluginState.STARTED;
|
&& plugin.statusNonNull().getPhase() == PluginState.STARTED;
|
||||||
|
@ -187,11 +230,15 @@ public class PluginReconciler implements Reconciler<Request> {
|
||||||
if (desiredState.equals(currentState)) {
|
if (desiredState.equals(currentState)) {
|
||||||
plugin.getSpec().setEnabled(PluginState.STARTED.equals(currentState));
|
plugin.getSpec().setEnabled(PluginState.STARTED.equals(currentState));
|
||||||
} else {
|
} else {
|
||||||
|
String pluginName = plugin.getMetadata().getName();
|
||||||
PluginStartingError startingError =
|
PluginStartingError startingError =
|
||||||
haloPluginManager.getPluginStartingError(plugin.getMetadata().getName());
|
haloPluginManager.getPluginStartingError(plugin.getMetadata().getName());
|
||||||
|
if (startingError == null) {
|
||||||
|
startingError = PluginStartingError.of(pluginName, "Unknown error", "");
|
||||||
|
}
|
||||||
status.setReason(startingError.getMessage());
|
status.setReason(startingError.getMessage());
|
||||||
status.setMessage(startingError.getDevMessage());
|
status.setMessage(startingError.getDevMessage());
|
||||||
// requeue the plugin for reconciliation
|
client.fetch(Plugin.class, pluginName).ifPresent(client::update);
|
||||||
throw new PluginRuntimeException(startingError.getMessage());
|
throw new PluginRuntimeException(startingError.getMessage());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,37 @@
|
||||||
|
package run.halo.app.infra;
|
||||||
|
|
||||||
|
import com.github.zafarkhaja.semver.Version;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default implementation of system version supplier.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class DefaultSystemVersionSupplier implements SystemVersionSupplier {
|
||||||
|
private static final String DEFAULT_VERSION = "0.0.0";
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private BuildProperties buildProperties;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
public void setBuildProperties(@Nullable BuildProperties buildProperties) {
|
||||||
|
this.buildProperties = buildProperties;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Version get() {
|
||||||
|
if (buildProperties == null) {
|
||||||
|
return Version.valueOf(DEFAULT_VERSION);
|
||||||
|
}
|
||||||
|
String projectVersion =
|
||||||
|
StringUtils.defaultString(buildProperties.getVersion(), DEFAULT_VERSION);
|
||||||
|
return Version.valueOf(projectVersion);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,15 @@
|
||||||
|
package run.halo.app.infra;
|
||||||
|
|
||||||
|
import com.github.zafarkhaja.semver.Version;
|
||||||
|
import java.util.function.Supplier;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The supplier to gets the project version.
|
||||||
|
* If it cannot be obtained, return 0.0.0.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @see <a href="https://semver.org">Semantic Versioning 2.0.0</a>
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
public interface SystemVersionSupplier extends Supplier<Version> {
|
||||||
|
}
|
|
@ -0,0 +1,20 @@
|
||||||
|
package run.halo.app.infra.exception;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Null;
|
||||||
|
import org.springframework.lang.Nullable;
|
||||||
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* {@link ServerWebInputException} subclass that indicates an unsatisfied
|
||||||
|
* attribute value in request parameters.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
public class UnsatisfiedAttributeValueException extends ServerWebInputException {
|
||||||
|
|
||||||
|
public UnsatisfiedAttributeValueException(String reason, @Nullable String messageDetailCode,
|
||||||
|
@Null Object[] messageDetailArguments) {
|
||||||
|
super(reason, null, null, messageDetailCode, messageDetailArguments);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,49 @@
|
||||||
|
package run.halo.app.infra.utils;
|
||||||
|
|
||||||
|
import com.github.zafarkhaja.semver.Version;
|
||||||
|
import com.github.zafarkhaja.semver.expr.Expression;
|
||||||
|
import lombok.experimental.UtilityClass;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
|
|
||||||
|
@UtilityClass
|
||||||
|
public class VersionUtils {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if this "requires" param satisfies for a given (system) version.
|
||||||
|
*
|
||||||
|
* @param version the version to check
|
||||||
|
* @return true if version satisfies the "requires" or if requires was left blank
|
||||||
|
*/
|
||||||
|
public static boolean satisfiesRequires(String version, String requires) {
|
||||||
|
String requiresVersion = StringUtils.trim(requires);
|
||||||
|
|
||||||
|
// an exact version x.y.z will implicitly mean the same as >=x.y.z
|
||||||
|
if (requiresVersion.matches("^\\d+\\.\\d+\\.\\d+$")) {
|
||||||
|
// If exact versions are not allowed in requires, rewrite to >= expression
|
||||||
|
requiresVersion = ">=" + requiresVersion;
|
||||||
|
}
|
||||||
|
return version.equals("0.0.0") || checkVersionConstraint(version, requiresVersion);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a version satisfies the specified SemVer {@link Expression} string.
|
||||||
|
* If the constraint is empty or null then the method returns true.
|
||||||
|
* Constraint examples: {@code >2.0.0} (simple), {@code ">=1.4.0 & <1.6.0"} (range).
|
||||||
|
* See
|
||||||
|
* <a href="https://github.com/zafarkhaja/jsemver#semver-expressions-api-ranges">semver-expressions-api-ranges</a> for more info.
|
||||||
|
*
|
||||||
|
* @param version the version to check
|
||||||
|
* @param constraint the SemVer Expression string
|
||||||
|
* @return true if version satisfies the constraint or if constraint was left blank
|
||||||
|
*/
|
||||||
|
public static boolean checkVersionConstraint(String version, String constraint) {
|
||||||
|
try {
|
||||||
|
return StringUtils.isBlank(constraint)
|
||||||
|
|| "*".equals(constraint)
|
||||||
|
|| Version.valueOf(version).satisfies(constraint);
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new ServerWebInputException("Illegal requires version expression.", null, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,6 +25,7 @@ import org.springframework.beans.factory.InitializingBean;
|
||||||
import org.springframework.context.ApplicationContext;
|
import org.springframework.context.ApplicationContext;
|
||||||
import org.springframework.context.ApplicationContextAware;
|
import org.springframework.context.ApplicationContextAware;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
import run.halo.app.plugin.event.HaloPluginBeforeStopEvent;
|
import run.halo.app.plugin.event.HaloPluginBeforeStopEvent;
|
||||||
import run.halo.app.plugin.event.HaloPluginLoadedEvent;
|
import run.halo.app.plugin.event.HaloPluginLoadedEvent;
|
||||||
import run.halo.app.plugin.event.HaloPluginStartedEvent;
|
import run.halo.app.plugin.event.HaloPluginStartedEvent;
|
||||||
|
@ -216,6 +217,11 @@ public class HaloPluginManager extends DefaultPluginManager
|
||||||
doStopPlugins();
|
doStopPlugins();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public boolean validatePluginVersion(PluginWrapper pluginWrapper) {
|
||||||
|
Assert.notNull(pluginWrapper, "The pluginWrapper must not be null.");
|
||||||
|
return isPluginValid(pluginWrapper);
|
||||||
|
}
|
||||||
|
|
||||||
private PluginState doStartPlugin(String pluginId) {
|
private PluginState doStartPlugin(String pluginId) {
|
||||||
checkPluginId(pluginId);
|
checkPluginId(pluginId);
|
||||||
|
|
||||||
|
|
|
@ -8,6 +8,7 @@ import java.lang.reflect.Constructor;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.pf4j.ClassLoadingStrategy;
|
import org.pf4j.ClassLoadingStrategy;
|
||||||
import org.pf4j.CompoundPluginLoader;
|
import org.pf4j.CompoundPluginLoader;
|
||||||
import org.pf4j.CompoundPluginRepository;
|
import org.pf4j.CompoundPluginRepository;
|
||||||
|
@ -27,13 +28,13 @@ import org.springframework.boot.autoconfigure.web.WebProperties;
|
||||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
|
import org.springframework.web.reactive.accept.RequestedContentTypeResolver;
|
||||||
import org.springframework.web.reactive.function.BodyInserters;
|
import org.springframework.web.reactive.function.BodyInserters;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunction;
|
import org.springframework.web.reactive.function.server.RouterFunction;
|
||||||
import org.springframework.web.reactive.function.server.RouterFunctions;
|
import org.springframework.web.reactive.function.server.RouterFunctions;
|
||||||
import org.springframework.web.reactive.function.server.ServerResponse;
|
import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
|
import run.halo.app.infra.SystemVersionSupplier;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Plugin autoconfiguration for Spring Boot.
|
* Plugin autoconfiguration for Spring Boot.
|
||||||
|
@ -48,12 +49,16 @@ public class PluginAutoConfiguration {
|
||||||
|
|
||||||
private final PluginProperties pluginProperties;
|
private final PluginProperties pluginProperties;
|
||||||
|
|
||||||
|
private final SystemVersionSupplier systemVersionSupplier;
|
||||||
|
|
||||||
@Qualifier("webFluxContentTypeResolver")
|
@Qualifier("webFluxContentTypeResolver")
|
||||||
private final RequestedContentTypeResolver requestedContentTypeResolver;
|
private final RequestedContentTypeResolver requestedContentTypeResolver;
|
||||||
|
|
||||||
public PluginAutoConfiguration(PluginProperties pluginProperties,
|
public PluginAutoConfiguration(PluginProperties pluginProperties,
|
||||||
|
SystemVersionSupplier systemVersionSupplier,
|
||||||
RequestedContentTypeResolver requestedContentTypeResolver) {
|
RequestedContentTypeResolver requestedContentTypeResolver) {
|
||||||
this.pluginProperties = pluginProperties;
|
this.pluginProperties = pluginProperties;
|
||||||
|
this.systemVersionSupplier = systemVersionSupplier;
|
||||||
this.requestedContentTypeResolver = requestedContentTypeResolver;
|
this.requestedContentTypeResolver = requestedContentTypeResolver;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -77,13 +82,12 @@ public class PluginAutoConfiguration {
|
||||||
|
|
||||||
// Setup Plugin folder
|
// Setup Plugin folder
|
||||||
String pluginsRoot =
|
String pluginsRoot =
|
||||||
StringUtils.hasText(pluginProperties.getPluginsRoot())
|
StringUtils.defaultString(pluginProperties.getPluginsRoot(), "plugins");
|
||||||
? pluginProperties.getPluginsRoot()
|
|
||||||
: "plugins";
|
|
||||||
System.setProperty("pf4j.pluginsDir", pluginsRoot);
|
System.setProperty("pf4j.pluginsDir", pluginsRoot);
|
||||||
String appHome = System.getProperty("app.home");
|
String appHome = System.getProperty("app.home");
|
||||||
if (RuntimeMode.DEPLOYMENT == pluginProperties.getRuntimeMode()
|
if (RuntimeMode.DEPLOYMENT == pluginProperties.getRuntimeMode()
|
||||||
&& StringUtils.hasText(appHome)) {
|
&& StringUtils.isNotBlank(appHome)) {
|
||||||
System.setProperty("pf4j.pluginsDir", appHome + File.separator + pluginsRoot);
|
System.setProperty("pf4j.pluginsDir", appHome + File.separator + pluginsRoot);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -170,10 +174,17 @@ public class PluginAutoConfiguration {
|
||||||
};
|
};
|
||||||
|
|
||||||
pluginManager.setExactVersionAllowed(pluginProperties.isExactVersionAllowed());
|
pluginManager.setExactVersionAllowed(pluginProperties.isExactVersionAllowed());
|
||||||
pluginManager.setSystemVersion(pluginProperties.getSystemVersion());
|
// only for development mode
|
||||||
|
if (RuntimeMode.DEPLOYMENT.equals(pluginManager.getRuntimeMode())) {
|
||||||
|
pluginManager.setSystemVersion(getSystemVersion());
|
||||||
|
}
|
||||||
return pluginManager;
|
return pluginManager;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String getSystemVersion() {
|
||||||
|
return systemVersionSupplier.get().getNormalVersion();
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public RouterFunction<ServerResponse> pluginJsBundleRoute(HaloPluginManager haloPluginManager,
|
public RouterFunction<ServerResponse> pluginJsBundleRoute(HaloPluginManager haloPluginManager,
|
||||||
WebProperties webProperties) {
|
WebProperties webProperties) {
|
||||||
|
|
|
@ -71,9 +71,4 @@ public class PluginProperties {
|
||||||
* Allows providing custom plugin loaders.
|
* Allows providing custom plugin loaders.
|
||||||
*/
|
*/
|
||||||
private Class<PluginLoader> customPluginLoader;
|
private Class<PluginLoader> customPluginLoader;
|
||||||
|
|
||||||
/**
|
|
||||||
* The system version used for comparisons to the plugin requires attribute.
|
|
||||||
*/
|
|
||||||
private String systemVersion = "0.0.0";
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
# Title definitions
|
# Title definitions
|
||||||
problemDetail.title.org.springframework.web.server.ServerWebInputException=Bad Request
|
problemDetail.title.org.springframework.web.server.ServerWebInputException=Bad Request
|
||||||
|
problemDetail.title.run.halo.app.infra.exception.UnsatisfiedAttributeValueException=Unsatisfied Request Attribute value
|
||||||
problemDetail.title.org.springframework.web.server.UnsupportedMediaTypeStatusException=Unsupported Media Type
|
problemDetail.title.org.springframework.web.server.UnsupportedMediaTypeStatusException=Unsupported Media Type
|
||||||
problemDetail.title.org.springframework.web.server.MissingRequestValueException=Missing Request Value
|
problemDetail.title.org.springframework.web.server.MissingRequestValueException=Missing Request Value
|
||||||
problemDetail.title.org.springframework.web.server.UnsatisfiedRequestParameterException=Unsatisfied Request Parameter
|
problemDetail.title.org.springframework.web.server.UnsatisfiedRequestParameterException=Unsatisfied Request Parameter
|
||||||
|
@ -34,3 +35,5 @@ problemDetail.theme.upgrade.nameMismatch=The current theme name {0} did not matc
|
||||||
problemDetail.theme.install.missingManifest=Missing theme manifest file "theme.yaml" or "theme.yml".
|
problemDetail.theme.install.missingManifest=Missing theme manifest file "theme.yaml" or "theme.yml".
|
||||||
problemDetail.theme.install.alreadyExists=Theme {0} already exists.
|
problemDetail.theme.install.alreadyExists=Theme {0} already exists.
|
||||||
problemDetail.directoryTraversal=Directory traversal detected. Base path is {0}, but real path is {1}.
|
problemDetail.directoryTraversal=Directory traversal detected. Base path is {0}, but real path is {1}.
|
||||||
|
|
||||||
|
problemDetail.plugin.version.unsatisfied.requires=Plugin requires a minimum system version of {0}, but the current version is {1}.
|
||||||
|
|
|
@ -1,4 +1,7 @@
|
||||||
problemDetail.title.org.springframework.web.server.ServerWebInputException=请求参数有误
|
problemDetail.title.org.springframework.web.server.ServerWebInputException=请求参数有误
|
||||||
|
problemDetail.title.run.halo.app.infra.exception.UnsatisfiedAttributeValueException=请求参数属性值不满足要求
|
||||||
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=附件已存在
|
problemDetail.title.run.halo.app.infra.exception.AttachmentAlreadyExistsException=附件已存在
|
||||||
|
|
||||||
problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=文件 {0} 已存在,建议更名后重试。
|
problemDetail.run.halo.app.infra.exception.AttachmentAlreadyExistsException=文件 {0} 已存在,建议更名后重试。
|
||||||
|
|
||||||
|
problemDetail.plugin.version.unsatisfied.requires=插件要求一个最小的系统版本为 {0}, 但当前版本为 {1}。
|
||||||
|
|
|
@ -13,6 +13,7 @@ import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction;
|
import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction;
|
||||||
import static org.springframework.web.reactive.function.BodyInserters.fromMultipartData;
|
import static org.springframework.web.reactive.function.BodyInserters.fromMultipartData;
|
||||||
|
|
||||||
|
import com.github.zafarkhaja.semver.Version;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.net.URISyntaxException;
|
import java.net.URISyntaxException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
|
@ -40,6 +41,7 @@ import run.halo.app.core.extension.Plugin;
|
||||||
import run.halo.app.extension.ListResult;
|
import run.halo.app.extension.ListResult;
|
||||||
import run.halo.app.extension.Metadata;
|
import run.halo.app.extension.Metadata;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
|
import run.halo.app.infra.SystemVersionSupplier;
|
||||||
import run.halo.app.infra.utils.FileUtils;
|
import run.halo.app.infra.utils.FileUtils;
|
||||||
import run.halo.app.plugin.PluginProperties;
|
import run.halo.app.plugin.PluginProperties;
|
||||||
|
|
||||||
|
@ -53,6 +55,9 @@ class PluginEndpointTest {
|
||||||
@Mock
|
@Mock
|
||||||
private ReactiveExtensionClient client;
|
private ReactiveExtensionClient client;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
SystemVersionSupplier systemVersionSupplier;
|
||||||
|
|
||||||
@InjectMocks
|
@InjectMocks
|
||||||
PluginEndpoint endpoint;
|
PluginEndpoint endpoint;
|
||||||
|
|
||||||
|
@ -200,7 +205,7 @@ class PluginEndpointTest {
|
||||||
webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint())
|
webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint())
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
lenient().when(systemVersionSupplier.get()).thenReturn(Version.valueOf("0.0.0"));
|
||||||
tempDirectory = Files.createTempDirectory("halo-test-plugin-upgrade-");
|
tempDirectory = Files.createTempDirectory("halo-test-plugin-upgrade-");
|
||||||
lenient().when(pluginProperties.getPluginsRoot())
|
lenient().when(pluginProperties.getPluginsRoot())
|
||||||
.thenReturn(tempDirectory.resolve("plugins").toString());
|
.thenReturn(tempDirectory.resolve("plugins").toString());
|
||||||
|
|
|
@ -60,7 +60,8 @@ class PluginReconcilerTest {
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
pluginReconciler = new PluginReconciler(extensionClient, haloPluginManager);
|
pluginReconciler = new PluginReconciler(extensionClient, haloPluginManager);
|
||||||
|
lenient().when(haloPluginManager.validatePluginVersion(any())).thenReturn(true);
|
||||||
|
lenient().when(haloPluginManager.getSystemVersion()).thenReturn("0.0.0");
|
||||||
lenient().when(haloPluginManager.getPlugin(any())).thenReturn(pluginWrapper);
|
lenient().when(haloPluginManager.getPlugin(any())).thenReturn(pluginWrapper);
|
||||||
lenient().when(haloPluginManager.getUnresolvedPlugins()).thenReturn(List.of());
|
lenient().when(haloPluginManager.getUnresolvedPlugins()).thenReturn(List.of());
|
||||||
}
|
}
|
||||||
|
@ -127,7 +128,7 @@ class PluginReconcilerTest {
|
||||||
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
||||||
|
|
||||||
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
|
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
|
||||||
verify(extensionClient, times(3)).update(any(Plugin.class));
|
verify(extensionClient, times(4)).update(any(Plugin.class));
|
||||||
|
|
||||||
Plugin updateArgs = pluginCaptor.getValue();
|
Plugin updateArgs = pluginCaptor.getValue();
|
||||||
assertThat(updateArgs).isNotNull();
|
assertThat(updateArgs).isNotNull();
|
||||||
|
@ -162,7 +163,7 @@ class PluginReconcilerTest {
|
||||||
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
||||||
|
|
||||||
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
|
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
|
||||||
verify(extensionClient, times(3)).update(any(Plugin.class));
|
verify(extensionClient, times(4)).update(any(Plugin.class));
|
||||||
|
|
||||||
Plugin updateArgs = pluginCaptor.getValue();
|
Plugin updateArgs = pluginCaptor.getValue();
|
||||||
assertThat(updateArgs).isNotNull();
|
assertThat(updateArgs).isNotNull();
|
||||||
|
|
|
@ -0,0 +1,60 @@
|
||||||
|
package run.halo.app.infra;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import com.github.zafarkhaja.semver.Version;
|
||||||
|
import java.util.Properties;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.info.BuildProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link DefaultSystemVersionSupplier}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
|
||||||
|
class DefaultSystemVersionSupplierTest {
|
||||||
|
|
||||||
|
private DefaultSystemVersionSupplier systemVersionSupplier;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
systemVersionSupplier = new DefaultSystemVersionSupplier();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getWhenBuildPropertiesNotSet() {
|
||||||
|
Version version = systemVersionSupplier.get();
|
||||||
|
assertThat(version.toString()).isEqualTo("0.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getWhenBuildPropertiesButVersionIsNull() {
|
||||||
|
Properties properties = new Properties();
|
||||||
|
BuildProperties buildProperties = new BuildProperties(properties);
|
||||||
|
systemVersionSupplier.setBuildProperties(buildProperties);
|
||||||
|
|
||||||
|
Version version = systemVersionSupplier.get();
|
||||||
|
assertThat(version.toString()).isEqualTo("0.0.0");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void getWhenBuildPropertiesAndVersionNotEmpty() {
|
||||||
|
Properties properties = new Properties();
|
||||||
|
properties.put("version", "2.0.0");
|
||||||
|
BuildProperties buildProperties = new BuildProperties(properties);
|
||||||
|
systemVersionSupplier.setBuildProperties(buildProperties);
|
||||||
|
|
||||||
|
Version version = systemVersionSupplier.get();
|
||||||
|
assertThat(version.toString()).isEqualTo("2.0.0");
|
||||||
|
|
||||||
|
properties.put("version", "2.0.0-SNAPSHOT");
|
||||||
|
buildProperties = new BuildProperties(properties);
|
||||||
|
systemVersionSupplier.setBuildProperties(buildProperties);
|
||||||
|
version = systemVersionSupplier.get();
|
||||||
|
assertThat(version.toString()).isEqualTo("2.0.0-SNAPSHOT");
|
||||||
|
assertThat(version.getPreReleaseVersion()).isEqualTo("SNAPSHOT");
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,51 @@
|
||||||
|
package run.halo.app.infra.utils;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link VersionUtils}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.2.0
|
||||||
|
*/
|
||||||
|
class VersionUtilsTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void satisfiesRequires() {
|
||||||
|
// match all requires
|
||||||
|
String systemVersion = "0.0.0";
|
||||||
|
String requires = ">=2.2.0";
|
||||||
|
boolean result = VersionUtils.satisfiesRequires(systemVersion, requires);
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
|
||||||
|
systemVersion = "2.0.0";
|
||||||
|
requires = "*";
|
||||||
|
result = VersionUtils.satisfiesRequires(systemVersion, requires);
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
|
||||||
|
systemVersion = "2.0.0";
|
||||||
|
requires = "";
|
||||||
|
result = VersionUtils.satisfiesRequires(systemVersion, requires);
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
|
||||||
|
// match exact version
|
||||||
|
systemVersion = "2.0.0";
|
||||||
|
requires = ">=2.0.0";
|
||||||
|
result = VersionUtils.satisfiesRequires(systemVersion, requires);
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
|
||||||
|
systemVersion = "2.0.0";
|
||||||
|
requires = ">2.0.0";
|
||||||
|
result = VersionUtils.satisfiesRequires(systemVersion, requires);
|
||||||
|
assertThat(result).isFalse();
|
||||||
|
|
||||||
|
//an exact version x.y.z will implicitly mean the same as >=x.y.z
|
||||||
|
systemVersion = "2.1.0";
|
||||||
|
// means >=2.0.0
|
||||||
|
requires = "2.0.0";
|
||||||
|
result = VersionUtils.satisfiesRequires(systemVersion, requires);
|
||||||
|
assertThat(result).isTrue();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue