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 000000000..e8e2bfd77 --- /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 0162fd7e5..e78c2a619 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 000000000..5c6d97b54 --- /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 28f3e6e98..4812a38b6 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 @@ 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 { + 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 { 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 { }); } + 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 @@ public class ThemeReconciler implements Reconciler { 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 6aed6bcea..993695b4b 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.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 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 @@ public class ThemeServiceImpl implements ThemeService { .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 1c18aff05..367338548 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.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 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 @@ class ThemeUtils { .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 @@ class ThemeUtils { } 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 2134e773f..e6e07eabd 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.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 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 5f991b08e..5c67a2044 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.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>) 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 @@ 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 -> {