diff --git a/src/main/java/run/halo/app/core/extension/AnnotationSetting.java b/src/main/java/run/halo/app/core/extension/AnnotationSetting.java new file mode 100644 index 0000000000..e8e2bfd777 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/AnnotationSetting.java @@ -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 formSchema; + } +} diff --git a/src/main/java/run/halo/app/core/extension/Theme.java b/src/main/java/run/halo/app/core/extension/Theme.java index 0162fd7e5b..e78c2a6198 100644 --- a/src/main/java/run/halo/app/core/extension/Theme.java +++ b/src/main/java/run/halo/app/core/extension/Theme.java @@ -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; diff --git a/src/main/java/run/halo/app/core/extension/reconciler/AnnotationSettingReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/AnnotationSettingReconciler.java new file mode 100644 index 0000000000..5c6d97b542 --- /dev/null +++ b/src/main/java/run/halo/app/core/extension/reconciler/AnnotationSettingReconciler.java @@ -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 { + + 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 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(); + } +} diff --git a/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java b/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java index 28f3e6e988..4812a38b61 100644 --- a/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java +++ b/src/main/java/run/halo/app/core/extension/reconciler/ThemeReconciler.java @@ -2,16 +2,22 @@ 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 @@ */ @Component public class ThemeReconciler implements Reconciler { + private static final String FINALIZER_NAME = "theme-protection"; private final ExtensionClient client; private final ThemePathPolicy themePathPolicy; @@ -44,8 +51,10 @@ public Result reconcile(Request 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 @@ private void themeSettingDefaultConfig(Theme theme) { }); } + private void addFinalizerIfNecessary(Theme oldTheme) { + Set finalizers = oldTheme.getMetadata().getFinalizers(); + if (finalizers != null && finalizers.contains(FINALIZER_NAME)) { + return; + } + client.fetch(Theme.class, oldTheme.getMetadata().getName()) + .ifPresent(theme -> { + Set 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 @@ private void reconcileThemeDeletion(Theme theme) { client.fetch(Setting.class, settingName) .ifPresent(client::delete); } + // delete annotation setting + deleteAnnotationSettings(theme.getMetadata().getName()); + } + + private void deleteAnnotationSettings(String themeName) { + List result = client.list(AnnotationSetting.class, annotationSetting -> { + Map 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) { diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java b/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java index 6aed6bcea3..993695b4b0 100644 --- a/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeServiceImpl.java @@ -11,6 +11,7 @@ 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.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 Mono persistent(Unstructured themeManifest) { 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 Mono reloadTheme(String name) { 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 Mono reloadTheme(String name) { })) .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 labels = unstructured.getMetadata().getLabels(); + if (labels == null) { + labels = new HashMap<>(); + unstructured.getMetadata().setLabels(labels); + } + labels.put(Theme.THEME_NAME_LABEL, themeName); + } + @Override public Mono resetSettingConfig(String name) { return client.fetch(Theme.class, name) @@ -245,6 +271,25 @@ private Mono waitForSettingDeleted(String settingName) { .then(); } + private Mono waitForAnnotationSettingsDeleted(String themeName) { + return client.list(AnnotationSetting.class, + annotationSetting -> { + Map 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()); } diff --git a/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java b/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java index 1c18aff05f..3673385488 100644 --- a/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java +++ b/src/main/java/run/halo/app/core/extension/theme/ThemeUtils.java @@ -19,7 +19,6 @@ 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 loadThemeSetting(Path themePath) { - return loadUnstructured(themePath, THEME_SETTING); - } - static Flux 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 @@ private static Flux walkThemesFromPath(Path path) { .subscribeOn(Schedulers.boundedElastic()); } - private static List loadUnstructured(Path themePath, - String[] themeSetting) { + private static List findThemeManifest(Path themePath) { List 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 @@ private static List loadUnstructured(Path themePath, } static List loadThemeResources(Path themePath) { - String[] resourceNames = ArrayUtils.addAll(THEME_SETTING, THEME_CONFIG); - return loadUnstructured(themePath, resourceNames); + try (Stream paths = Files.list(themePath)) { + List 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 unzipThemeTo(InputStream inputStream, Path themeWorkDir) { diff --git a/src/main/java/run/halo/app/infra/SchemeInitializer.java b/src/main/java/run/halo/app/infra/SchemeInitializer.java index 2134e773fa..e6e07eabd6 100644 --- a/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -5,6 +5,7 @@ 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 void onApplicationEvent(@NonNull ApplicationStartedEvent event) { 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); diff --git a/src/main/resources/extensions/role-template-authenticated.yaml b/src/main/resources/extensions/role-template-authenticated.yaml index 434a77f6e0..822fc4d01a 100644 --- a/src/main/resources/extensions/role-template-authenticated.yaml +++ b/src/main/resources/extensions/role-template-authenticated.yaml @@ -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" ] \ No newline at end of file diff --git a/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java b/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java index 8d7165013c..b2988e6a81 100644 --- a/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java +++ b/src/test/java/run/halo/app/core/extension/reconciler/ThemeReconcilerTest.java @@ -26,6 +26,7 @@ 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 @@ void reconcileDelete() throws IOException { 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 @@ void themeSettingDefaultValue() throws IOException, JSONException { 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 @@ void themeSettingDefaultValue() throws IOException, JSONException { assertThat(theme.getSpec().getConfigMapName()).isNull(); ArgumentCaptor 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(); diff --git a/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java b/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java index 5f991b08eb..5c67a20443 100644 --- a/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java +++ b/src/test/java/run/halo/app/core/extension/theme/ThemeServiceImplTest.java @@ -33,8 +33,10 @@ 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 @@ void reloadThemeWhenSettingNameSetBeforeThenDeleteSetting() throws IOException { 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 @@ void reloadThemeWhenSettingNameNotSetBefore() throws IOException { return Mono.just(argument); }); - when(client.create(any(Setting.class))) - .thenAnswer((Answer>) invocation -> { - Setting argument = invocation.getArgument(0); + when(client.create(any(Unstructured.class))) + .thenAnswer((Answer>) invocation -> { + Unstructured argument = invocation.getArgument(0); JSONAssert.assertEquals(""" { "spec": { @@ -342,7 +346,10 @@ void reloadThemeWhenSettingNameNotSetBefore() throws IOException { "apiVersion": "v1alpha1", "kind": "Setting", "metadata": { - "name": "fake-setting" + "name": "fake-setting", + "labels": { + "theme.halo.run/theme-name": "fake-theme" + } } } """, @@ -351,6 +358,8 @@ void reloadThemeWhenSettingNameNotSetBefore() throws IOException { 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 -> {