mirror of https://github.com/halo-dev/halo
feat: add an API for reloading plugin (#3749)
#### What type of PR is this? /kind feature /area core /area plugin /kind api-change #### What this PR does / why we need it: 新增 reload 插件的 API how to test it? 通过以下 API 测试是否可以在不重启 Halo 的情况下使新改动的插件代码生效 ```shell ./gradlew clean build && curl -u your-name:your-password -X PUT http://127.0.0.1:8090/apis/api.console.halo.run/v1alpha1/plugins/{plugin-name}/reload ``` #### Which issue(s) this PR fixes: Fixes #3748 #### Does this PR introduce a user-facing change? ```release-note None ```pull/3756/head
parent
d760d4d362
commit
8755c24b11
|
@ -123,6 +123,19 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
.response(responseBuilder()
|
||||
.implementation(ConfigMap.class))
|
||||
)
|
||||
.PUT("plugins/{name}/reload", this::reload,
|
||||
builder -> builder.operationId("reloadPlugin")
|
||||
.description("Reload a plugin by name.")
|
||||
.tag(tag)
|
||||
.parameter(parameterBuilder()
|
||||
.name("name")
|
||||
.in(ParameterIn.PATH)
|
||||
.required(true)
|
||||
.implementation(String.class)
|
||||
)
|
||||
.response(responseBuilder()
|
||||
.implementation(Plugin.class))
|
||||
)
|
||||
.GET("plugins", this::list, builder -> {
|
||||
builder.operationId("ListPlugins")
|
||||
.tag(tag)
|
||||
|
@ -164,6 +177,11 @@ public class PluginEndpoint implements CustomEndpoint {
|
|||
.build();
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> reload(ServerRequest serverRequest) {
|
||||
var name = serverRequest.pathVariable("name");
|
||||
return ServerResponse.ok().body(pluginService.reload(name), Plugin.class);
|
||||
}
|
||||
|
||||
private Mono<ServerResponse> listPresets(ServerRequest request) {
|
||||
return ServerResponse.ok().body(pluginService.getPresets(), Plugin.class);
|
||||
}
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package run.halo.app.core.extension.service;
|
||||
|
||||
import java.nio.file.Path;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Flux;
|
||||
import reactor.core.publisher.Mono;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
|
@ -27,4 +28,16 @@ public interface PluginService {
|
|||
|
||||
Mono<Plugin> upgrade(String name, Path path);
|
||||
|
||||
/**
|
||||
* <p>Reload a plugin by name.</p>
|
||||
* Note that this method will set <code>spec.enabled</code> to true it means that the plugin
|
||||
* will be started.
|
||||
*
|
||||
* @param name plugin name
|
||||
* @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);
|
||||
}
|
||||
|
|
|
@ -9,7 +9,9 @@ import java.nio.file.Path;
|
|||
import java.nio.file.Paths;
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.springframework.core.io.Resource;
|
||||
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
|
||||
import org.springframework.retry.RetryException;
|
||||
|
@ -30,11 +32,13 @@ import run.halo.app.infra.exception.PluginAlreadyExistsException;
|
|||
import run.halo.app.infra.exception.UnsatisfiedAttributeValueException;
|
||||
import run.halo.app.infra.utils.FileUtils;
|
||||
import run.halo.app.infra.utils.VersionUtils;
|
||||
import run.halo.app.plugin.HaloPluginManager;
|
||||
import run.halo.app.plugin.PluginProperties;
|
||||
import run.halo.app.plugin.YamlPluginFinder;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class PluginServiceImpl implements PluginService {
|
||||
|
||||
private static final String PRESET_LOCATION_PREFIX = "classpath:/presets/plugins/";
|
||||
|
@ -46,12 +50,7 @@ public class PluginServiceImpl implements PluginService {
|
|||
|
||||
private final PluginProperties pluginProperties;
|
||||
|
||||
public PluginServiceImpl(ReactiveExtensionClient client,
|
||||
SystemVersionSupplier systemVersion, PluginProperties pluginProperties) {
|
||||
this.client = client;
|
||||
this.systemVersion = systemVersion;
|
||||
this.pluginProperties = pluginProperties;
|
||||
}
|
||||
private final HaloPluginManager pluginManager;
|
||||
|
||||
@Override
|
||||
public Flux<Plugin> getPresets() {
|
||||
|
@ -123,6 +122,25 @@ public class PluginServiceImpl implements PluginService {
|
|||
});
|
||||
}
|
||||
|
||||
@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."));
|
||||
}
|
||||
YamlPluginFinder yamlPluginFinder = new YamlPluginFinder();
|
||||
Plugin newPlugin = yamlPluginFinder.find(pluginWrapper.getPluginPath());
|
||||
// reload plugin
|
||||
pluginManager.reloadPlugin(name);
|
||||
return client.get(Plugin.class, name)
|
||||
.flatMap(plugin -> {
|
||||
newPlugin.getMetadata().setVersion(plugin.getMetadata().getVersion());
|
||||
newPlugin.getSpec().setEnabled(true);
|
||||
return client.update(newPlugin);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy plugin into plugin home.
|
||||
*
|
||||
|
|
|
@ -353,8 +353,14 @@ public class HaloPluginManager extends DefaultPluginManager
|
|||
}
|
||||
|
||||
/**
|
||||
* Reload plugin by id,it will be clean up memory resources of plugin and reload plugin from
|
||||
* disk.
|
||||
* <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
|
||||
|
@ -368,8 +374,7 @@ public class HaloPluginManager extends DefaultPluginManager
|
|||
} catch (Exception ex) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return doStartPlugin(pluginId);
|
||||
return getPlugin(pluginId).getPluginState();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
@ -16,7 +16,7 @@ rules:
|
|||
resources: [ "plugins" ]
|
||||
verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
|
||||
- apiGroups: [ "api.console.halo.run" ]
|
||||
resources: [ "plugins/upgrade", "plugins/resetconfig", "plugins/config" ]
|
||||
resources: [ "plugins/upgrade", "plugins/resetconfig", "plugins/config", "plugins/reload" ]
|
||||
verbs: [ "*" ]
|
||||
- apiGroups: [ "api.console.halo.run" ]
|
||||
resources: [ "plugin-presets" ]
|
||||
|
|
|
@ -1,9 +1,12 @@
|
|||
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.assertTrue;
|
||||
import static org.mockito.ArgumentMatchers.any;
|
||||
import static org.mockito.ArgumentMatchers.argThat;
|
||||
import static org.mockito.ArgumentMatchers.eq;
|
||||
import static org.mockito.ArgumentMatchers.isA;
|
||||
import static org.mockito.Mockito.lenient;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
@ -23,19 +26,23 @@ 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.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import org.pf4j.PluginState;
|
||||
import org.pf4j.PluginWrapper;
|
||||
import org.springframework.web.server.ServerErrorException;
|
||||
import org.springframework.web.server.ServerWebInputException;
|
||||
import reactor.core.publisher.Mono;
|
||||
import reactor.test.StepVerifier;
|
||||
import run.halo.app.core.extension.Plugin;
|
||||
import run.halo.app.extension.Metadata;
|
||||
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.PluginProperties;
|
||||
import run.halo.app.plugin.YamlPluginFinder;
|
||||
|
||||
|
@ -51,6 +58,9 @@ class PluginServiceImplTest {
|
|||
@Mock
|
||||
PluginProperties pluginProperties;
|
||||
|
||||
@Mock
|
||||
HaloPluginManager pluginManager;
|
||||
|
||||
@InjectMocks
|
||||
PluginServiceImpl pluginService;
|
||||
|
||||
|
@ -204,4 +214,45 @@ class PluginServiceImplTest {
|
|||
verify(client).<Plugin>create(argThat(p -> p.getSpec().getEnabled()));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void reload() throws IOException, URISyntaxException {
|
||||
var fakePluginUri = requireNonNull(
|
||||
getClass().getClassLoader().getResource("plugin/plugin-0.0.2")).toURI();
|
||||
Path tempDirectory = Files.createTempDirectory("halo-ut-plugin-service-impl-");
|
||||
Path fakePluginPath = tempDirectory.resolve("plugin-0.0.2.jar");
|
||||
try {
|
||||
FileUtils.jar(Paths.get(fakePluginUri), tempDirectory.resolve("plugin-0.0.2.jar"));
|
||||
|
||||
final String pluginName = "fake-plugin";
|
||||
PluginWrapper pluginWrapper = mock(PluginWrapper.class);
|
||||
when(pluginManager.getPlugin(eq(pluginName))).thenReturn(pluginWrapper);
|
||||
when(pluginWrapper.getPluginPath()).thenReturn(fakePluginPath);
|
||||
|
||||
Plugin plugin = new Plugin();
|
||||
plugin.setMetadata(new Metadata());
|
||||
plugin.getMetadata().setName(pluginName);
|
||||
plugin.setSpec(new Plugin.PluginSpec());
|
||||
plugin.getSpec().setEnabled(false);
|
||||
plugin.getSpec().setDisplayName("Fake Plugin");
|
||||
|
||||
when(client.get(eq(Plugin.class), eq(pluginName))).thenReturn(Mono.just(plugin));
|
||||
when(client.update(any(Plugin.class))).thenReturn(Mono.empty());
|
||||
|
||||
pluginService.reload(pluginName).block();
|
||||
|
||||
verify(pluginManager).reloadPlugin(eq(pluginName));
|
||||
verify(client).get(eq(Plugin.class), eq(pluginName));
|
||||
|
||||
ArgumentCaptor<Plugin> captor = ArgumentCaptor.forClass(Plugin.class);
|
||||
verify(client).update(captor.capture());
|
||||
|
||||
Plugin updatedPlugin = captor.getValue();
|
||||
assertThat(updatedPlugin.getSpec().getEnabled()).isTrue();
|
||||
assertThat(updatedPlugin.getSpec().getDisplayName()).isEqualTo("Fake Display Name");
|
||||
assertThat(updatedPlugin.getSpec().getDescription()).isEqualTo("Fake description");
|
||||
} finally {
|
||||
FileUtils.deleteRecursivelyAndSilently(tempDirectory);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue