From 17c5369fb1f0a112e9d2a453fcf92e42859266f6 Mon Sep 17 00:00:00 2001 From: John Niang Date: Mon, 8 Jan 2024 23:41:03 +0800 Subject: [PATCH] Refactor plugin reconciliation to ensure only one update on plugin Signed-off-by: John Niang --- .../run/halo/app/core/extension/Plugin.java | 17 +- .../controller/DefaultController.java | 4 +- .../app/extension/controller/Reconciler.java | 4 + .../controller/RequeueException.java | 31 + .../controller/DefaultControllerTest.java | 42 + .../extension/endpoint/PluginEndpoint.java | 7 +- .../reconciler/PluginReconciler.java | 1000 +++++------------ .../extension/service/DefaultRoleService.java | 8 +- .../core/extension/theme/SettingUtils.java | 9 + .../halo/app/plugin/HaloPluginManager.java | 28 - .../java/run/halo/app/plugin/PluginConst.java | 2 + .../plugin/PluginDevelopmentInitializer.java | 8 +- .../java/run/halo/app/plugin/PluginUtils.java | 13 + .../app/plugin/SpringExtensionFactory.java | 34 +- .../run/halo/app/plugin/YamlPluginFinder.java | 3 +- .../plugin/resources/BundleResourceUtils.java | 42 +- .../reconciler/PluginReconcilerTest.java | 956 +++++++--------- .../service/impl/PluginServiceImplTest.java | 3 +- .../halo/app/plugin/YamlPluginFinderTest.java | 5 +- .../resources/BundleResourceUtilsTest.java | 20 - .../reconciler/extensions/setting.yaml | 6 + 21 files changed, 907 insertions(+), 1335 deletions(-) create mode 100644 api/src/main/java/run/halo/app/extension/controller/RequeueException.java create mode 100644 application/src/test/resources/run/halo/app/core/extension/reconciler/extensions/setting.yaml diff --git a/api/src/main/java/run/halo/app/core/extension/Plugin.java b/api/src/main/java/run/halo/app/core/extension/Plugin.java index 168afd449d8..a012291ae3b 100644 --- a/api/src/main/java/run/halo/app/core/extension/Plugin.java +++ b/api/src/main/java/run/halo/app/core/extension/Plugin.java @@ -110,12 +110,14 @@ public static class License { @Data public static class PluginStatus { - private PluginState phase; + private Phase phase; private ConditionList conditions; private Instant lastStartTime; + private PluginState lastProbeState; + private String entry; private String stylesheet; @@ -134,6 +136,19 @@ public static ConditionList nullSafeConditions(@NonNull PluginStatus status) { } } + public enum Phase { + PENDING, + STARTING, + CREATED, + DISABLED, + RESOLVED, + STARTED, + STOPPED, + FAILED, + UNKNOWN, + ; + } + @Data @ToString public static class PluginAuthor { diff --git a/api/src/main/java/run/halo/app/extension/controller/DefaultController.java b/api/src/main/java/run/halo/app/extension/controller/DefaultController.java index 5156fb7e32b..532acfd9fa2 100644 --- a/api/src/main/java/run/halo/app/extension/controller/DefaultController.java +++ b/api/src/main/java/run/halo/app/extension/controller/DefaultController.java @@ -165,15 +165,17 @@ public void run() { log.debug("{} >>> Reconciled request: {} with result: {}, usage: {}", this.name, entry.getEntry(), result, watch.getTotalTimeMillis()); } catch (Throwable t) { + result = new Reconciler.Result(true, null); if (t instanceof OptimisticLockingFailureException) { log.warn("Optimistic locking failure when reconciling request: {}/{}", this.name, entry.getEntry()); + } else if (t instanceof RequeueException re) { + result = re.getResult(); } else { log.error("Reconciler in " + this.name + " aborted with an error, re-enqueuing...", t); } - result = new Reconciler.Result(true, null); } finally { queue.done(entry.getEntry()); } diff --git a/api/src/main/java/run/halo/app/extension/controller/Reconciler.java b/api/src/main/java/run/halo/app/extension/controller/Reconciler.java index 36e68b6c263..c8a6c92328a 100644 --- a/api/src/main/java/run/halo/app/extension/controller/Reconciler.java +++ b/api/src/main/java/run/halo/app/extension/controller/Reconciler.java @@ -16,5 +16,9 @@ record Result(boolean reEnqueue, Duration retryAfter) { public static Result doNotRetry() { return new Result(false, null); } + + public static Result requeue(Duration retryAfter) { + return new Result(true, retryAfter); + } } } diff --git a/api/src/main/java/run/halo/app/extension/controller/RequeueException.java b/api/src/main/java/run/halo/app/extension/controller/RequeueException.java new file mode 100644 index 00000000000..bbb96f2305d --- /dev/null +++ b/api/src/main/java/run/halo/app/extension/controller/RequeueException.java @@ -0,0 +1,31 @@ +package run.halo.app.extension.controller; + +import run.halo.app.extension.controller.Reconciler.Result; + + +/** + * Requeue with result data after throwing this exception. + * + * @author johnniang + */ +public class RequeueException extends RuntimeException { + + private final Result result; + + public RequeueException(Result result) { + this(result, null); + } + + public RequeueException(Result result, String reason) { + this(result, reason, null); + } + + public RequeueException(Result result, String reason, Throwable t) { + super(reason, t); + this.result = result; + } + + public Result getResult() { + return result; + } +} diff --git a/api/src/test/java/run/halo/app/extension/controller/DefaultControllerTest.java b/api/src/test/java/run/halo/app/extension/controller/DefaultControllerTest.java index 6d599241bfe..86f6d0d3c30 100644 --- a/api/src/test/java/run/halo/app/extension/controller/DefaultControllerTest.java +++ b/api/src/test/java/run/halo/app/extension/controller/DefaultControllerTest.java @@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.doNothing; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -139,6 +140,47 @@ void shouldReRunIfReconcilerThrowException() throws InterruptedException { verify(reconciler, times(1)).reconcile(any(Request.class)); } + @Test + void canReRunIfReconcilerThrowRequeueException() throws InterruptedException { + when(queue.take()).thenReturn(new DelayedEntry<>( + new Request("fake-request"), Duration.ofSeconds(1), () -> now + )) + .thenThrow(InterruptedException.class); + when(queue.add(any())).thenReturn(true); + var expectException = new RequeueException(Result.requeue(Duration.ofSeconds(2))); + when(reconciler.reconcile(any(Request.class))).thenThrow(expectException); + + controller.new Worker().run(); + + verify(synchronizer).start(); + verify(queue, times(2)).take(); + verify(queue).done(any()); + verify(queue).add(argThat(de -> + de.getEntry().name().equals("fake-request") + && de.getRetryAfter().equals(Duration.ofSeconds(2)))); + verify(reconciler).reconcile(any(Request.class)); + } + + @Test + void doNotReRunIfReconcilerThrowsRequeueExceptionWithoutRequeue() + throws InterruptedException { + when(queue.take()).thenReturn(new DelayedEntry<>( + new Request("fake-request"), Duration.ofSeconds(1), () -> now + )) + .thenThrow(InterruptedException.class); + var expectException = new RequeueException(Result.doNotRetry()); + when(reconciler.reconcile(any(Request.class))).thenThrow(expectException); + + controller.new Worker().run(); + + verify(synchronizer).start(); + verify(queue, times(2)).take(); + verify(queue).done(any()); + + verify(queue, never()).add(any()); + verify(reconciler).reconcile(any(Request.class)); + } + @Test void shouldSetMinRetryAfterWhenTakeZeroDelayedEntry() throws InterruptedException { when(queue.take()).thenReturn(new DelayedEntry<>( diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java index b85189765a8..d6c6fe8fb1e 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/PluginEndpoint.java @@ -42,7 +42,6 @@ import lombok.AllArgsConstructor; import lombok.Data; import lombok.extern.slf4j.Slf4j; -import org.pf4j.PluginState; import org.reactivestreams.Publisher; import org.springdoc.webflux.core.fn.SpringdocRouteBuilder; import org.springframework.beans.factory.DisposableBean; @@ -297,10 +296,10 @@ Mono changePluginRunningState(ServerRequest request) { // when enabled = false,excepted phase = !started var phase = p.statusNonNull().getPhase(); if (enable) { - return PluginState.STARTED.equals(phase) - || PluginState.FAILED.equals(phase); + return Plugin.Phase.STARTED.equals(phase) + || Plugin.Phase.FAILED.equals(phase); } - return !PluginState.STARTED.equals(phase); + return !Plugin.Phase.STARTED.equals(phase); }); }); }) diff --git a/application/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java b/application/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java index 0e02b6922bd..84b66f36e60 100644 --- a/application/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java +++ b/application/src/main/java/run/halo/app/core/extension/reconciler/PluginReconciler.java @@ -1,55 +1,43 @@ package run.halo.app.core.extension.reconciler; -import static org.pf4j.util.FileUtils.isJarFile; +import static run.halo.app.core.extension.Plugin.PluginStatus.nullSafeConditions; +import static run.halo.app.extension.ExtensionUtil.addFinalizers; +import static run.halo.app.extension.ExtensionUtil.removeFinalizers; import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; -import static run.halo.app.extension.MetadataUtil.nullSafeLabels; -import static run.halo.app.plugin.PluginConst.DELETE_STAGE; import static run.halo.app.plugin.PluginConst.PLUGIN_PATH; import static run.halo.app.plugin.PluginConst.RELOAD_ANNO; +import static run.halo.app.plugin.PluginConst.RUNTIME_MODE_ANNO; +import static run.halo.app.plugin.PluginUtils.generateFileName; +import static run.halo.app.plugin.PluginUtils.isDevelopmentMode; +import java.io.FileNotFoundException; import java.io.IOException; import java.net.MalformedURLException; -import java.net.URI; import java.net.URISyntaxException; import java.net.URL; -import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.Paths; -import java.time.Instant; +import java.time.Clock; import java.util.ArrayList; -import java.util.Collection; import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; import java.util.Objects; -import java.util.Optional; import java.util.Set; -import java.util.function.Function; -import java.util.function.UnaryOperator; -import lombok.AllArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.StringUtils; -import org.pf4j.PluginDescriptor; -import org.pf4j.PluginRuntimeException; +import org.pf4j.PluginManager; 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.lang.Nullable; -import org.springframework.retry.support.RetryTemplate; +import org.springframework.core.io.support.PathMatchingResourcePatternResolver; import org.springframework.stereotype.Component; -import org.springframework.util.Assert; import org.springframework.util.ResourceUtils; import org.springframework.web.util.UriComponentsBuilder; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.ReverseProxy; 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; @@ -57,731 +45,383 @@ 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.extension.controller.RequeueException; import run.halo.app.infra.Condition; import run.halo.app.infra.ConditionStatus; -import run.halo.app.infra.utils.FileUtils; -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.HaloPluginWrapper; import run.halo.app.plugin.PluginConst; -import run.halo.app.plugin.PluginExtensionLoaderUtils; -import run.halo.app.plugin.PluginStartingError; import run.halo.app.plugin.PluginUtils; -import run.halo.app.plugin.YamlPluginFinder; -import run.halo.app.plugin.event.PluginCreatedEvent; -import run.halo.app.plugin.resources.BundleResourceUtils; /** * Plugin reconciler. * * @author guqing + * @author johnniang * @since 2.0.0 */ @Slf4j @Component -@AllArgsConstructor public class PluginReconciler implements Reconciler { private static final String FINALIZER_NAME = "plugin-protection"; 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(); + private final PluginManager pluginManager; - @Override - public Result reconcile(Request request) { - try { - return client.fetch(Plugin.class, request.name()) - .map(plugin -> { - if (plugin.getMetadata().getDeletionTimestamp() != null) { - cleanUpResourcesAndRemoveFinalizer(request.name()); - return Result.doNotRetry(); - } - addFinalizerIfNecessary(plugin); - - // if true returned, it means it is not ready - if (readinessDetection(request.name())) { - return new Result(true, null); - } + private Clock clock; - reconcilePluginState(plugin.getMetadata().getName()); - return Result.doNotRetry(); - }) - .orElse(Result.doNotRetry()); - } catch (DoNotRetryException e) { - log.error("Failed to reconcile plugin: [{}]", request.name(), e); - persistenceFailureStatus(request.name(), e); - return Result.doNotRetry(); - } catch (Exception e) { - persistenceFailureStatus(request.name(), e); - throw e; - } - } - - private void updatePluginPathAnno(String name) { - // TODO do it in a better way - client.fetch(Plugin.class, name).ifPresent(plugin -> { - Map annotations = nullSafeAnnotations(plugin); - String oldPluginPath = annotations.get(PLUGIN_PATH); - String pluginPath = StringUtils.isBlank(oldPluginPath) - ? Optional.ofNullable(plugin.statusNonNull().getLoadLocation()) - .map(URI::getPath) - .orElseGet(() -> PluginUtils.generateFileName(plugin)) : oldPluginPath; - String pluginPathAnno = resolvePluginPathForAnno(pluginPath); - annotations.put(PLUGIN_PATH, pluginPathAnno); - if (!StringUtils.equals(pluginPathAnno, oldPluginPath)) { - client.update(plugin); - } - }); + public PluginReconciler(ExtensionClient client, PluginManager pluginManager) { + this.client = client; + this.pluginManager = pluginManager; + this.clock = Clock.systemUTC(); } - boolean readinessDetection(String name) { - updatePluginPathAnno(name); - return client.fetch(Plugin.class, name) + @Override + public Result reconcile(Request request) { + return client.fetch(Plugin.class, request.name()) .map(plugin -> { - if (waitForSettingCreation(plugin)) { - return true; - } - recreateDefaultReverseProxy(plugin); - - updateStatus(name, status -> { - String logoUrl = generateAccessibleLogoUrl(plugin); - status.setLogo(logoUrl); - - // If phase in status is not equal to plugin state, then reset plugin to - // stopped state and keep the state in memory consistent with the database - PluginState pluginState = getPluginWrapper(name).getPluginState(); - status.setPhase(pluginState); - if (!Objects.equals(status.getPhase(), pluginState)) { - // stop and set phase - status.setPhase(haloPluginManager.stopPlugin(name)); - status.setEntry(StringUtils.EMPTY); - status.setStylesheet(StringUtils.EMPTY); + if (ExtensionUtil.isDeleted(plugin)) { + // CleanUp resources and remove finalizer. + if (removeFinalizers(plugin.getMetadata(), Set.of(FINALIZER_NAME))) { + cleanupResources(plugin); + syncPluginState(plugin); + client.update(plugin); } - return status; - }); - return false; - }) - .orElse(false); - } - - String generateAccessibleLogoUrl(Plugin plugin) { - String logo = plugin.getSpec().getLogo(); - if (StringUtils.isBlank(logo)) { - return null; - } - if (!PathUtils.isAbsoluteUri(logo)) { - String assetsPrefix = - PluginConst.assertsRoutePrefix(plugin.getMetadata().getName()); - String versionedLogo = - applyVersioningToStaticResource(logo, plugin.getSpec().getVersion()); - return PathUtils.combinePath(assetsPrefix, versionedLogo); - } - return logo; - } - - 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); - var runtimeMode = getRuntimeMode(name); - - var resourceLoader = - new DefaultResourceLoader(pluginWrapper.getPluginClassLoader()); - return PluginExtensionLoaderUtils.lookupExtensions(pluginWrapper.getPluginPath(), - runtimeMode) - .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; - } - - var runtimeMode = getRuntimeMode(pluginName); - Optional settingOption = lookupPluginSetting(pluginName, settingName) - .map(setting -> { - // This annotation is added to prevent it from being deleted when stopped. - Map settingAnnotations = 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; - }); - - // Fix gh-3224 - // Maybe Setting is being created and cannot be queried. so try again. - if (settingOption.isEmpty()) { - updateStatus(plugin.getMetadata().getName(), status -> { - 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); - return status; - }); - // need requeue - return true; - } - - final String configMapNameToUse = plugin.getSpec().getConfigMapName(); - if (StringUtils.isBlank(configMapNameToUse)) { - return false; - } - - SettingUtils.createOrUpdateConfigMap(client, settingName, configMapNameToUse); - return false; - } - - void startAction(String name) { - stateTransition(name, currentState -> { - boolean termination = false; - switch (currentState) { - case CREATED -> getPluginWrapper(name); - case STARTED -> termination = true; - // plugin can be started when it is stopped or failed - case RESOLVED, STOPPED, FAILED -> doStart(name); - default -> { + return Result.doNotRetry(); } - } - return termination; - }, PluginState.STARTED); - } + addFinalizers(plugin.getMetadata(), Set.of(FINALIZER_NAME)); + plugin.statusNonNull().setPhase(Plugin.Phase.PENDING); - void stopAction(String name) { - stateTransition(name, currentState -> { - boolean termination = false; - switch (currentState) { - case CREATED -> getPluginWrapper(name); - case RESOLVED, STARTED -> doStop(name); - case FAILED, STOPPED -> termination = true; - default -> { + // Prepare + try { + resolveLoadLocation(plugin); + + loadOrReload(plugin); + createOrUpdateSetting(plugin); + createOrUpdateReverseProxy(plugin); + resolveStaticResources(plugin); + + if (requestToEnable(plugin)) { + // Start + startPlugin(plugin); + } else { + // stop the plugin and disable it + stopAndDisablePlugin(plugin); + } + } catch (Throwable t) { + // populate condition + var condition = Condition.builder() + .type(PluginState.FAILED.toString()) + .reason("UnexpectedState") + .message(t.getMessage()) + .status(ConditionStatus.FALSE) + .lastTransitionTime(clock.instant()) + .build(); + var status = plugin.statusNonNull(); + nullSafeConditions(status).addAndEvictFIFO(condition); + status.setPhase(Plugin.Phase.FAILED); + throw t; + } finally { + syncPluginState(plugin); + client.update(plugin); } - } - return termination; - }, PluginState.STOPPED); - } - void stateTransition(String name, Function stateAction, - PluginState desiredState) { - PluginState currentState = getPluginWrapper(name).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 = getPluginWrapper(name).getPluginState(); - } catch (Throwable e) { - persistenceFailureStatus(name, e); - throw e; - } - } + return Result.doNotRetry(); + }) + .orElseGet(Result::doNotRetry); + } - if (currentState != desiredState) { - log.error("Plugin [{}] state transition failed: {}", name, - haloPluginManager.getPluginStartingError(name)); - throw new DoNotRetryException("Plugin [" + name + "] state transition from [" - + currentState + "] to [" + desiredState + "] failed"); + private void syncPluginState(Plugin plugin) { + var pluginName = plugin.getMetadata().getName(); + var p = pluginManager.getPlugin(pluginName); + if (p != null) { + plugin.statusNonNull().setLastProbeState(p.getPluginState()); + } else { + plugin.statusNonNull().setLastProbeState(null); } } - void persistenceFailureStatus(String pluginName, Throwable e) { - updateStatus(pluginName, status -> { - PluginWrapper pluginWrapper = haloPluginManager.getPlugin(pluginName); - if (pluginWrapper != null) { - haloPluginManager.stopPlugin(pluginName); - pluginWrapper.setPluginState(PluginState.FAILED); - pluginWrapper.setFailedException(e); - } - - status.setPhase(PluginState.FAILED); - - Condition condition = Condition.builder() - .type(PluginState.FAILED.toString()) - .reason("UnexpectedState") - .message(StringUtils.defaultString(e.getMessage())) - .status(ConditionStatus.FALSE) - .lastTransitionTime(Instant.now()) - .build(); - Plugin.PluginStatus.nullSafeConditions(status) - .addAndEvictFIFO(condition); - return status; - }); + private static boolean requestToReload(Plugin plugin) { + var annotations = plugin.getMetadata().getAnnotations(); + return annotations != null && annotations.containsKey(RELOAD_ANNO); } - @NonNull - private PluginWrapper getPluginWrapper(String name) { - PluginWrapper pluginWrapper = haloPluginManager.getPlugin(name); - if (pluginWrapper == null) { - ensurePluginLoaded(name); - pluginWrapper = haloPluginManager.getPlugin(name); - } - - if (pluginWrapper == null) { - String errorMsg = "Plugin " + name + " not found in plugin manager."; - updateStatus(name, status -> { - status.setPhase(PluginState.FAILED); - - 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); - return status; + private void cleanupResources(Plugin plugin) { + var pluginName = plugin.getMetadata().getName(); + var reverseProxyName = buildReverseProxyName(pluginName); + log.info("Deleting reverse proxy {} for plugin {}", reverseProxyName, pluginName); + client.fetch(ReverseProxy.class, reverseProxyName) + .ifPresent(reverseProxy -> { + client.delete(reverseProxy); + throw new RequeueException(Result.requeue(null), + String.format(""" + Waiting for reverse proxy %s to be deleted.""", reverseProxyName) + ); }); - throw new DoNotRetryException(errorMsg); + var settingName = plugin.getSpec().getSettingName(); + if (StringUtils.isNotBlank(settingName)) { + log.info("Deleting settings {} for plugin {}", settingName, pluginName); + client.fetch(Setting.class, settingName) + .ifPresent(setting -> { + client.delete(setting); + throw new RequeueException(Result.requeue(null), String.format(""" + Waiting for setting %s to be deleted.""", settingName)); + }); } - return pluginWrapper; - } - - void updateStatus(String name, UnaryOperator operator) { - try { - retryTemplate.execute(callback -> { - try { - doUpdateStatus(name, operator); - } catch (Exception e) { - // trigger retry - throw new IllegalStateException(e); - } - return null; - }); - } catch (Exception e) { - PluginWrapper pluginWrapper = haloPluginManager.getPlugin(name); - if (pluginWrapper != null) { - haloPluginManager.stopPlugin(name); - pluginWrapper.setPluginState(PluginState.FAILED); - } - throw e; + if (pluginManager.getPlugin(pluginName) != null) { + log.info("Deleting plugin {} in plugin manager.", pluginName); + pluginManager.deletePlugin(pluginName); } } - void doUpdateStatus(String name, UnaryOperator operator) { - client.fetch(Plugin.class, name).ifPresent(plugin -> { - Plugin.PluginStatus oldStatus = JsonUtils.deepCopy(plugin.statusNonNull()); - Plugin.PluginStatus newStatus = - Optional.ofNullable(operator.apply(plugin.statusNonNull())) - .orElse(new Plugin.PluginStatus()); - plugin.setStatus(newStatus); - - URI loadLocation = newStatus.getLoadLocation(); - if (loadLocation == null) { - String pluginPath = nullSafeAnnotations(plugin).get(PLUGIN_PATH); - if (StringUtils.isNotBlank(pluginPath)) { - String absolutePath = buildPluginLocation(name, pluginPath); - loadLocation = toUri(absolutePath); - } else { - loadLocation = getPluginWrapper(name).getPluginPath().toUri(); - } - newStatus.setLoadLocation(loadLocation); + private void startPlugin(Plugin plugin) { + // start the plugin + var pluginName = plugin.getMetadata().getName(); + var wrapper = pluginManager.getPlugin(pluginName); + plugin.statusNonNull().setPhase(Plugin.Phase.STARTING); + if (!PluginState.STARTED.equals(wrapper.getPluginState())) { + var pluginState = pluginManager.startPlugin(pluginName); + if (!PluginState.STARTED.equals(pluginState)) { + throw new IllegalStateException("Failed to start plugin " + pluginName); } - if (!Objects.equals(oldStatus, newStatus)) { - 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."); - } - - PluginState currentState = haloPluginManager.startPlugin(name); - if (!PluginState.STARTED.equals(currentState)) { - PluginStartingError staringErrorInfo = getStaringErrorInfo(name); - log.debug("Failed to start plugin: " + staringErrorInfo.getDevMessage(), - pluginWrapper.getFailedException()); - throw new IllegalStateException(staringErrorInfo.getMessage(), - pluginWrapper.getFailedException()); - } - - String pluginVersion = pluginWrapper.getDescriptor().getVersion(); - updateStatus(name, status -> { - status.setLastStartTime(Instant.now()); - - String jsBundlePath = - BundleResourceUtils.getJsBundlePath(haloPluginManager, name); - jsBundlePath = applyVersioningToStaticResource(jsBundlePath, pluginVersion); - status.setEntry(jsBundlePath); - - String cssBundlePath = - BundleResourceUtils.getCssBundlePath(haloPluginManager, name); - cssBundlePath = applyVersioningToStaticResource(cssBundlePath, pluginVersion); - status.setStylesheet(cssBundlePath); - - status.setPhase(currentState); - Condition condition = Condition.builder() + plugin.statusNonNull().setLastStartTime(clock.instant()); + var condition = Condition.builder() .type(PluginState.STARTED.toString()) .reason(PluginState.STARTED.toString()) .message("Started successfully") - .lastTransitionTime(Instant.now()) + .lastTransitionTime(clock.instant()) .status(ConditionStatus.TRUE) .build(); - Plugin.PluginStatus.nullSafeConditions(status) - .addAndEvictFIFO(condition); - return status; - }); - } - - private String applyVersioningToStaticResource(@Nullable String path, String pluginVersion) { - if (StringUtils.isNotBlank(path)) { - return UriComponentsBuilder.fromUriString(path) - .queryParam("version", pluginVersion) - .build().toString(); - } - return path; - } - - PluginStartingError getStaringErrorInfo(String name) { - PluginStartingError startingError = - haloPluginManager.getPluginStartingError(name); - if (startingError == null) { - startingError = PluginStartingError.of(name, "Unknown error", ""); + nullSafeConditions(plugin.statusNonNull()).addAndEvictFIFO(condition); } - return startingError; + plugin.statusNonNull().setPhase(Plugin.Phase.STARTED); } - void doStop(String name) { - PluginState currentState = haloPluginManager.stopPlugin(name); - if (!PluginState.STOPPED.equals(currentState)) { - throw new IllegalStateException("Failed to stop plugin: " + name); + private void stopAndDisablePlugin(Plugin plugin) { + var pluginName = plugin.getMetadata().getName(); + if (pluginManager.getPlugin(pluginName) != null) { + pluginManager.disablePlugin(pluginName); } - updateStatus(name, status -> { - 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); - return status; - }); + plugin.statusNonNull().setPhase(Plugin.Phase.DISABLED); } - @Override - public Controller setupWith(ControllerBuilder builder) { - return builder - .extension(new Plugin()) - .build(); + private static boolean requestToEnable(Plugin plugin) { + var enabled = plugin.getSpec().getEnabled(); + return enabled != null && enabled; } - private void reconcilePluginState(String name) { - client.fetch(Plugin.class, name).ifPresent(plugin -> { - // reload detection - Map annotations = nullSafeAnnotations(plugin); - if (annotations.containsKey(RELOAD_ANNO)) { - reload(plugin); - // update will requeue to make next reconciliation - return; - } - - // Transition plugin status if necessary - if (shouldReconcileStartState(plugin)) { - startAction(name); - } - - if (shouldReconcileStopState(plugin)) { - stopAction(name); - } - }); - } - - void reload(Plugin plugin) { - String newPluginPath = nullSafeAnnotations(plugin).get(RELOAD_ANNO); - if (StringUtils.isBlank(newPluginPath)) { - return; + private void resolveStaticResources(Plugin plugin) { + var pluginName = plugin.getMetadata().getName(); + var pluginVersion = plugin.getSpec().getVersion(); + if (isDevelopmentMode(plugin)) { + // when we are in dev mode, the plugin version is not always changed. + pluginVersion = String.valueOf(clock.instant().toEpochMilli()); } - final String pluginName = plugin.getMetadata().getName(); - URI oldPluginLocation = plugin.statusNonNull().getLoadLocation(); - if (shouldDeleteFile(newPluginPath, oldPluginLocation)) { - try { - // delete old plugin jar file - Files.deleteIfExists(Paths.get(oldPluginLocation.getPath())); - } catch (IOException e) { - throw new PluginRuntimeException(e); - } - } - final var pluginFinder = new YamlPluginFinder(); - final var pluginInPath = pluginFinder.find(toPath(newPluginPath)); - client.fetch(Plugin.class, plugin.getMetadata().getName()) - .ifPresent(persisted -> { - if (!persisted.getMetadata().getName() - .equals(pluginInPath.getMetadata().getName())) { - throw new DoNotRetryException("Plugin name is not match, skip reload."); + var status = plugin.statusNonNull(); + var specLogo = plugin.getSpec().getLogo(); + if (StringUtils.isNotBlank(specLogo)) { + log.info("Resolving logo resource for plugin {}", pluginName); + // the logo might be: + // 1. URL + // 2. file name + // 3. base64 format data image + var logo = specLogo; + if (!specLogo.startsWith("data:image")) { + try { + logo = new URL(specLogo).toString(); + } catch (MalformedURLException ignored) { + // indicate the logo is a path + logo = UriComponentsBuilder.newInstance() + .pathSegment("plugins", pluginName, "assets", specLogo) + .queryParam("version", pluginVersion) + .build(true) + .toString(); } - persisted.setSpec(pluginInPath.getSpec()); - // merge annotations and labels - Map newAnnotations = nullSafeAnnotations(persisted); - newAnnotations.putAll(nullSafeAnnotations(pluginInPath)); - - newAnnotations.put(PLUGIN_PATH, resolvePluginPathForAnno(newPluginPath)); - newAnnotations.remove(RELOAD_ANNO); - nullSafeLabels(persisted).putAll(nullSafeLabels(pluginInPath)); - persisted.statusNonNull().setLoadLocation(toUri(newPluginPath)); - - // reload - haloPluginManager.reloadPluginWithPath(pluginName, toPath(newPluginPath)); - // update plugin - client.update(persisted); - }); - } - - String resolvePluginPathForAnno(String pluginPathString) { - Path pluginsRoot = toPath(haloPluginManager.getPluginsRoot().toString()); - Path pluginPath = toPath(pluginPathString); - if (pluginPath.startsWith(pluginsRoot)) { - return pluginsRoot.relativize(pluginPath).toString(); - } - return pluginPath.toString(); - } - - /** - * Returns an absolute plugin path. - * if a plugin path is absolute, use it directly in development mode. - * otherwise, combine a plugin path with a plugin root path. - * Note: plugin location without a scheme - */ - String buildPluginLocation(String name, String pluginPathString) { - Assert.notNull(name, "Plugin name must not be null"); - Assert.notNull(pluginPathString, "Plugin path must not be null"); - Path pluginsRoot = toPath(haloPluginManager.getPluginsRoot().toString()); - Path pluginPath = toPath(pluginPathString); - // if a plugin path is absolute, use it directly in development mode - if (pluginPath.isAbsolute()) { - if (!isDevelopmentMode(name) && !pluginPath.startsWith(pluginsRoot)) { - throw new DoNotRetryException( - "Plugin path must be relative path or relative to plugin root path."); } - return pluginPath.toString(); - } - var result = pluginsRoot.resolve(pluginPath); - if (!isDevelopmentMode(name)) { - FileUtils.checkDirectoryTraversal(pluginsRoot, result); - } - return result.toString(); - } - - boolean shouldDeleteFile(String newPluginPath, URI oldPluginLocation) { - if (oldPluginLocation == null) { - return false; + status.setLogo(logo); + } + + log.info("Resolving main.js and style.css for plugin {}", pluginName); + var p = pluginManager.getPlugin(pluginName); + var classLoader = p.getPluginClassLoader(); + var resLoader = new DefaultResourceLoader(classLoader); + var entryRes = resLoader.getResource("classpath:/console/main.js"); + var cssRes = resLoader.getResource("classpath:/console/style.css"); + if (entryRes.exists()) { + var entry = UriComponentsBuilder.newInstance() + .pathSegment("plugins", pluginName, "assets", "console", "main.js") + .queryParam("version", pluginVersion) + .build(true) + .toString(); + status.setEntry(entry); } - - if (oldPluginLocation.equals(toUri(newPluginPath))) { - return false; + if (cssRes.exists()) { + var stylesheet = UriComponentsBuilder.newInstance() + .pathSegment("plugins", pluginName, "assets", "console", "style.css") + .queryParam("version", pluginVersion) + .build(true) + .toString(); + status.setStylesheet(stylesheet); } - return isJarFile(Paths.get(oldPluginLocation)); } - private void ensurePluginLoaded(String name) { - client.fetch(Plugin.class, name).ifPresent(plugin -> { - PluginWrapper pluginWrapper = haloPluginManager.getPlugin(name); - if (pluginWrapper != null) { - return; + private void loadOrReload(Plugin plugin) { + // TODO Try to check dependencies before. + var pluginName = plugin.getMetadata().getName(); + try { + var p = pluginManager.getPlugin(pluginName); + var requestToReload = requestToReload(plugin); + if (requestToReload) { + log.info("Unloading plugin {}", pluginName); + if (p != null) { + pluginManager.unloadPlugin(pluginName); + } } - Path pluginLocation = determinePluginLocation(plugin); - if (!Files.exists(pluginLocation)) { - return; + if (p == null || requestToReload) { + log.info("Loading plugin {}", pluginName); + var loadLocation = plugin.getStatus().getLoadLocation(); + pluginManager.loadPlugin(Paths.get(loadLocation)); + log.info("Loaded plugin {}", pluginName); } - haloPluginManager.loadPlugin(pluginLocation); - }); - } - - Path toPath(String pathString) { - if (StringUtils.isBlank(pathString)) { - return null; - } - try { - var pathURL = new URL(pathString); - if (!ResourceUtils.isFileURL(pathURL)) { - throw new IllegalArgumentException("The path cannot be resolved to absolute file" - + " path because it does not reside in the file system: " - + pathString); + } catch (Throwable t) { + // unload the plugin + if (pluginManager.getPlugin(pluginName) != null) { + pluginManager.unloadPlugin(pluginName); } - var pathURI = ResourceUtils.toURI(pathURL); - return Paths.get(pathURI); - } catch (MalformedURLException | URISyntaxException ignored) { - // the given path string is not a valid URL. - } - return Paths.get(pathString); - } - - URI toUri(String pathString) { - if (StringUtils.isBlank(pathString)) { - throw new IllegalArgumentException("Path string must not be blank"); - } - return Paths.get(pathString).toUri(); - } - - boolean shouldReconcileStartState(Plugin plugin) { - PluginWrapper pluginWrapper = getPluginWrapper(plugin.getMetadata().getName()); - if (BooleanUtils.isNotTrue(plugin.getSpec().getEnabled())) { - return false; + throw t; } - // phase is not started, or plugin state is not started should start - return !PluginState.STARTED.equals(plugin.statusNonNull().getPhase()) - || !PluginState.STARTED.equals(pluginWrapper.getPluginState()); } - boolean shouldReconcileStopState(Plugin plugin) { - PluginWrapper pluginWrapper = getPluginWrapper(plugin.getMetadata().getName()); - if (BooleanUtils.isNotFalse(plugin.getSpec().getEnabled())) { - return false; + private void createOrUpdateSetting(Plugin plugin) { + log.info("Initializing setting and config map for plugin {}", + plugin.getMetadata().getName()); + var settingName = plugin.getSpec().getSettingName(); + if (StringUtils.isBlank(settingName)) { + // do nothing if no setting name provided. + return; } - // phase is not stopped, or plugin state is not stopped should stop - return !PluginState.STOPPED.equals(plugin.statusNonNull().getPhase()) - || !PluginState.STOPPED.equals(pluginWrapper.getPluginState()); - } - private void addFinalizerIfNecessary(Plugin oldPlugin) { - Set finalizers = oldPlugin.getMetadata().getFinalizers(); - if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { + var pluginName = plugin.getMetadata().getName(); + var p = pluginManager.getPlugin(pluginName); + var classLoader = p.getPluginClassLoader(); + var resolver = new PathMatchingResourcePatternResolver(classLoader); + Resource[] resources; + try { + resources = resolver.getResources("classpath:extensions/*.{ext:yaml|yml}"); + } catch (IOException e) { + throw new RuntimeException(""" + Failed to get extension resources while resolving plugin setting.""", e); + } + var loader = new YamlUnstructuredLoader(resources); + var settingGvk = GroupVersionKind.fromExtension(Setting.class); + var setting = loader.load().stream() + .filter(u -> { + var gvk = u.groupVersionKind(); + return Objects.equals(settingName, u.getMetadata().getName()) + && Objects.equals(settingGvk, gvk); + }) + .findFirst() + .map(u -> Unstructured.OBJECT_MAPPER.convertValue(u, Setting.class)) + .orElseThrow(() -> new IllegalStateException(String.format(""" + Setting name %s was provided but setting extension \ + was not found in plugin %s.""", + settingName, pluginName))); + + client.fetch(Setting.class, settingName) + .ifPresentOrElse(oldSetting -> { + // overwrite the setting + var version = oldSetting.getMetadata().getVersion(); + setting.getMetadata().setVersion(version); + // TODO Remove this line in the future + removeFinalizers(setting.getMetadata(), Set.of("plugin-protector")); + client.update(setting); + }, () -> client.create(setting)); + + log.info("Initialized setting {} for plugin {}", settingName, pluginName); + + // create default config map + var configMapName = plugin.getSpec().getConfigMapName(); + if (StringUtils.isBlank(configMapName)) { return; } - client.fetch(Plugin.class, oldPlugin.getMetadata().getName()) - .ifPresent(plugin -> { - Set newFinalizers = plugin.getMetadata().getFinalizers(); - if (newFinalizers == null) { - newFinalizers = new HashSet<>(); - plugin.getMetadata().setFinalizers(newFinalizers); - } - newFinalizers.add(FINALIZER_NAME); - client.update(plugin); - eventPublisher.publishEvent( - new PluginCreatedEvent(this, plugin.getMetadata().getName())); - }); - } - - private void cleanUpResourcesAndRemoveFinalizer(String pluginName) { - client.fetch(Plugin.class, pluginName).ifPresent(plugin -> { - cleanUpResources(plugin); - if (plugin.getMetadata().getFinalizers() != null) { - plugin.getMetadata().getFinalizers().remove(FINALIZER_NAME); + var defaultConfigMap = SettingUtils.populateDefaultConfig(setting, configMapName); + + client.fetch(ConfigMap.class, configMapName) + .ifPresentOrElse(configMap -> { + // merge data + var oldData = configMap.getData(); + var defaultData = defaultConfigMap.getData(); + var mergedData = SettingUtils.mergePatch(oldData, defaultData); + configMap.setData(mergedData); + client.update(configMap); + }, () -> client.create(defaultConfigMap)); + log.info("Initialized config map {} for plugin {}", configMapName, pluginName); + } + + private void resolveLoadLocation(Plugin plugin) { + log.debug("Resolving load location for plugin {}", plugin.getMetadata().getName()); + + // populate load location from annotations + var pluginName = plugin.getMetadata().getName(); + var annotations = nullSafeAnnotations(plugin); + var pluginPathAnno = annotations.get(PLUGIN_PATH); + var isDevMode = PluginUtils.isDevelopmentMode(plugin); + if (isDevMode) { + log.debug("Plugin {} is in development mode", pluginName); + if (StringUtils.isBlank(pluginPathAnno)) { + // should never happen. + throw new IllegalArgumentException(String.format(""" + Please set plugin path annotation "%s" \ + in development mode for plugin %s.""", + RUNTIME_MODE_ANNO, pluginName)); + } + try { + var loadLocation = ResourceUtils.getURL(pluginPathAnno).toURI(); + plugin.statusNonNull().setLoadLocation(loadLocation); + return; + } catch (URISyntaxException | FileNotFoundException e) { + throw new IllegalArgumentException( + "Invalid plugin path " + pluginPathAnno + " configured.", e); } - client.update(plugin); - }); - } - - private void cleanUpResources(Plugin plugin) { - 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) { - // pluginWrapper must not be null in below code - // stop and unload plugin, see also PluginBeforeStopSyncListener - if (!haloPluginManager.deletePlugin(name)) { - throw new IllegalStateException("Failed to delete plugin: " + name); + if (StringUtils.isBlank(pluginPathAnno)) { + log.info(""" + No plugin path provided, which name is {}. \ + By default, we will automatically generate file name instead.""", + pluginName); + pluginPathAnno = generateFileName(plugin); + annotations.put(PLUGIN_PATH, pluginPathAnno); + } + var pluginPath = Paths.get(pluginPathAnno); + var pluginRoot = pluginManager.getPluginsRoot(); + if (pluginPath.isAbsolute()) { + if (pluginPath.startsWith(pluginRoot)) { + // ensure the plugin path is a relative path. + annotations.put(PLUGIN_PATH, pluginRoot.relativize(pluginPath).toString()); } + } else { + pluginPath = pluginRoot.resolve(pluginPath); } + + // Currently, + // 1. plugin path is absolute but not starts with plugin root. + // 2. plugin path is relative. + var status = plugin.statusNonNull(); + status.setLoadLocation(pluginPath.toUri()); + log.debug("Populated load location {} for plugin {}", status.getLoadLocation(), pluginName); } - @NonNull - Path determinePluginLocation(Plugin plugin) { - String pluginPath = nullSafeAnnotations(plugin).get(PLUGIN_PATH); - String name = plugin.getMetadata().getName(); - if (StringUtils.isBlank(pluginPath)) { - URI loadLocation = plugin.statusNonNull().getLoadLocation(); - if (loadLocation != null) { - pluginPath = loadLocation.getPath(); - } else { - throw new DoNotRetryException( - "Cannot determine plugin path for plugin: " + name); - } - } - String pluginLocation = buildPluginLocation(name, pluginPath); - return Paths.get(pluginLocation); + @Override + public Controller setupWith(ControllerBuilder builder) { + return builder + .extension(new Plugin()) + .build(); } - void recreateDefaultReverseProxy(Plugin plugin) { + void createOrUpdateReverseProxy(Plugin plugin) { String pluginName = plugin.getMetadata().getName(); - String reverseProxyName = initialReverseProxyName(pluginName); + String reverseProxyName = buildReverseProxyName(pluginName); ReverseProxy reverseProxy = new ReverseProxy(); reverseProxy.setMetadata(new Metadata()); reverseProxy.getMetadata().setName(reverseProxyName); @@ -806,30 +446,8 @@ void recreateDefaultReverseProxy(Plugin plugin) { }, () -> client.create(reverseProxy)); } - static class DoNotRetryException extends PluginRuntimeException { - public DoNotRetryException(String message) { - super(message); - } - } - - static String initialReverseProxyName(String pluginName) { + static String buildReverseProxyName(String pluginName) { return pluginName + "-system-generated-reverse-proxy"; } - private boolean isDevelopmentMode(String name) { - return RuntimeMode.DEVELOPMENT.equals(getRuntimeMode(name)); - } - - private RuntimeMode getRuntimeMode(String name) { - var pluginWrapper = haloPluginManager.getPlugin(name); - if (pluginWrapper == null) { - return haloPluginManager.getRuntimeMode(); - } - if (pluginWrapper instanceof HaloPluginWrapper haloPluginWrapper) { - return haloPluginWrapper.getRuntimeMode(); - } - return Files.isDirectory(pluginWrapper.getPluginPath()) - ? RuntimeMode.DEVELOPMENT - : RuntimeMode.DEPLOYMENT; - } } diff --git a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java index e9ec3cca4fc..2feb50f8781 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java +++ b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java @@ -65,8 +65,8 @@ public Flux listPermissions(Set names) { if (containsSuperRole(names)) { // search all permissions return extensionClient.list(Role.class, - shouldFilterHidden(true), - compareCreationTimestamp(true)); + shouldFilterHidden(true), + compareCreationTimestamp(true)); } return listDependencies(names, shouldFilterHidden(true)); } @@ -118,7 +118,9 @@ private Flux listDependencies(Set names, Predicate additiona if (visited.contains(name)) { return Flux.empty(); } - log.debug("Expand role: {}", role.getMetadata().getName()); + if (log.isTraceEnabled()) { + log.trace("Expand role: {}", role.getMetadata().getName()); + } visited.add(name); var annotations = MetadataUtil.nullSafeAnnotations(role); var dependenciesJson = annotations.get(Role.ROLE_DEPENDENCIES_ANNO); diff --git a/application/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java b/application/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java index 90c4322c401..0a3b33bdfba 100644 --- a/application/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java +++ b/application/src/main/java/run/halo/app/core/extension/theme/SettingUtils.java @@ -96,6 +96,15 @@ public static void createOrUpdateConfigMap(ExtensionClient client, String settin }); } + public static ConfigMap populateDefaultConfig(Setting setting, String configMapName) { + var data = settingDefinedDefaultValueMap(setting); + ConfigMap configMap = new ConfigMap(); + configMap.setMetadata(new Metadata()); + configMap.getMetadata().setName(configMapName); + configMap.setData(data); + return configMap; + } + /** * Construct a JsonMergePatch from a difference between two Maps and apply patch to * {@code source}. diff --git a/application/src/main/java/run/halo/app/plugin/HaloPluginManager.java b/application/src/main/java/run/halo/app/plugin/HaloPluginManager.java index 41a61a86e74..3ba7ef3bad7 100644 --- a/application/src/main/java/run/halo/app/plugin/HaloPluginManager.java +++ b/application/src/main/java/run/halo/app/plugin/HaloPluginManager.java @@ -1,7 +1,6 @@ package run.halo.app.plugin; import java.nio.file.Path; -import java.util.ArrayList; import java.util.Collections; import java.util.HashMap; import java.util.Iterator; @@ -342,33 +341,6 @@ private void doStopPlugins() { } } - /** - * Unload plugin and restart. - * - * @param restartStartedOnly If true, only reload started plugin - */ - public void reloadPlugins(boolean restartStartedOnly) { - doStopPlugins(); - List startedPluginIds = new ArrayList<>(); - getPlugins().forEach(plugin -> { - if (plugin.getPluginState() == PluginState.STARTED) { - startedPluginIds.add(plugin.getPluginId()); - } - unloadPlugin(plugin.getPluginId()); - }); - loadPlugins(); - if (restartStartedOnly) { - startedPluginIds.forEach(pluginId -> { - // restart started plugin - if (getPlugin(pluginId) != null) { - doStartPlugin(pluginId); - } - }); - } else { - startPlugins(); - } - } - /** *

Reload plugin by id,it will be clean up memory resources of plugin and reload plugin from * disk.

diff --git a/application/src/main/java/run/halo/app/plugin/PluginConst.java b/application/src/main/java/run/halo/app/plugin/PluginConst.java index b2a5d4a2e32..a0decc0b7ef 100644 --- a/application/src/main/java/run/halo/app/plugin/PluginConst.java +++ b/application/src/main/java/run/halo/app/plugin/PluginConst.java @@ -20,6 +20,8 @@ public interface PluginConst { String PLUGIN_PATH = "plugin.halo.run/plugin-path"; + String RUNTIME_MODE_ANNO = "plugin.halo.run/runtime-mode"; + static String assertsRoutePrefix(String pluginName) { return "/plugins/" + pluginName + "/assets/"; } diff --git a/application/src/main/java/run/halo/app/plugin/PluginDevelopmentInitializer.java b/application/src/main/java/run/halo/app/plugin/PluginDevelopmentInitializer.java index dac1eee7dd5..a7bb190e2bd 100644 --- a/application/src/main/java/run/halo/app/plugin/PluginDevelopmentInitializer.java +++ b/application/src/main/java/run/halo/app/plugin/PluginDevelopmentInitializer.java @@ -1,5 +1,7 @@ package run.halo.app.plugin; +import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations; + import java.nio.file.Path; import java.time.Duration; import lombok.extern.slf4j.Slf4j; @@ -48,9 +50,13 @@ private void createFixedPluginIfNecessary() { extensionClient.fetch(Plugin.class, plugin.getMetadata().getName()) .flatMap(persistent -> { plugin.getMetadata().setVersion(persistent.getMetadata().getVersion()); + nullSafeAnnotations(plugin).put(PluginConst.RUNTIME_MODE_ANNO, "dev"); return extensionClient.update(plugin); }) - .switchIfEmpty(Mono.defer(() -> extensionClient.create(plugin))) + .switchIfEmpty(Mono.defer(() -> { + nullSafeAnnotations(plugin).put(PluginConst.RUNTIME_MODE_ANNO, "dev"); + return extensionClient.create(plugin); + })) .retryWhen(Retry.backoff(10, Duration.ofMillis(100)) .filter(t -> t instanceof OptimisticLockingFailureException)) .block(); diff --git a/application/src/main/java/run/halo/app/plugin/PluginUtils.java b/application/src/main/java/run/halo/app/plugin/PluginUtils.java index e88530934be..f9fcb66c1bf 100644 --- a/application/src/main/java/run/halo/app/plugin/PluginUtils.java +++ b/application/src/main/java/run/halo/app/plugin/PluginUtils.java @@ -1,5 +1,6 @@ package run.halo.app.plugin; +import java.util.Objects; import lombok.experimental.UtilityClass; import org.apache.commons.lang3.StringUtils; import org.springframework.util.Assert; @@ -19,4 +20,16 @@ public static String generateFileName(Plugin plugin) { } return String.format("%s-%s.jar", plugin.getMetadata().getName(), version); } + + /** + * Determine if the plugin is in development mode. Currently, we detect it from annotations. + * + * @param plugin is a manifest about plugin. + * @return true if the plugin is in development mode; false otherwise. + */ + public static boolean isDevelopmentMode(Plugin plugin) { + var annotations = plugin.getMetadata().getAnnotations(); + return annotations != null + && Objects.equals("dev", annotations.get(PluginConst.RUNTIME_MODE_ANNO)); + } } diff --git a/application/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java b/application/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java index 6477f48115c..3d6ad7f9669 100644 --- a/application/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java +++ b/application/src/main/java/run/halo/app/plugin/SpringExtensionFactory.java @@ -97,8 +97,10 @@ protected T createWithoutSpring(final Class extensionClass) () -> new IllegalArgumentException("Extension class '" + nameOf(extensionClass) + "' must have at least one public constructor.")); try { - log.debug("Instantiate '" + nameOf(extensionClass) + "' by calling '" + constructor - + "'with standard Java reflection."); + if (log.isTraceEnabled()) { + log.trace("Instantiate '" + nameOf(extensionClass) + "' by calling '" + constructor + + "'with standard Java reflection."); + } // Creating the instance by calling the constructor with null-parameters (if there // are any). return (T) constructor.newInstance(nullParameters(constructor)); @@ -139,19 +141,25 @@ protected Optional getPluginApplicationContextBy( .map(plugin -> { var pluginName = plugin.getContext().getName(); if (this.pluginManager instanceof HaloPluginManager haloPluginManager) { - log.debug(" Extension class ' " + nameOf(extensionClass) - + "' belongs to a non halo-plugin (or main application)" - + " '" + nameOf(plugin) - + ", but the used Halo plugin-manager is a spring-plugin-manager. Therefore" - + " the extension class will be autowired by using the managers " - + "application " - + "contexts"); + if (log.isTraceEnabled()) { + log.trace(" Extension class ' " + nameOf(extensionClass) + + "' belongs to a non halo-plugin (or main application)" + + " '" + nameOf(plugin) + + ", but the used Halo plugin-manager is a spring-plugin-manager. " + + "Therefore" + + " the extension class will be autowired by using the managers " + + "application " + + "contexts"); + } return haloPluginManager.getPluginApplicationContext(pluginName); } - log.debug( - " Extension class ' " + nameOf(extensionClass) + "' belongs to halo-plugin '" - + nameOf(plugin) - + "' and will be autowired by using its application context."); + if (log.isTraceEnabled()) { + log.trace( + " Extension class ' " + nameOf(extensionClass) + + "' belongs to halo-plugin '" + + nameOf(plugin) + + "' and will be autowired by using its application context."); + } return ExtensionContextRegistry.getInstance().getByPluginId(pluginName); }); } diff --git a/application/src/main/java/run/halo/app/plugin/YamlPluginFinder.java b/application/src/main/java/run/halo/app/plugin/YamlPluginFinder.java index 882ed99c44f..5cdc8f6e959 100644 --- a/application/src/main/java/run/halo/app/plugin/YamlPluginFinder.java +++ b/application/src/main/java/run/halo/app/plugin/YamlPluginFinder.java @@ -7,7 +7,6 @@ import lombok.extern.slf4j.Slf4j; import org.pf4j.DevelopmentPluginClasspath; import org.pf4j.PluginRuntimeException; -import org.pf4j.PluginState; import org.pf4j.util.FileUtils; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -65,7 +64,7 @@ public Plugin find(Path pluginPath) { Plugin plugin = readPluginDescriptor(pluginPath); if (plugin.getStatus() == null) { Plugin.PluginStatus pluginStatus = new Plugin.PluginStatus(); - pluginStatus.setPhase(PluginState.RESOLVED); + pluginStatus.setPhase(Plugin.Phase.PENDING); pluginStatus.setLoadLocation(pluginPath.toUri()); plugin.setStatus(pluginStatus); } diff --git a/application/src/main/java/run/halo/app/plugin/resources/BundleResourceUtils.java b/application/src/main/java/run/halo/app/plugin/resources/BundleResourceUtils.java index 5f14e6a3e79..536386e997c 100644 --- a/application/src/main/java/run/halo/app/plugin/resources/BundleResourceUtils.java +++ b/application/src/main/java/run/halo/app/plugin/resources/BundleResourceUtils.java @@ -1,5 +1,6 @@ package run.halo.app.plugin.resources; +import org.pf4j.PluginManager; import org.pf4j.PluginWrapper; import org.springframework.core.io.DefaultResourceLoader; import org.springframework.core.io.Resource; @@ -8,8 +9,6 @@ import org.springframework.util.StringUtils; import run.halo.app.infra.utils.FileUtils; import run.halo.app.infra.utils.PathUtils; -import run.halo.app.plugin.HaloPluginManager; -import run.halo.app.plugin.PluginConst; /** * Plugin bundle resources utils. @@ -22,48 +21,13 @@ public abstract class BundleResourceUtils { public static final String JS_BUNDLE = "main.js"; public static final String CSS_BUNDLE = "style.css"; - /** - * Gets plugin css bundle resource path relative to the plugin classpath if exists. - * - * @return css bundle resource path if exists, otherwise return null. - */ - @Nullable - public static String getCssBundlePath(HaloPluginManager haloPluginManager, - String pluginName) { - Resource jsBundleResource = getJsBundleResource(haloPluginManager, pluginName, CSS_BUNDLE); - if (jsBundleResource != null) { - return consoleResourcePath(pluginName, CSS_BUNDLE); - } - return null; - } - - private static String consoleResourcePath(String pluginName, String name) { - return PathUtils.combinePath(PluginConst.assertsRoutePrefix(pluginName), - CONSOLE_BUNDLE_LOCATION, name); - } - - /** - * Gets plugin js bundle resource path relative to the plugin classpath if exists. - * - * @return js bundle resource path if exists, otherwise return null. - */ - @Nullable - public static String getJsBundlePath(HaloPluginManager haloPluginManager, - String pluginName) { - Resource jsBundleResource = getJsBundleResource(haloPluginManager, pluginName, JS_BUNDLE); - if (jsBundleResource != null) { - return consoleResourcePath(pluginName, JS_BUNDLE); - } - return null; - } - /** * Gets js bundle resource by plugin name in console location. * * @return js bundle resource if exists, otherwise null */ @Nullable - public static Resource getJsBundleResource(HaloPluginManager pluginManager, String pluginName, + public static Resource getJsBundleResource(PluginManager pluginManager, String pluginName, String bundleName) { Assert.hasText(pluginName, "The pluginName must not be blank"); Assert.hasText(bundleName, "Bundle name must not be blank"); @@ -80,7 +44,7 @@ public static Resource getJsBundleResource(HaloPluginManager pluginManager, Stri } @Nullable - public static DefaultResourceLoader getResourceLoader(HaloPluginManager pluginManager, + public static DefaultResourceLoader getResourceLoader(PluginManager pluginManager, String pluginName) { Assert.notNull(pluginManager, "Plugin manager must not be null"); PluginWrapper plugin = pluginManager.getPlugin(pluginName); diff --git a/application/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java b/application/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java index 254a0ea05b0..b5a0bf52cfa 100644 --- a/application/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java +++ b/application/src/test/java/run/halo/app/core/extension/reconciler/PluginReconcilerTest.java @@ -1,60 +1,55 @@ package run.halo.app.core.extension.reconciler; -import static java.util.Objects.requireNonNull; -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.ArgumentMatchers.isA; -import static org.mockito.Mockito.doNothing; -import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; -import static run.halo.app.core.extension.reconciler.PluginReconciler.initialReverseProxyName; +import static run.halo.app.plugin.PluginConst.PLUGIN_PATH; +import static run.halo.app.plugin.PluginConst.RELOAD_ANNO; +import static run.halo.app.plugin.PluginConst.RUNTIME_MODE_ANNO; import java.io.IOException; -import java.net.URI; -import java.net.URISyntaxException; -import java.nio.file.Files; +import java.net.URL; +import java.net.URLClassLoader; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; +import java.time.Clock; +import java.util.Collections; import java.util.HashMap; -import java.util.List; +import java.util.HashSet; +import java.util.Map; import java.util.Optional; -import org.json.JSONException; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.DisplayName; +import java.util.Set; +import java.util.function.Consumer; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; +import org.junit.jupiter.api.io.TempDir; +import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.mockito.stubbing.Answer; -import org.pf4j.PluginDescriptor; +import org.pf4j.PluginManager; import org.pf4j.PluginState; import org.pf4j.PluginWrapper; -import org.skyscreamer.jsonassert.JSONAssert; -import org.springframework.context.ApplicationEventPublisher; import run.halo.app.core.extension.Plugin; import run.halo.app.core.extension.ReverseProxy; +import run.halo.app.core.extension.Setting; +import run.halo.app.extension.ConfigMap; import run.halo.app.extension.ExtensionClient; import run.halo.app.extension.Metadata; import run.halo.app.extension.controller.Reconciler; -import run.halo.app.infra.utils.FileUtils; -import run.halo.app.infra.utils.JsonUtils; -import run.halo.app.plugin.HaloPluginManager; -import run.halo.app.plugin.HaloPluginWrapper; -import run.halo.app.plugin.PluginConst; -import run.halo.app.plugin.PluginStartingError; +import run.halo.app.extension.controller.Reconciler.Request; +import run.halo.app.extension.controller.RequeueException; +import run.halo.app.infra.ConditionStatus; /** * Tests for {@link PluginReconciler}. @@ -66,572 +61,479 @@ class PluginReconcilerTest { @Mock - HaloPluginManager haloPluginManager; + PluginManager pluginManager; @Mock - ExtensionClient extensionClient; + ExtensionClient client; - @Mock - HaloPluginWrapper pluginWrapper; + @InjectMocks + PluginReconciler reconciler; - @Mock - ApplicationEventPublisher eventPublisher; - - PluginReconciler pluginReconciler; - - @BeforeEach - void setUp() { - pluginReconciler = new PluginReconciler(extensionClient, haloPluginManager, eventPublisher); - lenient().when(haloPluginManager.getPluginsRoot()).thenReturn(Paths.get("plugins")); - lenient().when(haloPluginManager.validatePluginVersion(any())).thenReturn(true); - lenient().when(haloPluginManager.getSystemVersion()).thenReturn("0.0.0"); - lenient().when(haloPluginManager.getPlugin(any())).thenReturn(pluginWrapper); - lenient().when(haloPluginManager.getUnresolvedPlugins()).thenReturn(List.of()); - } - @Test - @DisplayName("Reconcile to start successfully") - void reconcileOkWhenPluginManagerStartSuccessfully() { - Plugin plugin = need2ReconcileForStartupState(); - when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED); - var pluginDescriptor = mock(PluginDescriptor.class); - when(pluginWrapper.getDescriptor()).thenReturn(pluginDescriptor); - when(pluginDescriptor.getVersion()).thenReturn("1.0.0"); - - 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)); - - Plugin updateArgs = pluginCaptor.getAllValues().get(1); - assertThat(updateArgs).isNotNull(); - assertThat(updateArgs.getSpec().getEnabled()).isTrue(); - assertThat(updateArgs.getStatus().getPhase()).isEqualTo(PluginState.STARTED); - assertThat(updateArgs.getStatus().getLastStartTime()).isNotNull(); - } + Clock clock = Clock.systemUTC(); - @Test - @DisplayName("Reconcile to start failed") - void reconcileOkWhenPluginManagerStartFailed() { - Plugin plugin = need2ReconcileForStartupState(); - - // mock start plugin failed - when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin)); - when(haloPluginManager.startPlugin(any())).thenReturn(PluginState.FAILED); - - // mock plugin real state is started - when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED); - var pluginDescriptor = mock(PluginDescriptor.class); - - PluginStartingError pluginStartingError = - PluginStartingError.of("apples", "error message", "dev message"); - when(haloPluginManager.getPluginStartingError(any())).thenReturn(pluginStartingError); - - assertThatThrownBy(() -> { - ArgumentCaptor pluginCaptor = doReconcileNeedRequeue(); - - // Verify the state before the update plugin - Plugin updateArgs = pluginCaptor.getValue(); - assertThat(updateArgs).isNotNull(); - assertThat(updateArgs.getSpec().getEnabled()).isTrue(); - - Plugin.PluginStatus status = updateArgs.getStatus(); - assertThat(status.getPhase()).isEqualTo(PluginState.FAILED); - assertThat(status.getConditions().peek().getReason()).isEqualTo("error message"); - assertThat(status.getConditions().peek().getMessage()).isEqualTo("dev message"); - assertThat(status.getLastStartTime()).isNull(); - }).isInstanceOf(IllegalStateException.class) - .hasMessage("error message"); + String finalizer = "plugin-protection"; + String name = "fake-plugin"; - } + String reverseProxyName = "fake-plugin-system-generated-reverse-proxy"; - @Test - @DisplayName("Reconcile to stop successfully") - void shouldReconcileStopWhenEnabledIsFalseAndPhaseIsStarted() { - Plugin plugin = need2ReconcileForStopState(); - when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin)); - 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(3)).update(any(Plugin.class)); - - Plugin updateArgs = pluginCaptor.getValue(); - assertThat(updateArgs).isNotNull(); - assertThat(updateArgs.getSpec().getEnabled()).isFalse(); - assertThat(updateArgs.getStatus().getPhase()).isEqualTo(PluginState.STOPPED); - } + String settingName = "fake-setting"; - @Test - @DisplayName("Reconcile to stop successfully when 'spec.enabled' is inconsistent" - + " with 'status.phase'") - void shouldReconcileStopWhenEnabledIsFalseAndPhaseIsStopped() { - // 模拟插件的实际状态与status.phase记录的状态不一致 - Plugin plugin = JsonUtils.jsonToObject(""" - { - "apiVersion": "plugin.halo.run/v1alpha1", - "kind": "Plugin", - "metadata": { - "name": "apples" - }, - "spec": { - "displayName": "测试插件", - "enabled": false - }, - "status": { - "phase": "STOPPED", - "loadLocation": "/tmp/plugins/apples.jar" - } - } - """, Plugin.class); - when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin)); - 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)); - - Plugin updateArgs = pluginCaptor.getValue(); - assertThat(updateArgs).isNotNull(); - assertThat(updateArgs.getSpec().getEnabled()).isFalse(); - assertThat(updateArgs.getStatus().getPhase()).isEqualTo(PluginState.STOPPED); - } + String configMapName = "fake-configmap"; @Test - @DisplayName("Reconcile to stop failed") - void shouldReconcileStopWhenEnabledIsFalseAndPhaseIsStartedButStopFailed() { - Plugin plugin = need2ReconcileForStopState(); - when(extensionClient.fetch(eq(Plugin.class), eq("apples"))).thenReturn(Optional.of(plugin)); - // mock stop failed - when(haloPluginManager.stopPlugin(any())).thenReturn(PluginState.FAILED); - - // mock plugin real state is started - when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED); - - assertThatThrownBy(() -> { - ArgumentCaptor pluginCaptor = doReconcileNeedRequeue(); - - Plugin updateArgs = pluginCaptor.getValue(); - assertThat(updateArgs).isNotNull(); - assertThat(updateArgs.getSpec().getEnabled()).isFalse(); - - Plugin.PluginStatus status = updateArgs.getStatus(); - assertThat(status.getPhase()).isEqualTo(PluginState.FAILED); - }).isInstanceOf(IllegalStateException.class) - .hasMessage("Failed to stop plugin: apples"); + void shouldNotRequeueIfPluginNotFound() { + when(client.fetch(Plugin.class, "fake-plugin")).thenReturn(Optional.empty()); + var result = reconciler.reconcile(new Request("fake-plugin")); + assertFalse(result.reEnqueue()); + verify(client).fetch(Plugin.class, "fake-plugin"); } - @Test - void recreateDefaultReverseProxyWhenNotExistAndLogoIsPath() throws JSONException { - Plugin plugin = need2ReconcileForStopState(); - String reverseProxyName = initialReverseProxyName(plugin.getMetadata().getName()); - when(extensionClient.fetch(eq(ReverseProxy.class), eq(reverseProxyName))) - .thenReturn(Optional.empty()); - - plugin.getSpec().setLogo("/logo.png"); - pluginReconciler.recreateDefaultReverseProxy(plugin); - ArgumentCaptor captor = ArgumentCaptor.forClass(ReverseProxy.class); - verify(extensionClient, times(1)).create(captor.capture()); - ReverseProxy value = captor.getValue(); - JSONAssert.assertEquals(""" - { - "rules": [ - { - "path": "/logo.png", - "file": { - "filename": "/logo.png" - } - } - ], - "apiVersion": "plugin.halo.run/v1alpha1", - "kind": "ReverseProxy", - "metadata": { - "name": "apples-system-generated-reverse-proxy", - "labels": { - "plugin.halo.run/plugin-name": "apples" - } - } - } - """, - JsonUtils.objectToJson(value), - true); - } - - @Test - void recreateDefaultReverseProxyWhenNotExistAndLogoIsAbsolute() { - Plugin plugin = need2ReconcileForStopState(); - String reverseProxyName = initialReverseProxyName(plugin.getMetadata().getName()); - when(extensionClient.fetch(eq(ReverseProxy.class), eq(reverseProxyName))) - .thenReturn(Optional.empty()); - - plugin.getSpec().setLogo("http://example.com/logo"); - pluginReconciler.recreateDefaultReverseProxy(plugin); - ArgumentCaptor captor = ArgumentCaptor.forClass(ReverseProxy.class); - verify(extensionClient, times(1)).create(captor.capture()); - ReverseProxy value = captor.getValue(); - assertThat(value.getRules()).isEmpty(); - } + @Nested + class WhenNotDeleting { - @Test - void recreateDefaultReverseProxyWhenExist() { - Plugin plugin = need2ReconcileForStopState(); - plugin.getSpec().setLogo("/logo.png"); + @TempDir + Path tempPath; - String reverseProxyName = initialReverseProxyName(plugin.getMetadata().getName()); - ReverseProxy reverseProxy = new ReverseProxy(); - reverseProxy.setMetadata(new Metadata()); - reverseProxy.getMetadata().setName(reverseProxyName); - reverseProxy.setRules(new ArrayList<>()); + @Test + void shouldStartInDevMode() { + var fakePlugin = createPlugin(name, plugin -> { + var spec = plugin.getSpec(); + spec.setVersion("1.2.3"); + spec.setLogo("fake-logo.svg"); + spec.setEnabled(true); + plugin.getMetadata() + .setAnnotations(new HashMap<>(Map.of(RUNTIME_MODE_ANNO, "dev", + PLUGIN_PATH, "fake-path"))); + }); - when(extensionClient.fetch(eq(ReverseProxy.class), eq(reverseProxyName))) - .thenReturn(Optional.of(reverseProxy)); + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); + when(pluginManager.getPlugin(name)) + .thenReturn(null) + .thenReturn(mockPluginWrapper(PluginState.RESOLVED)); - pluginReconciler.recreateDefaultReverseProxy(plugin); - verify(extensionClient).update(any()); - } + when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED); - @Nested - class PluginLogoTest { + var result = reconciler.reconcile(new Request(name)); + assertFalse(result.reEnqueue()); + assertEquals(Paths.get("fake-path").toUri(), fakePlugin.getStatus().getLoadLocation()); - @Test - void absoluteUri() { - Plugin plugin = new Plugin(); - plugin.setSpec(new Plugin.PluginSpec()); - plugin.getSpec().setLogo("https://example.com/logo.png"); - plugin.getSpec().setVersion("1.0.0"); - String logo = pluginReconciler.generateAccessibleLogoUrl(plugin); - assertThat(logo).isEqualTo("https://example.com/logo.png"); + verify(pluginManager).startPlugin(name); } @Test - void absoluteUriWithQueryParam() { - Plugin plugin = new Plugin(); - plugin.setSpec(new Plugin.PluginSpec()); - plugin.getSpec().setLogo("https://example.com/logo.png?hello=world"); - plugin.getSpec().setVersion("1.0.0"); - assertThat(pluginReconciler.generateAccessibleLogoUrl(plugin)) - .isEqualTo("https://example.com/logo.png?hello=world"); - } + void shouldThrowExceptionIfNoPluginPathProvidedInDevMode() { + var fakePlugin = createPlugin(name, plugin -> { + var spec = plugin.getSpec(); + spec.setVersion("1.2.3"); + spec.setLogo("fake-logo.svg"); + spec.setEnabled(true); + plugin.getMetadata() + .setAnnotations(new HashMap<>(Map.of(RUNTIME_MODE_ANNO, "dev"))); + }); - @Test - void logoIsNull() { - Plugin plugin = new Plugin(); - plugin.setSpec(new Plugin.PluginSpec()); - plugin.getSpec().setLogo(null); - plugin.getSpec().setVersion("1.0.0"); - assertThat(pluginReconciler.generateAccessibleLogoUrl(plugin)).isNull(); - } + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); + when(pluginManager.getPlugin(name)) + // loading plugin + .thenReturn(null); - @Test - void logoIsEmpty() { - Plugin plugin = new Plugin(); - plugin.setSpec(new Plugin.PluginSpec()); - plugin.getSpec().setLogo(""); - plugin.getSpec().setVersion("1.0.0"); - assertThat(pluginReconciler.generateAccessibleLogoUrl(plugin)).isNull(); + var gotException = assertThrows(IllegalArgumentException.class, + () -> reconciler.reconcile(new Request(name))); + + assertEquals(""" + Please set plugin path annotation "plugin.halo.run/runtime-mode" in development \ + mode for plugin fake-plugin.""", gotException.getMessage()); } @Test - void relativePath() { - Plugin plugin = new Plugin(); - plugin.setSpec(new Plugin.PluginSpec()); - plugin.setMetadata(new Metadata()); - plugin.getMetadata().setName("fake-plugin"); - plugin.getSpec().setLogo("/static/logo.jpg"); - plugin.getSpec().setVersion("1.0.0"); - assertThat(pluginReconciler.generateAccessibleLogoUrl(plugin)) - .isEqualTo("/plugins/fake-plugin/assets/static/logo.jpg?version=1.0.0"); + void shouldUnloadIfFailedToLoad() { + var fakePlugin = createPlugin(name, plugin -> { + var spec = plugin.getSpec(); + spec.setVersion("1.2.3"); + spec.setLogo("fake-logo.svg"); + spec.setEnabled(true); + }); + + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); + when(pluginManager.getPluginsRoot()).thenReturn(tempPath); + when(pluginManager.getPlugin(name)) + // before loading + .thenReturn(null) + .thenReturn(mock(PluginWrapper.class)) + ; + var expectException = mock(RuntimeException.class); + when(expectException.getMessage()).thenReturn("Something went wrong."); + doThrow(expectException).when(pluginManager).loadPlugin(any(Path.class)); + + var gotException = assertThrows(RuntimeException.class, + () -> reconciler.reconcile(new Request(name))); + + assertEquals(expectException, gotException); + var condition = fakePlugin.getStatus().getConditions().peek(); + assertEquals("FAILED", condition.getType()); + assertEquals(ConditionStatus.FALSE, condition.getStatus()); + assertEquals("UnexpectedState", condition.getReason()); + assertEquals(expectException.getMessage(), condition.getMessage()); + verify(pluginManager, times(3)).getPlugin(name); + verify(pluginManager).loadPlugin(any(Path.class)); + verify(pluginManager).unloadPlugin(name); } @Test - void dataBlob() { - Plugin plugin = new Plugin(); - plugin.setSpec(new Plugin.PluginSpec()); - plugin.setMetadata(new Metadata()); - plugin.getMetadata().setName("fake-plugin"); - plugin.getSpec().setLogo("data:image/gif;base64,R0lGODfake"); - plugin.getSpec().setVersion("2.0.0"); - assertThat(pluginReconciler.generateAccessibleLogoUrl(plugin)) - .isEqualTo("data:image/gif;base64,R0lGODfake"); - } - } + void shouldReloadIfReloadAnnotationPresent() { + var fakePlugin = createPlugin(name, plugin -> { + var spec = plugin.getSpec(); + spec.setVersion("1.2.3"); + spec.setLogo("fake-logo.svg"); + spec.setEnabled(true); + plugin.getMetadata().setAnnotations(new HashMap<>(Map.of(RELOAD_ANNO, "true"))); + }); - @Test - void resolvePluginPathAnnotation() { - var pluginRoot = Paths.get("tmp", "plugins"); - when(haloPluginManager.getPluginsRoot()).thenReturn(pluginRoot); - var path = pluginReconciler.resolvePluginPathForAnno( - pluginRoot.resolve("sitemap-1.0.jar").toString()); - assertThat(path).isEqualTo("sitemap-1.0.jar"); - - var givenPath = Paths.get("abc", "plugins", "sitemap-1.0.jar"); - path = pluginReconciler.resolvePluginPathForAnno(givenPath.toString()); - assertThat(path).isEqualTo(givenPath.toString()); - } + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); + when(pluginManager.getPluginsRoot()).thenReturn(tempPath); + when(pluginManager.getPlugin(name)).thenReturn(mock(PluginWrapper.class)); + when(pluginManager.unloadPlugin(name)).thenReturn(true); + when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED); - @Nested - class ReloadPluginTest { - private static final String PLUGIN_NAME = "fake-plugin"; - private static final Path OLD_PLUGIN_PATH = Paths.get("/path/to/old/plugin.jar"); + var result = reconciler.reconcile(new Request(name)); + assertFalse(result.reEnqueue()); - @Test - void reload() throws IOException, URISyntaxException { - var fakePluginUri = requireNonNull( - getClass().getClassLoader().getResource("plugin/plugin-0.0.2")).toURI(); - Path tempDirectory = Files.createTempDirectory("halo-ut-plugin-service-impl-"); - final Path fakePluginPath = tempDirectory.resolve("plugin-0.0.2.jar"); - try { - FileUtils.jar(Paths.get(fakePluginUri), tempDirectory.resolve("plugin-0.0.2.jar")); - when(haloPluginManager.getPluginsRoot()).thenReturn(tempDirectory); - // mock plugin - Plugin plugin = mock(Plugin.class); - Metadata metadata = new Metadata(); - metadata.setName(PLUGIN_NAME); - when(plugin.getMetadata()).thenReturn(metadata); - metadata.setAnnotations(new HashMap<>()); - metadata.getAnnotations() - .put(PluginConst.RELOAD_ANNO, fakePluginPath.toString()); - Plugin.PluginStatus pluginStatus = mock(Plugin.PluginStatus.class); - when(pluginStatus.getLoadLocation()).thenReturn(OLD_PLUGIN_PATH.toUri()); - when(plugin.statusNonNull()).thenReturn(pluginStatus); - - when(extensionClient.fetch(Plugin.class, PLUGIN_NAME)) - .thenReturn(Optional.of(plugin)); - - // call reload method - pluginReconciler.reload(plugin); - - // verify that the plugin is updated with the new plugin's spec, annotations, and - // labels - verify(plugin).setSpec(any(Plugin.PluginSpec.class)); - verify(extensionClient).update(plugin); - - // verify that the plugin's load location is updated to the new plugin path - verify(pluginStatus).setLoadLocation(fakePluginPath.toUri()); - - // verify that the new plugin is reloaded - verify(haloPluginManager).reloadPluginWithPath(PLUGIN_NAME, fakePluginPath); - } finally { - FileUtils.deleteRecursivelyAndSilently(tempDirectory); - } + verify(pluginManager).unloadPlugin(name); + var loadLocation = Paths.get(fakePlugin.getStatus().getLoadLocation()); + verify(pluginManager).loadPlugin(loadLocation); } @Test - void shouldDeleteFile() throws IOException { - String newPluginPath = "/path/to/new/plugin.jar"; - - // Case 1: oldPluginLocation is null - assertFalse(pluginReconciler.shouldDeleteFile(newPluginPath, null)); - - // Case 2: oldPluginLocation is the same as newPluginPath - assertFalse(pluginReconciler.shouldDeleteFile(newPluginPath, - pluginReconciler.toUri(newPluginPath))); - - Path tempDirectory = Files.createTempDirectory("halo-ut-plugin-service-impl-"); - try { - Path oldPluginPath = tempDirectory.resolve("plugin.jar"); - final URI oldPluginLocation = oldPluginPath.toUri(); - Files.createFile(oldPluginPath); - // Case 3: oldPluginLocation is different from newPluginPath and is a JAR file - assertTrue(pluginReconciler.shouldDeleteFile(newPluginPath, oldPluginLocation)); - } finally { - FileUtils.deleteRecursivelyAndSilently(tempDirectory); - } - - // Case 4: oldPluginLocation is different from newPluginPath and is not a JAR file - assertFalse(pluginReconciler.shouldDeleteFile(newPluginPath, - Paths.get("/path/to/old/plugin.txt").toUri())); + void shouldReportIfFailedToStartPlugin() throws IOException { + var fakePlugin = createPlugin(name, plugin -> { + var spec = plugin.getSpec(); + spec.setVersion("1.2.3"); + spec.setLogo("fake-logo.svg"); + spec.setEnabled(true); + spec.setSettingName(settingName); + spec.setConfigMapName(configMapName); + }); + + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); + when(pluginManager.getPluginsRoot()).thenReturn(tempPath); + when(pluginManager.getPlugin(name)) + // loading plugin + .thenReturn(null) + // get setting extension + .thenReturn(mockPluginWrapperForSetting()) + .thenReturn(mockPluginWrapperForStaticResources()) + // before starting + .thenReturn(mockPluginWrapper(PluginState.RESOLVED)) + // sync plugin state + .thenReturn(mockPluginWrapper(PluginState.STARTED)); + when(pluginManager.startPlugin(name)).thenReturn(PluginState.FAILED); + + var e = assertThrows(IllegalStateException.class, + () -> reconciler.reconcile(new Request(name))); + assertEquals("Failed to start plugin fake-plugin", e.getMessage()); } @Test - void toPath() { - assertThat(pluginReconciler.toPath(null)).isNull(); - assertThat(pluginReconciler.toPath("")).isNull(); - assertThat(pluginReconciler.toPath(" ")).isNull(); - - final var filePath = Paths.get("path", "to", "file.txt").toAbsolutePath(); - - // test for file:/// - assertEquals(filePath, pluginReconciler.toPath(filePath.toUri().toString())); - // test for absolute path /home/xyz or C:\Windows - assertEquals(filePath, pluginReconciler.toPath(filePath.toString())); - - var exception = assertThrows(IllegalArgumentException.class, () -> { - var fileUri = filePath.toUri(); - pluginReconciler.toPath(fileUri.toString().replaceFirst("file", "http")); + void shouldEnablePluginIfEnabled() throws IOException { + var fakePlugin = createPlugin(name, plugin -> { + var spec = plugin.getSpec(); + spec.setVersion("1.2.3"); + spec.setLogo("fake-logo.svg"); + spec.setEnabled(true); + spec.setSettingName(settingName); + spec.setConfigMapName(configMapName); }); - assertTrue(exception.getMessage().contains("not reside in the file system")); + + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); + when(pluginManager.getPluginsRoot()).thenReturn(tempPath); + when(pluginManager.getPlugin(name)) + // loading plugin + .thenReturn(null) + // get setting extension + .thenReturn(mockPluginWrapperForSetting()) + .thenReturn(mockPluginWrapperForStaticResources()) + // before starting + .thenReturn(mockPluginWrapper(PluginState.RESOLVED)) + // sync plugin state + .thenReturn(mockPluginWrapper(PluginState.STARTED)); + when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED); + + var result = reconciler.reconcile(new Request(name)); + + assertFalse(result.reEnqueue()); + assertTrue(fakePlugin.getMetadata().getFinalizers().contains(finalizer)); + + assertEquals("fake-plugin-1.2.3.jar", + fakePlugin.getMetadata().getAnnotations().get(PLUGIN_PATH)); + var loadLocation = Paths.get(fakePlugin.getStatus().getLoadLocation()); + assertEquals(tempPath.resolve("fake-plugin-1.2.3.jar"), loadLocation); + assertEquals("/plugins/fake-plugin/assets/fake-logo.svg?version=1.2.3", + fakePlugin.getStatus().getLogo()); + assertEquals("/plugins/fake-plugin/assets/console/main.js?version=1.2.3", + fakePlugin.getStatus().getEntry()); + assertEquals("/plugins/fake-plugin/assets/console/style.css?version=1.2.3", + fakePlugin.getStatus().getStylesheet()); + assertEquals(Plugin.Phase.STARTED, fakePlugin.getStatus().getPhase()); + assertEquals(PluginState.STARTED, fakePlugin.getStatus().getLastProbeState()); + assertNotNull(fakePlugin.getStatus().getLastStartTime()); + + var condition = fakePlugin.getStatus().getConditions().peek(); + assertEquals("STARTED", condition.getType()); + assertEquals(ConditionStatus.TRUE, condition.getStatus()); + + verify(pluginManager).startPlugin(name); + verify(pluginManager).loadPlugin(loadLocation); + verify(pluginManager, times(5)).getPlugin(name); + verify(client).update(fakePlugin); + verify(client).fetch(Setting.class, settingName); + verify(client).create(any(Setting.class)); + verify(client).fetch(ConfigMap.class, configMapName); + verify(client).create(any(ConfigMap.class)); + verify(client).fetch(ReverseProxy.class, reverseProxyName); + verify(client).create(any(ReverseProxy.class)); } @Test - void toUri() { - // Test with null pathString - Assertions.assertThrows(IllegalArgumentException.class, () -> { - pluginReconciler.toUri(null); - }); - - // Test with empty pathString - Assertions.assertThrows(IllegalArgumentException.class, () -> { - pluginReconciler.toUri(""); + void shouldDisablePluginIfDisabled() throws IOException { + var fakePlugin = createPlugin(name, plugin -> { + var spec = plugin.getSpec(); + spec.setVersion("1.2.3"); + spec.setLogo("fake-logo.svg"); + spec.setEnabled(false); + spec.setSettingName(settingName); + spec.setConfigMapName(configMapName); }); - // Test with non-empty pathString - var filePath = Paths.get("path", "to", "file"); - URI uri = pluginReconciler.toUri(filePath.toString()); - assertEquals(filePath.toUri(), uri); + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); + when(pluginManager.getPluginsRoot()).thenReturn(tempPath); + + when(pluginManager.getPlugin(name)) + // loading plugin + .thenReturn(null) + // get setting files. + .thenReturn(mockPluginWrapperForSetting()) + // resolving static resources + .thenReturn(mockPluginWrapperForStaticResources()) + // before disabling plugin + .thenReturn(mock(PluginWrapper.class)) + // sync plugin state + .thenReturn(mockPluginWrapper(PluginState.DISABLED)); + + var result = reconciler.reconcile(new Request("fake-plugin")); + + assertFalse(result.reEnqueue()); + assertTrue(fakePlugin.getMetadata().getFinalizers().contains(finalizer)); + + assertEquals("fake-plugin-1.2.3.jar", + fakePlugin.getMetadata().getAnnotations().get(PLUGIN_PATH)); + var loadLocation = Paths.get(fakePlugin.getStatus().getLoadLocation()); + assertEquals(tempPath.resolve("fake-plugin-1.2.3.jar"), loadLocation); + assertEquals("/plugins/fake-plugin/assets/fake-logo.svg?version=1.2.3", + fakePlugin.getStatus().getLogo()); + assertEquals("/plugins/fake-plugin/assets/console/main.js?version=1.2.3", + fakePlugin.getStatus().getEntry()); + assertEquals("/plugins/fake-plugin/assets/console/style.css?version=1.2.3", + fakePlugin.getStatus().getStylesheet()); + assertEquals(Plugin.Phase.DISABLED, fakePlugin.getStatus().getPhase()); + assertEquals(PluginState.DISABLED, fakePlugin.getStatus().getLastProbeState()); + + verify(pluginManager).disablePlugin(name); + verify(pluginManager).loadPlugin(loadLocation); + verify(pluginManager, times(5)).getPlugin(name); + verify(client).update(fakePlugin); + verify(client).fetch(Setting.class, settingName); + verify(client).create(any(Setting.class)); + verify(client).fetch(ConfigMap.class, configMapName); + verify(client).create(any(ConfigMap.class)); + verify(client).fetch(ReverseProxy.class, reverseProxyName); + verify(client).create(any(ReverseProxy.class)); } - } - @Test - void persistenceFailureStatus() { - String name = "fake-plugin"; - Plugin plugin = new Plugin(); - Plugin.PluginStatus status = new Plugin.PluginStatus(); - plugin.setMetadata(new Metadata()); - plugin.getMetadata().setName(name); - plugin.setStatus(status); - when(extensionClient.fetch(eq(Plugin.class), eq(name))) - .thenReturn(Optional.of(plugin)); - PluginWrapper pluginWrapper = mock(PluginWrapper.class); - when(pluginWrapper.getPluginPath()).thenReturn(Paths.get("/path/to/plugin.jar")); - when(haloPluginManager.getPlugin(eq(name))) - .thenReturn(pluginWrapper); - Throwable error = mock(Throwable.class); - pluginReconciler.persistenceFailureStatus(name, error); - - assertThat(status.getPhase()).isEqualTo(PluginState.FAILED); - assertThat(status.getConditions()).hasSize(1); - assertThat(status.getConditions().peek().getType()) - .isEqualTo(PluginState.FAILED.toString()); - - verify(pluginWrapper).setPluginState(eq(PluginState.FAILED)); - verify(pluginWrapper).setFailedException(eq(error)); - } + PluginWrapper mockPluginWrapperForSetting() throws IOException { + var pluginWrapper = mock(PluginWrapper.class); - @Test - void shouldReconcileStartState() { - Plugin plugin = new Plugin(); - plugin.setMetadata(new Metadata()); - plugin.getMetadata().setName("fake-plugin"); - plugin.setSpec(new Plugin.PluginSpec()); + var urlEnumeration = getClass().getClassLoader() + .getResources("run/halo/app/core/extension/reconciler/"); + var urls = Collections.list(urlEnumeration).toArray(new URL[] {}); + var classLoader = new URLClassLoader(urls, null); + when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader); + return pluginWrapper; + } - PluginWrapper pluginWrapper = mock(PluginWrapper.class); - when(haloPluginManager.getPlugin(eq("fake-plugin"))).thenReturn(pluginWrapper); + PluginWrapper mockPluginWrapperForStaticResources() { + // check + var pluginWrapper = mock(PluginWrapper.class); + var pluginClassLoader = mock(ClassLoader.class); + when(pluginClassLoader.getResource("console/main.js")).thenReturn( + mock(URL.class)); + when(pluginClassLoader.getResource("console/style.css")).thenReturn( + mock(URL.class)); + when(pluginWrapper.getPluginClassLoader()).thenReturn(pluginClassLoader); + return pluginWrapper; + } - plugin.getSpec().setEnabled(false); - assertThat(pluginReconciler.shouldReconcileStartState(plugin)).isFalse(); + PluginWrapper mockPluginWrapper(PluginState state) { + var pluginWrapper = mock(PluginWrapper.class); + when(pluginWrapper.getPluginState()).thenReturn(state); + return pluginWrapper; + } - plugin.getSpec().setEnabled(true); - plugin.statusNonNull().setPhase(PluginState.RESOLVED); - assertThat(pluginReconciler.shouldReconcileStartState(plugin)).isTrue(); + } - when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED); - assertThat(pluginReconciler.shouldReconcileStartState(plugin)).isTrue(); + @Nested + class WhenDeleting { - plugin.statusNonNull().setPhase(PluginState.STARTED); - when(pluginWrapper.getPluginState()).thenReturn(PluginState.STARTED); - assertThat(pluginReconciler.shouldReconcileStartState(plugin)).isFalse(); - } + @Test + void shouldDoNothingWithoutFinalizer() { + var fakePlugin = createPlugin(name, plugin -> { + var metadata = plugin.getMetadata(); + metadata.setDeletionTimestamp(clock.instant()); + }); - @Test - void shouldReconcileStopState() { - Plugin plugin = new Plugin(); - plugin.setMetadata(new Metadata()); - plugin.getMetadata().setName("fake-plugin"); - plugin.setSpec(new Plugin.PluginSpec()); + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); - PluginWrapper pluginWrapper = mock(PluginWrapper.class); - when(haloPluginManager.getPlugin(eq("fake-plugin"))).thenReturn(pluginWrapper); + var result = reconciler.reconcile(new Request(name)); + assertFalse(result.reEnqueue()); + verify(client).fetch(Plugin.class, name); + verify(client, never()).update(fakePlugin); + verify(pluginManager, never()).getPlugin(name); + verify(pluginManager, never()).deletePlugin(name); + } - plugin.getSpec().setEnabled(true); - assertThat(pluginReconciler.shouldReconcileStopState(plugin)).isFalse(); + @Test + void shouldCleanUpResourceFully() { + var fakePlugin = createPlugin(name, plugin -> { + var metadata = plugin.getMetadata(); + metadata.setDeletionTimestamp(clock.instant()); + metadata.setFinalizers(new HashSet<>(Set.of(finalizer))); + plugin.getStatus().setLastProbeState(PluginState.STARTED); + plugin.getSpec().setConfigMapName("fake-configmap"); + plugin.getSpec().setSettingName("fake-setting"); + }); - plugin.getSpec().setEnabled(false); - plugin.statusNonNull().setPhase(PluginState.RESOLVED); - assertThat(pluginReconciler.shouldReconcileStopState(plugin)).isTrue(); + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); + when(client.fetch(Setting.class, "fake-setting")) + .thenReturn(Optional.empty()); + when(client.fetch(ReverseProxy.class, reverseProxyName)) + .thenReturn(Optional.empty()); + + when(pluginManager.getPlugin(name)) + .thenReturn(mock(PluginWrapper.class)) + .thenReturn(null); + + var result = reconciler.reconcile(new Request(name)); + + assertFalse(result.reEnqueue()); + // make sure the finalizer is removed. + assertFalse(fakePlugin.getMetadata().getFinalizers().contains(finalizer)); + assertNull(fakePlugin.getStatus().getLastProbeState()); + verify(pluginManager, times(2)).getPlugin(name); + verify(pluginManager).deletePlugin(name); + verify(client).fetch(Plugin.class, name); + verify(client).fetch(Setting.class, "fake-setting"); + verify(client).fetch(ReverseProxy.class, reverseProxyName); + verify(client).update(fakePlugin); + } - when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED); - assertThat(pluginReconciler.shouldReconcileStopState(plugin)).isTrue(); + @Test + void shouldDeleteSettingAndRequeueIfExists() { + var fakePlugin = createPlugin(name, plugin -> { + var metadata = plugin.getMetadata(); + metadata.setDeletionTimestamp(clock.instant()); + metadata.setFinalizers(new HashSet<>(Set.of(finalizer))); + plugin.getStatus().setLastProbeState(PluginState.STARTED); + plugin.getSpec().setSettingName(settingName); + }); - plugin.statusNonNull().setPhase(PluginState.STOPPED); - when(pluginWrapper.getPluginState()).thenReturn(PluginState.STOPPED); - assertThat(pluginReconciler.shouldReconcileStopState(plugin)).isFalse(); - } + var fakeSetting = createSetting(settingName); + + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); + when(client.fetch(Setting.class, settingName)) + .thenReturn(Optional.of(fakeSetting)); + when(client.fetch(ReverseProxy.class, reverseProxyName)) + .thenReturn(Optional.empty()); + + var exception = assertThrows( + RequeueException.class, + () -> reconciler.reconcile(new Request(name)) + ); + assertEquals(Reconciler.Result.requeue(null), exception.getResult()); + assertEquals("Waiting for setting fake-setting to be deleted.", exception.getMessage()); + + // make sure the finalizer is removed. + assertFalse(fakePlugin.getMetadata().getFinalizers().contains(finalizer)); + assertEquals(PluginState.STARTED, fakePlugin.getStatus().getLastProbeState()); + verify(pluginManager, never()).getPlugin(name); + verify(pluginManager, never()).deletePlugin(name); + verify(client).fetch(Plugin.class, name); + verify(client).fetch(ReverseProxy.class, reverseProxyName); + verify(client).fetch(Setting.class, settingName); + verify(client).delete(fakeSetting); + verify(client, never()).update(fakePlugin); + } - private ArgumentCaptor doReconcileNeedRequeue() { - ArgumentCaptor pluginCaptor = ArgumentCaptor.forClass(Plugin.class); - doNothing().when(extensionClient).update(pluginCaptor.capture()); + @Test + void shouldDeleteReverseProxyAndRequeueIfExists() { + var fakePlugin = createPlugin(name, plugin -> { + var metadata = plugin.getMetadata(); + metadata.setDeletionTimestamp(clock.instant()); + metadata.setFinalizers(new HashSet<>(Set.of(finalizer))); + plugin.getStatus().setLastProbeState(PluginState.STARTED); + plugin.getSpec().setSettingName(settingName); + }); - // reconcile - Reconciler.Result result = pluginReconciler.reconcile(new Reconciler.Request("apples")); - assertThat(result).isNotNull(); - assertThat(result.reEnqueue()).isEqualTo(true); + var reverseProxy = createReverseProxy(reverseProxyName); + + when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin)); + when(client.fetch(ReverseProxy.class, reverseProxyName)) + .thenReturn(Optional.of(reverseProxy)); + + var exception = assertThrows(RequeueException.class, + () -> reconciler.reconcile(new Request(name)), + "Waiting for setting fake-setting to be deleted."); + assertEquals(Reconciler.Result.requeue(null), exception.getResult()); + assertEquals("Waiting for reverse proxy " + reverseProxyName + " to be deleted.", + exception.getMessage()); + + // make sure the finalizer is removed. + assertFalse(fakePlugin.getMetadata().getFinalizers().contains(finalizer)); + assertEquals(PluginState.STARTED, fakePlugin.getStatus().getLastProbeState()); + verify(pluginManager, never()).getPlugin(name); + verify(pluginManager, never()).deletePlugin(name); + verify(client).fetch(Plugin.class, name); + verify(client).fetch(ReverseProxy.class, reverseProxyName); + verify(client).delete(reverseProxy); + verify(client, never()).fetch(Setting.class, settingName); + verify(client, never()).update(fakePlugin); + } - verify(extensionClient, times(2)).update(any()); - return pluginCaptor; } - private ArgumentCaptor doReconcileWithoutRequeue() { - ArgumentCaptor pluginCaptor = ArgumentCaptor.forClass(Plugin.class); - doNothing().when(extensionClient).update(pluginCaptor.capture()); - - // reconcile - Reconciler.Result result = pluginReconciler.reconcile(new Reconciler.Request("apples")); - assertThat(result).isNotNull(); - assertThat(result.reEnqueue()).isEqualTo(false); - return pluginCaptor; + Setting createSetting(String name) { + var setting = new Setting(); + var metadata = new Metadata(); + metadata.setName(name); + setting.setMetadata(metadata); + return setting; } - private Plugin need2ReconcileForStartupState() { - return JsonUtils.jsonToObject(""" - { - "apiVersion": "plugin.halo.run/v1alpha1", - "kind": "Plugin", - "metadata": { - "name": "apples" - }, - "spec": { - "displayName": "测试插件", - "enabled": true - }, - "status": { - "phase": "STOPPED", - "loadLocation": "/tmp/plugins/apples.jar" - } - } - """, Plugin.class); + ReverseProxy createReverseProxy(String name) { + var reverseProxy = new ReverseProxy(); + var metadata = new Metadata(); + metadata.setName(name); + reverseProxy.setMetadata(metadata); + return reverseProxy; } - private Plugin need2ReconcileForStopState() { - return JsonUtils.jsonToObject(""" - { - "apiVersion": "plugin.halo.run/v1alpha1", - "kind": "Plugin", - "metadata": { - "name": "apples" - }, - "spec": { - "displayName": "测试插件", - "enabled": false - }, - "status": { - "phase": "STARTED", - "loadLocation": "/tmp/plugins/apples.jar" - } - } - """, Plugin.class); + Plugin createPlugin(String name, Consumer pluginConsumer) { + var plugin = new Plugin(); + var metadata = new Metadata(); + plugin.setMetadata(metadata); + metadata.setName(name); + plugin.setSpec(new Plugin.PluginSpec()); + plugin.setStatus(new Plugin.PluginStatus()); + pluginConsumer.accept(plugin); + return plugin; } + } \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java b/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java index 4cd510e2ef6..a9098e6fe91 100644 --- a/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java +++ b/application/src/test/java/run/halo/app/core/extension/service/impl/PluginServiceImplTest.java @@ -30,7 +30,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.pf4j.PluginDescriptor; -import org.pf4j.PluginState; import org.pf4j.PluginWrapper; import org.pf4j.RuntimeMode; import org.springframework.web.server.ServerWebInputException; @@ -72,7 +71,7 @@ void getPresetsTest() { .assertNext(plugin -> { assertEquals("fake-plugin", plugin.getMetadata().getName()); assertEquals("0.0.2", plugin.getSpec().getVersion()); - assertEquals(PluginState.RESOLVED, plugin.getStatus().getPhase()); + assertEquals(Plugin.Phase.PENDING, plugin.getStatus().getPhase()); }) .verifyComplete(); } diff --git a/application/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java b/application/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java index d01208590ec..a858f830a60 100644 --- a/application/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java +++ b/application/src/test/java/run/halo/app/plugin/YamlPluginFinderTest.java @@ -17,7 +17,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.pf4j.PluginRuntimeException; -import org.pf4j.PluginState; import org.skyscreamer.jsonassert.JSONAssert; import org.springframework.core.io.FileSystemResource; import org.springframework.core.io.Resource; @@ -48,7 +47,7 @@ void setUp() throws FileNotFoundException { } @Test - void find() throws IOException, JSONException { + void find() throws IOException { var tempDirectory = Files.createTempDirectory("halo-test-plugin"); try { var directories = @@ -58,7 +57,7 @@ void find() throws IOException, JSONException { var plugin = pluginFinder.find(tempDirectory); assertThat(plugin).isNotNull(); var status = plugin.getStatus(); - assertEquals(PluginState.RESOLVED, status.getPhase()); + assertEquals(Plugin.Phase.PENDING, status.getPhase()); assertEquals(tempDirectory.toUri(), status.getLoadLocation()); } finally { FileUtils.deleteRecursivelyAndSilently(tempDirectory); diff --git a/application/src/test/java/run/halo/app/plugin/resources/BundleResourceUtilsTest.java b/application/src/test/java/run/halo/app/plugin/resources/BundleResourceUtilsTest.java index 7d36a0ced18..a69801c443c 100644 --- a/application/src/test/java/run/halo/app/plugin/resources/BundleResourceUtilsTest.java +++ b/application/src/test/java/run/halo/app/plugin/resources/BundleResourceUtilsTest.java @@ -44,26 +44,6 @@ void setUp() throws MalformedURLException { new URL("file://console/style.css")); } - @Test - void getCssBundlePath() { - String cssBundlePath = - BundleResourceUtils.getCssBundlePath(pluginManager, "nothing-plugin"); - assertThat(cssBundlePath).isNull(); - - cssBundlePath = BundleResourceUtils.getCssBundlePath(pluginManager, "fake-plugin"); - assertThat(cssBundlePath).isEqualTo("/plugins/fake-plugin/assets/console/style.css"); - } - - @Test - void getJsBundlePath() { - String jsBundlePath = - BundleResourceUtils.getJsBundlePath(pluginManager, "nothing-plugin"); - assertThat(jsBundlePath).isNull(); - - jsBundlePath = BundleResourceUtils.getJsBundlePath(pluginManager, "fake-plugin"); - assertThat(jsBundlePath).isEqualTo("/plugins/fake-plugin/assets/console/main.js"); - } - @Test void getJsBundleResource() { Resource jsBundleResource = diff --git a/application/src/test/resources/run/halo/app/core/extension/reconciler/extensions/setting.yaml b/application/src/test/resources/run/halo/app/core/extension/reconciler/extensions/setting.yaml new file mode 100644 index 00000000000..d2bf76bdb45 --- /dev/null +++ b/application/src/test/resources/run/halo/app/core/extension/reconciler/extensions/setting.yaml @@ -0,0 +1,6 @@ +apiVersion: v1alpha1 +kind: Setting +metadata: + name: fake-setting +spec: + forms: [ ] \ No newline at end of file