refactor: the way of plugin initialize load (#2242)

<!--  Thanks for sending a pull request!  Here are some tips for you:
1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。
1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>.
2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。
2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request.
3. 请确保你已经添加并运行了适当的测试。
3. Ensure you have added or ran the appropriate tests for your PR.
-->

#### What type of PR is this?
/kind improvement
/area core
/milestone 2.0
<!--
添加其中一个类别:
Add one of the following kinds:

/kind bug
/kind cleanup
/kind documentation
/kind feature
/kind improvement

适当添加其中一个或多个类别(可选):
Optionally add one or more of the following kinds if applicable:

/kind api-change
/kind deprecation
/kind failing-test
/kind flake
/kind regression
-->

#### What this PR does / why we need it:
1. 优化插件初始化加载方式及 Plugin 自定义模型资源的更新
2. 插件 plugin.yaml 中 license 配置不再支持只配置字符串,而使用如下替代
```yaml
license:
  - name: "MIT"
```
3. 可以在 application.yaml 中配置
```yaml
halo:
  initial-extension-locations:
    - "path/to/extensions/yaml"
```
用于在系统启动时创建或更新自定义模型数据
#### Which issue(s) this PR fixes:

<!--
PR 合并时自动关闭 issue。
Automatically closes linked issue when PR is merged.

用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)`
Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`.
-->
Fixes #

#### Special notes for your reviewer:
/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

<!--
如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。
否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change),
Release Note 需要以 `action required` 开头。
If no, just write "NONE" in the release-note block below.
If yes, a release note is required:
Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required".
-->

```release-note
None
```
pull/2247/head
guqing 2022-07-14 22:55:09 +08:00 committed by GitHub
parent 1cbd3c74e3
commit 90da5a13a1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 286 additions and 103 deletions

View File

