mirror of https://github.com/halo-dev/halo
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
parent
9b9a57b427
commit
ddf47f6600
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -25,6 +25,8 @@ public class Theme extends AbstractExtension {
|
||||||
|
|
||||||
public static final String KIND = "Theme";
|
public static final String KIND = "Theme";
|
||||||
|
|
||||||
|
public static final String THEME_NAME_LABEL = "theme.halo.run/theme-name";
|
||||||
|
|
||||||
@Schema(required = true)
|
@Schema(required = true)
|
||||||
private ThemeSpec spec;
|
private ThemeSpec spec;
|
||||||
|
|
||||||
|
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -2,16 +2,22 @@ package run.halo.app.core.extension.reconciler;
|
||||||
|
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
import java.nio.file.Path;
|
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 java.util.UUID;
|
||||||
import org.apache.commons.lang3.StringUtils;
|
import org.apache.commons.lang3.StringUtils;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
import org.springframework.util.CollectionUtils;
|
import org.springframework.util.CollectionUtils;
|
||||||
import org.springframework.util.FileSystemUtils;
|
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.Setting;
|
||||||
import run.halo.app.core.extension.Theme;
|
import run.halo.app.core.extension.Theme;
|
||||||
import run.halo.app.core.extension.theme.SettingUtils;
|
import run.halo.app.core.extension.theme.SettingUtils;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
import run.halo.app.extension.ExtensionClient;
|
import run.halo.app.extension.ExtensionClient;
|
||||||
|
import run.halo.app.extension.ExtensionUtil;
|
||||||
import run.halo.app.extension.Metadata;
|
import run.halo.app.extension.Metadata;
|
||||||
import run.halo.app.extension.controller.Controller;
|
import run.halo.app.extension.controller.Controller;
|
||||||
import run.halo.app.extension.controller.ControllerBuilder;
|
import run.halo.app.extension.controller.ControllerBuilder;
|
||||||
|
@ -30,6 +36,7 @@ import run.halo.app.theme.ThemePathPolicy;
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class ThemeReconciler implements Reconciler<Request> {
|
public class ThemeReconciler implements Reconciler<Request> {
|
||||||
|
private static final String FINALIZER_NAME = "theme-protection";
|
||||||
|
|
||||||
private final ExtensionClient client;
|
private final ExtensionClient client;
|
||||||
private final ThemePathPolicy themePathPolicy;
|
private final ThemePathPolicy themePathPolicy;
|
||||||
|
@ -44,8 +51,10 @@ public class ThemeReconciler implements Reconciler<Request> {
|
||||||
client.fetch(Theme.class, request.name())
|
client.fetch(Theme.class, request.name())
|
||||||
.ifPresent(theme -> {
|
.ifPresent(theme -> {
|
||||||
if (isDeleted(theme)) {
|
if (isDeleted(theme)) {
|
||||||
reconcileThemeDeletion(theme);
|
cleanUpResourcesAndRemoveFinalizer(request.name());
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
addFinalizerIfNecessary(theme);
|
||||||
themeSettingDefaultConfig(theme);
|
themeSettingDefaultConfig(theme);
|
||||||
reconcileStatus(request.name());
|
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) {
|
private void reconcileThemeDeletion(Theme theme) {
|
||||||
deleteThemeFiles(theme);
|
deleteThemeFiles(theme);
|
||||||
// delete theme setting form
|
// delete theme setting form
|
||||||
|
@ -122,6 +158,19 @@ public class ThemeReconciler implements Reconciler<Request> {
|
||||||
client.fetch(Setting.class, settingName)
|
client.fetch(Setting.class, settingName)
|
||||||
.ifPresent(client::delete);
|
.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) {
|
private void deleteThemeFiles(Theme theme) {
|
||||||
|
|
|
@ -11,6 +11,7 @@ import java.io.IOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
import java.util.concurrent.atomic.AtomicReference;
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
@ -30,9 +31,11 @@ import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.core.scheduler.Schedulers;
|
import reactor.core.scheduler.Schedulers;
|
||||||
import reactor.util.retry.Retry;
|
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.Setting;
|
||||||
import run.halo.app.core.extension.Theme;
|
import run.halo.app.core.extension.Theme;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
|
import run.halo.app.extension.ExtensionUtil;
|
||||||
import run.halo.app.extension.ReactiveExtensionClient;
|
import run.halo.app.extension.ReactiveExtensionClient;
|
||||||
import run.halo.app.extension.Unstructured;
|
import run.halo.app.extension.Unstructured;
|
||||||
import run.halo.app.infra.ThemeRootGetter;
|
import run.halo.app.infra.ThemeRootGetter;
|
||||||
|
@ -149,21 +152,26 @@ public class ThemeServiceImpl implements ThemeService {
|
||||||
return Mono.error(new IllegalStateException(
|
return Mono.error(new IllegalStateException(
|
||||||
"Theme must only have one config.yaml or config.yml."));
|
"Theme must only have one config.yaml or config.yml."));
|
||||||
}
|
}
|
||||||
|
var spec = theme.getSpec();
|
||||||
return Flux.fromIterable(unstructureds)
|
return Flux.fromIterable(unstructureds)
|
||||||
.flatMap(unstructured -> {
|
.filter(unstructured -> {
|
||||||
var spec = theme.getSpec();
|
|
||||||
String name = unstructured.getMetadata().getName();
|
String name = unstructured.getMetadata().getName();
|
||||||
|
|
||||||
boolean isThemeSetting = unstructured.getKind().equals(Setting.KIND)
|
boolean isThemeSetting = unstructured.getKind().equals(Setting.KIND)
|
||||||
&& StringUtils.equals(spec.getSettingName(), name);
|
&& StringUtils.equals(spec.getSettingName(), name);
|
||||||
|
|
||||||
boolean isThemeConfig = unstructured.getKind().equals(ConfigMap.KIND)
|
boolean isThemeConfig = unstructured.getKind().equals(ConfigMap.KIND)
|
||||||
&& StringUtils.equals(spec.getConfigMapName(), name);
|
&& StringUtils.equals(spec.getConfigMapName(), name);
|
||||||
if (isThemeSetting || isThemeConfig) {
|
|
||||||
return client.create(unstructured);
|
boolean isAnnotationSetting = unstructured.getKind()
|
||||||
}
|
.equals(AnnotationSetting.KIND);
|
||||||
return Mono.empty();
|
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));
|
.then(Mono.just(theme));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
@ -178,7 +186,14 @@ public class ThemeServiceImpl implements ThemeService {
|
||||||
log.error("Failed to delete setting: {}", settingName,
|
log.error("Failed to delete setting: {}", settingName,
|
||||||
ExceptionUtils.getRootCause(error));
|
ExceptionUtils.getRootCause(error));
|
||||||
throw new AsyncRequestTimeoutException("Reload theme timeout.");
|
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(() -> {
|
.then(Mono.defer(() -> {
|
||||||
Path themePath = themeRoot.get().resolve(name);
|
Path themePath = themeRoot.get().resolve(name);
|
||||||
|
@ -199,15 +214,26 @@ public class ThemeServiceImpl implements ThemeService {
|
||||||
}))
|
}))
|
||||||
.flatMap(theme -> {
|
.flatMap(theme -> {
|
||||||
String settingName = theme.getSpec().getSettingName();
|
String settingName = theme.getSpec().getSettingName();
|
||||||
return Flux.fromIterable(ThemeUtils.loadThemeSetting(getThemePath(theme)))
|
return Flux.fromIterable(ThemeUtils.loadThemeResources(getThemePath(theme)))
|
||||||
.map(setting -> Unstructured.OBJECT_MAPPER.convertValue(setting, Setting.class))
|
.filter(unstructured -> (Setting.KIND.equals(unstructured.getKind())
|
||||||
.filter(setting -> setting.getMetadata().getName().equals(settingName))
|
&& unstructured.getMetadata().getName().equals(settingName))
|
||||||
.next()
|
|| AnnotationSetting.KIND.equals(unstructured.getKind())
|
||||||
|
)
|
||||||
|
.doOnNext(unstructured -> populateThemeNameLabel(unstructured, name))
|
||||||
.flatMap(client::create)
|
.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
|
@Override
|
||||||
public Mono<ConfigMap> resetSettingConfig(String name) {
|
public Mono<ConfigMap> resetSettingConfig(String name) {
|
||||||
return client.fetch(Theme.class, name)
|
return client.fetch(Theme.class, name)
|
||||||
|
@ -245,6 +271,25 @@ public class ThemeServiceImpl implements ThemeService {
|
||||||
.then();
|
.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) {
|
private Path getThemePath(Theme theme) {
|
||||||
return themeRoot.get().resolve(theme.getMetadata().getName());
|
return themeRoot.get().resolve(theme.getMetadata().getName());
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,7 +19,6 @@ import java.util.concurrent.atomic.AtomicReference;
|
||||||
import java.util.stream.BaseStream;
|
import java.util.stream.BaseStream;
|
||||||
import java.util.stream.Stream;
|
import java.util.stream.Stream;
|
||||||
import java.util.zip.ZipInputStream;
|
import java.util.zip.ZipInputStream;
|
||||||
import org.apache.commons.lang3.ArrayUtils;
|
|
||||||
import org.springframework.core.io.FileSystemResource;
|
import org.springframework.core.io.FileSystemResource;
|
||||||
import org.springframework.core.io.Resource;
|
import org.springframework.core.io.Resource;
|
||||||
import org.springframework.lang.Nullable;
|
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_TMP_PREFIX = "halo-theme-";
|
||||||
private static final String[] THEME_MANIFESTS = {"theme.yaml", "theme.yml"};
|
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) {
|
static Flux<Theme> listAllThemesFromThemeDir(Path themesDir) {
|
||||||
return walkThemesFromPath(themesDir)
|
return walkThemesFromPath(themesDir)
|
||||||
.filter(Files::isDirectory)
|
.filter(Files::isDirectory)
|
||||||
.map(themePath -> loadUnstructured(themePath, THEME_MANIFESTS))
|
.map(ThemeUtils::findThemeManifest)
|
||||||
.flatMap(Flux::fromIterable)
|
.flatMap(Flux::fromIterable)
|
||||||
|
.filter(unstructured -> unstructured.getKind().equals(Theme.KIND))
|
||||||
.map(unstructured -> Unstructured.OBJECT_MAPPER.convertValue(unstructured,
|
.map(unstructured -> Unstructured.OBJECT_MAPPER.convertValue(unstructured,
|
||||||
Theme.class))
|
Theme.class))
|
||||||
.sort(Comparator.comparing(theme -> theme.getMetadata().getName()));
|
.sort(Comparator.comparing(theme -> theme.getMetadata().getName()));
|
||||||
|
@ -64,10 +56,9 @@ class ThemeUtils {
|
||||||
.subscribeOn(Schedulers.boundedElastic());
|
.subscribeOn(Schedulers.boundedElastic());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<Unstructured> loadUnstructured(Path themePath,
|
private static List<Unstructured> findThemeManifest(Path themePath) {
|
||||||
String[] themeSetting) {
|
|
||||||
List<Resource> resources = new ArrayList<>(4);
|
List<Resource> resources = new ArrayList<>(4);
|
||||||
for (String themeResource : themeSetting) {
|
for (String themeResource : THEME_MANIFESTS) {
|
||||||
Path resourcePath = themePath.resolve(themeResource);
|
Path resourcePath = themePath.resolve(themeResource);
|
||||||
if (Files.exists(resourcePath)) {
|
if (Files.exists(resourcePath)) {
|
||||||
resources.add(new FileSystemResource(resourcePath));
|
resources.add(new FileSystemResource(resourcePath));
|
||||||
|
@ -81,8 +72,28 @@ class ThemeUtils {
|
||||||
}
|
}
|
||||||
|
|
||||||
static List<Unstructured> loadThemeResources(Path themePath) {
|
static List<Unstructured> loadThemeResources(Path themePath) {
|
||||||
String[] resourceNames = ArrayUtils.addAll(THEME_SETTING, THEME_CONFIG);
|
try (Stream<Path> paths = Files.list(themePath)) {
|
||||||
return loadUnstructured(themePath, resourceNames);
|
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) {
|
static Mono<Unstructured> unzipThemeTo(InputStream inputStream, Path themeWorkDir) {
|
||||||
|
|
|
@ -5,6 +5,7 @@ import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.context.ApplicationListener;
|
import org.springframework.context.ApplicationListener;
|
||||||
import org.springframework.lang.NonNull;
|
import org.springframework.lang.NonNull;
|
||||||
import org.springframework.stereotype.Component;
|
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.Counter;
|
||||||
import run.halo.app.core.extension.Menu;
|
import run.halo.app.core.extension.Menu;
|
||||||
import run.halo.app.core.extension.MenuItem;
|
import run.halo.app.core.extension.MenuItem;
|
||||||
|
@ -57,6 +58,7 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
|
||||||
schemeManager.register(User.class);
|
schemeManager.register(User.class);
|
||||||
schemeManager.register(ReverseProxy.class);
|
schemeManager.register(ReverseProxy.class);
|
||||||
schemeManager.register(Setting.class);
|
schemeManager.register(Setting.class);
|
||||||
|
schemeManager.register(AnnotationSetting.class);
|
||||||
schemeManager.register(ConfigMap.class);
|
schemeManager.register(ConfigMap.class);
|
||||||
schemeManager.register(Theme.class);
|
schemeManager.register(Theme.class);
|
||||||
schemeManager.register(Menu.class);
|
schemeManager.register(Menu.class);
|
||||||
|
|
|
@ -7,7 +7,13 @@ metadata:
|
||||||
halo.run/hidden: "true"
|
halo.run/hidden: "true"
|
||||||
annotations:
|
annotations:
|
||||||
rbac.authorization.halo.run/dependencies: |
|
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:
|
rules:
|
||||||
- apiGroups: [ "" ]
|
- apiGroups: [ "" ]
|
||||||
resources: [ "configmaps" ]
|
resources: [ "configmaps" ]
|
||||||
|
@ -64,3 +70,16 @@ rules:
|
||||||
- apiGroups: [ "api.console.halo.run" ]
|
- apiGroups: [ "api.console.halo.run" ]
|
||||||
resources: [ "stats" ]
|
resources: [ "stats" ]
|
||||||
verbs: [ "get", "list" ]
|
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" ]
|
|
@ -26,6 +26,7 @@ import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
import org.skyscreamer.jsonassert.JSONAssert;
|
import org.skyscreamer.jsonassert.JSONAssert;
|
||||||
import org.springframework.util.FileSystemUtils;
|
import org.springframework.util.FileSystemUtils;
|
||||||
import org.springframework.util.ResourceUtils;
|
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.Setting;
|
||||||
import run.halo.app.core.extension.Theme;
|
import run.halo.app.core.extension.Theme;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
|
@ -103,8 +104,10 @@ class ThemeReconcilerTest {
|
||||||
|
|
||||||
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
|
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
|
||||||
|
|
||||||
verify(extensionClient, times(3)).fetch(eq(Theme.class), eq(metadata.getName()));
|
verify(extensionClient, times(2)).fetch(eq(Theme.class), eq(metadata.getName()));
|
||||||
verify(extensionClient, times(2)).fetch(eq(Setting.class), eq(themeSpec.getSettingName()));
|
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(testWorkDir)).isTrue();
|
||||||
assertThat(Files.exists(defaultThemePath)).isFalse();
|
assertThat(Files.exists(defaultThemePath)).isFalse();
|
||||||
|
@ -134,7 +137,7 @@ class ThemeReconcilerTest {
|
||||||
Reconciler.Result reconcile =
|
Reconciler.Result reconcile =
|
||||||
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
|
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
|
||||||
assertThat(reconcile.reEnqueue()).isFalse();
|
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
|
// setting exists
|
||||||
themeSpec.setSettingName("theme-test-setting");
|
themeSpec.setSettingName("theme-test-setting");
|
||||||
|
@ -143,9 +146,9 @@ class ThemeReconcilerTest {
|
||||||
assertThat(theme.getSpec().getConfigMapName()).isNull();
|
assertThat(theme.getSpec().getConfigMapName()).isNull();
|
||||||
ArgumentCaptor<Theme> captor = ArgumentCaptor.forClass(Theme.class);
|
ArgumentCaptor<Theme> captor = ArgumentCaptor.forClass(Theme.class);
|
||||||
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
|
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
|
||||||
verify(extensionClient, times(5))
|
verify(extensionClient, times(6))
|
||||||
.fetch(eq(Theme.class), eq(metadata.getName()));
|
.fetch(eq(Theme.class), eq(metadata.getName()));
|
||||||
verify(extensionClient, times(2))
|
verify(extensionClient, times(3))
|
||||||
.update(captor.capture());
|
.update(captor.capture());
|
||||||
Theme value = captor.getValue();
|
Theme value = captor.getValue();
|
||||||
assertThat(value.getSpec().getConfigMapName()).isNotNull();
|
assertThat(value.getSpec().getConfigMapName()).isNotNull();
|
||||||
|
|
|
@ -33,8 +33,10 @@ import org.mockito.stubbing.Answer;
|
||||||
import org.skyscreamer.jsonassert.JSONAssert;
|
import org.skyscreamer.jsonassert.JSONAssert;
|
||||||
import org.springframework.util.ResourceUtils;
|
import org.springframework.util.ResourceUtils;
|
||||||
import org.springframework.web.server.ServerWebInputException;
|
import org.springframework.web.server.ServerWebInputException;
|
||||||
|
import reactor.core.publisher.Flux;
|
||||||
import reactor.core.publisher.Mono;
|
import reactor.core.publisher.Mono;
|
||||||
import reactor.test.StepVerifier;
|
import reactor.test.StepVerifier;
|
||||||
|
import run.halo.app.core.extension.AnnotationSetting;
|
||||||
import run.halo.app.core.extension.Setting;
|
import run.halo.app.core.extension.Setting;
|
||||||
import run.halo.app.core.extension.Theme;
|
import run.halo.app.core.extension.Theme;
|
||||||
import run.halo.app.extension.ConfigMap;
|
import run.halo.app.extension.ConfigMap;
|
||||||
|
@ -240,6 +242,8 @@ class ThemeServiceImplTest {
|
||||||
return Mono.just(argument);
|
return Mono.just(argument);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
when(client.list(eq(AnnotationSetting.class), any(), eq(null))).thenReturn(Flux.empty());
|
||||||
|
|
||||||
themeService.reloadTheme("fake-theme")
|
themeService.reloadTheme("fake-theme")
|
||||||
.as(StepVerifier::create)
|
.as(StepVerifier::create)
|
||||||
.consumeNextWith(themeUpdated -> {
|
.consumeNextWith(themeUpdated -> {
|
||||||
|
@ -320,9 +324,9 @@ class ThemeServiceImplTest {
|
||||||
return Mono.just(argument);
|
return Mono.just(argument);
|
||||||
});
|
});
|
||||||
|
|
||||||
when(client.create(any(Setting.class)))
|
when(client.create(any(Unstructured.class)))
|
||||||
.thenAnswer((Answer<Mono<Setting>>) invocation -> {
|
.thenAnswer((Answer<Mono<Unstructured>>) invocation -> {
|
||||||
Setting argument = invocation.getArgument(0);
|
Unstructured argument = invocation.getArgument(0);
|
||||||
JSONAssert.assertEquals("""
|
JSONAssert.assertEquals("""
|
||||||
{
|
{
|
||||||
"spec": {
|
"spec": {
|
||||||
|
@ -342,7 +346,10 @@ class ThemeServiceImplTest {
|
||||||
"apiVersion": "v1alpha1",
|
"apiVersion": "v1alpha1",
|
||||||
"kind": "Setting",
|
"kind": "Setting",
|
||||||
"metadata": {
|
"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));
|
return Mono.just(invocation.getArgument(0));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
when(client.list(eq(AnnotationSetting.class), any(), eq(null))).thenReturn(Flux.empty());
|
||||||
|
|
||||||
themeService.reloadTheme("fake-theme")
|
themeService.reloadTheme("fake-theme")
|
||||||
.as(StepVerifier::create)
|
.as(StepVerifier::create)
|
||||||
.consumeNextWith(themeUpdated -> {
|
.consumeNextWith(themeUpdated -> {
|
||||||
|
|
Loading…
Reference in New Issue