From 6c2064f1e07af123077760450426f3b2922ed9b1 Mon Sep 17 00:00:00 2001 From: guqing <38999863+guqing@users.noreply.github.com> Date: Thu, 23 Feb 2023 17:26:12 +0800 Subject: [PATCH] feat: plugin supports configuration when not started (#3355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### 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 允许插件在未启动时更改设置 ``` --- .../run/halo/app/core/extension/Plugin.java | 16 +- .../reconciler/PluginReconciler.java | 632 ++++++++++++------ .../java/run/halo/app/infra/Condition.java | 5 + .../run/halo/app/infra/ConditionList.java | 3 + .../plugin/PluginBeforeStopSyncListener.java | 9 + .../java/run/halo/app/plugin/PluginConst.java | 7 + .../plugin/PluginExtensionLoaderUtils.java | 82 +++ .../app/plugin/PluginStartedListener.java | 82 +-- .../reconciler/PluginReconcilerTest.java | 55 +- .../app/plugin/PluginStartedListenerTest.java | 4 +- 10 files changed, 563 insertions(+), 332 deletions(-) create mode 100644 src/main/java/run/halo/app/plugin/PluginExtensionLoaderUtils.java diff --git a/src/main/java/run/halo/app/core/extension/Plugin.java b/src/main/java/run/halo/app/core/extension/Plugin.java index 1348ee607..bdbb37aed 100644 --- a/src/main/java/run/halo/app/core/extension/Plugin.java +++ b/src/main/java/run/halo/app/core/extension/Plugin.java @@ -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 diff --git a/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java index abc79a64c..d14f44dba 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java @@ -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 { 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 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 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 settingOption = lookupPluginSetting(pluginName, settingName) + .map(setting -> { + // This annotation is added to prevent it from being deleted when stopped. + Map 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 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 { 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 { } 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 { } 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 { }, () -> 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 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"; } diff --git a/src/main/java/run/halo/app/infra/Condition.java b/src/main/java/run/halo/app/infra/Condition.java index 557d6c906..2d834cf13 100644 --- a/src/main/java/run/halo/app/infra/Condition.java +++ b/src/main/java/run/halo/app/infra/Condition.java @@ -5,9 +5,13 @@ import java.time.Instant; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; +import lombok.EqualsAndHashCode; import lombok.NoArgsConstructor; /** + * EqualsAndHashCode 排除了lastTransitionTime否则失败时,lastTransitionTime 会被更新 + * 导致 equals 为 false,一直被加入队列. + * * @author guqing * @see * pod-conditions @@ -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. diff --git a/src/main/java/run/halo/app/infra/ConditionList.java b/src/main/java/run/halo/app/infra/ConditionList.java index 6bd31ab2f..28fdc42cf 100644 --- a/src/main/java/run/halo/app/infra/ConditionList.java +++ b/src/main/java/run/halo/app/infra/ConditionList.java @@ -31,6 +31,9 @@ public class ConditionList extends AbstractCollection { } public boolean addFirst(@NonNull Condition condition) { + if (isSame(conditions.peekFirst(), condition)) { + return false; + } conditions.addFirst(condition); return true; } diff --git a/src/main/java/run/halo/app/plugin/PluginBeforeStopSyncListener.java b/src/main/java/run/halo/app/plugin/PluginBeforeStopSyncListener.java index 06065fef2..a22ec8936 100644 --- a/src/main/java/run/halo/app/plugin/PluginBeforeStopSyncListener.java +++ b/src/main/java/run/halo/app/plugin/PluginBeforeStopSyncListener.java @@ -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 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(); } diff --git a/src/main/java/run/halo/app/plugin/PluginConst.java b/src/main/java/run/halo/app/plugin/PluginConst.java index d497c5fa9..6713dd4ae 100644 --- a/src/main/java/run/halo/app/plugin/PluginConst.java +++ b/src/main/java/run/halo/app/plugin/PluginConst.java @@ -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 + } } diff --git a/src/main/java/run/halo/app/plugin/PluginExtensionLoaderUtils.java b/src/main/java/run/halo/app/plugin/PluginExtensionLoaderUtils.java new file mode 100644 index 000000000..05febc797 --- /dev/null +++ b/src/main/java/run/halo/app/plugin/PluginExtensionLoaderUtils.java @@ -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 lookupExtensions(Path pluginPath, RuntimeMode runtimeMode) { + if (RuntimeMode.DEVELOPMENT.equals(runtimeMode)) { + return lookupFromClasses(pluginPath); + } else { + return lookupFromJar(pluginPath); + } + } + + public static Set lookupFromClasses(Path pluginPath) { + Set 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 walkExtensionFiles(Path location) { + try (Stream 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"); + } + + /** + *

Lists the path of the unstructured yaml configuration file from the plugin jar.

+ * + * @param pluginJarPath plugin jar path + * @return Unstructured file paths relative to plugin classpath + * @throws PluginRuntimeException If loading the file fails + */ + static Set 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); + } + } +} diff --git a/src/main/java/run/halo/app/plugin/PluginStartedListener.java b/src/main/java/run/halo/app/plugin/PluginStartedListener.java index 2a500002a..27fea6675 100644 --- a/src/main/java/run/halo/app/plugin/PluginStartedListener.java +++ b/src/main/java/run/halo/app/plugin/PluginStartedListener.java @@ -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 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 lookupFromClasses(Path pluginPath) { - Set 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 walkExtensionFiles(Path location) { - try (Stream 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"); - } - - /** - *

Lists the path of the unstructured yaml configuration file from the plugin jar.

- * - * @param pluginJarPath plugin jar path - * @return Unstructured file paths relative to plugin classpath - * @throws PluginRuntimeException If loading the file fails - */ - static Set 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); - } - } - } } diff --git a/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java index af93287d6..b7ff0ac45 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java @@ -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) invocation -> { + // mock plugin real state is started + when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED); + return PluginState.STARTED; + }); ArgumentCaptor 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) 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) invocation -> { + when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED); + return PluginState.STOPPED; + }); // mock plugin real state is started when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED); ArgumentCaptor 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) invocation -> { + when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED); + return PluginState.STOPPED; + }); // mock plugin real state is started when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED); ArgumentCaptor 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 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 diff --git a/src/test/java/run/halo/app/plugin/PluginStartedListenerTest.java b/src/test/java/run/halo/app/plugin/PluginStartedListenerTest.java index 888c9c507..c5c65c9bf 100644 --- a/src/test/java/run/halo/app/plugin/PluginStartedListenerTest.java +++ b/src/test/java/run/halo/app/plugin/PluginStartedListenerTest.java @@ -35,7 +35,7 @@ class PluginStartedListenerTest { Files.createFile(extensions.resolve("roles.yaml")); Set 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 unstructuredFilePathFromJar = - PluginStartedListener.PluginExtensionLoaderUtils.lookupFromJar(targetJarPath); + PluginExtensionLoaderUtils.lookupFromJar(targetJarPath); assertThat(unstructuredFilePathFromJar).hasSize(3); assertThat(unstructuredFilePathFromJar).containsAll(Set.of( Path.of("extensions/roles.yaml").toString(),