feat: add annotation setting extension (#3028)

#### What type of PR is this?
/kind feature
/milestone 2.1.x
/area core

#### What this PR does / why we need it:
新增 AnnotationSetting 自定义模型以扩展自定义元数据设置表单

主题安装/更新/重载时都会重新加载与 theme.yaml 同层级的其他 yaml,但只会保存 kind 为 Setting 和 AnnotationSetting的,主题卸载时会删除这些 yaml 资源

#### Which issue(s) this PR fixes:

Fixes #3005

#### Special notes for your reviewer:
how to test it?
- 修改影响到了主题安装、更新、重载和删除,需要检查这些功能是否正确加载了 Setting 和 AnnotationSetting
- 插件启动时初始化的 AnnotationSetting 在插件停止时会被删除
- 主题添加了 annotation setting 资源,使用非超级管理员也可以获取

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

```release-note
新增 AnnotationSetting 以扩展自定义元数据设置表单
```
pull/3042/head^2
guqing 2022-12-26 21:54:36 +08:00 committed by GitHub
parent 9b9a57b427
commit ddf47f6600
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 269 additions and 39 deletions

View File

@ -0,0 +1,36 @@
package run.halo.app.core.extension;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static run.halo.app.core.extension.AnnotationSetting.KIND;
import io.swagger.v3.oas.annotations.media.Schema;
import java.util.List;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
import run.halo.app.extension.GroupKind;
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@GVK(group = "", version = "v1alpha1", kind = KIND,
plural = "annotationsettings", singular = "annotationsetting")
public class AnnotationSetting extends AbstractExtension {
public static final String TARGET_REF_LABEL = "halo.run/target-ref";
public static final String KIND = "AnnotationSetting";
@Schema(requiredMode = REQUIRED)
private AnnotationSettingSpec spec;
@Data
public static class AnnotationSettingSpec {
@Schema(requiredMode = REQUIRED)
private GroupKind targetRef;
@Schema(requiredMode = REQUIRED, minLength = 1)
private List<Object> formSchema;
}
}

View File

@ -25,6 +25,8 @@ public class Theme extends AbstractExtension {
public static final String KIND = "Theme";
public static final String THEME_NAME_LABEL = "theme.halo.run/theme-name";
@Schema(required = true)
private ThemeSpec spec;

View File

@ -0,0 +1,54 @@
package run.halo.app.core.extension.reconciler;
import java.util.Map;
import lombok.AllArgsConstructor;
import org.springframework.stereotype.Component;
import org.thymeleaf.util.StringUtils;
import run.halo.app.core.extension.AnnotationSetting;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.ExtensionUtil;
import run.halo.app.extension.GroupKind;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
/**
* Reconciler for {@link AnnotationSetting}.
*
* @author guqing
* @since 2.0.0
*/
@Component
@AllArgsConstructor
public class AnnotationSettingReconciler implements Reconciler<Reconciler.Request> {
private final ExtensionClient client;
@Override
public Result reconcile(Request request) {
populateDefaultLabels(request.name());
return new Result(false, null);
}
private void populateDefaultLabels(String name) {
client.fetch(AnnotationSetting.class, name).ifPresent(annotationSetting -> {
Map<String, String> labels = ExtensionUtil.nullSafeLabels(annotationSetting);
String oldTargetRef = labels.get(AnnotationSetting.TARGET_REF_LABEL);
GroupKind targetRef = annotationSetting.getSpec().getTargetRef();
String targetRefLabel = targetRef.group() + "/" + targetRef.kind();
labels.put(AnnotationSetting.TARGET_REF_LABEL, targetRefLabel);
if (!StringUtils.equals(oldTargetRef, targetRefLabel)) {
client.update(annotationSetting);
}
});
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new AnnotationSetting())
.build();
}
}

View File

@ -2,16 +2,22 @@ package run.halo.app.core.extension.reconciler;
import java.io.IOException;
import java.nio.file.Path;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
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.AnnotationSetting;
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.ExtensionUtil;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
@ -30,6 +36,7 @@ import run.halo.app.theme.ThemePathPolicy;
*/
@Component
public class ThemeReconciler implements Reconciler<Request> {
private static final String FINALIZER_NAME = "theme-protection";
private final ExtensionClient client;
private final ThemePathPolicy themePathPolicy;
@ -44,8 +51,10 @@ public class ThemeReconciler implements Reconciler<Request> {
client.fetch(Theme.class, request.name())
.ifPresent(theme -> {
if (isDeleted(theme)) {
reconcileThemeDeletion(theme);
cleanUpResourcesAndRemoveFinalizer(request.name());
return;
}
addFinalizerIfNecessary(theme);
themeSettingDefaultConfig(theme);
reconcileStatus(request.name());
});
@ -114,6 +123,33 @@ public class ThemeReconciler implements Reconciler<Request> {
});
}
private void addFinalizerIfNecessary(Theme oldTheme) {
Set<String> finalizers = oldTheme.getMetadata().getFinalizers();
if (finalizers != null && finalizers.contains(FINALIZER_NAME)) {
return;
}
client.fetch(Theme.class, oldTheme.getMetadata().getName())
.ifPresent(theme -> {
Set<String> newFinalizers = theme.getMetadata().getFinalizers();
if (newFinalizers == null) {
newFinalizers = new HashSet<>();
theme.getMetadata().setFinalizers(newFinalizers);
}
newFinalizers.add(FINALIZER_NAME);
client.update(theme);
});
}
private void cleanUpResourcesAndRemoveFinalizer(String themeName) {
client.fetch(Theme.class, themeName).ifPresent(theme -> {
reconcileThemeDeletion(theme);
if (theme.getMetadata().getFinalizers() != null) {
theme.getMetadata().getFinalizers().remove(FINALIZER_NAME);
}
client.update(theme);
});
}
private void reconcileThemeDeletion(Theme theme) {
deleteThemeFiles(theme);
// delete theme setting form
@ -122,6 +158,19 @@ public class ThemeReconciler implements Reconciler<Request> {
client.fetch(Setting.class, settingName)
.ifPresent(client::delete);
}
// delete annotation setting
deleteAnnotationSettings(theme.getMetadata().getName());
}
private void deleteAnnotationSettings(String themeName) {
List<AnnotationSetting> result = client.list(AnnotationSetting.class, annotationSetting -> {
Map<String, String> labels = ExtensionUtil.nullSafeLabels(annotationSetting);
return themeName.equals(labels.get(Theme.THEME_NAME_LABEL));
}, null);
for (AnnotationSetting annotationSetting : result) {
client.delete(annotationSetting);
}
}
private void deleteThemeFiles(Theme theme) {

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.HashMap;
import java.util.Map;
import java.util.Objects;
import java.util.concurrent.atomic.AtomicReference;
@ -30,9 +31,11 @@ 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.AnnotationSetting;
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.ExtensionUtil;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.extension.Unstructured;
import run.halo.app.infra.ThemeRootGetter;
@ -149,21 +152,26 @@ public class ThemeServiceImpl implements ThemeService {
return Mono.error(new IllegalStateException(
"Theme must only have one config.yaml or config.yml."));
}
var spec = theme.getSpec();
return Flux.fromIterable(unstructureds)
.flatMap(unstructured -> {
var spec = theme.getSpec();
.filter(unstructured -> {
String name = unstructured.getMetadata().getName();
boolean isThemeSetting = unstructured.getKind().equals(Setting.KIND)
&& StringUtils.equals(spec.getSettingName(), name);
boolean isThemeConfig = unstructured.getKind().equals(ConfigMap.KIND)
&& StringUtils.equals(spec.getConfigMapName(), name);
if (isThemeSetting || isThemeConfig) {
return client.create(unstructured);
}
return Mono.empty();
boolean isAnnotationSetting = unstructured.getKind()
.equals(AnnotationSetting.KIND);
return isThemeSetting || isThemeConfig || isAnnotationSetting;
})
.doOnNext(unstructured ->
populateThemeNameLabel(unstructured, theme.getMetadata().getName()))
.flatMap(unstructured -> client.create(unstructured)
.retryWhen(Retry.backoff(5, Duration.ofMillis(100))
.filter(OptimisticLockingFailureException.class::isInstance))
)
.then(Mono.just(theme));
});
}
@ -178,7 +186,14 @@ public class ThemeServiceImpl implements ThemeService {
log.error("Failed to delete setting: {}", settingName,
ExceptionUtils.getRootCause(error));
throw new AsyncRequestTimeoutException("Reload theme timeout.");
});
})
.then(waitForAnnotationSettingsDeleted(name)
.doOnError(error -> {
log.error("Failed to delete AnnotationSetting by theme [{}]", name,
ExceptionUtils.getRootCause(error));
throw new AsyncRequestTimeoutException("Reload theme timeout.");
})
);
})
.then(Mono.defer(() -> {
Path themePath = themeRoot.get().resolve(name);
@ -199,15 +214,26 @@ public class ThemeServiceImpl implements ThemeService {
}))
.flatMap(theme -> {
String settingName = theme.getSpec().getSettingName();
return Flux.fromIterable(ThemeUtils.loadThemeSetting(getThemePath(theme)))
.map(setting -> Unstructured.OBJECT_MAPPER.convertValue(setting, Setting.class))
.filter(setting -> setting.getMetadata().getName().equals(settingName))
.next()
return Flux.fromIterable(ThemeUtils.loadThemeResources(getThemePath(theme)))
.filter(unstructured -> (Setting.KIND.equals(unstructured.getKind())
&& unstructured.getMetadata().getName().equals(settingName))
|| AnnotationSetting.KIND.equals(unstructured.getKind())
)
.doOnNext(unstructured -> populateThemeNameLabel(unstructured, name))
.flatMap(client::create)
.thenReturn(theme);
.then(Mono.just(theme));
});
}
private static void populateThemeNameLabel(Unstructured unstructured, String themeName) {
Map<String, String> labels = unstructured.getMetadata().getLabels();
if (labels == null) {
labels = new HashMap<>();
unstructured.getMetadata().setLabels(labels);
}
labels.put(Theme.THEME_NAME_LABEL, themeName);
}
@Override
public Mono<ConfigMap> resetSettingConfig(String name) {
return client.fetch(Theme.class, name)
@ -245,6 +271,25 @@ public class ThemeServiceImpl implements ThemeService {
.then();
}
private Mono<Void> waitForAnnotationSettingsDeleted(String themeName) {
return client.list(AnnotationSetting.class,
annotationSetting -> {
Map<String, String> labels = ExtensionUtil.nullSafeLabels(annotationSetting);
return StringUtils.equals(themeName, labels.get(Theme.THEME_NAME_LABEL));
}, null)
.flatMap(annotationSetting -> client.delete(annotationSetting)
.flatMap(deleted -> client.fetch(AnnotationSetting.class,
annotationSetting.getMetadata().getName())
.doOnNext(latest -> {
throw new RetryException("AnnotationSetting is not deleted yet.");
})
.retryWhen(Retry.fixedDelay(10, Duration.ofMillis(100))
.filter(t -> t instanceof RetryException))
)
)
.then();
}
private Path getThemePath(Theme theme) {
return themeRoot.get().resolve(theme.getMetadata().getName());
}

View File

@ -19,7 +19,6 @@ import java.util.concurrent.atomic.AtomicReference;
import java.util.stream.BaseStream;
import java.util.stream.Stream;
import java.util.zip.ZipInputStream;
import org.apache.commons.lang3.ArrayUtils;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.lang.Nullable;
@ -38,19 +37,12 @@ class ThemeUtils {
private static final String THEME_TMP_PREFIX = "halo-theme-";
private static final String[] THEME_MANIFESTS = {"theme.yaml", "theme.yml"};
private static final String[] THEME_CONFIG = {"config.yaml", "config.yml"};
private static final String[] THEME_SETTING = {"settings.yaml", "settings.yml"};
static List<Unstructured> loadThemeSetting(Path themePath) {
return loadUnstructured(themePath, THEME_SETTING);
}
static Flux<Theme> listAllThemesFromThemeDir(Path themesDir) {
return walkThemesFromPath(themesDir)
.filter(Files::isDirectory)
.map(themePath -> loadUnstructured(themePath, THEME_MANIFESTS))
.map(ThemeUtils::findThemeManifest)
.flatMap(Flux::fromIterable)
.filter(unstructured -> unstructured.getKind().equals(Theme.KIND))
.map(unstructured -> Unstructured.OBJECT_MAPPER.convertValue(unstructured,
Theme.class))
.sort(Comparator.comparing(theme -> theme.getMetadata().getName()));
@ -64,10 +56,9 @@ class ThemeUtils {
.subscribeOn(Schedulers.boundedElastic());
}
private static List<Unstructured> loadUnstructured(Path themePath,
String[] themeSetting) {
private static List<Unstructured> findThemeManifest(Path themePath) {
List<Resource> resources = new ArrayList<>(4);
for (String themeResource : themeSetting) {
for (String themeResource : THEME_MANIFESTS) {
Path resourcePath = themePath.resolve(themeResource);
if (Files.exists(resourcePath)) {
resources.add(new FileSystemResource(resourcePath));
@ -81,8 +72,28 @@ class ThemeUtils {
}
static List<Unstructured> loadThemeResources(Path themePath) {
String[] resourceNames = ArrayUtils.addAll(THEME_SETTING, THEME_CONFIG);
return loadUnstructured(themePath, resourceNames);
try (Stream<Path> paths = Files.list(themePath)) {
List<FileSystemResource> resources = paths
.filter(path -> {
String pathString = path.toString();
return pathString.endsWith(".yaml") || pathString.endsWith(".yml");
})
.filter(path -> {
String pathString = path.toString();
for (String themeManifest : THEME_MANIFESTS) {
if (pathString.endsWith(themeManifest)) {
return false;
}
}
return true;
})
.map(FileSystemResource::new)
.toList();
return new YamlUnstructuredLoader(resources.toArray(new Resource[0]))
.load();
} catch (IOException e) {
throw new RuntimeException(e);
}
}
static Mono<Unstructured> unzipThemeTo(InputStream inputStream, Path themeWorkDir) {

View File

@ -5,6 +5,7 @@ import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationListener;
import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import run.halo.app.core.extension.AnnotationSetting;
import run.halo.app.core.extension.Counter;
import run.halo.app.core.extension.Menu;
import run.halo.app.core.extension.MenuItem;
@ -57,6 +58,7 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
schemeManager.register(User.class);
schemeManager.register(ReverseProxy.class);
schemeManager.register(Setting.class);
schemeManager.register(AnnotationSetting.class);
schemeManager.register(ConfigMap.class);
schemeManager.register(Theme.class);
schemeManager.register(Menu.class);

View File

@ -7,7 +7,13 @@ metadata:
halo.run/hidden: "true"
annotations:
rbac.authorization.halo.run/dependencies: |
[ "role-template-own-user-info", "role-template-own-permissions", "role-template-change-own-password" ]
[
"role-template-own-user-info",
"role-template-own-permissions",
"role-template-change-own-password",
"role-template-stats",
"role-template-annotation-setting"
]
rules:
- apiGroups: [ "" ]
resources: [ "configmaps" ]
@ -63,4 +69,17 @@ metadata:
rules:
- apiGroups: [ "api.console.halo.run" ]
resources: [ "stats" ]
verbs: [ "get", "list" ]
---
apiVersion: v1alpha1
kind: Role
metadata:
name: role-template-annotation-setting
labels:
halo.run/role-template: "true"
halo.run/hidden: "true"
rules:
- apiGroups: [ "" ]
resources: [ "annotationsettings" ]
verbs: [ "get", "list" ]

View File

@ -26,6 +26,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.util.FileSystemUtils;
import org.springframework.util.ResourceUtils;
import run.halo.app.core.extension.AnnotationSetting;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
@ -103,8 +104,10 @@ class ThemeReconcilerTest {
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
verify(extensionClient, times(3)).fetch(eq(Theme.class), eq(metadata.getName()));
verify(extensionClient, times(2)).fetch(eq(Setting.class), eq(themeSpec.getSettingName()));
verify(extensionClient, times(2)).fetch(eq(Theme.class), eq(metadata.getName()));
verify(extensionClient, times(1)).fetch(eq(Setting.class), eq(themeSpec.getSettingName()));
verify(extensionClient, times(1)).list(eq(AnnotationSetting.class), any(), any());
assertThat(Files.exists(testWorkDir)).isTrue();
assertThat(Files.exists(defaultThemePath)).isFalse();
@ -134,7 +137,7 @@ class ThemeReconcilerTest {
Reconciler.Result reconcile =
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
assertThat(reconcile.reEnqueue()).isFalse();
verify(extensionClient, times(2)).fetch(eq(Theme.class), eq(metadata.getName()));
verify(extensionClient, times(3)).fetch(eq(Theme.class), eq(metadata.getName()));
// setting exists
themeSpec.setSettingName("theme-test-setting");
@ -143,9 +146,9 @@ class ThemeReconcilerTest {
assertThat(theme.getSpec().getConfigMapName()).isNull();
ArgumentCaptor<Theme> captor = ArgumentCaptor.forClass(Theme.class);
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
verify(extensionClient, times(5))
verify(extensionClient, times(6))
.fetch(eq(Theme.class), eq(metadata.getName()));
verify(extensionClient, times(2))
verify(extensionClient, times(3))
.update(captor.capture());
Theme value = captor.getValue();
assertThat(value.getSpec().getConfigMapName()).isNotNull();

View File

@ -33,8 +33,10 @@ import org.mockito.stubbing.Answer;
import org.skyscreamer.jsonassert.JSONAssert;
import org.springframework.util.ResourceUtils;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.core.extension.AnnotationSetting;
import run.halo.app.core.extension.Setting;
import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
@ -240,6 +242,8 @@ class ThemeServiceImplTest {
return Mono.just(argument);
});
when(client.list(eq(AnnotationSetting.class), any(), eq(null))).thenReturn(Flux.empty());
themeService.reloadTheme("fake-theme")
.as(StepVerifier::create)
.consumeNextWith(themeUpdated -> {
@ -320,9 +324,9 @@ class ThemeServiceImplTest {
return Mono.just(argument);
});
when(client.create(any(Setting.class)))
.thenAnswer((Answer<Mono<Setting>>) invocation -> {
Setting argument = invocation.getArgument(0);
when(client.create(any(Unstructured.class)))
.thenAnswer((Answer<Mono<Unstructured>>) invocation -> {
Unstructured argument = invocation.getArgument(0);
JSONAssert.assertEquals("""
{
"spec": {
@ -342,7 +346,10 @@ class ThemeServiceImplTest {
"apiVersion": "v1alpha1",
"kind": "Setting",
"metadata": {
"name": "fake-setting"
"name": "fake-setting",
"labels": {
"theme.halo.run/theme-name": "fake-theme"
}
}
}
""",
@ -351,6 +358,8 @@ class ThemeServiceImplTest {
return Mono.just(invocation.getArgument(0));
});
when(client.list(eq(AnnotationSetting.class), any(), eq(null))).thenReturn(Flux.empty());
themeService.reloadTheme("fake-theme")
.as(StepVerifier::create)
.consumeNextWith(themeUpdated -> {