mirror of https://github.com/halo-dev/halo
feat: plugin supports configuration when not started (#3355)
#### What type of PR is this? /kind feature /milestone 2.3.x /area core /area plugin #### What this PR does / why we need it: 顺便重构了一下插件 Reconciler ,否则经常容易出 bug,刚好一起测试一下免得反复整体测试这一块。 1. 插件 status 里的 message 和 reason 之前是直接放在 status 里的,现在改为一个 ConditionList, 最新的始终在 ConditionList 的第一个,这可以避免插件启动成功时还能在 message 上看到错误信息会很奇怪 2. 插件的 Setting 只会在卸载时删除,停止时不进行删除 3. 插件启动和停止状态的调和能从 CREATED、DISABLED、RESOLVED、STARTED、STOPPED、FAILED 这些状态之间进行循环过渡,如插件当前状态为 CREATED 期望达到 STARTED 则可能会经历 CREATED -> RESOLVED -> STARTED( -> FAILED) 这些过渡, 逻辑更清晰 Console 需要适配: 1. 需要去掉插件启动时才加载 Setting 的判断,让其在插件停用时也能加载 2. 需要修改发生错误时的字段取值,显示 conditons[0] #### Which issue(s) this PR fixes: Fixes #3352 #### Special notes for your reviewer: how to test it? 1. 测试在开发模式和生产模式下插件启用/设置/配置是否正常 2. 测试开发模式下插件启动失败后修复问题重启 halo 后能否正常启用 3. 测试插件卸载后 setting 是否被正确删除 4. 测试插件 logo 相对路径和 url 是否都能正常显示 5. 测试评论插件启动成功后主题端是否能显示评论列表(验证扩展点加载是否正常) 6. 测试插件配置了 settingName 但没有对应的 Setting 时的情况看 Reconciler 重试时间是否增长 /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? ```release-note 允许插件在未启动时更改设置 ```pull/3379/head
parent
03ec23f90f
commit
6c2064f1e0
|
@ -13,8 +13,10 @@ import lombok.EqualsAndHashCode;
|
||||||
import lombok.ToString;
|
import lombok.ToString;
|
||||||
import org.pf4j.PluginState;
|
import org.pf4j.PluginState;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
import run.halo.app.extension.AbstractExtension;
|
import run.halo.app.extension.AbstractExtension;
|
||||||
import run.halo.app.extension.GVK;
|
import run.halo.app.extension.GVK;
|
||||||
|
import run.halo.app.infra.ConditionList;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* A custom resource for Plugin.
|
* A custom resource for Plugin.
|
||||||
|
@ -102,19 +104,23 @@ public class Plugin extends AbstractExtension {
|
||||||
|
|
||||||
private PluginState phase;
|
private PluginState phase;
|
||||||
|
|
||||||
private String reason;
|
private ConditionList conditions;
|
||||||
|
|
||||||
private String message;
|
|
||||||
|
|
||||||
private Instant lastStartTime;
|
private Instant lastStartTime;
|
||||||
|
|
||||||
private Instant lastTransitionTime;
|
|
||||||
|
|
||||||
private String entry;
|
private String entry;
|
||||||
|
|
||||||
private String stylesheet;
|
private String stylesheet;
|
||||||
|
|
||||||
private String logo;
|
private String logo;
|
||||||
|
|
||||||
|
public static ConditionList nullSafeConditions(@NonNull PluginStatus status) {
|
||||||
|
Assert.notNull(status, "The status must not be null.");
|
||||||
|
if (status.getConditions() == null) {
|
||||||
|
status.setConditions(new ConditionList());
|
||||||
|
}
|
||||||
|
return status.getConditions();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Data
|
@Data
|
||||||
|
|
|
@ -1,25 +1,35 @@
|
||||||
package run.halo.app.core.extension.reconciler;
|
package run.halo.app.core.extension.reconciler;
|
||||||
|
|
||||||
|
import static run.halo.app.plugin.PluginConst.DELETE_STAGE;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
import java.util.Collection;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
import java.util.HashSet;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Objects;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
import java.util.function.Function;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.apache.commons.lang3.BooleanUtils;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.pf4j.PluginRuntimeException;
|
import org.pf4j.PluginDescriptor;
|
||||||
import org.pf4j.PluginState;
|
import org.pf4j.PluginState;
|
||||||
import org.pf4j.PluginWrapper;
|
import org.pf4j.PluginWrapper;
|
||||||
import org.pf4j.RuntimeMode;
|
import org.pf4j.RuntimeMode;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
|
import org.springframework.core.io.DefaultResourceLoader;
|
||||||
|
import org.springframework.core.io.Resource;
|
||||||
|
import org.springframework.lang.NonNull;
|
||||||
|
import org.springframework.retry.support.RetryTemplate;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.Assert;
|
import org.springframework.util.Assert;
|
||||||
import run.halo.app.core.extension.Plugin;
|
import run.halo.app.core.extension.Plugin;
|
||||||
|
@ -28,15 +38,22 @@ import run.halo.app.core.extension.Setting;
|
||||||
import run.halo.app.core.extension.theme.SettingUtils;
|
import run.halo.app.core.extension.theme.SettingUtils;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
import run.halo.app.extension.ExtensionClient;
|
import run.halo.app.extension.ExtensionClient;
|
||||||
|
import run.halo.app.extension.ExtensionUtil;
|
||||||
|
import run.halo.app.extension.GroupVersionKind;
|
||||||
import run.halo.app.extension.Metadata;
|
import run.halo.app.extension.Metadata;
|
||||||
|
import run.halo.app.extension.Unstructured;
|
||||||
import run.halo.app.extension.controller.Controller;
|
import run.halo.app.extension.controller.Controller;
|
||||||
import run.halo.app.extension.controller.ControllerBuilder;
|
import run.halo.app.extension.controller.ControllerBuilder;
|
||||||
import run.halo.app.extension.controller.Reconciler;
|
import run.halo.app.extension.controller.Reconciler;
|
||||||
import run.halo.app.extension.controller.Reconciler.Request;
|
import run.halo.app.extension.controller.Reconciler.Request;
|
||||||
|
import run.halo.app.infra.Condition;
|
||||||
|
import run.halo.app.infra.ConditionStatus;
|
||||||
import run.halo.app.infra.utils.JsonUtils;
|
import run.halo.app.infra.utils.JsonUtils;
|
||||||
import run.halo.app.infra.utils.PathUtils;
|
import run.halo.app.infra.utils.PathUtils;
|
||||||
|
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||||
import run.halo.app.plugin.HaloPluginManager;
|
import run.halo.app.plugin.HaloPluginManager;
|
||||||
import run.halo.app.plugin.PluginConst;
|
import run.halo.app.plugin.PluginConst;
|
||||||
|
import run.halo.app.plugin.PluginExtensionLoaderUtils;
|
||||||
import run.halo.app.plugin.PluginNotFoundException;
|
import run.halo.app.plugin.PluginNotFoundException;
|
||||||
import run.halo.app.plugin.PluginStartingError;
|
import run.halo.app.plugin.PluginStartingError;
|
||||||
import run.halo.app.plugin.event.PluginCreatedEvent;
|
import run.halo.app.plugin.event.PluginCreatedEvent;
|
||||||
|
@ -56,20 +73,367 @@ public class PluginReconciler implements Reconciler<Request> {
|
||||||
private final ExtensionClient client;
|
private final ExtensionClient client;
|
||||||
private final HaloPluginManager haloPluginManager;
|
private final HaloPluginManager haloPluginManager;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
private final ApplicationEventPublisher eventPublisher;
|
||||||
|
private final RetryTemplate retryTemplate = RetryTemplate.builder()
|
||||||
|
.maxAttempts(20)
|
||||||
|
.fixedBackoff(300)
|
||||||
|
.retryOn(IllegalStateException.class)
|
||||||
|
.build();
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Result reconcile(Request request) {
|
public Result reconcile(Request request) {
|
||||||
client.fetch(Plugin.class, request.name())
|
return client.fetch(Plugin.class, request.name())
|
||||||
.ifPresent(plugin -> {
|
.map(plugin -> {
|
||||||
if (plugin.getMetadata().getDeletionTimestamp() != null) {
|
if (plugin.getMetadata().getDeletionTimestamp() != null) {
|
||||||
cleanUpResourcesAndRemoveFinalizer(request.name());
|
cleanUpResourcesAndRemoveFinalizer(request.name());
|
||||||
return;
|
return Result.doNotRetry();
|
||||||
}
|
}
|
||||||
addFinalizerIfNecessary(plugin);
|
addFinalizerIfNecessary(plugin);
|
||||||
|
|
||||||
|
// if true returned, it means it is not ready
|
||||||
|
if (readinessDetection(request.name())) {
|
||||||
|
return new Result(true, null);
|
||||||
|
}
|
||||||
|
|
||||||
reconcilePluginState(plugin.getMetadata().getName());
|
reconcilePluginState(plugin.getMetadata().getName());
|
||||||
|
return Result.doNotRetry();
|
||||||
|
})
|
||||||
|
.orElse(Result.doNotRetry());
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean readinessDetection(String name) {
|
||||||
|
return client.fetch(Plugin.class, name)
|
||||||
|
.map(plugin -> {
|
||||||
|
if (waitForSettingCreation(plugin)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
createInitialReverseProxyIfNotPresent(plugin);
|
createInitialReverseProxyIfNotPresent(plugin);
|
||||||
|
|
||||||
|
Plugin.PluginStatus status = plugin.statusNonNull();
|
||||||
|
|
||||||
|
// filled logo path
|
||||||
|
String logo = plugin.getSpec().getLogo();
|
||||||
|
if (PathUtils.isAbsoluteUri(logo)) {
|
||||||
|
status.setLogo(logo);
|
||||||
|
} else {
|
||||||
|
String assetsPrefix =
|
||||||
|
PluginConst.assertsRoutePrefix(plugin.getMetadata().getName());
|
||||||
|
status.setLogo(PathUtils.combinePath(assetsPrefix, logo));
|
||||||
|
}
|
||||||
|
|
||||||
|
// update phase
|
||||||
|
PluginWrapper pluginWrapper = getPluginWrapper(name);
|
||||||
|
status.setPhase(pluginWrapper.getPluginState());
|
||||||
|
updateStatus(plugin.getMetadata().getName(), status);
|
||||||
|
return false;
|
||||||
|
})
|
||||||
|
.orElse(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<Setting> lookupPluginSetting(String name, String settingName) {
|
||||||
|
Assert.notNull(name, "Plugin name must not be null");
|
||||||
|
Assert.notNull(settingName, "Setting name must not be null");
|
||||||
|
PluginWrapper pluginWrapper = getPluginWrapper(name);
|
||||||
|
// If it already exists, do not look for setting
|
||||||
|
if (RuntimeMode.DEPLOYMENT.equals(pluginWrapper.getRuntimeMode())) {
|
||||||
|
Optional<Setting> existing = client.fetch(Setting.class, settingName);
|
||||||
|
if (existing.isPresent()) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var resourceLoader =
|
||||||
|
new DefaultResourceLoader(pluginWrapper.getPluginClassLoader());
|
||||||
|
return PluginExtensionLoaderUtils.lookupExtensions(pluginWrapper.getPluginPath(),
|
||||||
|
pluginWrapper.getRuntimeMode())
|
||||||
|
.stream()
|
||||||
|
.map(resourceLoader::getResource)
|
||||||
|
.filter(Resource::exists)
|
||||||
|
.map(resource -> new YamlUnstructuredLoader(resource).load())
|
||||||
|
.flatMap(Collection::stream)
|
||||||
|
.filter(unstructured -> {
|
||||||
|
GroupVersionKind groupVersionKind =
|
||||||
|
GroupVersionKind.fromAPIVersionAndKind(unstructured.getApiVersion(),
|
||||||
|
unstructured.getKind());
|
||||||
|
GroupVersionKind settingGvk = GroupVersionKind.fromExtension(Setting.class);
|
||||||
|
return settingGvk.groupKind().equals(groupVersionKind.groupKind())
|
||||||
|
&& settingName.equals(unstructured.getMetadata().getName());
|
||||||
|
})
|
||||||
|
.findFirst()
|
||||||
|
.map(unstructured -> Unstructured.OBJECT_MAPPER.convertValue(unstructured,
|
||||||
|
Setting.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean waitForSettingCreation(Plugin plugin) {
|
||||||
|
final String pluginName = plugin.getMetadata().getName();
|
||||||
|
|
||||||
|
final String settingName = plugin.getSpec().getSettingName();
|
||||||
|
if (StringUtils.isBlank(settingName)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<Setting> settingOption = lookupPluginSetting(pluginName, settingName)
|
||||||
|
.map(setting -> {
|
||||||
|
// This annotation is added to prevent it from being deleted when stopped.
|
||||||
|
Map<String, String> settingAnnotations = ExtensionUtil.nullSafeAnnotations(setting);
|
||||||
|
settingAnnotations.put(DELETE_STAGE, PluginConst.DeleteStage.UNINSTALL.name());
|
||||||
|
return setting;
|
||||||
|
})
|
||||||
|
.map(settingFromYaml -> {
|
||||||
|
client.fetch(Setting.class, settingName)
|
||||||
|
.ifPresentOrElse(setting -> {
|
||||||
|
settingFromYaml.getMetadata()
|
||||||
|
.setVersion(setting.getMetadata().getVersion());
|
||||||
|
client.update(settingFromYaml);
|
||||||
|
}, () -> client.create(settingFromYaml));
|
||||||
|
return settingFromYaml;
|
||||||
});
|
});
|
||||||
return new Result(false, null);
|
|
||||||
|
// Fix gh-3224
|
||||||
|
// Maybe Setting is being created and cannot be queried. so try again.
|
||||||
|
if (settingOption.isEmpty()) {
|
||||||
|
Plugin.PluginStatus status = plugin.statusNonNull();
|
||||||
|
status.setPhase(PluginState.FAILED);
|
||||||
|
var condition = Condition.builder()
|
||||||
|
.type("BackOff")
|
||||||
|
.reason("BackOff")
|
||||||
|
.message("Wait for setting [" + settingName + "] creation")
|
||||||
|
.status(ConditionStatus.FALSE)
|
||||||
|
.lastTransitionTime(Instant.now())
|
||||||
|
.build();
|
||||||
|
Plugin.PluginStatus.nullSafeConditions(status)
|
||||||
|
.addAndEvictFIFO(condition);
|
||||||
|
updateStatus(plugin.getMetadata().getName(), status);
|
||||||
|
// need requeue
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
final String configMapNameToUse = plugin.getSpec().getConfigMapName();
|
||||||
|
if (StringUtils.isBlank(configMapNameToUse)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
boolean existConfigMap = client.fetch(ConfigMap.class, configMapNameToUse)
|
||||||
|
.isPresent();
|
||||||
|
if (existConfigMap) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var data = SettingUtils.settingDefinedDefaultValueMap(settingOption.get());
|
||||||
|
// Create with or without default value
|
||||||
|
ConfigMap configMap = new ConfigMap();
|
||||||
|
configMap.setMetadata(new Metadata());
|
||||||
|
configMap.getMetadata().setName(configMapNameToUse);
|
||||||
|
configMap.setData(data);
|
||||||
|
client.create(configMap);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
void startAction(String name) {
|
||||||
|
stateTransition(name, currentState -> {
|
||||||
|
boolean termination = false;
|
||||||
|
switch (currentState) {
|
||||||
|
case CREATED -> ensurePluginLoaded();
|
||||||
|
case STARTED -> termination = true;
|
||||||
|
// plugin can be started when it is stopped or failed
|
||||||
|
case RESOLVED, STOPPED, FAILED -> doStart(name);
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return termination;
|
||||||
|
}, PluginState.STARTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stopAction(String name) {
|
||||||
|
stateTransition(name, currentState -> {
|
||||||
|
boolean termination = false;
|
||||||
|
switch (currentState) {
|
||||||
|
case CREATED -> ensurePluginLoaded();
|
||||||
|
case RESOLVED, STARTED -> doStop(name);
|
||||||
|
case FAILED, STOPPED -> termination = true;
|
||||||
|
default -> {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return termination;
|
||||||
|
}, PluginState.STOPPED);
|
||||||
|
}
|
||||||
|
|
||||||
|
void stateTransition(String name, Function<PluginState, Boolean> stateAction,
|
||||||
|
PluginState desiredState) {
|
||||||
|
PluginWrapper pluginWrapper = getPluginWrapper(name);
|
||||||
|
PluginState currentState = pluginWrapper.getPluginState();
|
||||||
|
int maxRetries = PluginState.values().length;
|
||||||
|
for (int i = 0; i < maxRetries && currentState != desiredState; i++) {
|
||||||
|
try {
|
||||||
|
if (log.isDebugEnabled() && i > 2) {
|
||||||
|
log.debug("Plugin [{}] state transition from [{}] to [{}]", name, currentState,
|
||||||
|
desiredState);
|
||||||
|
}
|
||||||
|
// When true is returned, the status loop is ended directly
|
||||||
|
if (BooleanUtils.isTrue(stateAction.apply(currentState))) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// update current state
|
||||||
|
currentState = pluginWrapper.getPluginState();
|
||||||
|
} catch (Throwable e) {
|
||||||
|
persistenceFailureStatus(name, e);
|
||||||
|
throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void persistenceFailureStatus(String pluginName, Throwable e) {
|
||||||
|
client.fetch(Plugin.class, pluginName).ifPresent(plugin -> {
|
||||||
|
Plugin.PluginStatus status = plugin.statusNonNull();
|
||||||
|
|
||||||
|
PluginWrapper pluginWrapper = getPluginWrapper(pluginName);
|
||||||
|
status.setPhase(pluginWrapper.getPluginState());
|
||||||
|
|
||||||
|
Plugin.PluginStatus oldStatus = JsonUtils.deepCopy(status);
|
||||||
|
Condition condition = Condition.builder()
|
||||||
|
.type(PluginState.FAILED.toString())
|
||||||
|
.reason("UnexpectedState")
|
||||||
|
.message(e.getMessage())
|
||||||
|
.status(ConditionStatus.FALSE)
|
||||||
|
.lastTransitionTime(Instant.now())
|
||||||
|
.build();
|
||||||
|
Plugin.PluginStatus.nullSafeConditions(status)
|
||||||
|
.addAndEvictFIFO(condition);
|
||||||
|
if (!Objects.equals(oldStatus, status)) {
|
||||||
|
client.update(plugin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
private PluginWrapper getPluginWrapper(String name) {
|
||||||
|
PluginWrapper pluginWrapper = haloPluginManager.getPlugin(name);
|
||||||
|
if (pluginWrapper == null) {
|
||||||
|
ensurePluginLoaded();
|
||||||
|
pluginWrapper = haloPluginManager.getPlugin(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pluginWrapper == null) {
|
||||||
|
Plugin.PluginStatus status = new Plugin.PluginStatus();
|
||||||
|
status.setPhase(PluginState.FAILED);
|
||||||
|
|
||||||
|
String errorMsg = "Plugin " + name + " not found in plugin manager.";
|
||||||
|
Condition condition = Condition.builder()
|
||||||
|
.type(PluginState.FAILED.toString())
|
||||||
|
.reason("PluginNotFound")
|
||||||
|
.message(errorMsg)
|
||||||
|
.status(ConditionStatus.FALSE)
|
||||||
|
.lastTransitionTime(Instant.now())
|
||||||
|
.build();
|
||||||
|
Plugin.PluginStatus.nullSafeConditions(status)
|
||||||
|
.addAndEvictFIFO(condition);
|
||||||
|
updateStatus(name, status);
|
||||||
|
throw new PluginNotFoundException(errorMsg);
|
||||||
|
}
|
||||||
|
return pluginWrapper;
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateStatus(String name, Plugin.PluginStatus status) {
|
||||||
|
if (status == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
client.fetch(Plugin.class, name).ifPresent(plugin -> {
|
||||||
|
Plugin.PluginStatus oldStatus = JsonUtils.deepCopy(plugin.statusNonNull());
|
||||||
|
plugin.setStatus(status);
|
||||||
|
if (!Objects.equals(oldStatus, status)) {
|
||||||
|
client.update(plugin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void doStart(String name) {
|
||||||
|
PluginWrapper pluginWrapper = getPluginWrapper(name);
|
||||||
|
// Check if this plugin version is match requires param.
|
||||||
|
if (!haloPluginManager.validatePluginVersion(pluginWrapper)) {
|
||||||
|
PluginDescriptor descriptor = pluginWrapper.getDescriptor();
|
||||||
|
String message = String.format(
|
||||||
|
"Plugin requires a minimum system version of [%s], and you have [%s].",
|
||||||
|
descriptor.getRequires(), haloPluginManager.getSystemVersion());
|
||||||
|
throw new IllegalStateException(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (PluginState.DISABLED.equals(pluginWrapper.getPluginState())) {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"The plugin is disabled for some reason and cannot be started.");
|
||||||
|
}
|
||||||
|
|
||||||
|
client.fetch(Plugin.class, name).ifPresent(plugin -> {
|
||||||
|
final Plugin.PluginStatus status = plugin.statusNonNull();
|
||||||
|
final Plugin.PluginStatus oldStatus = JsonUtils.deepCopy(status);
|
||||||
|
|
||||||
|
PluginState currentState = haloPluginManager.startPlugin(name);
|
||||||
|
if (!PluginState.STARTED.equals(currentState)) {
|
||||||
|
PluginStartingError staringErrorInfo = getStaringErrorInfo(name);
|
||||||
|
log.debug("Failed to start plugin: " + staringErrorInfo.getDevMessage());
|
||||||
|
throw new IllegalStateException(staringErrorInfo.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
plugin.statusNonNull().setLastStartTime(Instant.now());
|
||||||
|
|
||||||
|
String jsBundlePath =
|
||||||
|
BundleResourceUtils.getJsBundlePath(haloPluginManager, name);
|
||||||
|
status.setEntry(jsBundlePath);
|
||||||
|
|
||||||
|
String cssBundlePath =
|
||||||
|
BundleResourceUtils.getCssBundlePath(haloPluginManager, name);
|
||||||
|
status.setStylesheet(cssBundlePath);
|
||||||
|
|
||||||
|
status.setPhase(currentState);
|
||||||
|
Condition condition = Condition.builder()
|
||||||
|
.type(PluginState.STARTED.toString())
|
||||||
|
.reason(PluginState.STARTED.toString())
|
||||||
|
.message("Started successfully")
|
||||||
|
.lastTransitionTime(Instant.now())
|
||||||
|
.status(ConditionStatus.TRUE)
|
||||||
|
.build();
|
||||||
|
Plugin.PluginStatus.nullSafeConditions(status)
|
||||||
|
.addAndEvictFIFO(condition);
|
||||||
|
if (!Objects.equals(oldStatus, status)) {
|
||||||
|
client.update(plugin);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginStartingError getStaringErrorInfo(String name) {
|
||||||
|
PluginStartingError startingError =
|
||||||
|
haloPluginManager.getPluginStartingError(name);
|
||||||
|
if (startingError == null) {
|
||||||
|
startingError = PluginStartingError.of(name, "Unknown error", "");
|
||||||
|
}
|
||||||
|
return startingError;
|
||||||
|
}
|
||||||
|
|
||||||
|
void doStop(String name) {
|
||||||
|
client.fetch(Plugin.class, name).ifPresent(plugin -> {
|
||||||
|
final Plugin.PluginStatus status = plugin.statusNonNull();
|
||||||
|
final Plugin.PluginStatus oldStatus = JsonUtils.deepCopy(status);
|
||||||
|
|
||||||
|
PluginState currentState = haloPluginManager.stopPlugin(name);
|
||||||
|
if (!PluginState.STOPPED.equals(currentState)) {
|
||||||
|
throw new IllegalStateException("Failed to stop plugin: " + name);
|
||||||
|
}
|
||||||
|
status.setPhase(currentState);
|
||||||
|
// reset js bundle path
|
||||||
|
status.setStylesheet(StringUtils.EMPTY);
|
||||||
|
status.setEntry(StringUtils.EMPTY);
|
||||||
|
|
||||||
|
Condition condition = Condition.builder()
|
||||||
|
.type(PluginState.STOPPED.toString())
|
||||||
|
.reason(PluginState.STOPPED.toString())
|
||||||
|
.message("Stopped successfully")
|
||||||
|
.lastTransitionTime(Instant.now())
|
||||||
|
.status(ConditionStatus.TRUE)
|
||||||
|
.build();
|
||||||
|
Plugin.PluginStatus.nullSafeConditions(status)
|
||||||
|
.addAndEvictFIFO(condition);
|
||||||
|
if (!Objects.equals(oldStatus, status)) {
|
||||||
|
client.update(plugin);
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -84,60 +448,16 @@ public class PluginReconciler implements Reconciler<Request> {
|
||||||
ensurePluginLoaded();
|
ensurePluginLoaded();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!checkPluginState(name)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
client.fetch(Plugin.class, name).ifPresent(plugin -> {
|
client.fetch(Plugin.class, name).ifPresent(plugin -> {
|
||||||
Plugin oldPlugin = JsonUtils.deepCopy(plugin);
|
// Transition plugin status if necessary
|
||||||
Plugin.PluginStatus pluginStatus = plugin.statusNonNull();
|
if (shouldReconcileStartState(plugin)) {
|
||||||
String logo = plugin.getSpec().getLogo();
|
startAction(name);
|
||||||
if (PathUtils.isAbsoluteUri(logo)) {
|
|
||||||
pluginStatus.setLogo(logo);
|
|
||||||
} else {
|
|
||||||
String assetsPrefix =
|
|
||||||
PluginConst.assertsRoutePrefix(plugin.getMetadata().getName());
|
|
||||||
pluginStatus.setLogo(PathUtils.combinePath(assetsPrefix, logo));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!plugin.equals(oldPlugin)) {
|
if (shouldReconcileStopState(plugin)) {
|
||||||
client.update(plugin);
|
stopAction(name);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
startPlugin(name);
|
|
||||||
|
|
||||||
stopPlugin(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean checkPluginState(String name) {
|
|
||||||
// check plugin state
|
|
||||||
return client.fetch(Plugin.class, name)
|
|
||||||
.map(plugin -> {
|
|
||||||
Plugin oldPlugin = JsonUtils.deepCopy(plugin);
|
|
||||||
Plugin.PluginStatus pluginStatus = plugin.statusNonNull();
|
|
||||||
PluginWrapper pluginWrapper = haloPluginManager.getPlugin(name);
|
|
||||||
if (pluginWrapper == null) {
|
|
||||||
pluginStatus.setPhase(PluginState.FAILED);
|
|
||||||
pluginStatus.setReason("PluginNotFound");
|
|
||||||
pluginStatus.setMessage(
|
|
||||||
"Plugin " + plugin.getMetadata().getName()
|
|
||||||
+ " not found in plugin manager.");
|
|
||||||
if (!plugin.equals(oldPlugin)) {
|
|
||||||
client.update(plugin);
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
// Set to the correct state
|
|
||||||
pluginStatus.setPhase(pluginWrapper.getPluginState());
|
|
||||||
|
|
||||||
if (haloPluginManager.getUnresolvedPlugins().contains(pluginWrapper)) {
|
|
||||||
// load and resolve plugin
|
|
||||||
haloPluginManager.loadPlugin(pluginWrapper.getPluginPath());
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
})
|
|
||||||
.orElse(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ensurePluginLoaded() {
|
private void ensurePluginLoaded() {
|
||||||
|
@ -155,120 +475,15 @@ public class PluginReconciler implements Reconciler<Request> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean shouldReconcileStartState(Plugin plugin) {
|
private boolean shouldReconcileStartState(Plugin plugin) {
|
||||||
return plugin.getSpec().getEnabled()
|
PluginWrapper pluginWrapper = getPluginWrapper(plugin.getMetadata().getName());
|
||||||
&& plugin.statusNonNull().getPhase() != PluginState.STARTED;
|
return BooleanUtils.isTrue(plugin.getSpec().getEnabled())
|
||||||
}
|
&& !PluginState.STARTED.equals(pluginWrapper.getPluginState());
|
||||||
|
|
||||||
private void startPlugin(String pluginName) {
|
|
||||||
client.fetch(Plugin.class, pluginName).ifPresent(plugin -> {
|
|
||||||
final Plugin oldPlugin = JsonUtils.deepCopy(plugin);
|
|
||||||
|
|
||||||
// verify plugin meets the preconditions for startup
|
|
||||||
if (!verifyStartCondition(pluginName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (shouldReconcileStartState(plugin)) {
|
|
||||||
PluginState currentState = haloPluginManager.startPlugin(pluginName);
|
|
||||||
handleStatus(plugin, currentState, PluginState.STARTED);
|
|
||||||
plugin.statusNonNull().setLastStartTime(Instant.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
settingDefaultConfig(plugin);
|
|
||||||
|
|
||||||
Plugin.PluginStatus status = plugin.statusNonNull();
|
|
||||||
String jsBundlePath =
|
|
||||||
BundleResourceUtils.getJsBundlePath(haloPluginManager, pluginName);
|
|
||||||
status.setEntry(jsBundlePath);
|
|
||||||
|
|
||||||
String cssBundlePath =
|
|
||||||
BundleResourceUtils.getCssBundlePath(haloPluginManager, pluginName);
|
|
||||||
status.setStylesheet(cssBundlePath);
|
|
||||||
|
|
||||||
if (!plugin.equals(oldPlugin)) {
|
|
||||||
client.update(plugin);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private boolean verifyStartCondition(String pluginName) {
|
|
||||||
PluginWrapper pluginWrapper = haloPluginManager.getPlugin(pluginName);
|
|
||||||
if (pluginWrapper == null) {
|
|
||||||
throw new PluginNotFoundException(
|
|
||||||
"Plugin " + pluginName + " not found in plugin manager.");
|
|
||||||
}
|
|
||||||
return client.fetch(Plugin.class, pluginName).map(plugin -> {
|
|
||||||
Plugin.PluginStatus oldStatus = JsonUtils.deepCopy(plugin.statusNonNull());
|
|
||||||
|
|
||||||
Plugin.PluginStatus status = plugin.statusNonNull();
|
|
||||||
status.setLastTransitionTime(Instant.now());
|
|
||||||
|
|
||||||
// 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.");
|
|
||||||
if (!oldStatus.equals(status)) {
|
|
||||||
client.update(plugin);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}).orElse(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean shouldReconcileStopState(Plugin plugin) {
|
private boolean shouldReconcileStopState(Plugin plugin) {
|
||||||
return !plugin.getSpec().getEnabled()
|
PluginWrapper pluginWrapper = getPluginWrapper(plugin.getMetadata().getName());
|
||||||
&& plugin.statusNonNull().getPhase() == PluginState.STARTED;
|
return BooleanUtils.isFalse(plugin.getSpec().getEnabled())
|
||||||
}
|
&& PluginState.STARTED.equals(pluginWrapper.getPluginState());
|
||||||
|
|
||||||
private void stopPlugin(String pluginName) {
|
|
||||||
client.fetch(Plugin.class, pluginName).ifPresent(plugin -> {
|
|
||||||
Plugin oldPlugin = JsonUtils.deepCopy(plugin);
|
|
||||||
|
|
||||||
if (shouldReconcileStopState(plugin)) {
|
|
||||||
PluginState currentState = haloPluginManager.stopPlugin(pluginName);
|
|
||||||
handleStatus(plugin, currentState, PluginState.STOPPED);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!plugin.equals(oldPlugin)) {
|
|
||||||
client.update(plugin);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleStatus(Plugin plugin, PluginState currentState,
|
|
||||||
PluginState desiredState) {
|
|
||||||
Plugin.PluginStatus status = plugin.statusNonNull();
|
|
||||||
status.setPhase(currentState);
|
|
||||||
status.setLastTransitionTime(Instant.now());
|
|
||||||
if (desiredState.equals(currentState)) {
|
|
||||||
plugin.getSpec().setEnabled(PluginState.STARTED.equals(currentState));
|
|
||||||
} else {
|
|
||||||
String pluginName = plugin.getMetadata().getName();
|
|
||||||
PluginStartingError startingError =
|
|
||||||
haloPluginManager.getPluginStartingError(plugin.getMetadata().getName());
|
|
||||||
if (startingError == null) {
|
|
||||||
startingError = PluginStartingError.of(pluginName, "Unknown error", "");
|
|
||||||
}
|
|
||||||
status.setReason(startingError.getMessage());
|
|
||||||
status.setMessage(startingError.getDevMessage());
|
|
||||||
client.fetch(Plugin.class, pluginName).ifPresent(client::update);
|
|
||||||
throw new PluginRuntimeException(startingError.getMessage());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void addFinalizerIfNecessary(Plugin oldPlugin) {
|
private void addFinalizerIfNecessary(Plugin oldPlugin) {
|
||||||
|
@ -302,16 +517,41 @@ public class PluginReconciler implements Reconciler<Request> {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void cleanUpResources(Plugin plugin) {
|
private void cleanUpResources(Plugin plugin) {
|
||||||
PluginWrapper pluginWrapper = haloPluginManager.getPlugin(plugin.getMetadata().getName());
|
String name = plugin.getMetadata().getName();
|
||||||
|
// delete initial reverse proxy
|
||||||
|
String initialReverseProxyName = initialReverseProxyName(name);
|
||||||
|
client.fetch(ReverseProxy.class, initialReverseProxyName)
|
||||||
|
.ifPresent(client::delete);
|
||||||
|
retryTemplate.execute(callback -> {
|
||||||
|
client.fetch(ReverseProxy.class, initialReverseProxyName).ifPresent(item -> {
|
||||||
|
throw new IllegalStateException(
|
||||||
|
"Waiting for reverseproxy [" + initialReverseProxyName + "] to be deleted.");
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
|
// delete plugin setting form
|
||||||
|
String settingName = plugin.getSpec().getSettingName();
|
||||||
|
if (StringUtils.isNotBlank(settingName)) {
|
||||||
|
client.fetch(Setting.class, settingName)
|
||||||
|
.ifPresent(client::delete);
|
||||||
|
retryTemplate.execute(callback -> {
|
||||||
|
client.fetch(Setting.class, settingName).ifPresent(setting -> {
|
||||||
|
throw new IllegalStateException("Waiting for setting to be deleted.");
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
PluginWrapper pluginWrapper = haloPluginManager.getPlugin(name);
|
||||||
if (pluginWrapper == null) {
|
if (pluginWrapper == null) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
// pluginWrapper must not be null in below code
|
||||||
// stop and unload plugin, see also PluginBeforeStopSyncListener
|
// stop and unload plugin, see also PluginBeforeStopSyncListener
|
||||||
haloPluginManager.stopPlugin(pluginWrapper.getPluginId());
|
if (!haloPluginManager.unloadPlugin(name)) {
|
||||||
haloPluginManager.unloadPlugin(pluginWrapper.getPluginId());
|
throw new IllegalStateException("Failed to unload plugin: " + name);
|
||||||
// delete initial reverse proxy
|
}
|
||||||
client.fetch(ReverseProxy.class, initialReverseProxyName(pluginWrapper.getPluginId()))
|
|
||||||
.ifPresent(client::delete);
|
|
||||||
// delete plugin resources
|
// delete plugin resources
|
||||||
if (RuntimeMode.DEPLOYMENT.equals(pluginWrapper.getRuntimeMode())) {
|
if (RuntimeMode.DEPLOYMENT.equals(pluginWrapper.getRuntimeMode())) {
|
||||||
// delete plugin file
|
// delete plugin file
|
||||||
|
@ -352,56 +592,6 @@ public class PluginReconciler implements Reconciler<Request> {
|
||||||
}, () -> client.create(reverseProxy));
|
}, () -> client.create(reverseProxy));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void settingDefaultConfig(Plugin plugin) {
|
|
||||||
Assert.notNull(plugin, "The plugin must not be null.");
|
|
||||||
final String settingName = plugin.getSpec().getSettingName();
|
|
||||||
if (StringUtils.isBlank(settingName)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final String configMapNameToUse = plugin.getSpec().getConfigMapName();
|
|
||||||
if (StringUtils.isBlank(configMapNameToUse)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean existConfigMap = client.fetch(ConfigMap.class, configMapNameToUse)
|
|
||||||
.isPresent();
|
|
||||||
if (existConfigMap) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Optional<Setting> settingOption = client.fetch(Setting.class, settingName);
|
|
||||||
// Fix gh-3224
|
|
||||||
// Maybe Setting is being created and cannot be queried. so try again.
|
|
||||||
if (settingOption.isEmpty()) {
|
|
||||||
client.fetch(Plugin.class, plugin.getMetadata().getName())
|
|
||||||
.ifPresent(newPlugin -> {
|
|
||||||
final Plugin.PluginStatus oldStatus =
|
|
||||||
JsonUtils.deepCopy(newPlugin.statusNonNull());
|
|
||||||
Plugin.PluginStatus status = newPlugin.statusNonNull();
|
|
||||||
status.setPhase(PluginState.FAILED);
|
|
||||||
status.setReason("ResourceNotReady");
|
|
||||||
status.setMessage(
|
|
||||||
"Setting named " + settingName + " is not ready, retrying...");
|
|
||||||
status.setLastTransitionTime(Instant.now());
|
|
||||||
if (!oldStatus.equals(status)
|
|
||||||
&& !StringUtils.equals(oldStatus.getReason(), status.getReason())) {
|
|
||||||
client.update(newPlugin);
|
|
||||||
}
|
|
||||||
throw new IllegalStateException(status.getMessage());
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var data = SettingUtils.settingDefinedDefaultValueMap(settingOption.get());
|
|
||||||
// Create with or without default value
|
|
||||||
ConfigMap configMap = new ConfigMap();
|
|
||||||
configMap.setMetadata(new Metadata());
|
|
||||||
configMap.getMetadata().setName(configMapNameToUse);
|
|
||||||
configMap.setData(data);
|
|
||||||
client.create(configMap);
|
|
||||||
}
|
|
||||||
|
|
||||||
static String initialReverseProxyName(String pluginName) {
|
static String initialReverseProxyName(String pluginName) {
|
||||||
return pluginName + "-system-generated-reverse-proxy";
|
return pluginName + "-system-generated-reverse-proxy";
|
||||||
}
|
}
|
||||||
|
|
|
@ -5,9 +5,13 @@ import java.time.Instant;
|
||||||
import lombok.AllArgsConstructor;
|
import lombok.AllArgsConstructor;
|
||||||
import lombok.Builder;
|
import lombok.Builder;
|
||||||
import lombok.Data;
|
import lombok.Data;
|
||||||
|
import lombok.EqualsAndHashCode;
|
||||||
import lombok.NoArgsConstructor;
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* EqualsAndHashCode 排除了lastTransitionTime否则失败时,lastTransitionTime 会被更新
|
||||||
|
* 导致 equals 为 false,一直被加入队列.
|
||||||
|
*
|
||||||
* @author guqing
|
* @author guqing
|
||||||
* @see
|
* @see
|
||||||
* <a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-conditions">pod-conditions</a>
|
* <a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-conditions">pod-conditions</a>
|
||||||
|
@ -17,6 +21,7 @@ import lombok.NoArgsConstructor;
|
||||||
@Builder
|
@Builder
|
||||||
@NoArgsConstructor
|
@NoArgsConstructor
|
||||||
@AllArgsConstructor
|
@AllArgsConstructor
|
||||||
|
@EqualsAndHashCode(exclude = "lastTransitionTime")
|
||||||
public class Condition {
|
public class Condition {
|
||||||
/**
|
/**
|
||||||
* type of condition in CamelCase or in foo.example.com/CamelCase.
|
* type of condition in CamelCase or in foo.example.com/CamelCase.
|
||||||
|
|
|
@ -31,6 +31,9 @@ public class ConditionList extends AbstractCollection<Condition> {
|
||||||
}
|
}
|
||||||
|
|
||||||
public boolean addFirst(@NonNull Condition condition) {
|
public boolean addFirst(@NonNull Condition condition) {
|
||||||
|
if (isSame(conditions.peekFirst(), condition)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
conditions.addFirst(condition);
|
conditions.addFirst(condition);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
package run.halo.app.plugin;
|
package run.halo.app.plugin;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
@ -39,6 +40,14 @@ public class PluginBeforeStopSyncListener {
|
||||||
return Flux.fromIterable(gvkExtensionNames.entrySet())
|
return Flux.fromIterable(gvkExtensionNames.entrySet())
|
||||||
.flatMap(entry -> Flux.fromIterable(entry.getValue())
|
.flatMap(entry -> Flux.fromIterable(entry.getValue())
|
||||||
.flatMap(extensionName -> client.fetch(entry.getKey(), extensionName))
|
.flatMap(extensionName -> client.fetch(entry.getKey(), extensionName))
|
||||||
|
.filter(unstructured -> {
|
||||||
|
Map<String, String> annotations = unstructured.getMetadata().getAnnotations();
|
||||||
|
if (annotations == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
String stage = PluginConst.DeleteStage.STOP.name();
|
||||||
|
return stage.equals(annotations.getOrDefault(PluginConst.DELETE_STAGE, stage));
|
||||||
|
})
|
||||||
.flatMap(client::delete))
|
.flatMap(client::delete))
|
||||||
.then();
|
.then();
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,9 +12,16 @@ public interface PluginConst {
|
||||||
*/
|
*/
|
||||||
String PLUGIN_NAME_LABEL_NAME = "plugin.halo.run/plugin-name";
|
String PLUGIN_NAME_LABEL_NAME = "plugin.halo.run/plugin-name";
|
||||||
|
|
||||||
|
String DELETE_STAGE = "delete-stage";
|
||||||
|
|
||||||
String SYSTEM_PLUGIN_NAME = "system";
|
String SYSTEM_PLUGIN_NAME = "system";
|
||||||
|
|
||||||
static String assertsRoutePrefix(String pluginName) {
|
static String assertsRoutePrefix(String pluginName) {
|
||||||
return "/plugins/" + pluginName + "/assets/";
|
return "/plugins/" + pluginName + "/assets/";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum DeleteStage {
|
||||||
|
STOP,
|
||||||
|
UNINSTALL
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
package run.halo.app.plugin;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.util.Collections;
|
||||||
|
import java.util.HashSet;
|
||||||
|
import java.util.Set;
|
||||||
|
import java.util.jar.JarFile;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
import java.util.stream.Stream;
|
||||||
|
import java.util.zip.ZipEntry;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.pf4j.DevelopmentPluginClasspath;
|
||||||
|
import org.pf4j.PluginRuntimeException;
|
||||||
|
import org.pf4j.RuntimeMode;
|
||||||
|
|
||||||
|
@Slf4j
|
||||||
|
public class PluginExtensionLoaderUtils {
|
||||||
|
static final String EXTENSION_LOCATION = "extensions";
|
||||||
|
static final DevelopmentPluginClasspath PLUGIN_CLASSPATH = new DevelopmentPluginClasspath();
|
||||||
|
|
||||||
|
public static Set<String> lookupExtensions(Path pluginPath, RuntimeMode runtimeMode) {
|
||||||
|
if (RuntimeMode.DEVELOPMENT.equals(runtimeMode)) {
|
||||||
|
return lookupFromClasses(pluginPath);
|
||||||
|
} else {
|
||||||
|
return lookupFromJar(pluginPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Set<String> lookupFromClasses(Path pluginPath) {
|
||||||
|
Set<String> result = new HashSet<>();
|
||||||
|
for (String directory : PLUGIN_CLASSPATH.getClassesDirectories()) {
|
||||||
|
File file = pluginPath.resolve(directory).resolve(EXTENSION_LOCATION).toFile();
|
||||||
|
if (file.exists() && file.isDirectory()) {
|
||||||
|
result.addAll(walkExtensionFiles(file.toPath()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Set<String> walkExtensionFiles(Path location) {
|
||||||
|
try (Stream<Path> stream = Files.walk(location)) {
|
||||||
|
return stream.map(Path::normalize)
|
||||||
|
.filter(Files::isRegularFile)
|
||||||
|
.filter(path -> isYamlFile(path.getFileName().toString()))
|
||||||
|
.map(path -> location.getParent().relativize(path).toString())
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
} catch (IOException e) {
|
||||||
|
log.debug("Failed to walk extension files from [{}]", location);
|
||||||
|
return Collections.emptySet();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
static boolean isYamlFile(String path) {
|
||||||
|
return path.endsWith(".yaml") || path.endsWith(".yml");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* <p>Lists the path of the unstructured yaml configuration file from the plugin jar.</p>
|
||||||
|
*
|
||||||
|
* @param pluginJarPath plugin jar path
|
||||||
|
* @return Unstructured file paths relative to plugin classpath
|
||||||
|
* @throws PluginRuntimeException If loading the file fails
|
||||||
|
*/
|
||||||
|
static Set<String> lookupFromJar(Path pluginJarPath) {
|
||||||
|
try (JarFile jarFile = new JarFile(pluginJarPath.toFile())) {
|
||||||
|
return jarFile.stream()
|
||||||
|
.filter(jarEntry -> {
|
||||||
|
String name = jarEntry.getName();
|
||||||
|
return name.startsWith(EXTENSION_LOCATION)
|
||||||
|
&& !jarEntry.isDirectory()
|
||||||
|
&& isYamlFile(name);
|
||||||
|
})
|
||||||
|
.map(ZipEntry::getName)
|
||||||
|
.collect(Collectors.toSet());
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new PluginRuntimeException(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,22 +1,9 @@
|
||||||
package run.halo.app.plugin;
|
package run.halo.app.plugin;
|
||||||
|
|
||||||
import java.io.File;
|
import static run.halo.app.plugin.PluginExtensionLoaderUtils.lookupExtensions;
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.file.Files;
|
|
||||||
import java.nio.file.Path;
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.HashSet;
|
|
||||||
import java.util.Set;
|
|
||||||
import java.util.jar.JarFile;
|
|
||||||
import java.util.stream.Collectors;
|
|
||||||
import java.util.stream.Stream;
|
|
||||||
import java.util.zip.ZipEntry;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.pf4j.DevelopmentPluginClasspath;
|
|
||||||
import org.pf4j.PluginRuntimeException;
|
|
||||||
import org.pf4j.PluginWrapper;
|
import org.pf4j.PluginWrapper;
|
||||||
import org.pf4j.RuntimeMode;
|
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.core.io.DefaultResourceLoader;
|
import org.springframework.core.io.DefaultResourceLoader;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
|
@ -85,69 +72,4 @@ public class PluginStartedListener {
|
||||||
}).then();
|
}).then();
|
||||||
}).then();
|
}).then();
|
||||||
}
|
}
|
||||||
|
|
||||||
Set<String> lookupExtensions(Path pluginPath, RuntimeMode runtimeMode) {
|
|
||||||
if (RuntimeMode.DEVELOPMENT.equals(runtimeMode)) {
|
|
||||||
return PluginExtensionLoaderUtils.lookupFromClasses(pluginPath);
|
|
||||||
} else {
|
|
||||||
return PluginExtensionLoaderUtils.lookupFromJar(pluginPath);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Slf4j
|
|
||||||
static class PluginExtensionLoaderUtils {
|
|
||||||
static final String EXTENSION_LOCATION = "extensions";
|
|
||||||
static final DevelopmentPluginClasspath PLUGIN_CLASSPATH = new DevelopmentPluginClasspath();
|
|
||||||
|
|
||||||
static Set<String> lookupFromClasses(Path pluginPath) {
|
|
||||||
Set<String> result = new HashSet<>();
|
|
||||||
for (String directory : PLUGIN_CLASSPATH.getClassesDirectories()) {
|
|
||||||
File file = pluginPath.resolve(directory).resolve(EXTENSION_LOCATION).toFile();
|
|
||||||
if (file.exists() && file.isDirectory()) {
|
|
||||||
result.addAll(walkExtensionFiles(file.toPath()));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Set<String> walkExtensionFiles(Path location) {
|
|
||||||
try (Stream<Path> stream = Files.walk(location)) {
|
|
||||||
return stream.map(Path::normalize)
|
|
||||||
.filter(Files::isRegularFile)
|
|
||||||
.filter(path -> isYamlFile(path.getFileName().toString()))
|
|
||||||
.map(path -> location.getParent().relativize(path).toString())
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
} catch (IOException e) {
|
|
||||||
log.debug("Failed to walk extension files from [{}]", location);
|
|
||||||
return Collections.emptySet();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static boolean isYamlFile(String path) {
|
|
||||||
return path.endsWith(".yaml") || path.endsWith(".yml");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* <p>Lists the path of the unstructured yaml configuration file from the plugin jar.</p>
|
|
||||||
*
|
|
||||||
* @param pluginJarPath plugin jar path
|
|
||||||
* @return Unstructured file paths relative to plugin classpath
|
|
||||||
* @throws PluginRuntimeException If loading the file fails
|
|
||||||
*/
|
|
||||||
static Set<String> lookupFromJar(Path pluginJarPath) {
|
|
||||||
try (JarFile jarFile = new JarFile(pluginJarPath.toFile())) {
|
|
||||||
return jarFile.stream()
|
|
||||||
.filter(jarEntry -> {
|
|
||||||
String name = jarEntry.getName();
|
|
||||||
return name.startsWith(EXTENSION_LOCATION)
|
|
||||||
&& !jarEntry.isDirectory()
|
|
||||||
&& isYamlFile(name);
|
|
||||||
})
|
|
||||||
.map(ZipEntry::getName)
|
|
||||||
.collect(Collectors.toSet());
|
|
||||||
} catch (IOException e) {
|
|
||||||
throw new PluginRuntimeException(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -23,7 +23,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.ArgumentCaptor;
|
import org.mockito.ArgumentCaptor;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.pf4j.PluginRuntimeException;
|
import org.mockito.stubbing.Answer;
|
||||||
import org.pf4j.PluginState;
|
import org.pf4j.PluginState;
|
||||||
import org.pf4j.PluginWrapper;
|
import org.pf4j.PluginWrapper;
|
||||||
import org.pf4j.RuntimeMode;
|
import org.pf4j.RuntimeMode;
|
||||||
|
@ -74,15 +74,18 @@ class PluginReconcilerTest {
|
||||||
@DisplayName("Reconcile to start successfully")
|
@DisplayName("Reconcile to start successfully")
|
||||||
void reconcileOkWhenPluginManagerStartSuccessfully() {
|
void reconcileOkWhenPluginManagerStartSuccessfully() {
|
||||||
Plugin plugin = need2ReconcileForStartupState();
|
Plugin plugin = need2ReconcileForStartupState();
|
||||||
when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin));
|
|
||||||
when(haloPluginManager.startPlugin(any())).thenReturn(PluginState.STARTED);
|
|
||||||
// mock plugin real state is started
|
|
||||||
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED);
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED);
|
||||||
|
when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin));
|
||||||
|
when(haloPluginManager.startPlugin(any())).thenAnswer((Answer<PluginState>) invocation -> {
|
||||||
|
// mock plugin real state is started
|
||||||
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
||||||
|
return PluginState.STARTED;
|
||||||
|
});
|
||||||
|
|
||||||
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
|
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
|
||||||
verify(extensionClient, times(3)).update(isA(Plugin.class));
|
verify(extensionClient, times(2)).update(isA(Plugin.class));
|
||||||
|
|
||||||
Plugin updateArgs = pluginCaptor.getValue();
|
Plugin updateArgs = pluginCaptor.getAllValues().get(1);
|
||||||
assertThat(updateArgs).isNotNull();
|
assertThat(updateArgs).isNotNull();
|
||||||
assertThat(updateArgs.getSpec().getEnabled()).isTrue();
|
assertThat(updateArgs.getSpec().getEnabled()).isTrue();
|
||||||
assertThat(updateArgs.getStatus().getPhase()).isEqualTo(PluginState.STARTED);
|
assertThat(updateArgs.getStatus().getPhase()).isEqualTo(PluginState.STARTED);
|
||||||
|
@ -93,10 +96,15 @@ class PluginReconcilerTest {
|
||||||
@DisplayName("Reconcile to start failed")
|
@DisplayName("Reconcile to start failed")
|
||||||
void reconcileOkWhenPluginManagerStartFailed() {
|
void reconcileOkWhenPluginManagerStartFailed() {
|
||||||
Plugin plugin = need2ReconcileForStartupState();
|
Plugin plugin = need2ReconcileForStartupState();
|
||||||
when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin));
|
|
||||||
|
|
||||||
// mock start plugin failed
|
// mock start plugin failed
|
||||||
when(haloPluginManager.startPlugin(any())).thenReturn(PluginState.FAILED);
|
when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin));
|
||||||
|
when(haloPluginManager.startPlugin(any())).thenAnswer((Answer<PluginState>) invocation -> {
|
||||||
|
// mock plugin real state is started
|
||||||
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.FAILED);
|
||||||
|
return PluginState.FAILED;
|
||||||
|
});
|
||||||
|
|
||||||
// mock plugin real state is started
|
// mock plugin real state is started
|
||||||
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED);
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED);
|
||||||
|
|
||||||
|
@ -114,10 +122,10 @@ class PluginReconcilerTest {
|
||||||
|
|
||||||
Plugin.PluginStatus status = updateArgs.getStatus();
|
Plugin.PluginStatus status = updateArgs.getStatus();
|
||||||
assertThat(status.getPhase()).isEqualTo(PluginState.FAILED);
|
assertThat(status.getPhase()).isEqualTo(PluginState.FAILED);
|
||||||
assertThat(status.getReason()).isEqualTo("error message");
|
assertThat(status.getConditions().peek().getReason()).isEqualTo("error message");
|
||||||
assertThat(status.getMessage()).isEqualTo("dev message");
|
assertThat(status.getConditions().peek().getMessage()).isEqualTo("dev message");
|
||||||
assertThat(status.getLastStartTime()).isNull();
|
assertThat(status.getLastStartTime()).isNull();
|
||||||
}).isInstanceOf(PluginRuntimeException.class)
|
}).isInstanceOf(IllegalStateException.class)
|
||||||
.hasMessage("error message");
|
.hasMessage("error message");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -127,12 +135,15 @@ class PluginReconcilerTest {
|
||||||
void shouldReconcileStopWhenEnabledIsFalseAndPhaseIsStarted() {
|
void shouldReconcileStopWhenEnabledIsFalseAndPhaseIsStarted() {
|
||||||
Plugin plugin = need2ReconcileForStopState();
|
Plugin plugin = need2ReconcileForStopState();
|
||||||
when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin));
|
when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin));
|
||||||
when(haloPluginManager.stopPlugin(any())).thenReturn(PluginState.STOPPED);
|
when(haloPluginManager.stopPlugin(any())).thenAnswer((Answer<PluginState>) invocation -> {
|
||||||
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED);
|
||||||
|
return PluginState.STOPPED;
|
||||||
|
});
|
||||||
// mock plugin real state is started
|
// mock plugin real state is started
|
||||||
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
||||||
|
|
||||||
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
|
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
|
||||||
verify(extensionClient, times(4)).update(any(Plugin.class));
|
verify(extensionClient, times(2)).update(any(Plugin.class));
|
||||||
|
|
||||||
Plugin updateArgs = pluginCaptor.getValue();
|
Plugin updateArgs = pluginCaptor.getValue();
|
||||||
assertThat(updateArgs).isNotNull();
|
assertThat(updateArgs).isNotNull();
|
||||||
|
@ -162,12 +173,15 @@ class PluginReconcilerTest {
|
||||||
}
|
}
|
||||||
""", Plugin.class);
|
""", Plugin.class);
|
||||||
when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin));
|
when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin));
|
||||||
when(haloPluginManager.stopPlugin(any())).thenReturn(PluginState.STOPPED);
|
when(haloPluginManager.stopPlugin(any())).thenAnswer((Answer<PluginState>) invocation -> {
|
||||||
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED);
|
||||||
|
return PluginState.STOPPED;
|
||||||
|
});
|
||||||
// mock plugin real state is started
|
// mock plugin real state is started
|
||||||
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
||||||
|
|
||||||
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
|
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
|
||||||
verify(extensionClient, times(4)).update(any(Plugin.class));
|
verify(extensionClient, times(2)).update(any(Plugin.class));
|
||||||
|
|
||||||
Plugin updateArgs = pluginCaptor.getValue();
|
Plugin updateArgs = pluginCaptor.getValue();
|
||||||
assertThat(updateArgs).isNotNull();
|
assertThat(updateArgs).isNotNull();
|
||||||
|
@ -186,11 +200,6 @@ class PluginReconcilerTest {
|
||||||
// mock plugin real state is started
|
// mock plugin real state is started
|
||||||
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
|
||||||
|
|
||||||
// mock stop failed message
|
|
||||||
PluginStartingError pluginStartingError =
|
|
||||||
PluginStartingError.of("apples", "error message", "dev message");
|
|
||||||
when(haloPluginManager.getPluginStartingError(any())).thenReturn(pluginStartingError);
|
|
||||||
|
|
||||||
assertThatThrownBy(() -> {
|
assertThatThrownBy(() -> {
|
||||||
ArgumentCaptor<Plugin> pluginCaptor = doReconcileNeedRequeue();
|
ArgumentCaptor<Plugin> pluginCaptor = doReconcileNeedRequeue();
|
||||||
|
|
||||||
|
@ -200,10 +209,8 @@ class PluginReconcilerTest {
|
||||||
|
|
||||||
Plugin.PluginStatus status = updateArgs.getStatus();
|
Plugin.PluginStatus status = updateArgs.getStatus();
|
||||||
assertThat(status.getPhase()).isEqualTo(PluginState.FAILED);
|
assertThat(status.getPhase()).isEqualTo(PluginState.FAILED);
|
||||||
assertThat(status.getReason()).isEqualTo("error message");
|
}).isInstanceOf(IllegalStateException.class)
|
||||||
assertThat(status.getMessage()).isEqualTo("dev message");
|
.hasMessage("Failed to stop plugin: apples");
|
||||||
}).isInstanceOf(PluginRuntimeException.class)
|
|
||||||
.hasMessage("error message");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
@ -35,7 +35,7 @@ class PluginStartedListenerTest {
|
||||||
Files.createFile(extensions.resolve("roles.yaml"));
|
Files.createFile(extensions.resolve("roles.yaml"));
|
||||||
|
|
||||||
Set<String> extensionResources =
|
Set<String> extensionResources =
|
||||||
PluginStartedListener.PluginExtensionLoaderUtils.lookupFromClasses(tempPluginPath);
|
PluginExtensionLoaderUtils.lookupFromClasses(tempPluginPath);
|
||||||
assertThat(extensionResources)
|
assertThat(extensionResources)
|
||||||
.containsAll(Set.of(Path.of("extensions/roles.yaml").toString()));
|
.containsAll(Set.of(Path.of("extensions/roles.yaml").toString()));
|
||||||
}
|
}
|
||||||
|
@ -50,7 +50,7 @@ class PluginStartedListenerTest {
|
||||||
Path targetJarPath = tempDirectory.resolve("plugin-0.0.1.jar");
|
Path targetJarPath = tempDirectory.resolve("plugin-0.0.1.jar");
|
||||||
FileUtils.jar(Paths.get(plugin001Uri), targetJarPath);
|
FileUtils.jar(Paths.get(plugin001Uri), targetJarPath);
|
||||||
Set<String> unstructuredFilePathFromJar =
|
Set<String> unstructuredFilePathFromJar =
|
||||||
PluginStartedListener.PluginExtensionLoaderUtils.lookupFromJar(targetJarPath);
|
PluginExtensionLoaderUtils.lookupFromJar(targetJarPath);
|
||||||
assertThat(unstructuredFilePathFromJar).hasSize(3);
|
assertThat(unstructuredFilePathFromJar).hasSize(3);
|
||||||
assertThat(unstructuredFilePathFromJar).containsAll(Set.of(
|
assertThat(unstructuredFilePathFromJar).containsAll(Set.of(
|
||||||
Path.of("extensions/roles.yaml").toString(),
|
Path.of("extensions/roles.yaml").toString(),
|
||||||
|
|
Loading…
Reference in New Issue