mirror of https://github.com/halo-dev/halo
feat: add theme uninstall endpoint (#2315)
<!-- Thanks for sending a pull request! Here are some tips for you: 1. 如果这是你的第一次,请阅读我们的贡献指南:<https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>。 1. If this is your first time, please read our contributor guidelines: <https://github.com/halo-dev/halo/blob/master/CONTRIBUTING.md>. 2. 请根据你解决问题的类型为 Pull Request 添加合适的标签。 2. Please label this pull request according to what type of issue you are addressing, especially if this is a release targeted pull request. 3. 请确保你已经添加并运行了适当的测试。 3. Ensure you have added or ran the appropriate tests for your PR. --> #### What type of PR is this? /kind feature /milestone 2.0 /area core /kind api-change <!-- 添加其中一个类别: Add one of the following kinds: /kind bug /kind cleanup /kind documentation /kind feature /kind improvement 适当添加其中一个或多个类别(可选): Optionally add one or more of the following kinds if applicable: /kind api-change /kind deprecation /kind failing-test /kind flake /kind regression --> #### What this PR does / why we need it: 新增主题卸载 endpoint #### Which issue(s) this PR fixes: <!-- PR 合并时自动关闭 issue。 Automatically closes linked issue when PR is merged. 用法:`Fixes #<issue 号>`,或者 `Fixes (粘贴 issue 完整链接)` Usage: `Fixes #<issue number>`, or `Fixes (paste link of issue)`. --> Fixes #2306 #### Special notes for your reviewer: /cc @halo-dev/sig-halo #### Does this PR introduce a user-facing change? <!-- 如果当前 Pull Request 的修改不会造成用户侧的任何变更,在 `release-note` 代码块儿中填写 `NONE`。 否则请填写用户侧能够理解的 Release Note。如果当前 Pull Request 包含破坏性更新(Break Change), Release Note 需要以 `action required` 开头。 If no, just write "NONE" in the release-note block below. If yes, a release note is required: Enter your extended release note in the block below. If the PR requires additional action from users switching to the new release, include the string "action required". --> ```release-note None ```pull/2303/head^2
parent
349db687e3
commit
7026681747
|
@ -9,10 +9,12 @@ import org.springframework.web.reactive.function.server.ServerResponse;
|
||||||
import run.halo.app.core.extension.Plugin;
|
import run.halo.app.core.extension.Plugin;
|
||||||
import run.halo.app.core.extension.Role;
|
import run.halo.app.core.extension.Role;
|
||||||
import run.halo.app.core.extension.RoleBinding;
|
import run.halo.app.core.extension.RoleBinding;
|
||||||
|
import run.halo.app.core.extension.Theme;
|
||||||
import run.halo.app.core.extension.User;
|
import run.halo.app.core.extension.User;
|
||||||
import run.halo.app.core.extension.reconciler.PluginReconciler;
|
import run.halo.app.core.extension.reconciler.PluginReconciler;
|
||||||
import run.halo.app.core.extension.reconciler.RoleBindingReconciler;
|
import run.halo.app.core.extension.reconciler.RoleBindingReconciler;
|
||||||
import run.halo.app.core.extension.reconciler.RoleReconciler;
|
import run.halo.app.core.extension.reconciler.RoleReconciler;
|
||||||
|
import run.halo.app.core.extension.reconciler.ThemeReconciler;
|
||||||
import run.halo.app.core.extension.reconciler.UserReconciler;
|
import run.halo.app.core.extension.reconciler.UserReconciler;
|
||||||
import run.halo.app.core.extension.service.RoleService;
|
import run.halo.app.core.extension.service.RoleService;
|
||||||
import run.halo.app.extension.DefaultExtensionClient;
|
import run.halo.app.extension.DefaultExtensionClient;
|
||||||
|
@ -28,6 +30,7 @@ import run.halo.app.extension.controller.ControllerBuilder;
|
||||||
import run.halo.app.extension.controller.ControllerManager;
|
import run.halo.app.extension.controller.ControllerManager;
|
||||||
import run.halo.app.extension.router.ExtensionCompositeRouterFunction;
|
import run.halo.app.extension.router.ExtensionCompositeRouterFunction;
|
||||||
import run.halo.app.extension.store.ExtensionStoreClient;
|
import run.halo.app.extension.store.ExtensionStoreClient;
|
||||||
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
import run.halo.app.plugin.HaloPluginManager;
|
import run.halo.app.plugin.HaloPluginManager;
|
||||||
import run.halo.app.plugin.resources.JsBundleRuleProvider;
|
import run.halo.app.plugin.resources.JsBundleRuleProvider;
|
||||||
|
|
||||||
|
@ -99,6 +102,14 @@ public class ExtensionConfiguration {
|
||||||
.extension(new Plugin())
|
.extension(new Plugin())
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
Controller themeController(ExtensionClient client, HaloProperties haloProperties) {
|
||||||
|
return new ControllerBuilder("theme-controller", client)
|
||||||
|
.reconciler(new ThemeReconciler(client, haloProperties))
|
||||||
|
.extension(new Theme())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -42,7 +42,7 @@ import run.halo.app.core.extension.Theme;
|
||||||
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.Unstructured;
|
import run.halo.app.extension.Unstructured;
|
||||||
import run.halo.app.infra.ThemeInstallationException;
|
import run.halo.app.infra.exception.ThemeInstallationException;
|
||||||
import run.halo.app.infra.properties.HaloProperties;
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
import run.halo.app.infra.utils.FileUtils;
|
import run.halo.app.infra.utils.FileUtils;
|
||||||
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
import run.halo.app.infra.utils.YamlUnstructuredLoader;
|
||||||
|
|
|
@ -0,0 +1,64 @@
|
||||||
|
package run.halo.app.core.extension.reconciler;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import org.apache.commons.lang3.StringUtils;
|
||||||
|
import org.springframework.util.FileSystemUtils;
|
||||||
|
import run.halo.app.core.extension.Setting;
|
||||||
|
import run.halo.app.core.extension.Theme;
|
||||||
|
import run.halo.app.extension.ExtensionClient;
|
||||||
|
import run.halo.app.extension.controller.Reconciler;
|
||||||
|
import run.halo.app.infra.exception.ThemeUninstallException;
|
||||||
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
|
import run.halo.app.theme.ThemePathPolicy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reconciler for theme.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
public class ThemeReconciler implements Reconciler {
|
||||||
|
|
||||||
|
private final ExtensionClient client;
|
||||||
|
private final ThemePathPolicy themePathPolicy;
|
||||||
|
|
||||||
|
public ThemeReconciler(ExtensionClient client, HaloProperties haloProperties) {
|
||||||
|
this.client = client;
|
||||||
|
themePathPolicy = new ThemePathPolicy(haloProperties.getWorkDir());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Result reconcile(Request request) {
|
||||||
|
client.fetch(Theme.class, request.name())
|
||||||
|
.ifPresent(theme -> {
|
||||||
|
if (isDeleted(theme)) {
|
||||||
|
reconcileThemeDeletion(theme);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return new Result(false, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void reconcileThemeDeletion(Theme theme) {
|
||||||
|
deleteThemeFiles(theme);
|
||||||
|
// delete theme setting form
|
||||||
|
String settingName = theme.getSpec().getSettingName();
|
||||||
|
if (StringUtils.isNotBlank(settingName)) {
|
||||||
|
client.fetch(Setting.class, settingName)
|
||||||
|
.ifPresent(client::delete);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void deleteThemeFiles(Theme theme) {
|
||||||
|
Path themeDir = themePathPolicy.generate(theme);
|
||||||
|
try {
|
||||||
|
FileSystemUtils.deleteRecursively(themeDir);
|
||||||
|
} catch (IOException e) {
|
||||||
|
throw new ThemeUninstallException("Failed to delete theme files.", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private boolean isDeleted(Theme theme) {
|
||||||
|
return theme.getMetadata().getDeletionTimestamp() != null;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,4 +1,4 @@
|
||||||
package run.halo.app.infra;
|
package run.halo.app.infra.exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Not found exception.
|
* Not found exception.
|
|
@ -1,4 +1,4 @@
|
||||||
package run.halo.app.infra;
|
package run.halo.app.infra.exception;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @author guqing
|
* @author guqing
|
|
@ -0,0 +1,16 @@
|
||||||
|
package run.halo.app.infra.exception;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
public class ThemeUninstallException extends RuntimeException {
|
||||||
|
|
||||||
|
public ThemeUninstallException(String message) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
public ThemeUninstallException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
}
|
||||||
|
}
|
|
@ -11,7 +11,7 @@ import org.thymeleaf.dialect.IDialect;
|
||||||
import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine;
|
import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine;
|
||||||
import org.thymeleaf.templateresolver.FileTemplateResolver;
|
import org.thymeleaf.templateresolver.FileTemplateResolver;
|
||||||
import org.thymeleaf.templateresolver.ITemplateResolver;
|
import org.thymeleaf.templateresolver.ITemplateResolver;
|
||||||
import run.halo.app.infra.NotFoundException;
|
import run.halo.app.infra.exception.NotFoundException;
|
||||||
import run.halo.app.theme.engine.SpringWebFluxTemplateEngine;
|
import run.halo.app.theme.engine.SpringWebFluxTemplateEngine;
|
||||||
import run.halo.app.theme.message.ThemeMessageResolver;
|
import run.halo.app.theme.message.ThemeMessageResolver;
|
||||||
|
|
||||||
|
|
|
@ -0,0 +1,28 @@
|
||||||
|
package run.halo.app.theme;
|
||||||
|
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import org.springframework.util.Assert;
|
||||||
|
import run.halo.app.core.extension.Theme;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Policy for generating theme directory path.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
public class ThemePathPolicy {
|
||||||
|
public static final String THEME_WORK_DIR = "themes";
|
||||||
|
|
||||||
|
private final Path workDir;
|
||||||
|
|
||||||
|
public ThemePathPolicy(Path workDir) {
|
||||||
|
Assert.notNull(workDir, "The halo workDir must not be null.");
|
||||||
|
this.workDir = workDir;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Path generate(Theme theme) {
|
||||||
|
Assert.notNull(theme, "The theme must not be null.");
|
||||||
|
String name = theme.getMetadata().getName();
|
||||||
|
return workDir.resolve(THEME_WORK_DIR).resolve(name);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,102 @@
|
||||||
|
package run.halo.app.core.extension.reconciler;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.times;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.io.File;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.nio.file.Files;
|
||||||
|
import java.nio.file.Path;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.util.FileSystemUtils;
|
||||||
|
import org.springframework.util.ResourceUtils;
|
||||||
|
import run.halo.app.core.extension.Setting;
|
||||||
|
import run.halo.app.core.extension.Theme;
|
||||||
|
import run.halo.app.extension.ExtensionClient;
|
||||||
|
import run.halo.app.extension.Metadata;
|
||||||
|
import run.halo.app.extension.controller.Reconciler;
|
||||||
|
import run.halo.app.infra.properties.HaloProperties;
|
||||||
|
import run.halo.app.theme.ThemePathPolicy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests for {@link ThemeReconciler}.
|
||||||
|
*
|
||||||
|
* @author guqing
|
||||||
|
* @since 2.0.0
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class ThemeReconcilerTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private ExtensionClient extensionClient;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private HaloProperties haloProperties;
|
||||||
|
|
||||||
|
private File defaultTheme;
|
||||||
|
|
||||||
|
private Path tempDirectory;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() throws IOException {
|
||||||
|
tempDirectory = Files.createTempDirectory("halo-theme-");
|
||||||
|
defaultTheme = ResourceUtils.getFile("classpath:themes/default");
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() throws IOException {
|
||||||
|
FileSystemUtils.deleteRecursively(tempDirectory);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void reconcileDelete() throws IOException {
|
||||||
|
Path testWorkDir = tempDirectory.resolve("reconcile-delete");
|
||||||
|
Files.createDirectory(testWorkDir);
|
||||||
|
when(haloProperties.getWorkDir()).thenReturn(testWorkDir);
|
||||||
|
|
||||||
|
ThemeReconciler themeReconciler = new ThemeReconciler(extensionClient, haloProperties);
|
||||||
|
ThemePathPolicy themePathPolicy = new ThemePathPolicy(testWorkDir);
|
||||||
|
|
||||||
|
Theme theme = new Theme();
|
||||||
|
Metadata metadata = new Metadata();
|
||||||
|
metadata.setName("theme-test");
|
||||||
|
metadata.setDeletionTimestamp(Instant.now());
|
||||||
|
theme.setMetadata(metadata);
|
||||||
|
theme.setKind(Theme.KIND);
|
||||||
|
theme.setApiVersion("theme.halo.run/v1alpha1");
|
||||||
|
Theme.ThemeSpec themeSpec = new Theme.ThemeSpec();
|
||||||
|
themeSpec.setSettingName("theme-test-setting");
|
||||||
|
theme.setSpec(themeSpec);
|
||||||
|
|
||||||
|
Path defaultThemePath = themePathPolicy.generate(theme);
|
||||||
|
|
||||||
|
// copy to temp directory
|
||||||
|
FileSystemUtils.copyRecursively(defaultTheme.toPath(), defaultThemePath);
|
||||||
|
|
||||||
|
assertThat(testWorkDir).isNotEmptyDirectory();
|
||||||
|
assertThat(defaultThemePath).exists();
|
||||||
|
|
||||||
|
when(extensionClient.fetch(eq(Theme.class), eq(metadata.getName())))
|
||||||
|
.thenReturn(Optional.of(theme));
|
||||||
|
when(extensionClient.fetch(Setting.class, themeSpec.getSettingName()))
|
||||||
|
.thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
themeReconciler.reconcile(new Reconciler.Request(metadata.getName()));
|
||||||
|
|
||||||
|
verify(extensionClient, times(1)).fetch(eq(Theme.class), eq(metadata.getName()));
|
||||||
|
verify(extensionClient, times(1)).fetch(eq(Setting.class), eq(themeSpec.getSettingName()));
|
||||||
|
|
||||||
|
assertThat(Files.exists(testWorkDir)).isTrue();
|
||||||
|
assertThat(Files.exists(defaultThemePath)).isFalse();
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue