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
guqing 2023-02-23 17:26:12 +08:00 committed by GitHub
parent 03ec23f90f
commit 6c2064f1e0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 563 additions and 332 deletions

View File

@ -13,8 +13,10 @@ import lombok.EqualsAndHashCode;
import lombok.ToString;
import org.pf4j.PluginState;
import org.springframework.lang.NonNull;
import org.springframework.util.Assert;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import run.halo.app.infra.ConditionList;
/**
* A custom resource for Plugin.
@ -102,19 +104,23 @@ public class Plugin extends AbstractExtension {
private PluginState phase;
private String reason;
private String message;
private ConditionList conditions;
private Instant lastStartTime;
private Instant lastTransitionTime;
private String entry;
private String stylesheet;
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

View File

@ -1,25 +1,35 @@
package run.halo.app.core.extension.reconciler;
import static run.halo.app.plugin.PluginConst.DELETE_STAGE;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.function.Function;
import java.util.stream.Collectors;
import lombok.AllArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.BooleanUtils;
import org.apache.commons.lang3.StringUtils;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginDescriptor;
import org.pf4j.PluginState;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
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.util.Assert;
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.extension.ConfigMap;
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.Unstructured;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
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.PathUtils;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.PluginConst;
import run.halo.app.plugin.PluginExtensionLoaderUtils;
import run.halo.app.plugin.PluginNotFoundException;
import run.halo.app.plugin.PluginStartingError;
import run.halo.app.plugin.event.PluginCreatedEvent;
@ -56,20 +73,367 @@ public class PluginReconciler implements Reconciler<Request> {
private final ExtensionClient client;
private final HaloPluginManager haloPluginManager;
private final ApplicationEventPublisher eventPublisher;
private final RetryTemplate retryTemplate = RetryTemplate.builder()
.maxAttempts(20)
.fixedBackoff(300)
.retryOn(IllegalStateException.class)
.build();
@Override
public Result reconcile(Request request) {
client.fetch(Plugin.class, request.name())
.ifPresent(plugin -> {
return client.fetch(Plugin.class, request.name())
.map(plugin -> {
if (plugin.getMetadata().getDeletionTimestamp() != null) {
cleanUpResourcesAndRemoveFinalizer(request.name());
return;
return Result.doNotRetry();
}
addFinalizerIfNecessary(plugin);
// if true returned, it means it is not ready
if (readinessDetection(request.name())) {
return new Result(true, null);
}
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);
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
@ -84,60 +448,16 @@ public class PluginReconciler implements Reconciler<Request> {
ensurePluginLoaded();
}
if (!checkPluginState(name)) {
return;
}
client.fetch(Plugin.class, name).ifPresent(plugin -> {
Plugin oldPlugin = JsonUtils.deepCopy(plugin);
Plugin.PluginStatus pluginStatus = plugin.statusNonNull();
String logo = plugin.getSpec().getLogo();
if (PathUtils.isAbsoluteUri(logo)) {
pluginStatus.setLogo(logo);
} else {
String assetsPrefix =
PluginConst.assertsRoutePrefix(plugin.getMetadata().getName());
pluginStatus.setLogo(PathUtils.combinePath(assetsPrefix, logo));
// Transition plugin status if necessary
if (shouldReconcileStartState(plugin)) {
startAction(name);
}
if (!plugin.equals(oldPlugin)) {
client.update(plugin);
if (shouldReconcileStopState(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() {
@ -155,120 +475,15 @@ public class PluginReconciler implements Reconciler<Request> {
}
private boolean shouldReconcileStartState(Plugin plugin) {
return plugin.getSpec().getEnabled()
&& plugin.statusNonNull().getPhase() != PluginState.STARTED;
}
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);
PluginWrapper pluginWrapper = getPluginWrapper(plugin.getMetadata().getName());
return BooleanUtils.isTrue(plugin.getSpec().getEnabled())
&& !PluginState.STARTED.equals(pluginWrapper.getPluginState());
}
private boolean shouldReconcileStopState(Plugin plugin) {
return !plugin.getSpec().getEnabled()
&& plugin.statusNonNull().getPhase() == PluginState.STARTED;
}
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());
}
PluginWrapper pluginWrapper = getPluginWrapper(plugin.getMetadata().getName());
return BooleanUtils.isFalse(plugin.getSpec().getEnabled())
&& PluginState.STARTED.equals(pluginWrapper.getPluginState());
}
private void addFinalizerIfNecessary(Plugin oldPlugin) {
@ -302,16 +517,41 @@ public class PluginReconciler implements Reconciler<Request> {
}
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) {
return;
}
// pluginWrapper must not be null in below code
// stop and unload plugin, see also PluginBeforeStopSyncListener
haloPluginManager.stopPlugin(pluginWrapper.getPluginId());
haloPluginManager.unloadPlugin(pluginWrapper.getPluginId());
// delete initial reverse proxy
client.fetch(ReverseProxy.class, initialReverseProxyName(pluginWrapper.getPluginId()))
.ifPresent(client::delete);
if (!haloPluginManager.unloadPlugin(name)) {
throw new IllegalStateException("Failed to unload plugin: " + name);
}
// delete plugin resources
if (RuntimeMode.DEPLOYMENT.equals(pluginWrapper.getRuntimeMode())) {
// delete plugin file
@ -352,56 +592,6 @@ public class PluginReconciler implements Reconciler<Request> {
}, () -> 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) {
return pluginName + "-system-generated-reverse-proxy";
}

View File

@ -5,9 +5,13 @@ import java.time.Instant;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.NoArgsConstructor;
/**
* EqualsAndHashCode lastTransitionTimelastTransitionTime
* equals false.
*
* @author guqing
* @see
* <a href="https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#pod-conditions">pod-conditions</a>
@ -17,6 +21,7 @@ import lombok.NoArgsConstructor;
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode(exclude = "lastTransitionTime")
public class Condition {
/**
* type of condition in CamelCase or in foo.example.com/CamelCase.

View File

@ -31,6 +31,9 @@ public class ConditionList extends AbstractCollection<Condition> {
}
public boolean addFirst(@NonNull Condition condition) {
if (isSame(conditions.peekFirst(), condition)) {
return false;
}
conditions.addFirst(condition);
return true;
}

View File

@ -1,5 +1,6 @@
package run.halo.app.plugin;
import java.util.Map;
import org.springframework.context.event.EventListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
@ -39,6 +40,14 @@ public class PluginBeforeStopSyncListener {
return Flux.fromIterable(gvkExtensionNames.entrySet())
.flatMap(entry -> Flux.fromIterable(entry.getValue())
.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))
.then();
}

View File

@ -12,9 +12,16 @@ public interface PluginConst {
*/
String PLUGIN_NAME_LABEL_NAME = "plugin.halo.run/plugin-name";
String DELETE_STAGE = "delete-stage";
String SYSTEM_PLUGIN_NAME = "system";
static String assertsRoutePrefix(String pluginName) {
return "/plugins/" + pluginName + "/assets/";
}
enum DeleteStage {
STOP,
UNINSTALL
}
}

