mirror of https://github.com/halo-dev/halo
Refactor plugin reconciliation to ensure only one update on plugin (#5148)
Signed-off-by: John Niang <johnniang@foxmail.com>pull/5187/head
parent
7360a2eaca
commit
6d49047408
|
@ -110,12 +110,14 @@ public class Plugin extends AbstractExtension {
|
|||
@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 class Plugin extends AbstractExtension {
|
|||
}
|
||||
}
|
||||
|
||||
public enum Phase {
|
||||
PENDING,
|
||||
STARTING,
|
||||
CREATED,
|
||||
DISABLED,
|
||||
RESOLVED,
|
||||
STARTED,
|
||||
STOPPED,
|
||||
FAILED,
|
||||
UNKNOWN,
|
||||
;
|
||||
}
|
||||
|
||||
@Data
|
||||
@ToString
|
||||
public static class PluginAuthor {
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package run.halo.app.core.extension;
|
||||
|
||||
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
|
||||
import static run.halo.app.extension.GroupVersionKind.fromExtension;
|
||||
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import java.util.List;
|
||||
|
@ -8,6 +9,7 @@ import lombok.Data;
|
|||
import lombok.EqualsAndHashCode;
|
||||
import run.halo.app.extension.AbstractExtension;
|
||||
import run.halo.app.extension.GVK;
|
||||
import run.halo.app.extension.GroupVersionKind;
|
||||
|
||||
/**
|
||||
* {@link Setting} is a custom extension to generate forms based on configuration.
|
||||
|
@ -23,6 +25,8 @@ public class Setting extends AbstractExtension {
|
|||
|
||||
public static final String KIND = "Setting";
|
||||
|
||||
public static final GroupVersionKind GVK = fromExtension(Setting.class);
|
||||
|
||||
@Schema(requiredMode = REQUIRED)
|
||||
private SettingSpec spec;
|
||||
|
||||
|
|
|
@ -165,15 +165,17 @@ public class DefaultController<R> implements Controller {
|
|||
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());
|
||||
}
|
||||
|
|
|
@ -16,5 +16,9 @@ public interface Reconciler<R> {
|
|||
public static Result doNotRetry() {
|
||||
return new Result(false, null);
|
||||
}
|
||||
|
||||
public static Result requeue(Duration retryAfter) {
|
||||
return new Result(true, retryAfter);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -10,6 +10,7 @@ import static org.mockito.ArgumentMatchers.argThat;
|
|||
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 @@ class DefaultControllerTest {
|
|||
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<>(
|
||||
|
|
|
@ -42,7 +42,6 @@ import java.util.function.Supplier;
|
|||
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 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
// 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);
|
||||
});
|
||||
});
|
||||
})
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -65,8 +65,8 @@ public class DefaultRoleService implements RoleService {
|
|||
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 @@ public class DefaultRoleService implements RoleService {
|
|||
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);
|
||||
|
|
|
@ -38,7 +38,6 @@ public interface PluginService {
|
|||
* @return an updated plugin reloaded from plugin path
|
||||
* @throws ServerWebInputException if plugin not found by the given name
|
||||
* @see Plugin.PluginSpec#setEnabled(Boolean)
|
||||
* @see run.halo.app.plugin.HaloPluginManager#reloadPlugin(String)
|
||||
*/
|
||||
Mono<Plugin> reload(String name);
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package run.halo.app.core.extension.service.impl;
|
||||
|
||||
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
|
||||
import static run.halo.app.plugin.PluginConst.RELOAD_ANNO;
|
||||
|
||||
import com.github.zafarkhaja.semver.Version;
|
||||
import com.google.common.hash.Hashing;
|
||||
|
@ -10,13 +11,12 @@ import java.nio.file.Files;
|
|||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.Validate;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.pf4j.RuntimeMode;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
@ -26,6 +26,7 @@ import org.springframework.core.io.buffer.DefaultDataBufferFactory;
|
|||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.Assert;
|
||||
import org.springframework.util.CollectionUtils;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.Exceptions;
|
||||
import reactor.core.publisher.Flux;
|
||||
|
@ -33,7 +34,6 @@ import reactor.core.publisher.Mono;
|
|||
import reactor.core.scheduler.Schedulers;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.core.extension.service.PluginService;
|
||||
import run.halo.app.extension.MetadataUtil;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.SystemVersionSupplier;
|
||||
import run.halo.app.infra.exception.PluginAlreadyExistsException;
|
||||
|
@ -106,7 +106,8 @@ public class PluginServiceImpl implements PluginService {
|
|||
return findPluginManifest(path)
|
||||
.flatMap(pluginInPath -> {
|
||||
// pre-check the plugin in the path
|
||||
Validate.notNull(pluginInPath.statusNonNull().getLoadLocation());
|
||||
Assert.notNull(pluginInPath.statusNonNull().getLoadLocation(),
|
||||
"plugin.status.load-location must not be null");
|
||||
satisfiesRequiresVersion(pluginInPath);
|
||||
if (!Objects.equals(name, pluginInPath.getMetadata().getName())) {
|
||||
return Mono.error(new ServerWebInputException(
|
||||
|
@ -119,19 +120,28 @@ public class PluginServiceImpl implements PluginService {
|
|||
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
|
||||
"The given plugin with name " + name + " was not found.")))
|
||||
// copy plugin into plugin home
|
||||
.flatMap(prevPlugin -> copyToPluginHome(pluginInPath))
|
||||
.flatMap(pluginPath -> updateReloadAnno(name, pluginPath));
|
||||
.flatMap(oldPlugin -> copyToPluginHome(pluginInPath).thenReturn(oldPlugin))
|
||||
.doOnNext(oldPlugin -> updatePlugin(oldPlugin, pluginInPath))
|
||||
.flatMap(client::update);
|
||||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Plugin> reload(String name) {
|
||||
PluginWrapper pluginWrapper = pluginManager.getPlugin(name);
|
||||
if (pluginWrapper == null) {
|
||||
return Mono.error(() -> new ServerWebInputException(
|
||||
"The given plugin with name " + name + " was not found."));
|
||||
}
|
||||
return updateReloadAnno(name, pluginWrapper.getPluginPath());
|
||||
return client.get(Plugin.class, name)
|
||||
.flatMap(oldPlugin -> {
|
||||
if (oldPlugin.getStatus() == null
|
||||
|| oldPlugin.getStatus().getLoadLocation() == null) {
|
||||
return Mono.error(new IllegalStateException(
|
||||
"Load location of plugin has not been populated."));
|
||||
}
|
||||
var loadLocation = oldPlugin.getStatus().getLoadLocation();
|
||||
var loadPath = Path.of(loadLocation);
|
||||
return findPluginManifest(loadPath)
|
||||
.doOnNext(newPlugin -> updatePlugin(oldPlugin, newPlugin))
|
||||
.thenReturn(oldPlugin);
|
||||
})
|
||||
.flatMap(client::update);
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -217,16 +227,6 @@ public class PluginServiceImpl implements PluginService {
|
|||
);
|
||||
}
|
||||
|
||||
private Mono<Plugin> updateReloadAnno(String name, Path pluginPath) {
|
||||
return client.get(Plugin.class, name)
|
||||
.flatMap(plugin -> {
|
||||
// add reload annotation to flag the plugin to be reloaded
|
||||
Map<String, String> annotations = MetadataUtil.nullSafeAnnotations(plugin);
|
||||
annotations.put(PluginConst.RELOAD_ANNO, pluginPath.toString());
|
||||
return client.update(plugin);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy plugin into plugin home.
|
||||
*
|
||||
|
@ -251,7 +251,17 @@ public class PluginServiceImpl implements PluginService {
|
|||
FileUtils.copy(path, pluginFilePath, REPLACE_EXISTING);
|
||||
return pluginFilePath;
|
||||
})
|
||||
.subscribeOn(Schedulers.boundedElastic());
|
||||
.subscribeOn(Schedulers.boundedElastic())
|
||||
.doOnNext(loadLocation -> {
|
||||
// reset load location and annotation PLUGIN_PATH
|
||||
plugin.getStatus().setLoadLocation(loadLocation.toUri());
|
||||
var annotations = plugin.getMetadata().getAnnotations();
|
||||
if (annotations == null) {
|
||||
annotations = new HashMap<>();
|
||||
plugin.getMetadata().setAnnotations(annotations);
|
||||
}
|
||||
annotations.put(PluginConst.PLUGIN_PATH, loadLocation.toString());
|
||||
});
|
||||
}
|
||||
|
||||
private void satisfiesRequiresVersion(Plugin newPlugin) {
|
||||
|
@ -288,4 +298,38 @@ public class PluginServiceImpl implements PluginService {
|
|||
throw Exceptions.propagate(e);
|
||||
}
|
||||
}
|
||||
|
||||
private static void updatePlugin(Plugin oldPlugin, Plugin newPlugin) {
|
||||
var oldMetadata = oldPlugin.getMetadata();
|
||||
var newMetadata = newPlugin.getMetadata();
|
||||
// merge labels
|
||||
if (!CollectionUtils.isEmpty(newMetadata.getLabels())) {
|
||||
var labels = oldMetadata.getLabels();
|
||||
if (labels == null) {
|
||||
labels = new HashMap<>();
|
||||
oldMetadata.setLabels(labels);
|
||||
}
|
||||
labels.putAll(newMetadata.getLabels());
|
||||
}
|
||||
|
||||
var annotations = oldMetadata.getAnnotations();
|
||||
if (annotations == null) {
|
||||
annotations = new HashMap<>();
|
||||
oldMetadata.setAnnotations(annotations);
|
||||
}
|
||||
|
||||
// merge annotations
|
||||
if (!CollectionUtils.isEmpty(newMetadata.getAnnotations())) {
|
||||
annotations.putAll(newMetadata.getAnnotations());
|
||||
}
|
||||
|
||||
// request to reload
|
||||
annotations.put(RELOAD_ANNO,
|
||||
newPlugin.getStatus().getLoadLocation().toString());
|
||||
|
||||
// apply spec and keep enabled request
|
||||
var enabled = oldPlugin.getSpec().getEnabled();
|
||||
oldPlugin.setSpec(newPlugin.getSpec());
|
||||
oldPlugin.getSpec().setEnabled(enabled);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -96,6 +96,15 @@ public class SettingUtils {
|
|||
});
|
||||
}
|
||||
|
||||
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}.
|
||||
|
|
|
@ -1,7 +1,9 @@
|
|||
package run.halo.app.event.post;
|
||||
|
||||
import org.springframework.context.ApplicationEvent;
|
||||
import run.halo.app.plugin.SharedEvent;
|
||||
|
||||
@SharedEvent
|
||||
public class PostPublishedEvent extends ApplicationEvent implements PostEvent {
|
||||
|
||||
private final String postName;
|
||||
|
|
|
@ -26,10 +26,6 @@ public class DefaultDevelopmentPluginRepository extends DevelopmentPluginReposit
|
|||
super(pluginsRoots);
|
||||
}
|
||||
|
||||
public void addFixedPath(Path path) {
|
||||
fixedPaths.add(path);
|
||||
}
|
||||
|
||||
public void setFixedPaths(List<Path> paths) {
|
||||
if (CollectionUtils.isEmpty(paths)) {
|
||||
return;
|
||||
|
@ -47,7 +43,10 @@ public class DefaultDevelopmentPluginRepository extends DevelopmentPluginReposit
|
|||
|
||||
@Override
|
||||
public boolean deletePluginPath(Path pluginPath) {
|
||||
// do nothing
|
||||
return true;
|
||||
// If the plugin path is not included in the fixed paths,
|
||||
// return false and give another repository a chance.
|
||||
//
|
||||
// Meanwhile, there is no need to physically delete the plugin here.
|
||||
return fixedPaths.remove(pluginPath);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
@ -11,12 +10,10 @@ import lombok.extern.slf4j.Slf4j;
|
|||
import org.pf4j.DefaultPluginManager;
|
||||
import org.pf4j.ExtensionFactory;
|
||||
import org.pf4j.ExtensionFinder;
|
||||
import org.pf4j.PluginAlreadyLoadedException;
|
||||
import org.pf4j.PluginDependency;
|
||||
import org.pf4j.PluginDescriptor;
|
||||
import org.pf4j.PluginDescriptorFinder;
|
||||
import org.pf4j.PluginFactory;
|
||||
import org.pf4j.PluginRepository;
|
||||
import org.pf4j.PluginRuntimeException;
|
||||
import org.pf4j.PluginState;
|
||||
import org.pf4j.PluginStateEvent;
|
||||
|
@ -27,7 +24,6 @@ import org.springframework.beans.factory.InitializingBean;
|
|||
import org.springframework.context.ApplicationContext;
|
||||
import org.springframework.context.ApplicationContextAware;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.util.Assert;
|
||||
import run.halo.app.plugin.event.HaloPluginBeforeStopEvent;
|
||||
import run.halo.app.plugin.event.HaloPluginLoadedEvent;
|
||||
import run.halo.app.plugin.event.HaloPluginStartedEvent;
|
||||
|
@ -95,14 +91,6 @@ public class HaloPluginManager extends DefaultPluginManager
|
|||
rootApplicationContext.getBean(PluginRequestMappingManager.class);
|
||||
}
|
||||
|
||||
public PluginStartingError getPluginStartingError(String pluginId) {
|
||||
return startingErrors.get(pluginId);
|
||||
}
|
||||
|
||||
public PluginRepository getPluginRepository() {
|
||||
return this.pluginRepository;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected PluginDescriptorFinder createPluginDescriptorFinder() {
|
||||
return new YamlPluginDescriptorFinder();
|
||||
|
@ -232,11 +220,6 @@ public class HaloPluginManager extends DefaultPluginManager
|
|||
doStopPlugins();
|
||||
}
|
||||
|
||||
public boolean validatePluginVersion(PluginWrapper pluginWrapper) {
|
||||
Assert.notNull(pluginWrapper, "The pluginWrapper must not be null.");
|
||||
return isPluginValid(pluginWrapper);
|
||||
}
|
||||
|
||||
private PluginState doStartPlugin(String pluginId) {
|
||||
checkPluginId(pluginId);
|
||||
|
||||
|
@ -342,75 +325,6 @@ public class HaloPluginManager extends DefaultPluginManager
|
|||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Unload plugin and restart.
|
||||
*
|
||||
* @param restartStartedOnly If true, only reload started plugin
|
||||
*/
|
||||
public void reloadPlugins(boolean restartStartedOnly) {
|
||||
doStopPlugins();
|
||||
List<String> 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();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Reload plugin by id,it will be clean up memory resources of plugin and reload plugin from
|
||||
* disk.</p>
|
||||
* <p>
|
||||
* Note: This method will not start plugin, you need to start plugin manually.
|
||||
* this is to avoid starting plugins in different places, which will cause thread safety
|
||||
* issues, so all of them are handed over to the
|
||||
* {@link run.halo.app.core.extension.reconciler.PluginReconciler} to start the plugin
|
||||
* </p>
|
||||
*
|
||||
* @param pluginId plugin id
|
||||
* @return plugin startup status
|
||||
*/
|
||||
public PluginState reloadPlugin(String pluginId) {
|
||||
PluginWrapper plugin = getPlugin(pluginId);
|
||||
stopPlugin(pluginId, false);
|
||||
unloadPlugin(pluginId, false);
|
||||
try {
|
||||
loadPlugin(plugin.getPluginPath());
|
||||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
return getPlugin(pluginId).getPluginState();
|
||||
}
|
||||
|
||||
/**
|
||||
* Reload plugin by name and path.
|
||||
* Note: This method will ignore {@link PluginAlreadyLoadedException}.
|
||||
*
|
||||
* @param pluginName plugin name
|
||||
* @param pluginPath a new plugin path
|
||||
*/
|
||||
public void reloadPluginWithPath(String pluginName, Path pluginPath) {
|
||||
stopPlugin(pluginName, false);
|
||||
unloadPlugin(pluginName, false);
|
||||
try {
|
||||
loadPlugin(pluginPath);
|
||||
} catch (PluginAlreadyLoadedException ex) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Release plugin holding release on stop.
|
||||
*/
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package run.halo.app.plugin;
|
||||
|
||||
import java.util.Map;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.lang.NonNull;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
@ -40,14 +39,6 @@ public class PluginBeforeStopSyncListener {
|
|||
return Flux.fromIterable(gvkExtensionNames.entrySet())
|
||||
.flatMap(entry -> Flux.fromIterable(entry.getValue())
|
||||
.flatMap(extensionName -> client.fetch(entry.getKey(), extensionName))
|
||||
.filter(unstructured -> {
|
||||
Map<String, String> annotations = unstructured.getMetadata().getAnnotations();
|
||||
if (annotations == null) {
|
||||
return true;
|
||||
}
|
||||
String stage = PluginConst.DeleteStage.STOP.name();
|
||||
return stage.equals(annotations.getOrDefault(PluginConst.DELETE_STAGE, stage));
|
||||
})
|
||||
.flatMap(client::delete))
|
||||
.then();
|
||||
}
|
||||
|
|
|
@ -12,20 +12,16 @@ public interface PluginConst {
|
|||
*/
|
||||
String PLUGIN_NAME_LABEL_NAME = "plugin.halo.run/plugin-name";
|
||||
|
||||
String DELETE_STAGE = "delete-stage";
|
||||
|
||||
String SYSTEM_PLUGIN_NAME = "system";
|
||||
|
||||
String RELOAD_ANNO = "plugin.halo.run/reload";
|
||||
|
||||
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/";
|
||||
}
|
||||
|
||||
enum DeleteStage {
|
||||
STOP,
|
||||
UNINSTALL
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 @@ public class PluginDevelopmentInitializer implements ApplicationListener<Applica
|
|||
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();
|
||||
|
|
|
@ -1,82 +1,60 @@
|
|||
package run.halo.app.plugin;
|
||||
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.Collections;
|
||||
import java.util.HashSet;
|
||||
import java.util.Set;
|
||||
import java.util.jar.JarFile;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
import java.util.zip.ZipEntry;
|
||||
import java.net.URLClassLoader;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Predicate;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.pf4j.DevelopmentPluginClasspath;
|
||||
import org.pf4j.PluginRuntimeException;
|
||||
import org.pf4j.RuntimeMode;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.data.util.Predicates;
|
||||
import run.halo.app.core.extension.Setting;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
|
||||
@Slf4j
|
||||
public class PluginExtensionLoaderUtils {
|
||||
static final String EXTENSION_LOCATION = "extensions";
|
||||
static final DevelopmentPluginClasspath PLUGIN_CLASSPATH = new DevelopmentPluginClasspath();
|
||||
static final String EXTENSION_LOCATION_PATTERN = "classpath:extensions/*.{ext:yaml|yml}";
|
||||
|
||||
public static Set<String> lookupExtensions(Path pluginPath, RuntimeMode runtimeMode) {
|
||||
if (RuntimeMode.DEVELOPMENT.equals(runtimeMode)) {
|
||||
return lookupFromClasses(pluginPath);
|
||||
} else {
|
||||
return lookupFromJar(pluginPath);
|
||||
public static Predicate<Unstructured> isSetting(String settingName) {
|
||||
if (StringUtils.isBlank(settingName)) {
|
||||
return Predicates.isFalse();
|
||||
}
|
||||
var settingGk = Setting.GVK.groupKind();
|
||||
return unstructured -> {
|
||||
var gk = unstructured.groupVersionKind().groupKind();
|
||||
var name = unstructured.getMetadata().getName();
|
||||
return Objects.equals(settingName, name) && Objects.equals(settingGk, gk);
|
||||
};
|
||||
}
|
||||
|
||||
public static Set<String> lookupFromClasses(Path pluginPath) {
|
||||
Set<String> result = new HashSet<>();
|
||||
for (String directory : PLUGIN_CLASSPATH.getClassesDirectories()) {
|
||||
File file = pluginPath.resolve(directory).resolve(EXTENSION_LOCATION).toFile();
|
||||
if (file.exists() && file.isDirectory()) {
|
||||
result.addAll(walkExtensionFiles(file.toPath()));
|
||||
public static Resource[] lookupExtensions(ClassLoader classLoader) {
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Trying to lookup extensions from {}", classLoader);
|
||||
}
|
||||
if (classLoader instanceof URLClassLoader urlClassLoader) {
|
||||
var urls = urlClassLoader.getURLs();
|
||||
// The parent class loader must be null here because we don't want to
|
||||
// get any resources from parent class loader.
|
||||
classLoader = new URLClassLoader(urls, null);
|
||||
}
|
||||
var resolver = new PathMatchingResourcePatternResolver(classLoader);
|
||||
try {
|
||||
var resources = resolver.getResources(EXTENSION_LOCATION_PATTERN);
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("Looked up {} resources(s) from {}", resources.length, classLoader);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static Set<String> walkExtensionFiles(Path location) {
|
||||
try (Stream<Path> stream = Files.walk(location)) {
|
||||
return stream.map(Path::normalize)
|
||||
.filter(Files::isRegularFile)
|
||||
.filter(path -> isYamlFile(path.getFileName().toString()))
|
||||
.map(path -> location.getParent().relativize(path).toString())
|
||||
.collect(Collectors.toSet());
|
||||
return resources;
|
||||
} catch (FileNotFoundException ignored) {
|
||||
// Ignore the exception only if extensions folder was not found.
|
||||
} catch (IOException e) {
|
||||
log.debug("Failed to walk extension files from [{}]", location);
|
||||
return Collections.emptySet();
|
||||
throw new RuntimeException(String.format("""
|
||||
Failed to get extension resources while resolving plugin setting \
|
||||
in class loader %s.\
|
||||
""", classLoader), e);
|
||||
}
|
||||
return new Resource[] {};
|
||||
}
|
||||
|
||||
static boolean isYamlFile(String path) {
|
||||
return path.endsWith(".yaml") || path.endsWith(".yml");
|
||||
}
|
||||
|
||||
/**
|
||||
* <p>Lists the path of the unstructured yaml configuration file from the plugin jar.</p>
|
||||
*
|
||||
* @param pluginJarPath plugin jar path
|
||||
* @return Unstructured file paths relative to plugin classpath
|
||||
* @throws PluginRuntimeException If loading the file fails
|
||||
*/
|
||||
static Set<String> lookupFromJar(Path pluginJarPath) {
|
||||
try (JarFile jarFile = new JarFile(pluginJarPath.toFile())) {
|
||||
return jarFile.stream()
|
||||
.filter(jarEntry -> {
|
||||
String name = jarEntry.getName();
|
||||
return name.startsWith(EXTENSION_LOCATION)
|
||||
&& !jarEntry.isDirectory()
|
||||
&& isYamlFile(name);
|
||||
})
|
||||
.map(ZipEntry::getName)
|
||||
.collect(Collectors.toSet());
|
||||
} catch (IOException e) {
|
||||
throw new PluginRuntimeException(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,17 +1,18 @@
|
|||
package run.halo.app.plugin;
|
||||
|
||||
import static run.halo.app.plugin.PluginConst.PLUGIN_NAME_LABEL_NAME;
|
||||
import static run.halo.app.plugin.PluginExtensionLoaderUtils.isSetting;
|
||||
import static run.halo.app.plugin.PluginExtensionLoaderUtils.lookupExtensions;
|
||||
|
||||
import java.util.HashMap;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.stereotype.Component;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.extension.Unstructured;
|
||||
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||
import run.halo.app.plugin.event.HaloPluginStartedEvent;
|
||||
|
||||
|
@ -21,6 +22,7 @@ import run.halo.app.plugin.event.HaloPluginStartedEvent;
|
|||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class PluginStartedListener {
|
||||
|
||||
|
@ -30,46 +32,48 @@ public class PluginStartedListener {
|
|||
this.client = extensionClient;
|
||||
}
|
||||
|
||||
private Mono<Unstructured> createOrUpdate(Unstructured unstructured) {
|
||||
var name = unstructured.getMetadata().getName();
|
||||
return client.fetch(unstructured.groupVersionKind(), name)
|
||||
.doOnNext(old -> {
|
||||
unstructured.getMetadata().setVersion(old.getMetadata().getVersion());
|
||||
})
|
||||
.flatMap(client::update)
|
||||
.switchIfEmpty(Mono.defer(() -> client.create(unstructured)));
|
||||
}
|
||||
|
||||
@EventListener
|
||||
public Mono<Void> onApplicationEvent(HaloPluginStartedEvent event) {
|
||||
PluginWrapper pluginWrapper = event.getPlugin();
|
||||
var resourceLoader =
|
||||
new DefaultResourceLoader(pluginWrapper.getPluginClassLoader());
|
||||
var pluginWrapper = event.getPlugin();
|
||||
var pluginApplicationContext = ExtensionContextRegistry.getInstance()
|
||||
.getByPluginId(pluginWrapper.getPluginId());
|
||||
return client.get(Plugin.class, pluginWrapper.getPluginId())
|
||||
.zipWith(Mono.just(
|
||||
lookupExtensions(pluginWrapper.getPluginPath(), pluginWrapper.getRuntimeMode())))
|
||||
.flatMap(tuple2 -> {
|
||||
var plugin = tuple2.getT1();
|
||||
var extensionLocations = tuple2.getT2();
|
||||
return Flux.fromIterable(extensionLocations)
|
||||
.map(resourceLoader::getResource)
|
||||
.filter(Resource::exists)
|
||||
.map(resource -> new YamlUnstructuredLoader(resource).load())
|
||||
.flatMapIterable(rs -> rs)
|
||||
.flatMap(unstructured -> {
|
||||
var metadata = unstructured.getMetadata();
|
||||
// collector plugin initialize extension resources
|
||||
pluginApplicationContext.addExtensionMapping(
|
||||
unstructured.groupVersionKind(),
|
||||
metadata.getName());
|
||||
var labels = metadata.getLabels();
|
||||
if (labels == null) {
|
||||
labels = new HashMap<>();
|
||||
}
|
||||
labels.put(PluginConst.PLUGIN_NAME_LABEL_NAME,
|
||||
plugin.getMetadata().getName());
|
||||
metadata.setLabels(labels);
|
||||
|
||||
return client.fetch(unstructured.groupVersionKind(), metadata.getName())
|
||||
.flatMap(extension -> {
|
||||
unstructured.getMetadata()
|
||||
.setVersion(extension.getMetadata().getVersion());
|
||||
return client.update(unstructured);
|
||||
})
|
||||
.switchIfEmpty(Mono.defer(() -> client.create(unstructured)));
|
||||
}).then();
|
||||
}).then();
|
||||
var pluginName = pluginWrapper.getPluginId();
|
||||
|
||||
return client.get(Plugin.class, pluginName)
|
||||
.flatMap(plugin -> Flux.fromStream(
|
||||
() -> {
|
||||
log.debug("Collecting extensions for plugin {}", pluginName);
|
||||
var resources = lookupExtensions(pluginWrapper.getPluginClassLoader());
|
||||
var loader = new YamlUnstructuredLoader(resources);
|
||||
var settingName = plugin.getSpec().getSettingName();
|
||||
// TODO The load method may be over memory consumption.
|
||||
return loader.load()
|
||||
.stream()
|
||||
.filter(isSetting(settingName).negate());
|
||||
})
|
||||
.doOnNext(unstructured -> {
|
||||
var name = unstructured.getMetadata().getName();
|
||||
pluginApplicationContext
|
||||
.addExtensionMapping(unstructured.groupVersionKind(), name);
|
||||
var labels = unstructured.getMetadata().getLabels();
|
||||
if (labels == null) {
|
||||
labels = new HashMap<>();
|
||||
unstructured.getMetadata().setLabels(labels);
|
||||
}
|
||||
labels.put(PLUGIN_NAME_LABEL_NAME, plugin.getMetadata().getName());
|
||||
})
|
||||
.flatMap(this::createOrUpdate)
|
||||
.then());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 class PluginUtils {
|
|||
}
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
|
|
@ -97,8 +97,10 @@ public class SpringExtensionFactory implements ExtensionFactory {
|
|||
() -> 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 @@ public class SpringExtensionFactory implements ExtensionFactory {
|
|||
.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);
|
||||
});
|
||||
}
|
||||
|
|
|
@ -7,7 +7,6 @@ import java.util.List;
|
|||
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 class YamlPluginFinder {
|
|||
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);
|
||||
}
|
||||
|
|
|
@ -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.Assert;
|
|||
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 abstract class BundleResourceUtils {
|
|||
}
|
||||
|
||||
@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);
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -2,14 +2,13 @@ package run.halo.app.core.extension.service.impl;
|
|||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.isA;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
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;
|
||||
|
||||
|
@ -17,20 +16,19 @@ import com.github.zafarkhaja.semver.Version;
|
|||
import com.google.common.hash.Hashing;
|
||||
import java.io.IOException;
|
||||
import java.net.URISyntaxException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import java.util.function.Consumer;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.ExtendWith;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
import org.mockito.InjectMocks;
|
||||
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 +70,7 @@ class PluginServiceImplTest {
|
|||
.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();
|
||||
}
|
||||
|
@ -93,15 +91,15 @@ class PluginServiceImplTest {
|
|||
}
|
||||
|
||||
@Nested
|
||||
class InstallOrUpdateTest {
|
||||
class InstallUpdateReloadTest {
|
||||
|
||||
Path fakePluginPath;
|
||||
|
||||
@TempDir
|
||||
Path tempDirectory;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() throws URISyntaxException, IOException {
|
||||
tempDirectory = Files.createTempDirectory("halo-ut-plugin-service-impl-");
|
||||
fakePluginPath = tempDirectory.resolve("plugin-0.0.2.jar");
|
||||
var fakePluingUri = requireNonNull(
|
||||
getClass().getClassLoader().getResource("plugin/plugin-0.0.2")).toURI();
|
||||
|
@ -113,11 +111,6 @@ class PluginServiceImplTest {
|
|||
lenient().when(systemVersionSupplier.get()).thenReturn(Version.valueOf("0.0.0"));
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
void cleanUp() {
|
||||
FileUtils.deleteRecursivelyAndSilently(tempDirectory);
|
||||
}
|
||||
|
||||
@Test
|
||||
void installWhenPluginExists() {
|
||||
var existingPlugin = new YamlPluginFinder().find(fakePluginPath);
|
||||
|
@ -170,67 +163,87 @@ class PluginServiceImplTest {
|
|||
|
||||
@Test
|
||||
void upgradeNormally() {
|
||||
final var prevPlugin = mock(Plugin.class);
|
||||
final var spec = mock(Plugin.PluginSpec.class);
|
||||
final var updatedPlugin = mock(Plugin.class);
|
||||
Metadata metadata = new Metadata();
|
||||
metadata.setName("fake-plugin");
|
||||
when(prevPlugin.getMetadata()).thenReturn(metadata);
|
||||
|
||||
when(prevPlugin.getSpec()).thenReturn(spec);
|
||||
when(spec.getEnabled()).thenReturn(true);
|
||||
var oldFakePlugin = createPlugin("fake-plugin", plugin -> {
|
||||
plugin.getSpec().setEnabled(true);
|
||||
plugin.getSpec().setVersion("0.0.1");
|
||||
});
|
||||
|
||||
when(client.fetch(Plugin.class, "fake-plugin"))
|
||||
.thenReturn(Mono.just(prevPlugin))
|
||||
.thenReturn(Mono.just(prevPlugin))
|
||||
.thenReturn(Mono.just(oldFakePlugin))
|
||||
.thenReturn(Mono.just(oldFakePlugin))
|
||||
.thenReturn(Mono.empty());
|
||||
|
||||
when(client.get(Plugin.class, "fake-plugin"))
|
||||
.thenReturn(Mono.just(prevPlugin));
|
||||
|
||||
when(client.update(isA(Plugin.class))).thenReturn(Mono.just(updatedPlugin));
|
||||
when(client.update(oldFakePlugin)).thenReturn(Mono.just(oldFakePlugin));
|
||||
|
||||
var plugin = pluginService.upgrade("fake-plugin", fakePluginPath);
|
||||
|
||||
StepVerifier.create(plugin)
|
||||
.expectNext(updatedPlugin).verifyComplete();
|
||||
.expectNext(oldFakePlugin)
|
||||
.verifyComplete();
|
||||
|
||||
verify(client, times(1)).fetch(Plugin.class, "fake-plugin");
|
||||
verify(client, times(0)).delete(isA(Plugin.class));
|
||||
verify(client).<Plugin>update(argThat(p -> p.getSpec().getEnabled()));
|
||||
verify(client).fetch(Plugin.class, "fake-plugin");
|
||||
verify(client).update(oldFakePlugin);
|
||||
assertTrue(oldFakePlugin.getSpec().getEnabled());
|
||||
assertEquals("0.0.2", oldFakePlugin.getSpec().getVersion());
|
||||
assertEquals(
|
||||
tempDirectory.resolve("plugins").resolve("fake-plugin-0.0.2.jar").toString(),
|
||||
oldFakePlugin.getMetadata().getAnnotations().get(PluginConst.PLUGIN_PATH));
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotReloadIfLoadLocationIsNotReady() {
|
||||
var pluginName = "test-plugin";
|
||||
|
||||
var testPlugin = createPlugin(pluginName, plugin -> {
|
||||
});
|
||||
|
||||
when(client.get(Plugin.class, pluginName)).thenReturn(Mono.just(testPlugin));
|
||||
|
||||
pluginService.reload(pluginName)
|
||||
.as(StepVerifier::create)
|
||||
.consumeErrorWith(t -> {
|
||||
assertInstanceOf(IllegalStateException.class, t);
|
||||
assertEquals("Load location of plugin has not been populated.",
|
||||
t.getMessage());
|
||||
})
|
||||
.verify();
|
||||
|
||||
verify(client).get(Plugin.class, pluginName);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldReloadIfLoadLocationReady() {
|
||||
var pluginName = "test-plugin";
|
||||
|
||||
var testPlugin = createPlugin(pluginName, plugin -> {
|
||||
plugin.getStatus().setLoadLocation(fakePluginPath.toUri());
|
||||
});
|
||||
|
||||
when(client.get(Plugin.class, pluginName)).thenReturn(Mono.just(testPlugin));
|
||||
when(client.update(testPlugin)).thenReturn(Mono.just(testPlugin));
|
||||
|
||||
pluginService.reload(pluginName)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(testPlugin)
|
||||
.verifyComplete();
|
||||
|
||||
assertEquals(fakePluginPath.toString(),
|
||||
testPlugin.getMetadata().getAnnotations().get(PluginConst.PLUGIN_PATH));
|
||||
verify(client).get(Plugin.class, pluginName);
|
||||
verify(client).update(testPlugin);
|
||||
}
|
||||
|
||||
Plugin createPlugin(String name, Consumer<Plugin> pluginConsumer) {
|
||||
var plugin = new Plugin();
|
||||
plugin.setMetadata(new Metadata());
|
||||
plugin.getMetadata().setName(name);
|
||||
plugin.setSpec(new Plugin.PluginSpec());
|
||||
plugin.setStatus(new Plugin.PluginStatus());
|
||||
pluginConsumer.accept(plugin);
|
||||
return plugin;
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void reload() {
|
||||
// given
|
||||
String pluginName = "test-plugin";
|
||||
PluginWrapper pluginWrapper = mock(PluginWrapper.class);
|
||||
when(pluginManager.getPlugin(pluginName)).thenReturn(pluginWrapper);
|
||||
var pluginPath = Paths.get("tmp", "plugins", "fake-plugin.jar");
|
||||
when(pluginWrapper.getPluginPath())
|
||||
.thenReturn(pluginPath);
|
||||
Plugin plugin = new Plugin();
|
||||
plugin.setMetadata(new Metadata());
|
||||
plugin.getMetadata().setName(pluginName);
|
||||
plugin.setSpec(new Plugin.PluginSpec());
|
||||
when(client.get(Plugin.class, pluginName)).thenReturn(Mono.just(plugin));
|
||||
when(client.update(plugin)).thenReturn(Mono.just(plugin));
|
||||
|
||||
// when
|
||||
Mono<Plugin> result = pluginService.reload(pluginName);
|
||||
|
||||
// then
|
||||
assertDoesNotThrow(() -> result.block());
|
||||
verify(client, times(1)).update(
|
||||
argThat(p -> {
|
||||
String reloadPath = p.getMetadata().getAnnotations().get(PluginConst.RELOAD_ANNO);
|
||||
assertThat(reloadPath).isEqualTo(pluginPath.toString());
|
||||
return true;
|
||||
})
|
||||
);
|
||||
verify(pluginWrapper, times(1)).getPluginPath();
|
||||
}
|
||||
|
||||
@Test
|
||||
void generateJsBundleVersionTest() {
|
||||
|
|
|
@ -4,6 +4,7 @@ import static org.assertj.core.api.Assertions.assertThat;
|
|||
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.io.TempDir;
|
||||
|
@ -24,14 +25,15 @@ class DefaultDevelopmentPluginRepositoryTest {
|
|||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
this.developmentPluginRepository =
|
||||
new DefaultDevelopmentPluginRepository();
|
||||
var repository = new DefaultDevelopmentPluginRepository();
|
||||
repository.setFixedPaths(List.of(tempDir));
|
||||
this.developmentPluginRepository = repository;
|
||||
}
|
||||
|
||||
@Test
|
||||
void deletePluginPath() {
|
||||
boolean deleted = developmentPluginRepository.deletePluginPath(null);
|
||||
assertThat(deleted).isTrue();
|
||||
assertThat(deleted).isFalse();
|
||||
|
||||
// deletePluginPath is a no-op
|
||||
deleted = developmentPluginRepository.deletePluginPath(tempDir);
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
package run.halo.app.plugin;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static run.halo.app.plugin.PluginExtensionLoaderUtils.isSetting;
|
||||
import static run.halo.app.plugin.PluginExtensionLoaderUtils.lookupExtensions;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.net.URLClassLoader;
|
||||
import java.util.Arrays;
|
||||
import java.util.Objects;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||
|
||||
class PluginExtensionLoaderUtilsTest {
|
||||
|
||||
@Test
|
||||
void lookupExtensionsAndIsSettingTest() throws IOException {
|
||||
var resourceLoader = new DefaultResourceLoader();
|
||||
var rootResource = resourceLoader.getResource("classpath:plugin/plugin-0.0.1/");
|
||||
var classLoader = new URLClassLoader(new URL[] {rootResource.getURL()}, null);
|
||||
var resources = lookupExtensions(classLoader);
|
||||
assertTrue(resources.length >= 1);
|
||||
var settingResource = Arrays.stream(resources)
|
||||
.filter(r -> Objects.equals("setting.yaml", r.getFilename()))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
|
||||
var loader = new YamlUnstructuredLoader(settingResource);
|
||||
var unstructuredList = loader.load();
|
||||
assertEquals(1, unstructuredList.size());
|
||||
assertTrue(isSetting("fake-setting").test(unstructuredList.get(0)));
|
||||
assertFalse(isSetting("non-fake-setting").test(unstructuredList.get(0)));
|
||||
assertFalse(isSetting("").test(unstructuredList.get(0)));
|
||||
assertFalse(isSetting(null).test(unstructuredList.get(0)));
|
||||
}
|
||||
}
|
|
@ -1,64 +0,0 @@
|
|||
package run.halo.app.plugin;
|
||||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.Set;
|
||||
import org.junit.jupiter.api.Nested;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.util.FileSystemUtils;
|
||||
import org.springframework.util.ResourceUtils;
|
||||
import run.halo.app.infra.utils.FileUtils;
|
||||
|
||||
/**
|
||||
* Tests for {@link PluginStartedListener}.
|
||||
*
|
||||
* @author guqing
|
||||
* @since 2.0.0
|
||||
*/
|
||||
class PluginStartedListenerTest {
|
||||
|
||||
@Nested
|
||||
class PluginExtensionLoaderUtilsTest {
|
||||
|
||||
@Test
|
||||
void lookupFromClasses() throws IOException {
|
||||
Path tempPluginPath = Files.createTempDirectory("halo-test-plugin");
|
||||
|
||||
Path directories =
|
||||
Files.createDirectories(tempPluginPath.resolve("build/resources/main"));
|
||||
Path extensions = Files.createDirectory(directories.resolve("extensions"));
|
||||
Files.createFile(extensions.resolve("roles.yaml"));
|
||||
|
||||
Set<String> extensionResources =
|
||||
PluginExtensionLoaderUtils.lookupFromClasses(tempPluginPath);
|
||||
assertThat(extensionResources)
|
||||
.containsAll(Set.of(Path.of("extensions/roles.yaml").toString()));
|
||||
}
|
||||
|
||||
@Test
|
||||
void lookupFromJar() throws IOException {
|
||||
Path tempDirectory = Files.createTempDirectory("halo-plugin");
|
||||
try {
|
||||
var plugin001Uri = requireNonNull(
|
||||
ResourceUtils.getFile("classpath:plugin/plugin-0.0.1")).toURI();
|
||||
|
||||
Path targetJarPath = tempDirectory.resolve("plugin-0.0.1.jar");
|
||||
FileUtils.jar(Paths.get(plugin001Uri), targetJarPath);
|
||||
Set<String> unstructuredFilePathFromJar =
|
||||
PluginExtensionLoaderUtils.lookupFromJar(targetJarPath);
|
||||
assertThat(unstructuredFilePathFromJar).hasSize(3);
|
||||
assertThat(unstructuredFilePathFromJar).containsAll(Set.of(
|
||||
Path.of("extensions/roles.yaml").toString(),
|
||||
Path.of("extensions/reverseProxy.yaml").toString(),
|
||||
Path.of("extensions/test.yml").toString()));
|
||||
} finally {
|
||||
FileSystemUtils.deleteRecursively(tempDirectory);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -17,7 +17,6 @@ import org.json.JSONException;
|
|||
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 @@ class YamlPluginFinderTest {
|
|||
}
|
||||
|
||||
@Test
|
||||
void find() throws IOException, JSONException {
|
||||
void find() throws IOException {
|
||||
var tempDirectory = Files.createTempDirectory("halo-test-plugin");
|
||||
try {
|
||||
var directories =
|
||||
|
@ -58,7 +57,7 @@ class YamlPluginFinderTest {
|
|||
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);
|
||||
|
|
|
@ -44,26 +44,6 @@ class BundleResourceUtilsTest {
|
|||
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 =
|
||||
|
|
|
@ -0,0 +1,6 @@
|
|||
apiVersion: v1alpha1
|
||||
kind: Setting
|
||||
metadata:
|
||||
name: fake-setting
|
||||
spec:
|
||||
forms: [ ]
|
Loading…
Reference in New Issue