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
guqing 2023-04-14 17:36:49 +08:00 committed by GitHub
parent d760d4d362
commit 8755c24b11
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 116 additions and 11 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -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" ]

View File

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