View File

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

View File

@ -1,22 +1,9 @@
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 static run.halo.app.plugin.PluginExtensionLoaderUtils.lookupExtensions;
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.RuntimeMode;
import org.springframework.context.event.EventListener;
import org.springframework.core.io.DefaultResourceLoader;
import org.springframework.core.io.Resource;
@ -85,69 +72,4 @@ public class PluginStartedListener {
}).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);
}
}
}
}

View File

@ -23,7 +23,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.pf4j.PluginRuntimeException;
import org.mockito.stubbing.Answer;
import org.pf4j.PluginState;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
@ -74,15 +74,18 @@ class PluginReconcilerTest {
@DisplayName("Reconcile to start successfully")
void reconcileOkWhenPluginManagerStartSuccessfully() {
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(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();
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.getSpec().getEnabled()).isTrue();
assertThat(updateArgs.getStatus().getPhase()).isEqualTo(PluginState.STARTED);
@ -93,10 +96,15 @@ class PluginReconcilerTest {
@DisplayName("Reconcile to start failed")
void reconcileOkWhenPluginManagerStartFailed() {
Plugin plugin = need2ReconcileForStartupState();
when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin));
// 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
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED);
@ -114,10 +122,10 @@ class PluginReconcilerTest {
Plugin.PluginStatus status = updateArgs.getStatus();
assertThat(status.getPhase()).isEqualTo(PluginState.FAILED);
assertThat(status.getReason()).isEqualTo("error message");
assertThat(status.getMessage()).isEqualTo("dev message");
assertThat(status.getConditions().peek().getReason()).isEqualTo("error message");
assertThat(status.getConditions().peek().getMessage()).isEqualTo("dev message");
assertThat(status.getLastStartTime()).isNull();
}).isInstanceOf(PluginRuntimeException.class)
}).isInstanceOf(IllegalStateException.class)
.hasMessage("error message");
}
@ -127,12 +135,15 @@ class PluginReconcilerTest {
void shouldReconcileStopWhenEnabledIsFalseAndPhaseIsStarted() {
Plugin plugin = need2ReconcileForStopState();
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
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
verify(extensionClient, times(4)).update(any(Plugin.class));
verify(extensionClient, times(2)).update(any(Plugin.class));
Plugin updateArgs = pluginCaptor.getValue();
assertThat(updateArgs).isNotNull();
@ -162,12 +173,15 @@ class PluginReconcilerTest {
}
""", Plugin.class);
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
when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED);
ArgumentCaptor<Plugin> pluginCaptor = doReconcileWithoutRequeue();
verify(extensionClient, times(4)).update(any(Plugin.class));
verify(extensionClient, times(2)).update(any(Plugin.class));
Plugin updateArgs = pluginCaptor.getValue();
assertThat(updateArgs).isNotNull();
@ -186,11 +200,6 @@ class PluginReconcilerTest {
// mock plugin real state is 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(() -> {
ArgumentCaptor<Plugin> pluginCaptor = doReconcileNeedRequeue();
@ -200,10 +209,8 @@ class PluginReconcilerTest {
Plugin.PluginStatus status = updateArgs.getStatus();
assertThat(status.getPhase()).isEqualTo(PluginState.FAILED);
assertThat(status.getReason()).isEqualTo("error message");
assertThat(status.getMessage()).isEqualTo("dev message");
}).isInstanceOf(PluginRuntimeException.class)
.hasMessage("error message");
}).isInstanceOf(IllegalStateException.class)
.hasMessage("Failed to stop plugin: apples");
}
@Test

View File

@ -35,7 +35,7 @@ class PluginStartedListenerTest {
Files.createFile(extensions.resolve("roles.yaml"));
Set<String> extensionResources =
PluginStartedListener.PluginExtensionLoaderUtils.lookupFromClasses(tempPluginPath);
PluginExtensionLoaderUtils.lookupFromClasses(tempPluginPath);
assertThat(extensionResources)
.containsAll(Set.of(Path.of("extensions/roles.yaml").toString()));
}
@ -50,7 +50,7 @@ class PluginStartedListenerTest {
Path targetJarPath = tempDirectory.resolve("plugin-0.0.1.jar");
FileUtils.jar(Paths.get(plugin001Uri), targetJarPath);
Set<String> unstructuredFilePathFromJar =
PluginStartedListener.PluginExtensionLoaderUtils.lookupFromJar(targetJarPath);
PluginExtensionLoaderUtils.lookupFromJar(targetJarPath);
assertThat(unstructuredFilePathFromJar).hasSize(3);
assertThat(unstructuredFilePathFromJar).containsAll(Set.of(
Path.of("extensions/roles.yaml").toString(),