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
guqing 2022-08-10 17:12:21 +08:00 committed by GitHub
parent 349db687e3
commit 7026681747
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 225 additions and 4 deletions

View File

@ -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.Role;
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.reconciler.PluginReconciler;
import run.halo.app.core.extension.reconciler.RoleBindingReconciler;
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.service.RoleService;
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.router.ExtensionCompositeRouterFunction;
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.resources.JsBundleRuleProvider;
@ -99,6 +102,14 @@ public class ExtensionConfiguration {
.extension(new Plugin())
.build();
}
@Bean
Controller themeController(ExtensionClient client, HaloProperties haloProperties) {
return new ControllerBuilder("theme-controller", client)
.reconciler(new ThemeReconciler(client, haloProperties))
.extension(new Theme())
.build();
}
}
}

View File

@ -42,7 +42,7 @@ import run.halo.app.core.extension.Theme;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.ExtensionClient;
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.utils.FileUtils;
import run.halo.app.infra.utils.YamlUnstructuredLoader;

View File

@ -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;
}
}

View File

@ -1,4 +1,4 @@
package run.halo.app.infra;
package run.halo.app.infra.exception;
/**
* Not found exception.

View File

@ -1,4 +1,4 @@
package run.halo.app.infra;
package run.halo.app.infra.exception;
/**
* @author guqing

View File

@ -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);
}
}

View File

@ -11,7 +11,7 @@ import org.thymeleaf.dialect.IDialect;
import org.thymeleaf.spring6.ISpringWebFluxTemplateEngine;
import org.thymeleaf.templateresolver.FileTemplateResolver;
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.message.ThemeMessageResolver;

View File

@ -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);
}
}

View File

@ -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();
}
}