Refactor plugin reconciliation to ensure only one update on plugin (#5148)

Signed-off-by: John Niang <johnniang@foxmail.com>
pull/5187/head
John Niang 2024-01-14 22:58:42 +08:00 committed by GitHub
parent 7360a2eaca
commit 6d49047408
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
32 changed files with 1296 additions and 1705 deletions

View File

@ -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 {

View File

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

View File

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

View File

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

View File

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

View File

@ -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<>(

View File

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

View File

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

View File

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

View File

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

View File

@ -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}.

View File

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

View File

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

View File

@ -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.
*/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {

View File

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

View File

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

View File

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

View File

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

View File

@ -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 =

View File

@ -0,0 +1,6 @@
apiVersion: v1alpha1
kind: Setting
metadata:
name: fake-setting
spec:
forms: [ ]