feat: add reset config API for theme and plugin (#2964)

#### What type of PR is this?
/kind feature
/kind api-change
/area core

#### What this PR does / why we need it:
为主题和插件提供重置设置项 API

此 PR 会重新读取配置对应的 Setting 资源,从其中读取默认值后更新到现有的 ConfigMap 中替换其 data
see #2789 for more details
#### Which issue(s) this PR fixes:

Fixes #2789

#### Special notes for your reviewer:
how to test it?
1. 在主题设置或插件设置配置一些设置项后保存
2. 执行重置配置
3. 配置恢复为了 Setting 中指定的默认值

/cc @halo-dev/sig-halo 
#### Does this PR introduce a user-facing change?

```release-note
为主题和插件提供重置设置项 API
```
pull/3003/head
guqing 2022-12-19 10:28:10 +08:00 committed by GitHub
parent efc940df99
commit 27775c9ac9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 292 additions and 47 deletions

View File

@ -25,11 +25,13 @@ import java.time.Duration;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;
import lombok.extern.slf4j.Slf4j;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
@ -49,7 +51,10 @@ 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.Setting;
import run.halo.app.core.extension.theme.SettingUtils;
import run.halo.app.extension.Comparators;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest.QueryListRequest;
import run.halo.app.infra.utils.FileUtils;
@ -95,6 +100,19 @@ public class PluginEndpoint implements CustomEndpoint {
.content(contentBuilder().mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
.schema(schemaBuilder().implementation(InstallRequest.class))))
)
.PUT("plugins/{name}/reset-config", this::resetSettingConfig,
builder -> builder.operationId("ResetPluginConfig")
.description("Reset the configMap of plugin setting.")
.tag(tag)
.parameter(parameterBuilder()
.name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(ConfigMap.class))
)
.GET("plugins", this::list, builder -> {
builder.operationId("ListPlugins")
.tag(tag)
@ -105,6 +123,32 @@ public class PluginEndpoint implements CustomEndpoint {
.build();
}
private Mono<ServerResponse> resetSettingConfig(ServerRequest request) {
String name = request.pathVariable("name");
return client.fetch(Plugin.class, name)
.filter(plugin -> StringUtils.hasText(plugin.getSpec().getSettingName()))
.flatMap(plugin -> {
String configMapName = plugin.getSpec().getConfigMapName();
String settingName = plugin.getSpec().getSettingName();
return client.fetch(Setting.class, settingName)
.map(SettingUtils::settingDefinedDefaultValueMap)
.flatMap(data -> updateConfigMapData(configMapName, data));
})
.flatMap(configMap -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(configMap));
}
private Mono<ConfigMap> updateConfigMapData(String configMapName, Map<String, String> data) {
return client.fetch(ConfigMap.class, configMapName)
.flatMap(configMap -> {
configMap.setData(data);
return client.update(configMap);
})
.retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100))
.filter(t -> t instanceof OptimisticLockingFailureException));
}
private Mono<ServerResponse> upgrade(ServerRequest request) {
var pluginNameInPath = request.pathVariable("name");
var tempDirRef = new AtomicReference<Path>();

View File

@ -1,19 +1,15 @@
package run.halo.app.core.extension.reconciler;
import com.fasterxml.jackson.databind.JsonNode;
import java.io.IOException;
import java.nio.file.Path;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import org.apache.commons.lang3.StringUtils;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.FileSystemUtils;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme;
import run.halo.app.core.extension.theme.SettingUtils;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
@ -106,7 +102,7 @@ public class ThemeReconciler implements Reconciler<Request> {
client.fetch(Setting.class, theme.getSpec().getSettingName())
.ifPresent(setting -> {
Map<String, String> data = settingDefinedDefaultValueMap(setting);
var data = SettingUtils.settingDefinedDefaultValueMap(setting);
if (CollectionUtils.isEmpty(data)) {
return;
}
@ -118,31 +114,6 @@ public class ThemeReconciler implements Reconciler<Request> {
});
}
Map<String, String> settingDefinedDefaultValueMap(Setting setting) {
final String defaultValueField = "value";
final String nameField = "name";
List<Setting.SettingForm> forms = setting.getSpec().getForms();
if (CollectionUtils.isEmpty(forms)) {
return null;
}
Map<String, String> data = new LinkedHashMap<>();
for (Setting.SettingForm form : forms) {
String group = form.getGroup();
Map<String, JsonNode> groupValue = form.getFormSchema().stream()
.map(o -> JsonUtils.DEFAULT_JSON_MAPPER.convertValue(o, JsonNode.class))
.filter(jsonNode -> jsonNode.isObject() && jsonNode.has(nameField)
&& jsonNode.has(defaultValueField))
.map(jsonNode -> {
String name = jsonNode.findValue(nameField).asText();
JsonNode value = jsonNode.findValue(defaultValueField);
return Map.entry(name, value);
})
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
data.put(group, JsonUtils.objectToJson(groupValue));
}
return data;
}
private void reconcileThemeDeletion(Theme theme) {
deleteThemeFiles(theme);
// delete theme setting form

View File

@ -0,0 +1,48 @@
package run.halo.app.core.extension.theme;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import lombok.experimental.UtilityClass;
import org.springframework.lang.NonNull;
import org.springframework.util.CollectionUtils;
import run.halo.app.core.extension.Setting;
import run.halo.app.infra.utils.JsonUtils;
@UtilityClass
public class SettingUtils {
private static final String VALUE_FIELD = "value";
private static final String NAME_FIELD = "name";
/**
* Read setting default value from {@link Setting} forms.
*
* @param setting {@link Setting} extension
* @return a map of setting default value
*/
@NonNull
public static Map<String, String> settingDefinedDefaultValueMap(Setting setting) {
List<Setting.SettingForm> forms = setting.getSpec().getForms();
if (CollectionUtils.isEmpty(forms)) {
return Map.of();
}
Map<String, String> data = new LinkedHashMap<>();
for (Setting.SettingForm form : forms) {
String group = form.getGroup();
Map<String, JsonNode> groupValue = form.getFormSchema().stream()
.map(o -> JsonUtils.DEFAULT_JSON_MAPPER.convertValue(o, JsonNode.class))
.filter(jsonNode -> jsonNode.isObject() && jsonNode.has(NAME_FIELD)
&& jsonNode.has(VALUE_FIELD))
.map(jsonNode -> {
String name = jsonNode.get(NAME_FIELD).asText();
JsonNode value = jsonNode.get(VALUE_FIELD);
return Map.entry(name, value);
})
.collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));
data.put(group, JsonUtils.objectToJson(groupValue));
}
return data;
}
}

View File

@ -32,6 +32,7 @@ import reactor.core.Exceptions;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Theme;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ListResult;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.router.IListRequest;
@ -105,6 +106,19 @@ public class ThemeEndpoint implements CustomEndpoint {
.response(responseBuilder()
.implementation(Theme.class))
)
.PUT("themes/{name}/reset-config", this::resetSettingConfig,
builder -> builder.operationId("ResetThemeConfig")
.description("Reset the configMap of theme setting.")
.tag(tag)
.parameter(parameterBuilder()
.name("name")
.in(ParameterIn.PATH)
.required(true)
.implementation(String.class)
)
.response(responseBuilder()
.implementation(ConfigMap.class))
)
.GET("themes", this::listThemes,
builder -> {
builder.operationId("ListThemes")
@ -222,6 +236,14 @@ public class ThemeEndpoint implements CustomEndpoint {
.bodyValue(theme));
}
Mono<ServerResponse> resetSettingConfig(ServerRequest request) {
String name = request.pathVariable("name");
return themeService.resetSettingConfig(name)
.flatMap(theme -> ServerResponse.ok()
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(theme));
}
public record InstallRequest(
@Schema(required = true, description = "Theme zip file.") FilePart file) {
}

View File

@ -3,6 +3,7 @@ package run.halo.app.core.extension.theme;
import java.io.InputStream;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
public interface ThemeService {
@ -11,6 +12,8 @@ public interface ThemeService {
Mono<Theme> upgrade(String themeName, InputStream is);
Mono<Theme> reloadTheme(String name);
Mono<ConfigMap> resetSettingConfig(String name);
// TODO Migrate other useful methods in ThemeEndpoint in the future.
}

View File

@ -11,6 +11,7 @@ import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.time.Duration;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Predicate;
@ -18,6 +19,7 @@ import java.util.zip.ZipInputStream;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.exception.ExceptionUtils;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.retry.RetryException;
import org.springframework.stereotype.Service;
import org.springframework.util.Assert;
@ -206,6 +208,29 @@ public class ThemeServiceImpl implements ThemeService {
});
}
@Override
public Mono<ConfigMap> resetSettingConfig(String name) {
return client.fetch(Theme.class, name)
.filter(theme -> StringUtils.isNotBlank(theme.getSpec().getSettingName()))
.flatMap(theme -> {
String configMapName = theme.getSpec().getConfigMapName();
String settingName = theme.getSpec().getSettingName();
return client.fetch(Setting.class, settingName)
.map(SettingUtils::settingDefinedDefaultValueMap)
.flatMap(data -> updateConfigMapData(configMapName, data));
});
}
private Mono<ConfigMap> updateConfigMapData(String configMapName, Map<String, String> data) {
return client.fetch(ConfigMap.class, configMapName)
.flatMap(configMap -> {
configMap.setData(data);
return client.update(configMap);
})
.retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100))
.filter(t -> t instanceof OptimisticLockingFailureException));
}
private Mono<Void> waitForSettingDeleted(String settingName) {
return client.fetch(Setting.class, settingName)
.flatMap(setting -> client.delete(setting)

View File

@ -15,6 +15,9 @@ rules:
- apiGroups: [ "plugin.halo.run" ]
resources: [ "plugins" ]
verbs: [ "create", "patch", "update", "delete", "deletecollection" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "plugins/upgrade", "plugins/resetconfig" ]
verbs: [ "*" ]
- nonResourceURLs: [ "/apis/api.console.halo.run/v1alpha1/plugins/*" ]
verbs: [ "create" ]
---

View File

@ -15,7 +15,7 @@ rules:
resources: [ "themes" ]
verbs: [ "*" ]
- apiGroups: [ "api.console.halo.run" ]
resources: [ "themes", "themes/reload" ]
resources: [ "themes", "themes/reload", "themes/resetconfig" ]
verbs: [ "*" ]
- nonResourceURLs: [ "/apis/api.console.halo.run/themes/install" ]
verbs: [ "create" ]

View File

@ -174,21 +174,6 @@ class ThemeReconcilerTest {
true);
}
@Test
void settingDefinedDefaultValueMap() throws JSONException {
Setting setting = getFakeSetting();
when(haloProperties.getWorkDir()).thenReturn(tempDirectory);
Map<String, String> map = new ThemeReconciler(extensionClient, haloProperties)
.settingDefinedDefaultValueMap(setting);
JSONAssert.assertEquals("""
{
"sns": "{\\"email\\":\\"example@exmple.com\\"}"
}
""",
JsonUtils.objectToJson(map),
true);
}
private static Setting getFakeSetting() {
String settingJson = """
{

View File

@ -0,0 +1,67 @@
package run.halo.app.core.extension.theme;
import org.json.JSONException;
import org.junit.jupiter.api.Test;
import org.skyscreamer.jsonassert.JSONAssert;
import run.halo.app.core.extension.Setting;
import run.halo.app.infra.utils.JsonUtils;
/**
* Tests for {@link SettingUtils}.
*
* @author guqing
* @since 2.0.1
*/
class SettingUtilsTest {
@Test
void settingDefinedDefaultValueMap() throws JSONException {
Setting setting = getFakeSetting();
var map = SettingUtils.settingDefinedDefaultValueMap(setting);
JSONAssert.assertEquals("""
{
"sns": "{\\"email\\":\\"example@exmple.com\\"}"
}
""",
JsonUtils.objectToJson(map),
true);
}
private static Setting getFakeSetting() {
String settingJson = """
{
"apiVersion": "v1alpha1",
"kind": "Setting",
"metadata": {
"name": "theme-default-setting"
},
"spec": {
"forms": [{
"formSchema": [
{
"$el": "h1",
"children": "Register"
},
{
"$formkit": "text",
"label": "Email",
"name": "email",
"value": "example@exmple.com"
},
{
"$formkit": "password",
"label": "Password",
"name": "password",
"validation": "required|length:5,16",
"value": null
}
],
"group": "sns",
"label": "社交资料"
}]
}
}
""";
return JsonUtils.jsonToObject(settingJson, Setting.class);
}
}

View File

@ -170,4 +170,13 @@ class ThemeEndpointTest {
.exchange()
.expectStatus().isOk();
}
@Test
void resetSettingConfig() {
when(themeService.resetSettingConfig(any())).thenReturn(Mono.empty());
webTestClient.put()
.uri("/themes/fake/reset-config")
.exchange()
.expectStatus().isOk();
}
}

View File

@ -1,6 +1,7 @@
package run.halo.app.core.extension.theme;
import static java.nio.file.Files.createTempDirectory;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
@ -17,6 +18,7 @@ import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import java.util.Map;
import java.util.function.Consumer;
import org.json.JSONException;
import org.junit.jupiter.api.AfterEach;
@ -35,6 +37,7 @@ import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
@ -375,4 +378,69 @@ class ThemeServiceImplTest {
})
.verifyComplete();
}
@Test
void resetSettingConfig() {
Theme theme = new Theme();
theme.setMetadata(new Metadata());
theme.getMetadata().setName("fake-theme");
theme.setSpec(new Theme.ThemeSpec());
theme.getSpec().setSettingName("fake-setting");
theme.getSpec().setConfigMapName("fake-config");
theme.getSpec().setDisplayName("Hello");
when(client.fetch(Theme.class, "fake-theme"))
.thenReturn(Mono.just(theme));
Setting setting = new Setting();
setting.setMetadata(new Metadata());
setting.getMetadata().setName("fake-setting");
setting.setSpec(new Setting.SettingSpec());
var formSchemaItem = Map.of("name", "email", "value", "example@exmple.com");
Setting.SettingForm settingForm = new Setting.SettingForm();
settingForm.setGroup("basic");
settingForm.setFormSchema(List.of(formSchemaItem));
setting.getSpec().setForms(List.of(settingForm));
when(client.fetch(eq(Setting.class), eq("fake-setting")))
.thenReturn(Mono.just(setting));
ConfigMap configMap = new ConfigMap();
configMap.setMetadata(new Metadata());
configMap.getMetadata().setName("fake-config");
when(client.fetch(eq(ConfigMap.class), eq("fake-config")))
.thenReturn(Mono.just(configMap));
when(client.update(any(ConfigMap.class)))
.thenAnswer((Answer<Mono<ConfigMap>>) invocation -> {
ConfigMap argument = invocation.getArgument(0);
JSONAssert.assertEquals("""
{
"data": {
"basic": "{\\"email\\":\\"example@exmple.com\\"}"
},
"apiVersion": "v1alpha1",
"kind": "ConfigMap",
"metadata": {
"name": "fake-config"
}
}
""",
JsonUtils.objectToJson(argument),
true);
return Mono.just(invocation.getArgument(0));
});
themeService.resetSettingConfig("fake-theme")
.as(StepVerifier::create)
.consumeNextWith(next -> {
assertThat(next).isNotNull();
})
.verifyComplete();
verify(client, times(1))
.fetch(eq(Setting.class), eq(setting.getMetadata().getName()));
verify(client, times(1)).fetch(eq(ConfigMap.class), eq("fake-config"));
verify(client, times(1)).update(any(ConfigMap.class));
}
}