mirror of https://github.com/halo-dev/halo
Refactor plugin reconciliation for dependency mechanism (#5900)
#### What type of PR is this? /kind improvement /area core /area plugin #### What this PR does / why we need it: This PR wholly refactors plugin reconciliation to implement dependency mechanism. Currently, - If we disable plugin which has dependents, the plugin must wait for dependents to be disabled. - If we enable plugin which has dependencies , the plugin must wait for dependencies to be enabled. - If we upgrade plugin which has dependents, the plugin must request dependents to be unloaded. After the plugin is unloaded, the plugin must cancel unload request for dependents. #### Which issue(s) this PR fixes: Fixes #5872 #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note 优化被依赖的插件的升级,启用和禁用 ```pull/5994/head^2
parent
769b19c23c
commit
5df51bb715
|
@ -14089,8 +14089,6 @@
|
|||
"Condition": {
|
||||
"required": [
|
||||
"lastTransitionTime",
|
||||
"message",
|
||||
"reason",
|
||||
"status",
|
||||
"type"
|
||||
],
|
||||
|
@ -16944,6 +16942,7 @@
|
|||
"PENDING",
|
||||
"STARTING",
|
||||
"CREATED",
|
||||
"DISABLING",
|
||||
"DISABLED",
|
||||
"RESOLVED",
|
||||
"STARTED",
|
||||
|
|
|
@ -142,6 +142,7 @@ public class Plugin extends AbstractExtension {
|
|||
PENDING,
|
||||
STARTING,
|
||||
CREATED,
|
||||
DISABLING,
|
||||
DISABLED,
|
||||
RESOLVED,
|
||||
STARTED,
|
||||
|
|
|
@ -51,13 +51,15 @@ public class Condition {
|
|||
* Human-readable message indicating details about last transition.
|
||||
* This may be an empty string.
|
||||
*/
|
||||
@Schema(requiredMode = REQUIRED, maxLength = 32768)
|
||||
private String message;
|
||||
@Schema(maxLength = 32768)
|
||||
@Builder.Default
|
||||
private String message = "";
|
||||
|
||||
/**
|
||||
* Unique, one-word, CamelCase reason for the condition's last transition.
|
||||
*/
|
||||
@Schema(requiredMode = REQUIRED, maxLength = 1024,
|
||||
@Schema(maxLength = 1024,
|
||||
pattern = "^[A-Za-z]([A-Za-z0-9_,:]*[A-Za-z0-9_])?$")
|
||||
private String reason;
|
||||
@Builder.Default
|
||||
private String reason = "";
|
||||
}
|
||||
|
|
|
@ -55,13 +55,33 @@ public class ConditionList extends AbstractCollection<Condition> {
|
|||
* @param condition item to add
|
||||
*/
|
||||
public boolean addAndEvictFIFO(@NonNull Condition condition, int evictThreshold) {
|
||||
boolean result = this.addFirst(condition);
|
||||
var current = getCondition(condition.getType());
|
||||
if (current != null) {
|
||||
// do not update last transition time if status is not changed
|
||||
if (Objects.equals(condition.getStatus(), current.getStatus())) {
|
||||
condition.setLastTransitionTime(current.getLastTransitionTime());
|
||||
}
|
||||
}
|
||||
|
||||
conditions.remove(current);
|
||||
conditions.addFirst(condition);
|
||||
|
||||
while (conditions.size() > evictThreshold) {
|
||||
removeLast();
|
||||
}
|
||||
return result;
|
||||
return true;
|
||||
}
|
||||
|
||||
private Condition getCondition(String type) {
|
||||
for (Condition condition : conditions) {
|
||||
if (condition.getType().equals(type)) {
|
||||
return condition;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
public void remove(Condition condition) {
|
||||
conditions.remove(condition);
|
||||
}
|
||||
|
|
|
@ -284,51 +284,8 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
return request.bodyToMono(RunningStateRequest.class)
|
||||
.flatMap(runningState -> {
|
||||
var enable = runningState.isEnable();
|
||||
var updatedPlugin = Mono.defer(() -> client.get(Plugin.class, name))
|
||||
.flatMap(plugin -> {
|
||||
if (!Objects.equals(enable, plugin.getSpec().getEnabled())) {
|
||||
plugin.getSpec().setEnabled(enable);
|
||||
log.debug("Updating plugin {} state to {}", name, enable);
|
||||
return client.update(plugin);
|
||||
}
|
||||
log.debug("Checking plugin {} state, no need to update", name);
|
||||
return Mono.just(plugin);
|
||||
});
|
||||
|
||||
var async = runningState.isAsync();
|
||||
if (!async) {
|
||||
// if we want to wait the state of plugin to be updated
|
||||
updatedPlugin = updatedPlugin
|
||||
.flatMap(plugin -> {
|
||||
var phase = plugin.statusNonNull().getPhase();
|
||||
if (enable) {
|
||||
// if we request to enable the plugin
|
||||
if (!(Plugin.Phase.STARTED.equals(phase)
|
||||
|| Plugin.Phase.FAILED.equals(phase))) {
|
||||
return Mono.error(UnexpectedPluginStateException::new);
|
||||
}
|
||||
} else {
|
||||
// if we request to disable the plugin
|
||||
if (Plugin.Phase.STARTED.equals(phase)) {
|
||||
return Mono.error(UnexpectedPluginStateException::new);
|
||||
}
|
||||
}
|
||||
return Mono.just(plugin);
|
||||
})
|
||||
.retryWhen(
|
||||
Retry.backoff(10, Duration.ofMillis(100))
|
||||
.filter(UnexpectedPluginStateException.class::isInstance)
|
||||
.doBeforeRetry(signal ->
|
||||
log.debug("Waiting for plugin {} to meet expected state", name)
|
||||
)
|
||||
)
|
||||
.doOnSuccess(plugin -> {
|
||||
log.info("Plugin {} met expected state {}",
|
||||
name, plugin.statusNonNull().getPhase());
|
||||
});
|
||||
}
|
||||
|
||||
return updatedPlugin;
|
||||
return pluginService.changeState(name, enable, !async);
|
||||
})
|
||||
.flatMap(plugin -> ServerResponse.ok().bodyValue(plugin));
|
||||
}
|
||||
|
@ -883,7 +840,4 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
}
|
||||
}
|
||||
|
||||
private static class UnexpectedPluginStateException extends IllegalStateException {
|
||||
|
||||
}
|
||||
}
|
||||
|
|
|
@ -6,13 +6,12 @@ import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
|
|||
import static run.halo.app.extension.MetadataUtil.nullSafeAnnotations;
|
||||
import static run.halo.app.plugin.PluginConst.PLUGIN_PATH;
|
||||
import static run.halo.app.plugin.PluginConst.RELOAD_ANNO;
|
||||
import static run.halo.app.plugin.PluginConst.RUNTIME_MODE_ANNO;
|
||||
import static run.halo.app.plugin.PluginConst.REQUEST_TO_UNLOAD_LABEL;
|
||||
import static run.halo.app.plugin.PluginExtensionLoaderUtils.isSetting;
|
||||
import static run.halo.app.plugin.PluginExtensionLoaderUtils.lookupExtensions;
|
||||
import static run.halo.app.plugin.PluginUtils.generateFileName;
|
||||
import static run.halo.app.plugin.PluginUtils.isDevelopmentMode;
|
||||
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.net.MalformedURLException;
|
||||
|
@ -24,17 +23,17 @@ import java.nio.file.Paths;
|
|||
import java.time.Clock;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedList;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.function.Supplier;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
import org.pf4j.PluginRuntimeException;
|
||||
import org.pf4j.PluginDependency;
|
||||
import org.pf4j.PluginState;
|
||||
import org.pf4j.PluginStateEvent;
|
||||
import org.pf4j.PluginStateListener;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.pf4j.RuntimeMode;
|
||||
import org.springframework.core.io.DefaultResourceLoader;
|
||||
|
@ -57,9 +56,8 @@ import run.halo.app.extension.controller.Reconciler;
|
|||
import run.halo.app.extension.controller.Reconciler.Request;
|
||||
import run.halo.app.extension.controller.RequeueException;
|
||||
import run.halo.app.infra.Condition;
|
||||
import run.halo.app.infra.ConditionList;
|
||||
import run.halo.app.infra.ConditionStatus;
|
||||
import run.halo.app.infra.utils.JsonParseException;
|
||||
import run.halo.app.infra.utils.JsonUtils;
|
||||
import run.halo.app.infra.utils.PathUtils;
|
||||
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||
import run.halo.app.plugin.PluginConst;
|
||||
|
@ -77,8 +75,12 @@ import run.halo.app.plugin.SpringPluginManager;
|
|||
@Component
|
||||
public class PluginReconciler implements Reconciler<Request> {
|
||||
private static final String FINALIZER_NAME = "plugin-protection";
|
||||
private static final String DEPENDENTS_ANNO_KEY = "plugin.halo.run/dependents-snapshot";
|
||||
|
||||
private static final Set<String> UNUSED_ANNOTATIONS =
|
||||
Set.of("plugin.halo.run/dependents-snapshot");
|
||||
|
||||
private final ExtensionClient client;
|
||||
|
||||
private final SpringPluginManager pluginManager;
|
||||
|
||||
private final PluginProperties pluginProperties;
|
||||
|
@ -91,9 +93,6 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
this.pluginManager = pluginManager;
|
||||
this.pluginProperties = pluginProperties;
|
||||
this.clock = Clock.systemUTC();
|
||||
|
||||
this.pluginManager.addPluginStateListener(new PluginStartedListener());
|
||||
this.pluginManager.addPluginStateListener(new PluginStoppedListener());
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -124,47 +123,69 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
return Result.doNotRetry();
|
||||
}
|
||||
addFinalizers(plugin.getMetadata(), Set.of(FINALIZER_NAME));
|
||||
plugin.statusNonNull().setPhase(Plugin.Phase.PENDING);
|
||||
removeUnusedAnnotations(plugin);
|
||||
|
||||
// Prepare
|
||||
try {
|
||||
resolveLoadLocation(plugin);
|
||||
|
||||
loadOrReload(plugin);
|
||||
createOrUpdateSetting(plugin);
|
||||
createOrUpdateReverseProxy(plugin);
|
||||
resolveStaticResources(plugin);
|
||||
|
||||
if (requestToEnable(plugin)) {
|
||||
// Start
|
||||
enablePlugin(plugin);
|
||||
} else {
|
||||
// stop the plugin and disable it
|
||||
disablePlugin(plugin);
|
||||
}
|
||||
} catch (Throwable t) {
|
||||
// populate condition
|
||||
var condition = Condition.builder()
|
||||
.type(PluginState.FAILED.toString())
|
||||
.reason("UnexpectedState")
|
||||
.message(t.getMessage())
|
||||
.status(ConditionStatus.FALSE)
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build();
|
||||
var status = plugin.statusNonNull();
|
||||
nullSafeConditions(status).addAndEvictFIFO(condition);
|
||||
status.setPhase(Plugin.Phase.FAILED);
|
||||
throw t;
|
||||
} finally {
|
||||
syncPluginState(plugin);
|
||||
client.update(plugin);
|
||||
var status = plugin.getStatus();
|
||||
if (status == null) {
|
||||
status = new Plugin.PluginStatus();
|
||||
plugin.setStatus(status);
|
||||
}
|
||||
// reset phase to pending
|
||||
status.setPhase(Plugin.Phase.PENDING);
|
||||
// init condition list if not exists
|
||||
if (status.getConditions() == null) {
|
||||
status.setConditions(new ConditionList());
|
||||
}
|
||||
|
||||
return Result.doNotRetry();
|
||||
var steps = new LinkedList<Supplier<Result>>();
|
||||
steps.add(() -> resolveLoadLocation(plugin));
|
||||
steps.add(() -> loadOrReload(plugin));
|
||||
steps.add(() -> createOrUpdateSetting(plugin));
|
||||
steps.add(() -> createOrUpdateReverseProxy(plugin));
|
||||
steps.add(() -> resolveStaticResources(plugin));
|
||||
if (requestToEnable(plugin)) {
|
||||
steps.add(() -> enablePlugin(plugin));
|
||||
} else {
|
||||
steps.add(() -> disablePlugin(plugin));
|
||||
}
|
||||
|
||||
Result result = null;
|
||||
try {
|
||||
for (var step : steps) {
|
||||
result = step.get();
|
||||
if (result != null) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
} catch (Exception e) {
|
||||
status.getConditions().addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.READY)
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason(ConditionReason.SYSTEM_ERROR)
|
||||
.message(e.getMessage())
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
status.setPhase(Plugin.Phase.UNKNOWN);
|
||||
throw e;
|
||||
} finally {
|
||||
var pw = pluginManager.getPlugin(plugin.getMetadata().getName());
|
||||
if (pw != null) {
|
||||
status.setLastProbeState(pw.getPluginState());
|
||||
}
|
||||
client.update(plugin);
|
||||
}
|
||||
})
|
||||
.orElseGet(Result::doNotRetry);
|
||||
}
|
||||
|
||||
private void removeUnusedAnnotations(Plugin plugin) {
|
||||
var annotations = plugin.getMetadata().getAnnotations();
|
||||
if (annotations != null) {
|
||||
UNUSED_ANNOTATIONS.forEach(annotations::remove);
|
||||
}
|
||||
}
|
||||
|
||||
private boolean checkDependents(Plugin plugin) {
|
||||
var pluginId = plugin.getMetadata().getName();
|
||||
var dependents = pluginManager.getDependents(pluginId);
|
||||
|
@ -173,17 +194,20 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
}
|
||||
var status = plugin.statusNonNull();
|
||||
var condition = Condition.builder()
|
||||
.type(PluginState.FAILED.toString())
|
||||
.reason("DependentsExist")
|
||||
.type(ConditionType.PROGRESSING)
|
||||
.status(ConditionStatus.UNKNOWN)
|
||||
.reason(ConditionReason.WAIT_FOR_DEPENDENTS_DELETED)
|
||||
.message(
|
||||
"The plugin has dependents %s, please delete them first."
|
||||
.formatted(dependents.stream().map(PluginWrapper::getPluginId).toList())
|
||||
)
|
||||
.status(ConditionStatus.FALSE)
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build();
|
||||
nullSafeConditions(status).addAndEvictFIFO(condition);
|
||||
status.setPhase(Plugin.Phase.FAILED);
|
||||
var conditions = nullSafeConditions(status);
|
||||
removeConditionBy(conditions, ConditionType.INITIALIZED);
|
||||
removeConditionBy(conditions, ConditionType.READY);
|
||||
conditions.addAndEvictFIFO(condition);
|
||||
status.setPhase(Plugin.Phase.UNKNOWN);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
@ -197,6 +221,14 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
}
|
||||
}
|
||||
|
||||
private static String requestToUnload(Plugin plugin) {
|
||||
var labels = plugin.getMetadata().getLabels();
|
||||
if (labels == null) {
|
||||
return null;
|
||||
}
|
||||
return labels.get(REQUEST_TO_UNLOAD_LABEL);
|
||||
}
|
||||
|
||||
private static boolean requestToReload(Plugin plugin) {
|
||||
var annotations = plugin.getMetadata().getAnnotations();
|
||||
return annotations != null && annotations.get(RELOAD_ANNO) != null;
|
||||
|
@ -209,7 +241,6 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private void cleanupResources(Plugin plugin) {
|
||||
var pluginName = plugin.getMetadata().getName();
|
||||
var reverseProxyName = buildReverseProxyName(pluginName);
|
||||
|
@ -241,90 +272,121 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
}
|
||||
}
|
||||
|
||||
private void enablePlugin(Plugin plugin) {
|
||||
private Result enablePlugin(Plugin plugin) {
|
||||
// start the plugin
|
||||
var pluginName = plugin.getMetadata().getName();
|
||||
log.info("Starting plugin {}", pluginName);
|
||||
plugin.statusNonNull().setPhase(Plugin.Phase.STARTING);
|
||||
var pluginState = pluginManager.startPlugin(pluginName);
|
||||
if (!PluginState.STARTED.equals(pluginState)) {
|
||||
throw new IllegalStateException("Failed to start plugin " + pluginName);
|
||||
var status = plugin.getStatus();
|
||||
status.setPhase(Plugin.Phase.STARTING);
|
||||
|
||||
// check if the parent plugin is started
|
||||
var pw = pluginManager.getPlugin(pluginName);
|
||||
var unstartedDependencies = pw.getDescriptor().getDependencies()
|
||||
.stream()
|
||||
.filter(pd -> {
|
||||
if (pd.isOptional()) {
|
||||
return false;
|
||||
}
|
||||
var parent = pluginManager.getPlugin(pd.getPluginId());
|
||||
return parent == null || !PluginState.STARTED.equals(parent.getPluginState());
|
||||
})
|
||||
.map(PluginDependency::getPluginId)
|
||||
.toList();
|
||||
var conditions = status.getConditions();
|
||||
if (!CollectionUtils.isEmpty(unstartedDependencies)) {
|
||||
removeConditionBy(conditions, ConditionType.READY);
|
||||
conditions.addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.PROGRESSING)
|
||||
.status(ConditionStatus.UNKNOWN)
|
||||
.reason(ConditionReason.WAIT_FOR_DEPENDENCIES_STARTED)
|
||||
.message("Wait for parent plugins " + unstartedDependencies + " to be started")
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
status.setPhase(Plugin.Phase.UNKNOWN);
|
||||
return Result.requeue(Duration.ofSeconds(1));
|
||||
}
|
||||
|
||||
var dependents = getAndRemoveDependents(plugin);
|
||||
log.info("Starting dependents {} for plugin {}", dependents, pluginName);
|
||||
dependents.stream()
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach(dependent -> {
|
||||
if (pluginManager.getPlugin(dependent) != null) {
|
||||
pluginManager.startPlugin(dependent);
|
||||
}
|
||||
});
|
||||
log.info("Started dependents {} for plugin {}", dependents, pluginName);
|
||||
try {
|
||||
var pluginState = pluginManager.startPlugin(pluginName);
|
||||
if (!PluginState.STARTED.equals(pluginState)) {
|
||||
throw new IllegalStateException("""
|
||||
Failed to start plugin %s(%s).\
|
||||
""".formatted(pluginName, pluginState));
|
||||
}
|
||||
} catch (Exception e) {
|
||||
conditions.addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.READY)
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason(ConditionReason.START_ERROR)
|
||||
.message(e.getMessage())
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
status.setPhase(Plugin.Phase.FAILED);
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
plugin.statusNonNull().setLastStartTime(clock.instant());
|
||||
var condition = Condition.builder()
|
||||
.type(PluginState.STARTED.toString())
|
||||
.reason(PluginState.STARTED.toString())
|
||||
removeConditionBy(conditions, ConditionType.PROGRESSING);
|
||||
status.setLastStartTime(clock.instant());
|
||||
conditions.addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.READY)
|
||||
.status(ConditionStatus.TRUE)
|
||||
.reason(ConditionReason.STARTED)
|
||||
.message("Started successfully")
|
||||
.lastTransitionTime(clock.instant())
|
||||
.status(ConditionStatus.TRUE)
|
||||
.build();
|
||||
nullSafeConditions(plugin.statusNonNull()).addAndEvictFIFO(condition);
|
||||
plugin.statusNonNull().setPhase(Plugin.Phase.STARTED);
|
||||
.build());
|
||||
status.setPhase(Plugin.Phase.STARTED);
|
||||
|
||||
log.info("Started plugin {}", pluginName);
|
||||
return null;
|
||||
}
|
||||
|
||||
private List<String> getAndRemoveDependents(Plugin plugin) {
|
||||
private Result disablePlugin(Plugin plugin) {
|
||||
var pluginName = plugin.getMetadata().getName();
|
||||
var annotations = plugin.getMetadata().getAnnotations();
|
||||
if (annotations == null) {
|
||||
return List.of();
|
||||
}
|
||||
var dependentsAnno = annotations.remove(DEPENDENTS_ANNO_KEY);
|
||||
List<String> dependents = List.of();
|
||||
if (StringUtils.isNotBlank(dependentsAnno)) {
|
||||
try {
|
||||
dependents = JsonUtils.jsonToObject(dependentsAnno, new TypeReference<>() {
|
||||
});
|
||||
} catch (JsonParseException ignored) {
|
||||
log.error("Failed to parse dependents annotation {} for plugin {}",
|
||||
dependentsAnno, pluginName);
|
||||
// Keep going to start the plugin.
|
||||
}
|
||||
}
|
||||
return dependents;
|
||||
}
|
||||
|
||||
private void setDependents(Plugin plugin) {
|
||||
var pluginName = plugin.getMetadata().getName();
|
||||
var annotations = plugin.getMetadata().getAnnotations();
|
||||
if (annotations == null) {
|
||||
annotations = new HashMap<>();
|
||||
plugin.getMetadata().setAnnotations(annotations);
|
||||
}
|
||||
if (!annotations.containsKey(DEPENDENTS_ANNO_KEY)) {
|
||||
// get dependents
|
||||
var status = plugin.getStatus();
|
||||
if (pluginManager.getPlugin(pluginName) != null) {
|
||||
// check if the plugin has children
|
||||
var dependents = pluginManager.getDependents(pluginName)
|
||||
.stream()
|
||||
.filter(pluginWrapper ->
|
||||
Objects.equals(PluginState.STARTED, pluginWrapper.getPluginState())
|
||||
)
|
||||
.filter(pw -> PluginState.STARTED.equals(pw.getPluginState()))
|
||||
.map(PluginWrapper::getPluginId)
|
||||
.toList();
|
||||
|
||||
annotations.put(DEPENDENTS_ANNO_KEY, JsonUtils.objectToJson(dependents));
|
||||
}
|
||||
}
|
||||
|
||||
private void disablePlugin(Plugin plugin) {
|
||||
var pluginName = plugin.getMetadata().getName();
|
||||
if (pluginManager.getPlugin(pluginName) != null) {
|
||||
setDependents(plugin);
|
||||
pluginManager.disablePlugin(pluginName);
|
||||
var conditions = status.getConditions();
|
||||
if (!CollectionUtils.isEmpty(dependents)) {
|
||||
removeConditionBy(conditions, ConditionType.READY);
|
||||
conditions.addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.PROGRESSING)
|
||||
.status(ConditionStatus.UNKNOWN)
|
||||
.reason(ConditionReason.WAIT_FOR_DEPENDENTS_DISABLED)
|
||||
.message("Wait for children plugins " + dependents + " to be disabled")
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
status.setPhase(Plugin.Phase.DISABLING);
|
||||
return Result.requeue(Duration.ofSeconds(1));
|
||||
}
|
||||
try {
|
||||
pluginManager.disablePlugin(pluginName);
|
||||
} catch (Exception e) {
|
||||
conditions.addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.READY)
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason(ConditionReason.DISABLE_ERROR)
|
||||
.message(e.getMessage())
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
status.setPhase(Plugin.Phase.FAILED);
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
}
|
||||
var conditions = plugin.getStatus().getConditions();
|
||||
removeConditionBy(conditions, ConditionType.PROGRESSING);
|
||||
conditions.addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.READY)
|
||||
.status(ConditionStatus.TRUE)
|
||||
.reason(ConditionReason.DISABLED)
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
plugin.statusNonNull().setPhase(Plugin.Phase.DISABLED);
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean requestToEnable(Plugin plugin) {
|
||||
|
@ -332,7 +394,7 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
return enabled != null && enabled;
|
||||
}
|
||||
|
||||
private void resolveStaticResources(Plugin plugin) {
|
||||
private Result resolveStaticResources(Plugin plugin) {
|
||||
var pluginName = plugin.getMetadata().getName();
|
||||
var pluginVersion = plugin.getSpec().getVersion();
|
||||
if (isDevelopmentMode(plugin)) {
|
||||
|
@ -386,44 +448,121 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
.toString();
|
||||
status.setStylesheet(stylesheet);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private void loadOrReload(Plugin plugin) {
|
||||
private Result loadOrReload(Plugin plugin) {
|
||||
var pluginName = plugin.getMetadata().getName();
|
||||
var p = pluginManager.getPlugin(pluginName);
|
||||
var conditions = plugin.getStatus().getConditions();
|
||||
|
||||
var requestToUnloadBy = requestToUnload(plugin);
|
||||
var requestToUnload = requestToUnloadBy != null;
|
||||
var notFullyLoaded = p != null && pluginManager.getUnresolvedPlugins().contains(p);
|
||||
var alreadyLoaded = p != null && pluginManager.getResolvedPlugins().contains(p);
|
||||
|
||||
var requestToReload = requestToReload(plugin);
|
||||
if (requestToReload) {
|
||||
if (p != null) {
|
||||
var loadLocation = plugin.getStatus().getLoadLocation();
|
||||
setDependents(plugin);
|
||||
var unloaded = pluginManager.reloadPlugin(pluginName, Paths.get(loadLocation));
|
||||
if (!unloaded) {
|
||||
throw new PluginRuntimeException("Failed to reload plugin " + pluginName);
|
||||
// TODO Check load location
|
||||
var shouldUnload = requestToUnload || requestToReload || notFullyLoaded;
|
||||
if (shouldUnload) {
|
||||
// check if the plugin is already loaded or not fully loaded.
|
||||
if (alreadyLoaded || notFullyLoaded) {
|
||||
// get all dependencies
|
||||
var dependents = requestToUnloadChildren(pluginName);
|
||||
if (!CollectionUtils.isEmpty(dependents)) {
|
||||
removeConditionBy(conditions, ConditionType.READY);
|
||||
conditions.addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.PROGRESSING)
|
||||
.status(ConditionStatus.UNKNOWN)
|
||||
.reason(ConditionReason.WAIT_FOR_DEPENDENTS_UNLOADED)
|
||||
.message("Wait for children plugins " + dependents + "to be unloaded")
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
plugin.getStatus().setPhase(Plugin.Phase.UNKNOWN);
|
||||
// wait for children plugins unloaded
|
||||
// retry after 1 second
|
||||
return Result.requeue(Duration.ofSeconds(1));
|
||||
}
|
||||
p = pluginManager.getPlugin(pluginName);
|
||||
|
||||
// unload the plugin exactly
|
||||
pluginManager.unloadPlugin(pluginName);
|
||||
|
||||
removeConditionBy(conditions, ConditionType.INITIALIZED);
|
||||
removeConditionBy(conditions, ConditionType.PROGRESSING);
|
||||
removeConditionBy(conditions, ConditionType.READY);
|
||||
|
||||
cancelUnloadRequest(pluginName);
|
||||
p = null;
|
||||
}
|
||||
|
||||
// ensure removing the reload annotation after the plugin is unloaded
|
||||
if (requestToUnload) {
|
||||
// skip loading and wait for removing the annotation by other plugins.
|
||||
var status = plugin.getStatus();
|
||||
status.getConditions().addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.INITIALIZED)
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason(ConditionReason.REQUEST_TO_UNLOAD)
|
||||
.message("Request to unload by " + requestToUnloadBy)
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
|
||||
if (requestToReload) {
|
||||
removeRequestToReload(plugin);
|
||||
}
|
||||
// ensure removing the reload annotation after the plugin is reloaded
|
||||
removeRequestToReload(plugin);
|
||||
}
|
||||
if (p != null && pluginManager.getUnresolvedPlugins().contains(p)) {
|
||||
pluginManager.unloadPlugin(pluginName);
|
||||
p = null;
|
||||
|
||||
// check dependencies before loading
|
||||
var unresolvedParentPlugins = plugin.getSpec().getPluginDependencies().keySet()
|
||||
.stream()
|
||||
.filter(dependency -> {
|
||||
var parentPlugin = pluginManager.getPlugin(dependency);
|
||||
return parentPlugin == null
|
||||
|| pluginManager.getUnresolvedPlugins().contains(parentPlugin);
|
||||
})
|
||||
.sorted()
|
||||
.toList();
|
||||
if (!unresolvedParentPlugins.isEmpty()) {
|
||||
// requeue if the parent plugin is not loaded yet.
|
||||
removeConditionBy(conditions, ConditionType.INITIALIZED);
|
||||
removeConditionBy(conditions, ConditionType.READY);
|
||||
conditions.addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.PROGRESSING)
|
||||
.status(ConditionStatus.UNKNOWN)
|
||||
.reason(ConditionReason.WAIT_FOR_DEPENDENCIES_LOADED)
|
||||
.message("Wait for parent plugins " + unresolvedParentPlugins + " to be loaded")
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
plugin.getStatus().setPhase(Plugin.Phase.UNKNOWN);
|
||||
return Result.requeue(Duration.ofSeconds(1));
|
||||
}
|
||||
|
||||
if (p == null) {
|
||||
var loadLocation = plugin.getStatus().getLoadLocation();
|
||||
log.info("Loading plugin {} from {}", pluginName, loadLocation);
|
||||
pluginManager.loadPlugin(Paths.get(loadLocation));
|
||||
log.info("Loaded plugin {} from {}", pluginName, loadLocation);
|
||||
}
|
||||
|
||||
conditions.addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.INITIALIZED)
|
||||
.status(ConditionStatus.TRUE)
|
||||
.reason(ConditionReason.LOADED)
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
plugin.getStatus().setPhase(Plugin.Phase.RESOLVED);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void createOrUpdateSetting(Plugin plugin) {
|
||||
private Result createOrUpdateSetting(Plugin plugin) {
|
||||
log.info("Initializing setting and config map for plugin {}",
|
||||
plugin.getMetadata().getName());
|
||||
var settingName = plugin.getSpec().getSettingName();
|
||||
if (StringUtils.isBlank(settingName)) {
|
||||
// do nothing if no setting name provided.
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
var pluginName = plugin.getMetadata().getName();
|
||||
|
@ -454,7 +593,7 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
// create default config map
|
||||
var configMapName = plugin.getSpec().getConfigMapName();
|
||||
if (StringUtils.isBlank(configMapName)) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
|
||||
var defaultConfigMap = SettingUtils.populateDefaultConfig(setting, configMapName);
|
||||
|
@ -469,9 +608,10 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
client.update(configMap);
|
||||
}, () -> client.create(defaultConfigMap));
|
||||
log.info("Initialized config map {} for plugin {}", configMapName, pluginName);
|
||||
return null;
|
||||
}
|
||||
|
||||
private void resolveLoadLocation(Plugin plugin) {
|
||||
private Result resolveLoadLocation(Plugin plugin) {
|
||||
log.debug("Resolving load location for plugin {}", plugin.getMetadata().getName());
|
||||
|
||||
// populate load location from annotations
|
||||
|
@ -481,24 +621,46 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
var status = plugin.statusNonNull();
|
||||
if (isDevelopmentMode(plugin)) {
|
||||
if (!isInDevEnvironment()) {
|
||||
throw new IllegalStateException(String.format("""
|
||||
Cannot run the plugin %s with dev mode in non-development environment.\
|
||||
""", pluginName));
|
||||
status.getConditions().addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.INITIALIZED)
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason(ConditionReason.INVALID_RUNTIME_MODE)
|
||||
.message("""
|
||||
Cannot run the plugin with development mode in non-development environment.\
|
||||
""")
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
status.setPhase(Plugin.Phase.UNKNOWN);
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
log.debug("Plugin {} is in development mode", pluginName);
|
||||
if (StringUtils.isBlank(pluginPathAnno)) {
|
||||
// should never happen.
|
||||
throw new IllegalArgumentException(String.format("""
|
||||
Please set plugin path annotation "%s" \
|
||||
in development mode for plugin %s.""",
|
||||
RUNTIME_MODE_ANNO, pluginName));
|
||||
status.getConditions().addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.INITIALIZED)
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason(ConditionReason.PLUGIN_PATH_NOT_SET)
|
||||
.message("""
|
||||
Plugin path annotation is not set. \
|
||||
Please set plugin path annotation "%s" in development mode.\
|
||||
""".formatted(PLUGIN_PATH))
|
||||
.build());
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
try {
|
||||
var loadLocation = ResourceUtils.getURL(pluginPathAnno).toURI();
|
||||
status.setLoadLocation(loadLocation);
|
||||
} catch (URISyntaxException | FileNotFoundException e) {
|
||||
throw new IllegalArgumentException(
|
||||
"Invalid plugin path " + pluginPathAnno + " configured.", e);
|
||||
// TODO Refactor this using event in the future.
|
||||
var condition = Condition.builder()
|
||||
.type(ConditionType.INITIALIZED)
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason(ConditionReason.INVALID_PLUGIN_PATH)
|
||||
.message("Invalid plugin path " + pluginPathAnno + " configured.")
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build();
|
||||
status.getConditions().addAndEvictFIFO(condition);
|
||||
status.setPhase(Plugin.Phase.UNKNOWN);
|
||||
return Result.doNotRetry();
|
||||
}
|
||||
} else {
|
||||
// reset annotation PLUGIN_PATH in non-dev mode
|
||||
|
@ -535,7 +697,16 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
}
|
||||
status.setLoadLocation(newLoadLocation);
|
||||
}
|
||||
|
||||
status.getConditions().addAndEvictFIFO(Condition.builder()
|
||||
.type(ConditionType.INITIALIZED)
|
||||
.status(ConditionStatus.TRUE)
|
||||
.reason(ConditionReason.LOAD_LOCATION_RESOLVED)
|
||||
.lastTransitionTime(clock.instant())
|
||||
.build());
|
||||
status.setPhase(Plugin.Phase.RESOLVED);
|
||||
log.debug("Populated load location {} for plugin {}", status.getLoadLocation(), pluginName);
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
|
@ -545,7 +716,7 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
.build();
|
||||
}
|
||||
|
||||
void createOrUpdateReverseProxy(Plugin plugin) {
|
||||
private Result createOrUpdateReverseProxy(Plugin plugin) {
|
||||
String pluginName = plugin.getMetadata().getName();
|
||||
String reverseProxyName = buildReverseProxyName(pluginName);
|
||||
ReverseProxy reverseProxy = new ReverseProxy();
|
||||
|
@ -570,6 +741,7 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
.setVersion(persisted.getMetadata().getVersion());
|
||||
client.update(reverseProxy);
|
||||
}, () -> client.create(reverseProxy));
|
||||
return null;
|
||||
}
|
||||
|
||||
private Path getPluginsRoot() {
|
||||
|
@ -587,40 +759,88 @@ public class PluginReconciler implements Reconciler<Request> {
|
|||
return pluginName + "-system-generated-reverse-proxy";
|
||||
}
|
||||
|
||||
public class PluginStartedListener implements PluginStateListener {
|
||||
|
||||
@Override
|
||||
public void pluginStateChanged(PluginStateEvent event) {
|
||||
if (PluginState.STARTED.equals(event.getPluginState())) {
|
||||
var pluginId = event.getPlugin().getPluginId();
|
||||
client.fetch(Plugin.class, pluginId)
|
||||
.ifPresent(plugin -> {
|
||||
if (!Objects.equals(true, plugin.getSpec().getEnabled())) {
|
||||
log.info("Observed plugin {} started, enabling it.", pluginId);
|
||||
plugin.getSpec().setEnabled(true);
|
||||
client.update(plugin);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
private List<String> requestToUnloadChildren(String pluginName) {
|
||||
// get all dependencies
|
||||
var dependents = pluginManager.getDependents(pluginName)
|
||||
.stream()
|
||||
.map(PluginWrapper::getPluginId)
|
||||
.toList();
|
||||
// request all dependents to reload.
|
||||
dependents.forEach(dependent -> client.fetch(Plugin.class, dependent)
|
||||
.ifPresent(childPlugin -> {
|
||||
var labels = childPlugin.getMetadata().getLabels();
|
||||
if (labels == null) {
|
||||
labels = new HashMap<>();
|
||||
childPlugin.getMetadata().setLabels(labels);
|
||||
}
|
||||
var label = labels.get(REQUEST_TO_UNLOAD_LABEL);
|
||||
if (!pluginName.equals(label)) {
|
||||
labels.put(REQUEST_TO_UNLOAD_LABEL, pluginName);
|
||||
client.update(childPlugin);
|
||||
}
|
||||
}));
|
||||
return dependents;
|
||||
}
|
||||
|
||||
public class PluginStoppedListener implements PluginStateListener {
|
||||
private void cancelUnloadRequest(String pluginName) {
|
||||
// remove label REQUEST_TO_UNLOAD_LABEL
|
||||
// TODO Use index mechanism
|
||||
Predicate<Plugin> filter = aplugin -> {
|
||||
var labels = aplugin.getMetadata().getLabels();
|
||||
return labels != null && pluginName.equals(labels.get(REQUEST_TO_UNLOAD_LABEL));
|
||||
};
|
||||
|
||||
client.list(Plugin.class, filter, null)
|
||||
.forEach(aplugin -> {
|
||||
var labels = aplugin.getMetadata().getLabels();
|
||||
if (labels != null && labels.remove(REQUEST_TO_UNLOAD_LABEL) != null) {
|
||||
client.update(aplugin);
|
||||
}
|
||||
});
|
||||
|
||||
@Override
|
||||
public void pluginStateChanged(PluginStateEvent event) {
|
||||
if (PluginState.STOPPED.equals(event.getPluginState())) {
|
||||
var pluginId = event.getPlugin().getPluginId();
|
||||
client.fetch(Plugin.class, pluginId)
|
||||
.ifPresent(plugin -> {
|
||||
if (!requestToReload(plugin)
|
||||
&& Objects.equals(true, plugin.getSpec().getEnabled())) {
|
||||
log.info("Observed plugin {} stopped, disabling it.", pluginId);
|
||||
plugin.getSpec().setEnabled(false);
|
||||
client.update(plugin);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void removeConditionBy(ConditionList conditions, String type) {
|
||||
conditions.removeIf(condition -> Objects.equals(type, condition.getType()));
|
||||
}
|
||||
|
||||
public static class ConditionType {
|
||||
/**
|
||||
* Indicates whether the plugin is initialized.
|
||||
*/
|
||||
public static final String INITIALIZED = "Initialized";
|
||||
|
||||
/**
|
||||
* Indicates whether the plugin is starting, disabling or deleting.
|
||||
*/
|
||||
public static final String PROGRESSING = "Progressing";
|
||||
|
||||
/**
|
||||
* Indicates whether the plugin is ready.
|
||||
*/
|
||||
public static final String READY = "Ready";
|
||||
}
|
||||
|
||||
public static class ConditionReason {
|
||||
public static final String LOAD_LOCATION_RESOLVED = "LoadLocationResolved";
|
||||
public static final String INVALID_PLUGIN_PATH = "InvalidPluginPath";
|
||||
|
||||
public static final String WAIT_FOR_DEPENDENCIES_STARTED = "WaitForDependenciesStarted";
|
||||
public static final String WAIT_FOR_DEPENDENCIES_LOADED = "WaitForDependenciesLoaded";
|
||||
|
||||
public static final String WAIT_FOR_DEPENDENTS_DELETED = "WaitForDependentsDeleted";
|
||||
public static final String WAIT_FOR_DEPENDENTS_DISABLED = "WaitForDependentsDisabled";
|
||||
public static final String WAIT_FOR_DEPENDENTS_UNLOADED = "WaitForDependentsUnloaded";
|
||||
|
||||
public static final String STARTED = "Started";
|
||||
public static final String DISABLED = "Disabled";
|
||||
public static final String SYSTEM_ERROR = "SystemError";
|
||||
public static final String REQUEST_TO_UNLOAD = "RequestToUnload";
|
||||
public static final String LOADED = "Loaded";
|
||||
public static final String START_ERROR = "StartError";
|
||||
public static final String DISABLE_ERROR = "DisableError";
|
||||
public static final String INVALID_RUNTIME_MODE = "InvalidRuntimeMode";
|
||||
public static final String PLUGIN_PATH_NOT_SET = "PluginPathNotSet";
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -62,4 +62,15 @@ public interface PluginService {
|
|||
* @return signed js bundle version by all enabled plugins version.
|
||||
*/
|
||||
Mono<String> generateJsBundleVersion();
|
||||
|
||||
/**
|
||||
* Enables or disables a plugin by name.
|
||||
*
|
||||
* @param pluginName plugin name
|
||||
* @param requestToEnable request to enable or disable
|
||||
* @param wait wait for plugin to be enabled or disabled
|
||||
* @return updated plugin
|
||||
*/
|
||||
Mono<Plugin> changeState(String pluginName, boolean requestToEnable, boolean wait);
|
||||
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package run.halo.app.core.extension.service.impl;
|
||||
|
||||
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
|
||||
import static org.pf4j.PluginState.STARTED;
|
||||
import static run.halo.app.plugin.PluginConst.RELOAD_ANNO;
|
||||
|
||||
import com.github.zafarkhaja.semver.Version;
|
||||
|
@ -10,6 +11,7 @@ import java.nio.charset.StandardCharsets;
|
|||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.HashMap;
|
||||
|
@ -20,7 +22,6 @@ import lombok.RequiredArgsConstructor;
|
|||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.pf4j.DependencyResolver;
|
||||
import org.pf4j.PluginDescriptor;
|
||||
import org.pf4j.PluginManager;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.pf4j.RuntimeMode;
|
||||
import org.springframework.core.io.Resource;
|
||||
|
@ -36,12 +37,15 @@ import reactor.core.Exceptions;
|
|||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.core.scheduler.Schedulers;
|
||||
import reactor.util.retry.Retry;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.core.extension.service.PluginService;
|
||||
import run.halo.app.extension.ReactiveExtensionClient;
|
||||
import run.halo.app.infra.SystemVersionSupplier;
|
||||
import run.halo.app.infra.exception.PluginAlreadyExistsException;
|
||||
import run.halo.app.infra.exception.PluginDependenciesNotEnabledException;
|
||||
import run.halo.app.infra.exception.PluginDependencyException;
|
||||
import run.halo.app.infra.exception.PluginDependentsNotDisabledException;
|
||||
import run.halo.app.infra.exception.PluginInstallationException;
|
||||
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
|
||||
import run.halo.app.infra.utils.FileUtils;
|
||||
|
@ -49,6 +53,7 @@ import run.halo.app.infra.utils.VersionUtils;
|
|||
import run.halo.app.plugin.PluginConst;
|
||||
import run.halo.app.plugin.PluginProperties;
|
||||
import run.halo.app.plugin.PluginUtils;
|
||||
import run.halo.app.plugin.SpringPluginManager;
|
||||
import run.halo.app.plugin.YamlPluginDescriptorFinder;
|
||||
import run.halo.app.plugin.YamlPluginFinder;
|
||||
import run.halo.app.plugin.resources.BundleResourceUtils;
|
||||
|
@ -67,7 +72,7 @@ public class PluginServiceImpl implements PluginService {
|
|||
|
||||
private final PluginProperties pluginProperties;
|
||||
|
||||
private final PluginManager pluginManager;
|
||||
private final SpringPluginManager pluginManager;
|
||||
|
||||
@Override
|
||||
public Flux<Plugin> getPresets() {
|
||||
|
@ -252,6 +257,86 @@ public class PluginServiceImpl implements PluginService {
|
|||
});
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Plugin> changeState(String pluginName, boolean requestToEnable, boolean wait) {
|
||||
var updatedPlugin = Mono.defer(() -> client.get(Plugin.class, pluginName))
|
||||
.flatMap(plugin -> {
|
||||
if (!Objects.equals(requestToEnable, plugin.getSpec().getEnabled())) {
|
||||
// preflight check
|
||||
if (requestToEnable) {
|
||||
// make sure the dependencies are enabled
|
||||
var dependencies = plugin.getSpec().getPluginDependencies().keySet();
|
||||
var notStartedDependencies = dependencies.stream()
|
||||
.filter(dependency -> {
|
||||
var pluginWrapper = pluginManager.getPlugin(dependency);
|
||||
return pluginWrapper == null
|
||||
|| !Objects.equals(STARTED, pluginWrapper.getPluginState());
|
||||
})
|
||||
.toList();
|
||||
if (!CollectionUtils.isEmpty(notStartedDependencies)) {
|
||||
return Mono.error(
|
||||
new PluginDependenciesNotEnabledException(notStartedDependencies)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// make sure the dependents are disabled
|
||||
var dependents = pluginManager.getDependents(pluginName);
|
||||
var notDisabledDependents = dependents.stream()
|
||||
.filter(
|
||||
dependent -> Objects.equals(STARTED, dependent.getPluginState())
|
||||
)
|
||||
.map(PluginWrapper::getPluginId)
|
||||
.toList();
|
||||
if (!CollectionUtils.isEmpty(notDisabledDependents)) {
|
||||
return Mono.error(
|
||||
new PluginDependentsNotDisabledException(notDisabledDependents)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
plugin.getSpec().setEnabled(requestToEnable);
|
||||
log.debug("Updating plugin {} state to {}", pluginName, requestToEnable);
|
||||
return client.update(plugin);
|
||||
}
|
||||
log.debug("Checking plugin {} state, no need to update", pluginName);
|
||||
return Mono.just(plugin);
|
||||
});
|
||||
|
||||
if (wait) {
|
||||
// if we want to wait the state of plugin to be updated
|
||||
updatedPlugin = updatedPlugin
|
||||
.flatMap(plugin -> {
|
||||
var phase = plugin.statusNonNull().getPhase();
|
||||
if (requestToEnable) {
|
||||
// if we request to enable the plugin
|
||||
if (!(Plugin.Phase.STARTED.equals(phase)
|
||||
|| Plugin.Phase.FAILED.equals(phase))) {
|
||||
return Mono.error(UnexpectedPluginStateException::new);
|
||||
}
|
||||
} else {
|
||||
// if we request to disable the plugin
|
||||
if (Plugin.Phase.STARTED.equals(phase)) {
|
||||
return Mono.error(UnexpectedPluginStateException::new);
|
||||
}
|
||||
}
|
||||
return Mono.just(plugin);
|
||||
})
|
||||
.retryWhen(
|
||||
Retry.backoff(10, Duration.ofMillis(100))
|
||||
.filter(UnexpectedPluginStateException.class::isInstance)
|
||||
.doBeforeRetry(signal ->
|
||||
log.debug("Waiting for plugin {} to meet expected state", pluginName)
|
||||
)
|
||||
)
|
||||
.doOnSuccess(plugin -> {
|
||||
log.info("Plugin {} met expected state {}",
|
||||
pluginName, plugin.statusNonNull().getPhase());
|
||||
});
|
||||
}
|
||||
|
||||
return updatedPlugin;
|
||||
}
|
||||
|
||||
Mono<Plugin> findPluginManifest(Path path) {
|
||||
return Mono.fromSupplier(
|
||||
() -> {
|
||||
|
@ -368,4 +453,9 @@ public class PluginServiceImpl implements PluginService {
|
|||
oldPlugin.setSpec(newPlugin.getSpec());
|
||||
oldPlugin.getSpec().setEnabled(enabled);
|
||||
}
|
||||
|
||||
private static class UnexpectedPluginStateException extends RuntimeException {
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
@ -0,0 +1,32 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
|
||||
/**
|
||||
* Plugin dependencies not enabled exception.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public class PluginDependenciesNotEnabledException extends ServerWebInputException {
|
||||
|
||||
public static final URI TYPE =
|
||||
URI.create("https://www.halo.run/probs/plugin-dependencies-not-enabled");
|
||||
|
||||
/**
|
||||
* Instantiates a new Plugin dependencies not enabled exception.
|
||||
*
|
||||
* @param dependencies dependencies that are not enabled
|
||||
*/
|
||||
public PluginDependenciesNotEnabledException(List<String> dependencies) {
|
||||
super("Plugin dependencies are not fully enabled, please enable them first.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new Object[] {dependencies});
|
||||
setType(TYPE);
|
||||
getBody().setProperty("dependencies", dependencies);
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,32 @@
|
|||
package run.halo.app.infra.exception;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.List;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
|
||||
/**
|
||||
* Plugin dependents not disabled exception.
|
||||
*
|
||||
* @author johnniang
|
||||
*/
|
||||
public class PluginDependentsNotDisabledException extends ServerWebInputException {
|
||||
|
||||
public static final URI TYPE =
|
||||
URI.create("https://www.halo.run/probs/plugin-dependents-not-disabled");
|
||||
|
||||
/**
|
||||
* Instantiates a new Plugin dependents not disabled exception.
|
||||
*
|
||||
* @param dependents dependents that are not disabled
|
||||
*/
|
||||
public PluginDependentsNotDisabledException(List<String> dependents) {
|
||||
super("Plugin dependents are not fully disabled, please disable them first.",
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
new Object[] {dependents});
|
||||
setType(TYPE);
|
||||
getBody().setProperty("dependents", dependents);
|
||||
}
|
||||
|
||||
}
|
|
@ -3,9 +3,7 @@ package run.halo.app.plugin;
|
|||
import java.nio.file.Path;
|
||||
import java.nio.file.Paths;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
import java.util.Objects;
|
||||
import java.util.Stack;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.apache.commons.lang3.StringUtils;
|
||||
|
@ -22,7 +20,6 @@ import org.pf4j.PluginDescriptorFinder;
|
|||
import org.pf4j.PluginFactory;
|
||||
import org.pf4j.PluginLoader;
|
||||
import org.pf4j.PluginRepository;
|
||||
import org.pf4j.PluginRuntimeException;
|
||||
import org.pf4j.PluginStatusProvider;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.springframework.context.ApplicationContext;
|
||||
|
@ -162,37 +159,6 @@ public class HaloPluginManager extends DefaultPluginManager implements SpringPlu
|
|||
return sharedContext.get();
|
||||
}
|
||||
|
||||
public boolean reloadPlugin(String pluginId, Path loadLocation) {
|
||||
log.info("Reloading plugin {} from {}", pluginId, loadLocation);
|
||||
|
||||
var dependents = getDependents(pluginId);
|
||||
dependents.forEach(plugin -> unloadPlugin(plugin.getPluginId(), false));
|
||||
|
||||
var unloaded = unloadPlugin(pluginId, false);
|
||||
if (!unloaded) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// load the root plugin
|
||||
var loadedPluginId = loadPlugin(loadLocation);
|
||||
|
||||
if (!Objects.equals(pluginId, loadedPluginId)) {
|
||||
throw new PluginRuntimeException("""
|
||||
The plugin {} is reloaded successfully, \
|
||||
but the plugin id is different from the original one.
|
||||
""");
|
||||
}
|
||||
|
||||
// load all dependents with reverse order
|
||||
dependents.stream()
|
||||
.map(PluginWrapper::getPluginPath)
|
||||
.sorted(Comparator.reverseOrder())
|
||||
.forEach(this::loadPlugin);
|
||||
|
||||
log.info("Reloaded plugin {} from {}", loadedPluginId, loadLocation);
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<PluginWrapper> getDependents(String pluginId) {
|
||||
var dependents = new ArrayList<PluginWrapper>();
|
||||
|
|
|
@ -16,6 +16,8 @@ public interface PluginConst {
|
|||
|
||||
String RELOAD_ANNO = "plugin.halo.run/reload";
|
||||
|
||||
String REQUEST_TO_UNLOAD_LABEL = "plugin.halo.run/request-to-unload";
|
||||
|
||||
String PLUGIN_PATH = "plugin.halo.run/plugin-path";
|
||||
|
||||
String RUNTIME_MODE_ANNO = "plugin.halo.run/runtime-mode";
|
||||
|
|
|
@ -1,6 +1,5 @@
|
|||
package run.halo.app.plugin;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import org.pf4j.PluginManager;
|
||||
import org.pf4j.PluginWrapper;
|
||||
|
@ -12,15 +11,6 @@ public interface SpringPluginManager extends PluginManager {
|
|||
|
||||
ApplicationContext getSharedContext();
|
||||
|
||||
/**
|
||||
* Reload the plugin and the plugins that depend on it.
|
||||
*
|
||||
* @param pluginId plugin id
|
||||
* @param loadLocation new load location
|
||||
* @return true if reload successfully, otherwise false
|
||||
*/
|
||||
boolean reloadPlugin(String pluginId, Path loadLocation);
|
||||
|
||||
/**
|
||||
* Get all dependents recursively.
|
||||
*
|
||||
|
|
|
@ -24,6 +24,8 @@ problemDetail.title.run.halo.app.infra.exception.EmailVerificationFailed=Email V
|
|||
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$CyclicException=Cyclic Dependency Detected
|
||||
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=Dependencies Not Found
|
||||
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Wrong Dependency Version
|
||||
problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Dependents Not Disabled
|
||||
problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Dependencies Not Enabled
|
||||
problemDetail.title.internalServerError=Internal Server Error
|
||||
|
||||
# Detail definitions
|
||||
|
@ -46,6 +48,8 @@ problemDetail.run.halo.app.infra.exception.EmailVerificationFailed=Invalid email
|
|||
problemDetail.run.halo.app.infra.exception.PluginDependencyException$CyclicException=A cyclic dependency was detected.
|
||||
problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=Dependencies "{0}" were not found.
|
||||
problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=Dependencies have wrong version: {0}.
|
||||
problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=Plugin dependents {0} are not fully disabled, please disable them first.
|
||||
problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=Plugin dependencies {0} are not fully enabled, please enable them first.
|
||||
|
||||
problemDetail.index.duplicateKey=The value of {0} already exists for unique index {1}, please rename it and retry.
|
||||
problemDetail.user.email.verify.maxAttempts=Too many verification attempts, please try again later.
|
||||
|
|
|
@ -12,6 +12,8 @@ problemDetail.title.run.halo.app.infra.exception.EmailVerificationFailed=邮箱
|
|||
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$CyclicException=循环依赖
|
||||
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=依赖未找到
|
||||
problemDetail.title.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本错误
|
||||
problemDetail.title.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件未禁用
|
||||
problemDetail.title.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=依赖未启用
|
||||
problemDetail.title.internalServerError=服务器内部错误
|
||||
|
||||
problemDetail.org.springframework.security.authentication.BadCredentialsException=用户名或密码错误。
|
||||
|
@ -23,6 +25,8 @@ problemDetail.run.halo.app.infra.exception.EmailVerificationFailed=验证码错
|
|||
problemDetail.run.halo.app.infra.exception.PluginDependencyException$CyclicException=检测到循环依赖。
|
||||
problemDetail.run.halo.app.infra.exception.PluginDependencyException$NotFoundException=依赖“{0}”未找到。
|
||||
problemDetail.run.halo.app.infra.exception.PluginDependencyException$WrongVersionsException=依赖版本有误:{0}。
|
||||
problemDetail.run.halo.app.infra.exception.PluginDependentsNotDisabledException=子插件 {0} 未完全禁用,请先禁用它们。
|
||||
problemDetail.run.halo.app.infra.exception.PluginDependenciesNotEnabledException=插件依赖 {0} 未完全启用,请先启用它们。
|
||||
|
||||
problemDetail.index.duplicateKey=唯一索引 {1} 中的值 {0} 已存在,请更名后重试。
|
||||
problemDetail.user.email.verify.maxAttempts=尝试次数过多,请稍候再试。
|
||||
|
|
|
@ -2,8 +2,6 @@ package run.halo.app.core.extension.endpoint;
|
|||
|
||||
import static java.util.Objects.requireNonNull;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.anyInt;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
|
@ -11,7 +9,6 @@ import static org.mockito.ArgumentMatchers.eq;
|
|||
import static org.mockito.ArgumentMatchers.isA;
|
||||
import static org.mockito.ArgumentMatchers.same;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static org.springframework.test.web.reactive.server.WebTestClient.bindToRouterFunction;
|
||||
|
@ -478,69 +475,4 @@ class PluginEndpointTest {
|
|||
}
|
||||
}
|
||||
|
||||
@Nested
|
||||
class PluginStateChangeTest {
|
||||
|
||||
WebTestClient webClient;
|
||||
|
||||
@BeforeEach
|
||||
void setUp() {
|
||||
webClient = WebTestClient.bindToRouterFunction(endpoint.endpoint())
|
||||
.build();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldEnablePluginIfPluginWasNotStarted() {
|
||||
var plugin = createPlugin("fake-plugin");
|
||||
plugin.getSpec().setEnabled(false);
|
||||
plugin.statusNonNull().setPhase(Plugin.Phase.RESOLVED);
|
||||
|
||||
when(client.get(Plugin.class, "fake-plugin")).thenReturn(Mono.just(plugin))
|
||||
.thenReturn(Mono.fromSupplier(() -> {
|
||||
plugin.statusNonNull().setPhase(Plugin.Phase.STARTED);
|
||||
return plugin;
|
||||
}));
|
||||
when(client.update(plugin)).thenReturn(Mono.just(plugin));
|
||||
|
||||
var requestBody = new PluginEndpoint.RunningStateRequest();
|
||||
requestBody.setEnable(true);
|
||||
requestBody.setAsync(false);
|
||||
webClient.put().uri("/plugins/fake-plugin/plugin-state")
|
||||
.bodyValue(requestBody)
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(Plugin.class)
|
||||
.value(p -> assertTrue(p.getSpec().getEnabled()));
|
||||
|
||||
verify(client, times(2)).get(Plugin.class, "fake-plugin");
|
||||
verify(client).update(plugin);
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisablePluginIfAlreadyStarted() {
|
||||
var plugin = createPlugin("fake-plugin");
|
||||
plugin.getSpec().setEnabled(true);
|
||||
plugin.statusNonNull().setPhase(Plugin.Phase.STARTED);
|
||||
|
||||
when(client.get(Plugin.class, "fake-plugin")).thenReturn(Mono.just(plugin))
|
||||
.thenReturn(Mono.fromSupplier(() -> {
|
||||
plugin.getStatus().setPhase(Plugin.Phase.STOPPED);
|
||||
return plugin;
|
||||
}));
|
||||
when(client.update(plugin)).thenReturn(Mono.just(plugin));
|
||||
|
||||
var requestBody = new PluginEndpoint.RunningStateRequest();
|
||||
requestBody.setEnable(false);
|
||||
requestBody.setAsync(false);
|
||||
webClient.put().uri("/plugins/fake-plugin/plugin-state")
|
||||
.bodyValue(requestBody)
|
||||
.exchange()
|
||||
.expectStatus().isOk()
|
||||
.expectBody(Plugin.class)
|
||||
.value(p -> assertFalse(p.getSpec().getEnabled()));
|
||||
|
||||
verify(client, times(2)).get(Plugin.class, "fake-plugin");
|
||||
verify(client).update(plugin);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@ import static org.junit.jupiter.api.Assertions.assertNull;
|
|||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
|
@ -40,6 +40,7 @@ import org.junit.jupiter.api.io.TempDir;
|
|||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.pf4j.DefaultPluginDescriptor;
|
||||
import org.pf4j.PluginState;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.pf4j.RuntimeMode;
|
||||
|
@ -53,6 +54,7 @@ import run.halo.app.extension.Metadata;
|
|||
import run.halo.app.extension.controller.Reconciler;
|
||||
import run.halo.app.extension.controller.Reconciler.Request;
|
||||
import run.halo.app.extension.controller.RequeueException;
|
||||
import run.halo.app.infra.Condition;
|
||||
import run.halo.app.infra.ConditionStatus;
|
||||
import run.halo.app.plugin.PluginProperties;
|
||||
import run.halo.app.plugin.SpringPluginManager;
|
||||
|
@ -122,11 +124,22 @@ class PluginReconcilerTest {
|
|||
|
||||
when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin));
|
||||
|
||||
var t = assertThrows(IllegalStateException.class,
|
||||
() -> reconciler.reconcile(new Request(name)));
|
||||
assertEquals(
|
||||
"Cannot run the plugin fake-plugin with dev mode in non-development environment.",
|
||||
t.getMessage());
|
||||
var result = reconciler.reconcile(new Request(name));
|
||||
assertFalse(result.reEnqueue());
|
||||
|
||||
var status = fakePlugin.getStatus();
|
||||
assertEquals(Plugin.Phase.UNKNOWN, status.getPhase());
|
||||
var condition = status.getConditions().peekFirst();
|
||||
assertEquals(Condition.builder()
|
||||
.type(PluginReconciler.ConditionType.INITIALIZED)
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason(PluginReconciler.ConditionReason.INVALID_RUNTIME_MODE)
|
||||
.message("""
|
||||
Cannot run the plugin with development mode in non-development environment.\
|
||||
""")
|
||||
.build(), condition);
|
||||
|
||||
verify(client).update(fakePlugin);
|
||||
verify(client).fetch(Plugin.class, name);
|
||||
verify(pluginProperties).getRuntimeMode();
|
||||
verify(pluginManager, never()).loadPlugin(any(Path.class));
|
||||
|
@ -177,12 +190,8 @@ class PluginReconcilerTest {
|
|||
.thenReturn(null);
|
||||
when(pluginProperties.getRuntimeMode()).thenReturn(RuntimeMode.DEVELOPMENT);
|
||||
|
||||
var gotException = assertThrows(IllegalArgumentException.class,
|
||||
() -> reconciler.reconcile(new Request(name)));
|
||||
|
||||
assertEquals("""
|
||||
Please set plugin path annotation "plugin.halo.run/runtime-mode" in development \
|
||||
mode for plugin fake-plugin.""", gotException.getMessage());
|
||||
var result = reconciler.reconcile(new Request(name));
|
||||
assertFalse(result.reEnqueue());
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -197,15 +206,18 @@ class PluginReconcilerTest {
|
|||
|
||||
when(client.fetch(Plugin.class, name)).thenReturn(Optional.of(fakePlugin));
|
||||
when(pluginManager.getPluginsRoots()).thenReturn(List.of(tempPath));
|
||||
when(pluginManager.getPlugin(name)).thenReturn(mock(PluginWrapper.class));
|
||||
when(pluginManager.reloadPlugin(eq(name), any(Path.class))).thenReturn(true);
|
||||
var pluginWrapper = mockPluginWrapper(PluginState.RESOLVED);
|
||||
when(pluginManager.getPlugin(name)).thenReturn(pluginWrapper);
|
||||
when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED);
|
||||
when(pluginManager.getUnresolvedPlugins()).thenReturn(List.of(pluginWrapper));
|
||||
when(pluginManager.getResolvedPlugins()).thenReturn(List.of());
|
||||
|
||||
var result = reconciler.reconcile(new Request(name));
|
||||
assertFalse(result.reEnqueue());
|
||||
|
||||
verify(pluginManager).unloadPlugin(name);
|
||||
var loadLocation = Paths.get(fakePlugin.getStatus().getLoadLocation());
|
||||
verify(pluginManager).reloadPlugin(name, loadLocation);
|
||||
verify(pluginManager).loadPlugin(loadLocation);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -229,9 +241,19 @@ class PluginReconcilerTest {
|
|||
.thenReturn(mockPluginWrapperForStaticResources());
|
||||
when(pluginManager.startPlugin(name)).thenReturn(PluginState.FAILED);
|
||||
|
||||
var e = assertThrows(IllegalStateException.class,
|
||||
() -> reconciler.reconcile(new Request(name)));
|
||||
assertEquals("Failed to start plugin fake-plugin", e.getMessage());
|
||||
var result = reconciler.reconcile(new Request(name));
|
||||
assertFalse(result.reEnqueue());
|
||||
|
||||
verify(client).update(fakePlugin);
|
||||
var status = fakePlugin.getStatus();
|
||||
assertEquals(Plugin.Phase.FAILED, status.getPhase());
|
||||
var condition = status.getConditions().peekFirst();
|
||||
assertEquals(Condition.builder()
|
||||
.type(PluginReconciler.ConditionType.READY)
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason(PluginReconciler.ConditionReason.START_ERROR)
|
||||
.message("Failed to start plugin fake-plugin(FAILED).")
|
||||
.build(), condition);
|
||||
}
|
||||
|
||||
@Test
|
||||
|
@ -253,6 +275,8 @@ class PluginReconcilerTest {
|
|||
// get setting extension
|
||||
.thenReturn(mockPluginWrapperForSetting())
|
||||
.thenReturn(mockPluginWrapperForStaticResources())
|
||||
// before starting
|
||||
.thenReturn(mockPluginWrapper(PluginState.STOPPED))
|
||||
// sync plugin state
|
||||
.thenReturn(mockPluginWrapper(PluginState.STARTED));
|
||||
when(pluginManager.startPlugin(name)).thenReturn(PluginState.STARTED);
|
||||
|
@ -277,13 +301,13 @@ class PluginReconcilerTest {
|
|||
assertNotNull(fakePlugin.getStatus().getLastStartTime());
|
||||
|
||||
var condition = fakePlugin.getStatus().getConditions().peek();
|
||||
assertEquals("STARTED", condition.getType());
|
||||
assertEquals(PluginReconciler.ConditionType.READY, condition.getType());
|
||||
assertEquals(ConditionStatus.TRUE, condition.getStatus());
|
||||
assertEquals(clock.instant(), condition.getLastTransitionTime());
|
||||
|
||||
verify(pluginManager).startPlugin(name);
|
||||
verify(pluginManager).loadPlugin(loadLocation);
|
||||
verify(pluginManager, times(4)).getPlugin(name);
|
||||
verify(pluginManager, times(5)).getPlugin(name);
|
||||
verify(client).update(fakePlugin);
|
||||
verify(client).fetch(Setting.class, settingName);
|
||||
verify(client).create(any(Setting.class));
|
||||
|
@ -354,8 +378,9 @@ class PluginReconcilerTest {
|
|||
|
||||
var pluginRootResource =
|
||||
new DefaultResourceLoader().getResource("classpath:plugin/plugin-0.0.1/");
|
||||
var classLoader = new URLClassLoader(new URL[]{pluginRootResource.getURL()}, null);
|
||||
var classLoader = new URLClassLoader(new URL[] {pluginRootResource.getURL()}, null);
|
||||
when(pluginWrapper.getPluginClassLoader()).thenReturn(classLoader);
|
||||
lenient().when(pluginWrapper.getDescriptor()).thenReturn(new DefaultPluginDescriptor());
|
||||
return pluginWrapper;
|
||||
}
|
||||
|
||||
|
@ -368,12 +393,14 @@ class PluginReconcilerTest {
|
|||
when(pluginClassLoader.getResource("console/style.css")).thenReturn(
|
||||
mock(URL.class));
|
||||
when(pluginWrapper.getPluginClassLoader()).thenReturn(pluginClassLoader);
|
||||
lenient().when(pluginWrapper.getDescriptor()).thenReturn(new DefaultPluginDescriptor());
|
||||
return pluginWrapper;
|
||||
}
|
||||
|
||||
PluginWrapper mockPluginWrapper(PluginState state) {
|
||||
var pluginWrapper = mock(PluginWrapper.class);
|
||||
when(pluginWrapper.getPluginState()).thenReturn(state);
|
||||
lenient().when(pluginWrapper.getPluginState()).thenReturn(state);
|
||||
lenient().when(pluginWrapper.getDescriptor()).thenReturn(new DefaultPluginDescriptor());
|
||||
return pluginWrapper;
|
||||
}
|
||||
|
||||
|
|
|
@ -3,6 +3,7 @@ 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.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.isA;
|
||||
|
@ -40,9 +41,9 @@ import run.halo.app.extension.ReactiveExtensionClient;
|
|||
import run.halo.app.infra.SystemVersionSupplier;
|
||||
import run.halo.app.infra.exception.PluginAlreadyExistsException;
|
||||
import run.halo.app.infra.utils.FileUtils;
|
||||
import run.halo.app.plugin.HaloPluginManager;
|
||||
import run.halo.app.plugin.PluginConst;
|
||||
import run.halo.app.plugin.PluginProperties;
|
||||
import run.halo.app.plugin.SpringPluginManager;
|
||||
import run.halo.app.plugin.YamlPluginFinder;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
|
@ -58,7 +59,7 @@ class PluginServiceImplTest {
|
|||
PluginProperties pluginProperties;
|
||||
|
||||
@Mock
|
||||
HaloPluginManager pluginManager;
|
||||
SpringPluginManager pluginManager;
|
||||
|
||||
@InjectMocks
|
||||
PluginServiceImpl pluginService;
|
||||
|
@ -233,15 +234,6 @@ class PluginServiceImplTest {
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
@ -299,4 +291,61 @@ class PluginServiceImplTest {
|
|||
|
||||
assertThat(result).isNotEqualTo(result2);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class PluginStateChangeTest {
|
||||
|
||||
@Test
|
||||
void shouldEnablePluginIfPluginWasNotStarted() {
|
||||
var plugin = createPlugin("fake-plugin", p -> {
|
||||
p.getSpec().setEnabled(false);
|
||||
p.statusNonNull().setPhase(Plugin.Phase.RESOLVED);
|
||||
});
|
||||
|
||||
when(client.get(Plugin.class, "fake-plugin")).thenReturn(Mono.just(plugin))
|
||||
.thenReturn(Mono.fromSupplier(() -> {
|
||||
plugin.statusNonNull().setPhase(Plugin.Phase.STARTED);
|
||||
return plugin;
|
||||
}));
|
||||
when(client.update(plugin)).thenReturn(Mono.just(plugin));
|
||||
|
||||
pluginService.changeState("fake-plugin", true, false)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(plugin)
|
||||
.verifyComplete();
|
||||
|
||||
assertTrue(plugin.getSpec().getEnabled());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldDisablePluginIfAlreadyStarted() {
|
||||
var plugin = createPlugin("fake-plugin", p -> {
|
||||
p.getSpec().setEnabled(true);
|
||||
p.statusNonNull().setPhase(Plugin.Phase.STARTED);
|
||||
});
|
||||
|
||||
when(client.get(Plugin.class, "fake-plugin")).thenReturn(Mono.just(plugin))
|
||||
.thenReturn(Mono.fromSupplier(() -> {
|
||||
plugin.getStatus().setPhase(Plugin.Phase.STOPPED);
|
||||
return plugin;
|
||||
}));
|
||||
when(client.update(plugin)).thenReturn(Mono.just(plugin));
|
||||
|
||||
pluginService.changeState("fake-plugin", false, false)
|
||||
.as(StepVerifier::create)
|
||||
.expectNext(plugin)
|
||||
.verifyComplete();
|
||||
assertFalse(plugin.getSpec().getEnabled());
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,7 +1,10 @@
|
|||
package run.halo.app.infra;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Iterator;
|
||||
import org.json.JSONException;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
@ -142,6 +145,86 @@ class ConditionListTest {
|
|||
assertThat(conditions.peek().getType()).isEqualTo("type3");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotAddIfTypeIsSame() {
|
||||
var conditions = new ConditionList();
|
||||
var condition = Condition.builder()
|
||||
.type("type")
|
||||
.status(ConditionStatus.TRUE)
|
||||
.reason("reason")
|
||||
.message("message")
|
||||
.build();
|
||||
|
||||
var anotherCondition = Condition.builder()
|
||||
.type("type")
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason("another reason")
|
||||
.message("another message")
|
||||
.build();
|
||||
|
||||
conditions.addAndEvictFIFO(condition);
|
||||
conditions.addAndEvictFIFO(anotherCondition);
|
||||
|
||||
assertEquals(1, conditions.size());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldNotUpdateLastTransitionTimeIfStatusNotChanged() {
|
||||
var now = Instant.now();
|
||||
var conditions = new ConditionList();
|
||||
conditions.addAndEvictFIFO(
|
||||
Condition.builder()
|
||||
.type("type")
|
||||
.status(ConditionStatus.TRUE)
|
||||
.reason("reason")
|
||||
.message("message")
|
||||
.lastTransitionTime(now)
|
||||
.build()
|
||||
);
|
||||
|
||||
conditions.addAndEvictFIFO(
|
||||
Condition.builder()
|
||||
.type("type")
|
||||
.status(ConditionStatus.TRUE)
|
||||
.reason("reason")
|
||||
.message("message")
|
||||
.lastTransitionTime(now.plus(Duration.ofSeconds(1)))
|
||||
.build()
|
||||
);
|
||||
|
||||
assertEquals(1, conditions.size());
|
||||
// make sure the last transition time was not modified.
|
||||
assertEquals(now, conditions.peek().getLastTransitionTime());
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldUpdateLastTransitionTimeIfStatusChanged() {
|
||||
var now = Instant.now();
|
||||
var conditions = new ConditionList();
|
||||
conditions.addAndEvictFIFO(
|
||||
Condition.builder()
|
||||
.type("type")
|
||||
.status(ConditionStatus.TRUE)
|
||||
.reason("reason")
|
||||
.message("message")
|
||||
.lastTransitionTime(now)
|
||||
.build()
|
||||
);
|
||||
|
||||
conditions.addAndEvictFIFO(
|
||||
Condition.builder()
|
||||
.type("type")
|
||||
.status(ConditionStatus.FALSE)
|
||||
.reason("reason")
|
||||
.message("message")
|
||||
.lastTransitionTime(now.plus(Duration.ofSeconds(1)))
|
||||
.build()
|
||||
);
|
||||
|
||||
assertEquals(1, conditions.size());
|
||||
assertEquals(now.plus(Duration.ofSeconds(1)), conditions.peek().getLastTransitionTime());
|
||||
}
|
||||
|
||||
private Condition condition(String type, String message, String reason,
|
||||
ConditionStatus status) {
|
||||
Condition condition = new Condition();
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
<script lang="ts" setup>
|
||||
import { VButton, VModal } from "@halo-dev/components";
|
||||
import type { Plugin } from "@halo-dev/api-client";
|
||||
import { formatDatetime, relativeTimeTo } from "@/utils/date";
|
||||
import { ref } from "vue";
|
||||
|
||||
withDefaults(defineProps<{ plugin: Plugin }>(), {});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(event: "close"): void;
|
||||
}>();
|
||||
|
||||
const modal = ref();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VModal
|
||||
ref="modal"
|
||||
:body-class="['!p-0']"
|
||||
:title="$t('core.plugin.conditions_modal.title')"
|
||||
:width="900"
|
||||
layer-closable
|
||||
@close="emit('close')"
|
||||
>
|
||||
<table class="min-w-full divide-y divide-gray-100">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th
|
||||
class="px-4 py-3 text-left text-sm font-semibold text-gray-900 sm:w-96"
|
||||
scope="col"
|
||||
>
|
||||
{{ $t("core.plugin.conditions_modal.fields.type") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
{{ $t("core.plugin.conditions_modal.fields.status") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
{{ $t("core.plugin.conditions_modal.fields.reason") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
{{ $t("core.plugin.conditions_modal.fields.message") }}
|
||||
</th>
|
||||
<th
|
||||
scope="col"
|
||||
class="px-4 py-3 text-left text-sm font-semibold text-gray-900"
|
||||
>
|
||||
{{ $t("core.plugin.conditions_modal.fields.last_transition_time") }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-gray-100 bg-white">
|
||||
<tr
|
||||
v-for="(condition, index) in plugin?.status?.conditions"
|
||||
:key="index"
|
||||
>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-900">
|
||||
{{ condition.type }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{{ condition.status }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{{ condition.reason || "-" }}
|
||||
</td>
|
||||
<td class="whitespace-nowrap px-4 py-3 text-sm text-gray-500">
|
||||
{{ condition.message || "-" }}
|
||||
</td>
|
||||
<td
|
||||
v-tooltip="formatDatetime(condition.lastTransitionTime)"
|
||||
class="whitespace-nowrap px-4 py-3 text-sm text-gray-500"
|
||||
>
|
||||
{{ relativeTimeTo(condition.lastTransitionTime) }}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<template #footer>
|
||||
<VButton @click="modal.close()">
|
||||
{{ $t("core.common.buttons.close") }}
|
||||
</VButton>
|
||||
</template>
|
||||
</VModal>
|
||||
</template>
|
|
@ -43,7 +43,8 @@ const { plugin } = toRefs(props);
|
|||
|
||||
const selectedNames = inject<Ref<string[]>>("selectedNames", ref([]));
|
||||
|
||||
const { getStatusMessage, uninstall } = usePluginLifeCycle(plugin);
|
||||
const { getStatusDotState, getStatusMessage, uninstall } =
|
||||
usePluginLifeCycle(plugin);
|
||||
|
||||
const pluginUpgradeModalVisible = ref(false);
|
||||
|
||||
|
@ -147,19 +148,11 @@ const { startFields, endFields } = useEntityFieldItemExtensionPoint<Plugin>(
|
|||
"plugin:list-item:field:create",
|
||||
plugin,
|
||||
computed((): EntityFieldItem[] => {
|
||||
const { enabled } = props.plugin.spec || {};
|
||||
const { phase } = props.plugin.status || {};
|
||||
|
||||
const shouldHideStatusDot =
|
||||
!enabled || (enabled && phase === PluginStatusPhaseEnum.Started);
|
||||
|
||||
const getStatusDotState = () => {
|
||||
if (enabled && phase === PluginStatusPhaseEnum.Failed) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
return "default";
|
||||
};
|
||||
phase === PluginStatusPhaseEnum.Started ||
|
||||
phase === PluginStatusPhaseEnum.Disabled;
|
||||
|
||||
return [
|
||||
{
|
||||
|
|
|
@ -9,6 +9,7 @@ import { useMutation } from "@tanstack/vue-query";
|
|||
|
||||
interface usePluginLifeCycleReturn {
|
||||
isStarted: ComputedRef<boolean | undefined>;
|
||||
getStatusDotState: () => string;
|
||||
getStatusMessage: () => string | undefined;
|
||||
changeStatus: () => void;
|
||||
changingStatus: Ref<boolean>;
|
||||
|
@ -27,26 +28,41 @@ export function usePluginLifeCycle(
|
|||
);
|
||||
});
|
||||
|
||||
const getStatusDotState = () => {
|
||||
const { phase } = plugin?.value?.status || {};
|
||||
const { enabled } = plugin?.value?.spec || {};
|
||||
|
||||
if (enabled && phase === PluginStatusPhaseEnum.Failed) {
|
||||
return "error";
|
||||
}
|
||||
|
||||
if (phase === PluginStatusPhaseEnum.Disabling) {
|
||||
return "warning";
|
||||
}
|
||||
|
||||
return "default";
|
||||
};
|
||||
|
||||
const getStatusMessage = () => {
|
||||
if (!plugin?.value) return;
|
||||
|
||||
const { enabled } = plugin.value.spec || {};
|
||||
const { phase } = plugin.value.status || {};
|
||||
|
||||
// Starting failed
|
||||
if (enabled && phase === PluginStatusPhaseEnum.Failed) {
|
||||
if (
|
||||
phase === PluginStatusPhaseEnum.Failed ||
|
||||
phase === PluginStatusPhaseEnum.Disabling
|
||||
) {
|
||||
const lastCondition = plugin.value.status?.conditions?.[0];
|
||||
|
||||
return (
|
||||
[lastCondition?.reason, lastCondition?.message]
|
||||
.filter(Boolean)
|
||||
.join(":") || "Unknown"
|
||||
.join(": ") || "Unknown"
|
||||
);
|
||||
}
|
||||
|
||||
// Starting up
|
||||
if (
|
||||
enabled &&
|
||||
phase !== (PluginStatusPhaseEnum.Started || PluginStatusPhaseEnum.Failed)
|
||||
) {
|
||||
return t("core.common.status.starting_up");
|
||||
|
@ -153,6 +169,7 @@ export function usePluginLifeCycle(
|
|||
|
||||
return {
|
||||
isStarted,
|
||||
getStatusDotState,
|
||||
getStatusMessage,
|
||||
changeStatus,
|
||||
changingStatus,
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
<script lang="ts" setup>
|
||||
import {
|
||||
VAlert,
|
||||
VButton,
|
||||
VDescription,
|
||||
VDescriptionItem,
|
||||
VSwitch,
|
||||
|
@ -8,12 +9,18 @@ import {
|
|||
import type { Ref } from "vue";
|
||||
import { computed, inject } from "vue";
|
||||
import { apiClient } from "@/utils/api-client";
|
||||
import type { Plugin, Role } from "@halo-dev/api-client";
|
||||
import {
|
||||
PluginStatusPhaseEnum,
|
||||
type Plugin,
|
||||
type Role,
|
||||
} from "@halo-dev/api-client";
|
||||
import { pluginLabels, roleLabels } from "@/constants/labels";
|
||||
import { rbacAnnotations } from "@/constants/annotations";
|
||||
import { usePluginLifeCycle } from "../composables/use-plugin";
|
||||
import { formatDatetime } from "@/utils/date";
|
||||
import { useQuery } from "@tanstack/vue-query";
|
||||
import { ref } from "vue";
|
||||
import PluginConditionsModal from "../components/PluginConditionsModal.vue";
|
||||
|
||||
const plugin = inject<Ref<Plugin | undefined>>("plugin");
|
||||
const { changeStatus, changingStatus } = usePluginLifeCycle(plugin);
|
||||
|
@ -60,9 +67,30 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
|
|||
});
|
||||
return groups;
|
||||
});
|
||||
|
||||
// Error alert
|
||||
const conditionsModalVisible = ref(false);
|
||||
|
||||
const errorAlertVisible = computed(() => {
|
||||
const { phase } = plugin?.value?.status || {};
|
||||
|
||||
return (
|
||||
phase !== PluginStatusPhaseEnum.Started &&
|
||||
phase !== PluginStatusPhaseEnum.Disabled
|
||||
);
|
||||
});
|
||||
|
||||
const lastCondition = computed(() => {
|
||||
return plugin?.value?.status?.conditions?.[0];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<PluginConditionsModal
|
||||
v-if="conditionsModalVisible && plugin"
|
||||
:plugin="plugin"
|
||||
@close="conditionsModalVisible = false"
|
||||
/>
|
||||
<Transition mode="out-in" name="fade">
|
||||
<div class="overflow-hidden rounded-b-base">
|
||||
<div class="flex items-center justify-between bg-white px-4 py-4 sm:px-6">
|
||||
|
@ -80,18 +108,21 @@ const pluginRoleTemplateGroups = computed<RoleTemplateGroup[]>(() => {
|
|||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-if="
|
||||
plugin?.status?.phase === 'FAILED' &&
|
||||
plugin?.status?.conditions?.length
|
||||
"
|
||||
v-if="errorAlertVisible && lastCondition"
|
||||
class="w-full px-4 pb-2 sm:px-6"
|
||||
>
|
||||
<VAlert
|
||||
type="error"
|
||||
:title="plugin?.status?.conditions?.[0].reason"
|
||||
:description="plugin?.status?.conditions?.[0].message"
|
||||
:title="lastCondition.reason"
|
||||
:description="lastCondition.message"
|
||||
:closable="false"
|
||||
/>
|
||||
>
|
||||
<template #actions>
|
||||
<VButton size="sm" @click="conditionsModalVisible = true">
|
||||
{{ $t("core.plugin.detail.operations.view_conditions.button") }}
|
||||
</VButton>
|
||||
</template>
|
||||
</VAlert>
|
||||
</div>
|
||||
<div class="border-t border-gray-200">
|
||||
<VDescription>
|
||||
|
|
|
@ -31,13 +31,13 @@ export interface Condition {
|
|||
* @type {string}
|
||||
* @memberof Condition
|
||||
*/
|
||||
'message': string;
|
||||
'message'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
* @memberof Condition
|
||||
*/
|
||||
'reason': string;
|
||||
'reason'?: string;
|
||||
/**
|
||||
*
|
||||
* @type {string}
|
||||
|
|
|
@ -88,6 +88,7 @@ export const PluginStatusPhaseEnum = {
|
|||
Pending: 'PENDING',
|
||||
Starting: 'STARTING',
|
||||
Created: 'CREATED',
|
||||
Disabling: 'DISABLING',
|
||||
Disabled: 'DISABLED',
|
||||
Resolved: 'RESOLVED',
|
||||
Started: 'STARTED',
|
||||
|
|
|
@ -908,6 +908,9 @@ core:
|
|||
repo: Source Repository
|
||||
load_location: Storage Location
|
||||
issues: Issues feedback
|
||||
operations:
|
||||
view_conditions:
|
||||
button: View recent conditions
|
||||
loader:
|
||||
toast:
|
||||
entry_load_failed: Failed to load plugins entry file
|
||||
|
@ -916,6 +919,14 @@ core:
|
|||
editor:
|
||||
providers:
|
||||
default: Default Editor
|
||||
conditions_modal:
|
||||
title: Recent conditions
|
||||
fields:
|
||||
type: Type
|
||||
status: Status
|
||||
reason: Reason
|
||||
message: Message
|
||||
last_transition_time: Last transition time
|
||||
user:
|
||||
title: Users
|
||||
actions:
|
||||
|
|
|
@ -864,6 +864,9 @@ core:
|
|||
repo: 源码仓库
|
||||
load_location: 存储位置
|
||||
issues: 问题反馈
|
||||
operations:
|
||||
view_conditions:
|
||||
button: 查看最近状态
|
||||
loader:
|
||||
toast:
|
||||
entry_load_failed: 加载插件入口文件失败
|
||||
|
@ -872,6 +875,14 @@ core:
|
|||
editor:
|
||||
providers:
|
||||
default: 默认编辑器
|
||||
conditions_modal:
|
||||
title: 插件最近状态
|
||||
fields:
|
||||
type: 类型
|
||||
status: 状态
|
||||
reason: 原因
|
||||
message: 信息
|
||||
last_transition_time: 时间
|
||||
user:
|
||||
title: 用户
|
||||
actions:
|
||||
|
|
|
@ -844,6 +844,9 @@ core:
|
|||
repo: 源碼倉庫
|
||||
load_location: 存儲位置
|
||||
issues: 問題回饋
|
||||
operations:
|
||||
view_conditions:
|
||||
button: 查看最近狀態
|
||||
loader:
|
||||
toast:
|
||||
entry_load_failed: 讀取插件入口文件失敗
|
||||
|
@ -852,6 +855,14 @@ core:
|
|||
editor:
|
||||
providers:
|
||||
default: 預設編輯器
|
||||
conditions_modal:
|
||||
title: 插件最近狀態
|
||||
fields:
|
||||
type: 類型
|
||||
status: 狀態
|
||||
reason: 原因
|
||||
message: 訊息
|
||||
last_transition_time: 時間
|
||||
user:
|
||||
title: 用戶
|
||||
actions:
|
||||
|
|
Loading…
Reference in New Issue