@ -1,17 +1,19 @@
package run.halo.app.core.extension;
import com.fasterxml.jackson.annotation.JsonFormat;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.pf4j.PluginState;
import org.springframework.lang.NonNull;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import run.halo.app.plugin.BasePlugin;
@ -34,6 +36,20 @@ public class Plugin extends AbstractExtension {
private PluginStatus status;
/**
* Gets plugin status.
*
* @return empty object if status is null.
*/
@NonNull
@JsonIgnore
public PluginStatus statusNonNull() {
if (this.status == null) {
return new PluginStatus();
}
return status;
}
@Data
public static class PluginSpec {
@ -51,7 +67,6 @@ public class Plugin extends AbstractExtension {
private String description;
@JsonFormat(with = JsonFormat.Feature.ACCEPT_SINGLE_VALUE_AS_ARRAY)
private List<License> license;
/**
@ -68,6 +83,12 @@ public class Plugin extends AbstractExtension {
private String settingName;
private String configMapName;
@NonNull
@JsonIgnore
public List<String> extensionLocationsNonNull() {
return Objects.requireNonNullElseGet(extensionLocations, List::of);
}
}
@Getter
@ -75,14 +96,6 @@ public class Plugin extends AbstractExtension {
public static class License {
private String name;
private String url;
public License() {
}
public License(String name) {
this.name = name;
this.url = "";
}
}
@Data

View File

@ -10,12 +10,14 @@ import lombok.extern.slf4j.Slf4j;
import org.pf4j.PluginRuntimeException;
import org.pf4j.PluginState;
import org.pf4j.PluginWrapper;
import org.pf4j.RuntimeMode;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.infra.utils.JsonUtils;
import run.halo.app.plugin.HaloPluginManager;
import run.halo.app.plugin.PluginStartingError;
import run.halo.app.plugin.YamlPluginFinder;
import run.halo.app.plugin.resources.JsBundleRuleProvider;
import run.halo.app.plugin.resources.ReverseProxyRouterFunctionFactory;
@ -66,7 +68,7 @@ public class PluginReconciler implements Reconciler {
}
private void reconcilePluginState(Plugin plugin) {
Plugin.PluginStatus pluginStatus = plugin.getStatus();
Plugin.PluginStatus pluginStatus = plugin.statusNonNull();
String name = plugin.getMetadata().getName();
PluginWrapper pluginWrapper = haloPluginManager.getPlugin(name);
if (pluginWrapper == null) {
@ -81,6 +83,8 @@ public class PluginReconciler implements Reconciler {
return;
}
ensureSpecUpToDateWhenDevelopmentMode(pluginWrapper, plugin);
if (!Objects.equals(pluginStatus.getPhase(), pluginWrapper.getPluginState())) {
// Set to the correct state
pluginStatus.setPhase(pluginWrapper.getPluginState());
@ -120,14 +124,14 @@ public class PluginReconciler implements Reconciler {
private boolean shouldReconcileStartState(Plugin plugin) {
return plugin.getSpec().getEnabled()
&& plugin.getStatus().getPhase() != PluginState.STARTED;
&& plugin.statusNonNull().getPhase() != PluginState.STARTED;
}
private void startPlugin(Plugin plugin) {
String pluginName = plugin.getMetadata().getName();
PluginState currentState = haloPluginManager.startPlugin(pluginName);
handleStatus(plugin, currentState, PluginState.STARTED);
Plugin.PluginStatus status = plugin.getStatus();
Plugin.PluginStatus status = plugin.statusNonNull();
// TODO Check whether the JS bundle rule exists. If it does not exist, do not populate
// populate stylesheet path
String jsBundleRoute = ReverseProxyRouterFunctionFactory.buildRoutePath(pluginName,
@ -141,7 +145,7 @@ public class PluginReconciler implements Reconciler {
private boolean shouldReconcileStopState(Plugin plugin) {
return !plugin.getSpec().getEnabled()
&& plugin.getStatus().getPhase() == PluginState.STARTED;
&& plugin.statusNonNull().getPhase() == PluginState.STARTED;
}
private void stopPlugin(Plugin plugin) {
@ -152,10 +156,7 @@ public class PluginReconciler implements Reconciler {
private void handleStatus(Plugin plugin, PluginState currentState,
PluginState desiredState) {
Plugin.PluginStatus status = plugin.getStatus();
if (status == null) {
status = new Plugin.PluginStatus();
}
Plugin.PluginStatus status = plugin.statusNonNull();
status.setPhase(currentState);
status.setLastTransitionTime(Instant.now());
if (desiredState.equals(currentState)) {
@ -169,4 +170,18 @@ public class PluginReconciler implements Reconciler {
throw new PluginRuntimeException(startingError.getMessage());
}
}
private void ensureSpecUpToDateWhenDevelopmentMode(PluginWrapper pluginWrapper,
Plugin oldPlugin) {
if (!RuntimeMode.DEPLOYMENT.equals(pluginWrapper.getRuntimeMode())) {
return;
}
YamlPluginFinder yamlPluginFinder = new YamlPluginFinder();
Plugin pluginFromPath = yamlPluginFinder.find(pluginWrapper.getPluginPath());
// ensure plugin spec is up to date
Plugin.PluginSpec pluginSpec = JsonUtils.deepCopy(pluginFromPath.getSpec());
if (!Objects.equals(oldPlugin.getSpec(), pluginSpec)) {
oldPlugin.setSpec(pluginSpec);
}
}
}

View File

@ -0,0 +1,82 @@
package run.halo.app.infra;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.List;
import java.util.Set;
import java.util.stream.Stream;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.thymeleaf.util.StringUtils;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Unstructured;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.YamlUnstructuredLoader;
/**
* <p>Extension resources initializer.</p>
* <p>Check whether {@link HaloProperties#getInitialExtensionLocations()} is configured
* When the system ready, and load resources according to it to creates {@link Unstructured}</p>
*
* @author guqing
* @since 2.0.0
*/
@Slf4j
@Component
public class ExtensionResourceInitializer implements ApplicationListener<ApplicationReadyEvent> {
private final HaloProperties haloProperties;
private final ExtensionClient extensionClient;
public ExtensionResourceInitializer(HaloProperties haloProperties,
ExtensionClient extensionClient) {
this.haloProperties = haloProperties;
this.extensionClient = extensionClient;
}
@Override
public void onApplicationEvent(@NonNull ApplicationReadyEvent event) {
Set<String> extensionLocations = haloProperties.getInitialExtensionLocations();
if (!CollectionUtils.isEmpty(extensionLocations)) {
Resource[] resources = extensionLocations.stream()
.map(this::listYamlFiles)
.flatMap(List::stream)
.toArray(Resource[]::new);
log.debug("Initialization loaded [{}] resources to establish.", resources.length);
new YamlUnstructuredLoader(resources).load()
.forEach(unstructured -> extensionClient.fetch(unstructured.groupVersionKind(),
unstructured.getMetadata().getName())
.ifPresentOrElse(persisted -> {
unstructured.getMetadata()
.setVersion(persisted.getMetadata().getVersion());
extensionClient.update(unstructured);
}, () -> extensionClient.create(unstructured)));
}
}
private List<FileSystemResource> listYamlFiles(String location) {
try (Stream<Path> walk = Files.walk(Paths.get(location))) {
return walk.filter(this::isYamlFile)
.map(path -> new FileSystemResource(path.toFile()))
.toList();
} catch (IOException e) {
throw new IllegalArgumentException(e);
}
}
private boolean isYamlFile(Path pathname) {
Path fileName = pathname.getFileName();
return StringUtils.endsWith(fileName, ".yaml")
|| StringUtils.endsWith(fileName, ".yml");
}
}

View File

@ -1,12 +1,16 @@
package run.halo.app.infra.properties;
import java.util.Set;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
/**
* @author guqing
* @date 2022-04-12
* @since 2022-04-12
*/
@Data
@ConfigurationProperties(prefix = "halo")
public class HaloProperties {
private Set<String> initialExtensionLocations;
}

View File

@ -1,35 +0,0 @@
package run.halo.app.plugin;
import org.pf4j.PluginWrapper;
import org.springframework.context.ApplicationListener;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.Plugin;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.plugin.event.HaloPluginLoadedEvent;
/**
* @author guqing
* @since 2.0.0
*/
@Component
public class PluginLoadedListener implements ApplicationListener<HaloPluginLoadedEvent> {
private final ExtensionClient extensionClient;
public PluginLoadedListener(ExtensionClient extensionClient) {
this.extensionClient = extensionClient;
}
@Override
public void onApplicationEvent(HaloPluginLoadedEvent event) {
PluginWrapper pluginWrapper = event.getPluginWrapper();
// TODO: Optimize plugin custom resource loading method
// load plugin.yaml
YamlPluginFinder yamlPluginFinder = new YamlPluginFinder();
Plugin plugin = yamlPluginFinder.find(pluginWrapper.getPluginPath());
extensionClient.fetch(Plugin.class, plugin.getMetadata().getName())
.ifPresentOrElse(persisted -> {
plugin.getMetadata().setVersion(persisted.getMetadata().getVersion());
extensionClient.update(plugin);
}, () -> extensionClient.create(plugin));
}
}

View File

@ -37,8 +37,7 @@ public class PluginStartedListener implements ApplicationListener<HaloPluginStar
// load unstructured
DefaultResourceLoader resourceLoader =
new DefaultResourceLoader(pluginWrapper.getPluginClassLoader());
plugin.getSpec().getExtensionLocations()
.stream()
plugin.getSpec().extensionLocationsNonNull().stream()
.map(resourceLoader::getResource)
.filter(Resource::exists)
.map(resource -> new YamlUnstructuredLoader(resource).load())

View File

@ -47,7 +47,8 @@ import run.halo.app.infra.utils.YamlUnstructuredLoader;
* # 'displayName' explains what the plugin does in only a few words
* displayName: "a name to show"
* description: "Tell me more about this plugin."
* license: MIT
* license:
* - name: MIT
* </pre>
*
* @author guqing

View File

@ -0,0 +1,147 @@
package run.halo.app.infra;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Optional;
import java.util.Set;
import org.json.JSONException;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.boot.context.event.ApplicationReadyEvent;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.GroupVersionKind;
import run.halo.app.extension.Unstructured;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.JsonUtils;
/**
* Tests for {@link ExtensionResourceInitializer}.
*
* @author guqing
* @since 2.0.0
*/
@ExtendWith(MockitoExtension.class)
class ExtensionResourceInitializerTest {
@Mock
private ExtensionClient extensionClient;
@Mock
private HaloProperties haloProperties;
@Mock
private ApplicationReadyEvent applicationReadyEvent;
private ExtensionResourceInitializer extensionResourceInitializer;
@BeforeEach
void setUp() throws IOException {
extensionResourceInitializer =
new ExtensionResourceInitializer(haloProperties, extensionClient);
Path tempDirectory = Files.createTempDirectory("extension-resource-initializer-test");
Path multiDirectory = Files.createDirectories(tempDirectory.resolve("a/b/c"));
Files.writeString(tempDirectory.resolve("hello.yml"), """
kind: FakeExtension
apiVersion: v1
metadata:
name: fake-extension
spec:
hello: world
""",
StandardCharsets.UTF_8);
Files.writeString(multiDirectory.getParent().resolve("fake-1.txt"), """
kind: FakeExtension
name: fake-extension
""",
StandardCharsets.UTF_8);
Files.writeString(multiDirectory.resolve("fake.yaml"), """
kind: FakeExtension
apiVersion: v1
metadata:
name: fake-extension
spec:
hello: world
""",
StandardCharsets.UTF_8);
// test file in directory
Path filePath = Files.createTempDirectory("extension-resource-file-test")
.resolve("good.yml");
Files.writeString(filePath, """
kind: FakeExtension
apiVersion: v1
metadata:
name: config-file-is-ok
spec:
key: value
""",
StandardCharsets.UTF_8);
when(haloProperties.getInitialExtensionLocations())
.thenReturn(Set.of(tempDirectory.toString(), filePath.toString()));
}
@Test
void onApplicationEvent() throws JSONException {
ArgumentCaptor<Unstructured> argumentCaptor = ArgumentCaptor.forClass(Unstructured.class);
when(extensionClient.fetch(any(GroupVersionKind.class), any()))
.thenReturn(Optional.empty());
extensionResourceInitializer.onApplicationEvent(applicationReadyEvent);
verify(extensionClient, times(3)).create(argumentCaptor.capture());
List<Unstructured> values = argumentCaptor.getAllValues();
assertThat(values).isNotNull();
assertThat(values).hasSize(3);
JSONAssert.assertEquals("""
[
{
"kind": "FakeExtension",
"apiVersion": "v1",
"metadata": {
"name": "config-file-is-ok"
},
"spec": {
"key": "value"
}
},
{
"kind": "FakeExtension",
"apiVersion": "v1",
"metadata": {
"name": "fake-extension"
},
"spec": {
"hello": "world"
}
},
{
"kind": "FakeExtension",
"apiVersion": "v1",
"metadata": {
"name": "fake-extension"
},
"spec": {
"hello": "world"
}
}
]
""", JsonUtils.objectToJson(values), false);
}
}

View File

@ -88,7 +88,7 @@ class YamlPluginFinderTest {
"license": [
{
"name": "MIT",
"url": ""
"url": null
}
],
"requires": ">=2.0.0",
@ -124,50 +124,6 @@ class YamlPluginFinderTest {
.hasMessage("Unable to find plugin descriptor file: plugin.yaml");
}
@Test
void acceptArrayLicense() throws JSONException {
Resource pluginResource = new InMemoryResource("""
apiVersion: v1
kind: Plugin
metadata:
name: plugin-1
spec:
license: "MIT"
""");
Plugin plugin = pluginFinder.unstructuredToPlugin(pluginResource);
assertThat(plugin.getSpec()).isNotNull();
JSONAssert.assertEquals("""
[{
"name": "MIT",
"url": ""
}]
""", JsonUtils.objectToJson(plugin.getSpec().getLicense()), false);
}
@Test
void acceptMultipleItemArrayLicense() throws JSONException {
Resource pluginResource = new InMemoryResource("""
apiVersion: v1
kind: Plugin
metadata:
name: plugin-1
spec:
license: ["MIT", "Apache-2.0"]
""");
Plugin plugin = pluginFinder.unstructuredToPlugin(pluginResource);
assertThat(plugin.getSpec()).isNotNull();
JSONAssert.assertEquals("""
[{
"name": "MIT",
"url": ""
},
{
"name": "Apache-2.0",
"url": ""
}]
""", JsonUtils.objectToJson(plugin.getSpec().getLicense()), false);
}
@Test
void acceptArrayObjectLicense() throws JSONException {
Resource pluginResource = new InMemoryResource("""

View File

@ -13,4 +13,5 @@ spec:
homepage: https://github.com/guqing/halo-plugin-1
displayName: "a name to show"
description: "Tell me more about this plugin."
license: MIT
license:
- name: MIT