Support backup and restore (#4206)

#### What type of PR is this?

/kind feature
/area core

#### What this PR does / why we need it:

See 9921deb076/docs/backup-and-restore.md for more.

<img width="1906" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/41531186-d305-44fd-8bdc-30df9b71af43">
<img width="1909" alt="image" src="https://github.com/halo-dev/halo/assets/21301288/3d7af1b9-37ad-4a40-9b81-f15ed0f1f6e8">


#### Which issue(s) this PR fixes:

Fixes https://github.com/halo-dev/halo/issues/4059
Fixes https://github.com/halo-dev/halo/issues/3274

#### Special notes for your reviewer:

#### Does this PR introduce a user-facing change?

```release-note
支持备份和恢复功能。
```
This commit is contained in:
John Niang 2023-07-24 16:26:16 +08:00 committed by GitHub
parent 5ce47190fa
commit bd912c36b9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
49 changed files with 3865 additions and 22 deletions

View File

@ -508,3 +508,6 @@ ij_html_text_wrap = normal
indent_size = 2
ij_yaml_keep_indents_on_empty_lines = false
ij_yaml_keep_line_breaks = true
[*.md]
indent_size = 2

View File

@ -1,5 +1,6 @@
package run.halo.app.extension;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
@ -11,21 +12,26 @@ public enum ExtensionUtil {
&& extension.getMetadata().getDeletionTimestamp() != null;
}
public static void addFinalizers(MetadataOperator metadata, Set<String> finalizers) {
var existingFinalizers = metadata.getFinalizers();
if (existingFinalizers == null) {
existingFinalizers = new HashSet<>();
public static boolean addFinalizers(MetadataOperator metadata, Set<String> finalizers) {
var modifiableFinalizers = new HashSet<>(
metadata.getFinalizers() == null ? Collections.emptySet() : metadata.getFinalizers());
var added = modifiableFinalizers.addAll(finalizers);
if (added) {
metadata.setFinalizers(modifiableFinalizers);
}
existingFinalizers.addAll(finalizers);
metadata.setFinalizers(existingFinalizers);
return added;
}
public static void removeFinalizers(MetadataOperator metadata, Set<String> finalizers) {
var existingFinalizers = metadata.getFinalizers();
if (existingFinalizers != null) {
existingFinalizers.removeAll(finalizers);
public static boolean removeFinalizers(MetadataOperator metadata, Set<String> finalizers) {
if (metadata.getFinalizers() == null) {
return false;
}
metadata.setFinalizers(existingFinalizers);
var existingFinalizers = new HashSet<>(metadata.getFinalizers());
var removed = existingFinalizers.removeAll(finalizers);
if (removed) {
metadata.setFinalizers(existingFinalizers);
}
return removed;
}
}

View File

@ -34,25 +34,25 @@ class ExtensionUtilTest {
void addFinalizers() {
var metadata = new Metadata();
assertNull(metadata.getFinalizers());
ExtensionUtil.addFinalizers(metadata, Set.of("fake"));
assertTrue(ExtensionUtil.addFinalizers(metadata, Set.of("fake")));
assertEquals(Set.of("fake"), metadata.getFinalizers());
ExtensionUtil.addFinalizers(metadata, Set.of("fake"));
assertFalse(ExtensionUtil.addFinalizers(metadata, Set.of("fake")));
assertEquals(Set.of("fake"), metadata.getFinalizers());
ExtensionUtil.addFinalizers(metadata, Set.of("another-fake"));
assertTrue(ExtensionUtil.addFinalizers(metadata, Set.of("another-fake")));
assertEquals(Set.of("fake", "another-fake"), metadata.getFinalizers());
}
@Test
void removeFinalizers() {
var metadata = new Metadata();
ExtensionUtil.removeFinalizers(metadata, Set.of("fake"));
assertFalse(ExtensionUtil.removeFinalizers(metadata, Set.of("fake")));
assertNull(metadata.getFinalizers());
metadata.setFinalizers(new HashSet<>(Set.of("fake")));
ExtensionUtil.removeFinalizers(metadata, Set.of("fake"));
assertTrue(ExtensionUtil.removeFinalizers(metadata, Set.of("fake")));
assertEquals(Set.of(), metadata.getFinalizers());
}

View File

@ -32,6 +32,7 @@ import run.halo.app.core.extension.content.Tag;
import run.halo.app.extension.ConfigMap;
import run.halo.app.extension.SchemeManager;
import run.halo.app.extension.Secret;
import run.halo.app.migration.Backup;
import run.halo.app.plugin.extensionpoint.ExtensionDefinition;
import run.halo.app.plugin.extensionpoint.ExtensionPointDefinition;
import run.halo.app.search.extension.SearchEngine;
@ -89,6 +90,9 @@ public class SchemeInitializer implements ApplicationListener<ApplicationStarted
schemeManager.register(AuthProvider.class);
schemeManager.register(UserConnection.class);
// migration.halo.run
schemeManager.register(Backup.class);
eventPublisher.publishEvent(new SchemeInitializedEvent(this));
}
}

View File

@ -1,5 +1,6 @@
package run.halo.app.infra.utils;
import static java.nio.file.StandardCopyOption.REPLACE_EXISTING;
import static org.springframework.util.FileSystemUtils.deleteRecursively;
import java.io.Closeable;
@ -12,7 +13,9 @@ import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.SimpleFileVisitor;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Set;
import java.util.function.Consumer;
import java.util.function.Predicate;
import java.util.jar.JarEntry;
import java.util.jar.JarOutputStream;
import java.util.stream.Stream;
@ -21,6 +24,7 @@ import java.util.zip.ZipInputStream;
import java.util.zip.ZipOutputStream;
import lombok.extern.slf4j.Slf4j;
import org.springframework.lang.NonNull;
import org.springframework.util.AntPathMatcher;
import org.springframework.util.Assert;
import run.halo.app.infra.exception.AccessDeniedException;
@ -246,4 +250,31 @@ public abstract class FileUtils {
throw new RuntimeException(e);
}
}
public static void copyRecursively(Path src, Path target, Set<String> excludes)
throws IOException {
var pathMatcher = new AntPathMatcher();
Predicate<Path> shouldExclude = path -> excludes.stream()
.anyMatch(pattern -> pathMatcher.match(pattern, path.toString()));
Files.walkFileTree(src, new SimpleFileVisitor<>() {
@Override
public FileVisitResult visitFile(Path file, BasicFileAttributes attrs)
throws IOException {
if (!shouldExclude.test(src.relativize(file))) {
Files.copy(file, target.resolve(src.relativize(file)), REPLACE_EXISTING);
}
return super.visitFile(file, attrs);
}
@Override
public FileVisitResult preVisitDirectory(Path dir, BasicFileAttributes attrs)
throws IOException {
if (shouldExclude.test(src.relativize(dir))) {
return FileVisitResult.SKIP_SUBTREE;
}
Files.createDirectories(target.resolve(src.relativize(dir)));
return super.preVisitDirectory(dir, attrs);
}
});
}
}

View File

@ -0,0 +1,65 @@
package run.halo.app.migration;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.Instant;
import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;
import run.halo.app.extension.AbstractExtension;
import run.halo.app.extension.GVK;
@Data
@ToString(callSuper = true)
@EqualsAndHashCode(callSuper = true)
@GVK(group = "migration.halo.run", version = "v1alpha1", kind = "Backup",
plural = "backups", singular = "backup")
public class Backup extends AbstractExtension {
private Spec spec = new Spec();
private Status status = new Status();
@Data
@Schema(name = "BackupSpec")
public static class Spec {
@Schema(description = "Backup file format. Currently, only zip format is supported.")
private String format;
private Instant expiresAt;
}
@Data
@Schema(name = "BackupStatus")
public static class Status {
private Phase phase = Phase.PENDING;
private Instant startTimestamp;
private Instant completionTimestamp;
private String failureReason;
private String failureMessage;
/**
* Size of backup file. Data unit: byte
*/
private Long size;
/**
* Name of backup file.
*/
private String filename;
}
public enum Phase {
PENDING,
RUNNING,
SUCCEEDED,
FAILED,
}
}

View File

@ -0,0 +1,133 @@
package run.halo.app.migration;
import static run.halo.app.extension.ExtensionUtil.addFinalizers;
import static run.halo.app.extension.ExtensionUtil.isDeleted;
import static run.halo.app.extension.ExtensionUtil.removeFinalizers;
import static run.halo.app.extension.controller.Reconciler.Result.doNotRetry;
import static run.halo.app.migration.Constant.HOUSE_KEEPER_FINALIZER;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.util.Set;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import reactor.core.Exceptions;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.controller.Controller;
import run.halo.app.extension.controller.ControllerBuilder;
import run.halo.app.extension.controller.Reconciler;
import run.halo.app.extension.controller.Reconciler.Request;
import run.halo.app.migration.Backup.Phase;
@Slf4j
@Component
public class BackupReconciler implements Reconciler<Request> {
private final ExtensionClient client;
private final MigrationService migrationService;
private Clock clock;
public BackupReconciler(ExtensionClient client, MigrationService migrationService) {
this.client = client;
this.migrationService = migrationService;
clock = Clock.systemDefaultZone();
}
/**
* Set clock. The method is only for unit test.
*
* @param clock is new clock
*/
void setClock(Clock clock) {
this.clock = clock;
}
@Override
public Result reconcile(Request request) {
return client.fetch(Backup.class, request.name())
.map(backup -> {
var metadata = backup.getMetadata();
var status = backup.getStatus();
var spec = backup.getSpec();
if (isDeleted(backup)) {
if (removeFinalizers(metadata, Set.of(HOUSE_KEEPER_FINALIZER))) {
migrationService.cleanup(backup).block();
client.update(backup);
}
return doNotRetry();
}
if (addFinalizers(metadata, Set.of(HOUSE_KEEPER_FINALIZER))) {
client.update(backup);
}
if (Phase.PENDING.equals(status.getPhase())) {
// Do backup
try {
status.setPhase(Phase.RUNNING);
status.setStartTimestamp(Instant.now(clock));
updateStatus(request.name(), status);
// Long period execution when backing up
migrationService.backup(backup).block();
status.setPhase(Phase.SUCCEEDED);
status.setCompletionTimestamp(Instant.now(clock));
updateStatus(request.name(), status);
} catch (Throwable t) {
var unwrapped = Exceptions.unwrap(t);
log.error("Failed to backup", unwrapped);
// Only happen when shutting down
status.setPhase(Phase.FAILED);
if (unwrapped instanceof InterruptedException) {
status.setFailureReason("Interrupted");
status.setFailureMessage("The backup process was interrupted.");
} else {
status.setFailureReason("SystemError");
status.setFailureMessage(
"Something went wrong! Error message: " + unwrapped.getMessage());
}
updateStatus(request.name(), status);
}
}
// Only happen when failing to update status when interrupted
if (Phase.RUNNING.equals(status.getPhase())) {
status.setPhase(Phase.FAILED);
status.setFailureReason("UnexpectedExit");
status.setFailureMessage("The backup process may exit abnormally.");
updateStatus(request.name(), status);
}
// Check the expires at and requeue if necessary
if (isTerminal(status.getPhase())) {
var expiresAt = spec.getExpiresAt();
if (expiresAt != null) {
var now = Instant.now(clock);
if (now.isBefore(expiresAt)) {
return new Result(true, Duration.between(now, expiresAt));
}
client.delete(backup);
}
}
return doNotRetry();
}).orElseGet(Result::doNotRetry);
}
private void updateStatus(String name, Backup.Status status) {
client.fetch(Backup.class, name)
.ifPresent(backup -> {
backup.setStatus(status);
client.update(backup);
});
}
private static boolean isTerminal(Phase phase) {
return Phase.FAILED.equals(phase) || Phase.SUCCEEDED.equals(phase);
}
@Override
public Controller setupWith(ControllerBuilder builder) {
return builder
.extension(new Backup())
.build();
}
}

View File

@ -0,0 +1,12 @@
package run.halo.app.migration;
public enum Constant {
;
public static final String GROUP = "migration.halo.run";
public static final String VERSION = "v1alpha1";
public static final String HOUSE_KEEPER_FINALIZER = "housekeeper";
}

View File

@ -0,0 +1,108 @@
package run.halo.app.migration;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static org.springdoc.core.fn.builders.content.Builder.contentBuilder;
import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder;
import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder;
import static org.springdoc.core.fn.builders.schema.Builder.schemaBuilder;
import io.swagger.v3.oas.annotations.enums.ParameterIn;
import io.swagger.v3.oas.annotations.media.Schema;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.Part;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ReactiveExtensionClient;
@Component
public class MigrationEndpoint implements CustomEndpoint {
private final MigrationService migrationService;
private final ReactiveExtensionClient client;
public MigrationEndpoint(MigrationService migrationService, ReactiveExtensionClient client) {
this.migrationService = migrationService;
this.client = client;
}
@Override
public RouterFunction<ServerResponse> endpoint() {
var tag = groupVersion().toString() + "/Migration";
return SpringdocRouteBuilder.route()
.GET("/backups/{name}/files/{filename}",
request -> {
var name = request.pathVariable("name");
return client.get(Backup.class, name)
.flatMap(migrationService::download)
.flatMap(backupResource -> ServerResponse.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename=\"" + backupResource.getFilename() + "\"")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.bodyValue(backupResource));
},
builder -> builder
.tag(tag)
.operationId("DownloadBackups")
.parameter(parameterBuilder()
.name("name")
.description("Backup name.")
.required(true)
.in(ParameterIn.PATH))
.parameter(parameterBuilder()
.name("filename")
.description("Backup filename.")
.required(true)
.in(ParameterIn.PATH))
.build())
.POST("/restorations", request -> request.multipartData()
.map(RestoreRequest::new)
.flatMap(restoreRequest -> migrationService.restore(
restoreRequest.getFile().content()))
.flatMap(v -> ServerResponse.ok().bodyValue("Restored successfully!")),
builder -> builder
.tag(tag)
.operationId("RestoreBackup")
.requestBody(requestBodyBuilder()
.required(true)
.content(contentBuilder()
.mediaType(MediaType.MULTIPART_FORM_DATA_VALUE)
.schema(schemaBuilder().implementation(RestoreRequest.class))
)
)
.build())
.build();
}
public static class RestoreRequest {
private final MultiValueMap<String, Part> multipart;
public RestoreRequest(MultiValueMap<String, Part> multipart) {
this.multipart = multipart;
}
@Schema(requiredMode = REQUIRED, name = "file", description = "Backup file.")
public FilePart getFile() {
var part = multipart.getFirst("file");
if (part instanceof FilePart filePart) {
return filePart;
}
throw new ServerWebInputException("Invalid file part");
}
}
@Override
public GroupVersion groupVersion() {
return GroupVersion.parseAPIVersion(
"api.console." + Constant.GROUP + "/" + Constant.VERSION);
}
}

View File

@ -0,0 +1,24 @@
package run.halo.app.migration;
import org.reactivestreams.Publisher;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBuffer;
import reactor.core.publisher.Mono;
public interface MigrationService {
Mono<Void> backup(Backup backup);
Mono<Resource> download(Backup backup);
Mono<Void> restore(Publisher<DataBuffer> content);
/**
* Clean up backup file.
*
* @param backup backup detail.
* @return void publisher.
*/
Mono<Void> cleanup(Backup backup);
}

View File

@ -0,0 +1,275 @@
package run.halo.app.migration.impl;
import static org.springframework.core.io.buffer.DataBufferUtils.releaseConsumer;
import static run.halo.app.infra.utils.FileUtils.closeQuietly;
import static run.halo.app.infra.utils.FileUtils.deleteRecursivelyAndSilently;
import com.fasterxml.jackson.core.util.MinimalPrettyPrinter;
import com.fasterxml.jackson.databind.MappingIterator;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.json.JsonMapper;
import java.io.IOException;
import java.io.PipedInputStream;
import java.io.PipedOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
import java.util.Locale;
import java.util.Set;
import java.util.zip.ZipInputStream;
import lombok.extern.slf4j.Slf4j;
import org.reactivestreams.Publisher;
import org.springframework.core.io.FileSystemResource;
import org.springframework.core.io.Resource;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.FileSystemUtils;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.Exceptions;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import reactor.util.context.Context;
import run.halo.app.extension.store.ExtensionStore;
import run.halo.app.extension.store.ExtensionStoreRepository;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.FileUtils;
import run.halo.app.migration.Backup;
import run.halo.app.migration.MigrationService;
@Slf4j
@Service
public class MigrationServiceImpl implements MigrationService {
private final ExtensionStoreRepository repository;
private final HaloProperties haloProperties;
private final ObjectMapper objectMapper;
private final Set<String> excludes = Set.of(
"**/.git/**",
"**/node_modules/**",
"backups/**",
"db/**",
"logs/**",
"docker-compose.yaml",
"docker-compose.yml",
"mysql/**",
"mysqlBackup/**",
"**/.idea/**",
"**/.vscode/**"
);
private final DateTimeFormatter dateTimeFormatter;
public MigrationServiceImpl(ExtensionStoreRepository repository,
HaloProperties haloProperties) {
this.repository = repository;
this.haloProperties = haloProperties;
this.objectMapper = JsonMapper.builder()
.defaultPrettyPrinter(new MinimalPrettyPrinter())
.build();
this.dateTimeFormatter = DateTimeFormatter
.ofPattern("yyyyMMddHHmmss")
.withLocale(Locale.getDefault())
.withZone(ZoneId.systemDefault());
}
DateTimeFormatter getDateTimeFormatter() {
return dateTimeFormatter;
}
ObjectMapper getObjectMapper() {
return objectMapper;
}
Path getBackupsRoot() {
return haloProperties.getWorkDir().resolve("backups");
}
@Override
public Mono<Void> backup(Backup backup) {
try {
// create temporary folder to store all backup files into single files.
var tempDir = Files.createTempDirectory("halo-full-backup-");
return backupExtensions(tempDir)
.and(backupWorkDir(tempDir))
.and(packageBackup(tempDir, backup))
.doFinally(signalType -> deleteRecursivelyAndSilently(tempDir))
.subscribeOn(Schedulers.boundedElastic());
} catch (IOException e) {
return Mono.error(e);
}
}
@Override
public Mono<Resource> download(Backup backup) {
var status = backup.getStatus();
if (!Backup.Phase.SUCCEEDED.equals(status.getPhase()) || status.getFilename() == null) {
return Mono.error(new ServerWebInputException("Current backup is not downloadable."));
}
var backupFile = getBackupsRoot()
.resolve(status.getFilename());
return Mono.just(new FileSystemResource(backupFile));
}
@Override
@Transactional
public Mono<Void> restore(Publisher<DataBuffer> content) {
return Mono.defer(() -> {
try {
var tempDir = Files.createTempDirectory("halo-restore-");
return unpackBackup(content, tempDir)
.and(restoreExtensions(tempDir))
.and(restoreWorkdir(tempDir))
.doFinally(signalType -> deleteRecursivelyAndSilently(tempDir))
.subscribeOn(Schedulers.boundedElastic());
} catch (IOException e) {
return Mono.error(e);
}
});
}
@Override
public Mono<Void> cleanup(Backup backup) {
return Mono.<Void>fromRunnable(() -> {
var status = backup.getStatus();
if (status == null || status.getFilename() == null) {
return;
}
var filename = status.getFilename();
var backupsRoot = getBackupsRoot();
var backupFile = backupsRoot.resolve(filename);
try {
FileUtils.checkDirectoryTraversal(backupsRoot, backupFile);
Files.deleteIfExists(backupFile);
} catch (IOException e) {
throw Exceptions.propagate(e);
}
}).subscribeOn(Schedulers.boundedElastic());
}
private Mono<Void> restoreWorkdir(Path backupRoot) {
return Mono.fromRunnable(() -> {
try {
var workdir = backupRoot.resolve("workdir");
if (Files.exists(workdir)) {
FileSystemUtils.copyRecursively(workdir, haloProperties.getWorkDir());
}
} catch (IOException e) {
throw Exceptions.propagate(e);
}
});
}
private Mono<Void> restoreExtensions(Path backupRoot) {
var extensionsPath = backupRoot.resolve("extensions.data");
var reader = objectMapper.readerFor(ExtensionStore.class);
return Mono.<Void, MappingIterator<ExtensionStore>>using(
() -> reader.readValues(extensionsPath.toFile()),
itr -> Flux.<ExtensionStore>create(
sink -> {
while (itr.hasNext()) {
sink.next(itr.next());
}
sink.complete();
})
// reset version
.doOnNext(extensionStore -> extensionStore.setVersion(null))
.buffer(100)
// We might encounter OptimisticLockingFailureException when saving extension store,
// So we have to delete all extension stores before saving.
.flatMap(extensionStores -> repository.deleteAll(extensionStores)
.thenMany(repository.saveAll(extensionStores)))
.doOnNext(extensionStore ->
log.info("Restored extension store: {}", extensionStore.getName()))
.then(),
itr -> {
try {
itr.close();
} catch (IOException e) {
throw Exceptions.propagate(e);
}
});
}
private Mono<Void> unpackBackup(Publisher<DataBuffer> content, Path target) {
return Mono.create(sink -> {
try (var pipedIs = new PipedInputStream();
var pipedOs = new PipedOutputStream(pipedIs);
var zipIs = new ZipInputStream(pipedIs)) {
DataBufferUtils.write(content, pipedOs)
.subscribe(
releaseConsumer(),
sink::error,
() -> closeQuietly(pipedOs),
Context.of(sink.contextView()));
FileUtils.unzip(zipIs, target);
sink.success();
} catch (IOException e) {
sink.error(e);
}
});
}
private Mono<Void> packageBackup(Path baseDir, Backup backup) {
return Mono.fromRunnable(() -> {
try {
var backupsFolder = getBackupsRoot();
Files.createDirectories(backupsFolder);
var backupName = backup.getMetadata().getName();
var startTimestamp = backup.getStatus().getStartTimestamp();
var timePart = this.dateTimeFormatter.format(startTimestamp);
var backupFile = backupsFolder.resolve(timePart + '-' + backupName + ".zip");
FileUtils.zip(baseDir, backupFile);
backup.getStatus().setFilename(backupFile.getFileName().toString());
backup.getStatus().setSize(Files.size(backupFile));
} catch (IOException e) {
throw Exceptions.propagate(e);
}
});
}
private Mono<Void> backupWorkDir(Path baseDir) {
return Mono.fromRunnable(() -> {
try {
var workdirPath = Files.createDirectory(baseDir.resolve("workdir"));
FileUtils.copyRecursively(haloProperties.getWorkDir(), workdirPath, excludes);
} catch (IOException e) {
throw Exceptions.propagate(e);
}
});
}
private Mono<Void> backupExtensions(Path baseDir) {
try {
var extensionsPath = Files.createFile(baseDir.resolve("extensions.data"));
return Mono.using(() -> objectMapper.writerFor(ExtensionStore.class)
.writeValuesAsArray(extensionsPath.toFile()),
seqWriter -> repository.findAll()
.doOnNext(extensionStore -> {
try {
seqWriter.write(extensionStore);
} catch (IOException e) {
throw Exceptions.propagate(e);
}
})
.then(),
seqWriter -> {
try {
seqWriter.close();
} catch (IOException e) {
throw Exceptions.propagate(e);
}
});
} catch (IOException e) {
return Mono.error(e);
}
}
}

View File

@ -64,8 +64,10 @@ management:
endpoints:
web:
exposure:
include: ["health", "info", "startup", "globalinfo", "logfile"]
include: ["health", "info", "startup", "globalinfo", "logfile", "shutdown"]
endpoint:
shutdown:
enabled: true
health:
probes:
enabled: true

View File

@ -0,0 +1,18 @@
apiVersion: v1alpha1
kind: "Role"
metadata:
name: role-template-manage-migration
labels:
halo.run/role-template: "true"
annotations:
rbac.authorization.halo.run/module: "Migration Management"
rbac.authorization.halo.run/display-name: "Migration Manage"
rbac.authorization.halo.run/ui-permissions: |
["system:migrations:manage"]
rules:
- apiGroups: ["api.console.migration.halo.run"]
resources: ["restorations"]
verbs: ["create"]
- apiGroups: ["migration.halo.run"]
resources: ["backups"]
verbs: ["list", "get", "create", "update", "delete"]

View File

@ -0,0 +1,255 @@
package run.halo.app.migration;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static run.halo.app.extension.ExtensionUtil.addFinalizers;
import java.io.IOException;
import java.time.Clock;
import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.util.Optional;
import java.util.Set;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.Mockito;
import org.mockito.junit.jupiter.MockitoExtension;
import reactor.core.Exceptions;
import reactor.core.publisher.Mono;
import run.halo.app.extension.ExtensionClient;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.controller.Reconciler;
@ExtendWith(MockitoExtension.class)
class BackupReconcilerTest {
@Mock
MigrationService migrationService;
@Mock
ExtensionClient client;
@InjectMocks
BackupReconciler reconciler;
@Test
void whenFreshBackupIsComing() {
var name = "fake-backup";
var backup = createPureBackup(name);
backup.getSpec().setFormat("zip");
when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup));
doNothing().when(client).update(backup);
when(migrationService.backup(backup)).thenReturn(Mono.fromRunnable(() -> {
var status = backup.getStatus();
status.setFilename("fake-backup-filename");
status.setSize(1024L);
}));
var result = reconciler.reconcile(new Reconciler.Request(name));
assertNotNull(result);
assertFalse(result.reEnqueue());
var status = backup.getStatus();
assertEquals(Backup.Phase.SUCCEEDED, status.getPhase());
assertNotNull(status.getStartTimestamp());
assertNotNull(status.getCompletionTimestamp());
assertEquals("fake-backup-filename", status.getFilename());
assertEquals(1024L, status.getSize());
// 1. query
// 2. pending -> running
// 3. running -> succeeded
verify(client, times(3)).fetch(Backup.class, name);
verify(client, times(3)).update(backup);
verify(migrationService).backup(backup);
}
@Test
void whenBackupDeleted() {
var name = "fake-deleted-backup";
var backup = createPureBackup(name);
backup.getMetadata().setDeletionTimestamp(Instant.now());
addFinalizers(backup.getMetadata(), Set.of(Constant.HOUSE_KEEPER_FINALIZER));
when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup));
when(migrationService.cleanup(backup)).thenReturn(Mono.empty());
doNothing().when(client).update(backup);
var result = reconciler.reconcile(new Reconciler.Request(name));
assertNotNull(result);
assertFalse(result.reEnqueue());
assertFalse(backup.getMetadata().getFinalizers().contains(Constant.HOUSE_KEEPER_FINALIZER));
verify(client).fetch(Backup.class, name);
verify(migrationService).cleanup(backup);
verify(client).update(backup);
}
@Test
void setPhaseToFailedIfPhaseIsRunning() {
var name = "fake-backup";
var backup = createPureBackup(name);
var status = backup.getStatus();
status.setPhase(Backup.Phase.RUNNING);
when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup));
doNothing().when(client).update(backup);
var result = reconciler.reconcile(new Reconciler.Request(name));
assertNotNull(result);
assertFalse(result.reEnqueue());
assertEquals(Backup.Phase.FAILED, status.getPhase());
assertEquals("UnexpectedExit", status.getFailureReason());
// 1. add finalizer
// 2. update status
verify(client, times(2)).fetch(Backup.class, name);
verify(client, times(2)).update(backup);
}
@Test
void shouldReQueueIfExpiresAtSetAndNotExpired() {
var now = Instant.now();
reconciler.setClock(Clock.fixed(now, ZoneId.systemDefault()));
var name = "fake-backup";
var backup = createPureBackup(name);
addFinalizers(backup.getMetadata(), Set.of(Constant.HOUSE_KEEPER_FINALIZER));
backup.getSpec().setExpiresAt(now.plus(Duration.ofSeconds(3)));
var status = backup.getStatus();
status.setPhase(Backup.Phase.SUCCEEDED);
when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup));
var result = reconciler.reconcile(new Reconciler.Request(name));
assertNotNull(result);
assertTrue(result.reEnqueue());
assertEquals(Duration.ofSeconds(3), result.retryAfter());
verify(client).fetch(Backup.class, name);
verify(client, never()).update(backup);
verify(client, never()).delete(backup);
}
@Test
void shouldDeleteIfExpiresAtSetAndExpired() {
var now = Instant.now();
reconciler.setClock(Clock.fixed(now, ZoneId.systemDefault()));
var name = "fake-backup";
var backup = createPureBackup(name);
addFinalizers(backup.getMetadata(), Set.of(Constant.HOUSE_KEEPER_FINALIZER));
backup.getSpec().setExpiresAt(now.minus(Duration.ofSeconds(3)));
var status = backup.getStatus();
status.setPhase(Backup.Phase.SUCCEEDED);
when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup));
doNothing().when(client).delete(backup);
var result = reconciler.reconcile(new Reconciler.Request(name));
assertNotNull(result);
assertFalse(result.reEnqueue());
verify(client).fetch(Backup.class, name);
verify(client, never()).update(backup);
verify(client).delete(backup);
}
@Test
void whenBackupInterrupted() {
var name = "fake-backup";
var backup = createPureBackup(name);
backup.getSpec().setFormat("zip");
when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup));
doNothing().when(client).update(backup);
Mono<Void> mono = mock(Mono.class);
when(mono.block()).thenThrow(Exceptions.propagate(new InterruptedException()));
when(migrationService.backup(backup)).thenReturn(mono);
var result = reconciler.reconcile(new Reconciler.Request(name));
assertNotNull(result);
assertFalse(result.reEnqueue());
var status = backup.getStatus();
assertEquals(Backup.Phase.FAILED, status.getPhase());
assertNotNull(status.getStartTimestamp());
assertNull(status.getCompletionTimestamp());
assertEquals("Interrupted", status.getFailureReason());
// 1. query
// 2. pending -> running
// 3. running -> failed
verify(client, times(3)).fetch(Backup.class, name);
verify(client, times(3)).update(backup);
verify(migrationService).backup(backup);
verify(mono).block();
}
@Test
void somethingWentWrongWhenBackup() {
var name = "fake-backup";
var backup = createPureBackup(name);
backup.getSpec().setFormat("zip");
when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup));
doNothing().when(client).update(backup);
Mono<Void> mono = mock(Mono.class);
when(mono.block()).thenThrow(Exceptions.propagate(new IOException("File not found")));
when(migrationService.backup(backup)).thenReturn(mono);
var result = reconciler.reconcile(new Reconciler.Request(name));
assertNotNull(result);
assertFalse(result.reEnqueue());
var status = backup.getStatus();
assertEquals(Backup.Phase.FAILED, status.getPhase());
assertNotNull(status.getStartTimestamp());
assertNull(status.getCompletionTimestamp());
assertEquals("SystemError", status.getFailureReason());
// 1. query
// 2. pending -> running
// 3. running -> failed
verify(client, times(3)).fetch(Backup.class, name);
verify(client, times(3)).update(backup);
verify(migrationService).backup(backup);
verify(mono).block();
}
@Test
void whenBackupWasFailed() {
var name = "fake-backup";
var backup = createPureBackup(name);
backup.getStatus().setPhase(Backup.Phase.FAILED);
when(client.fetch(Backup.class, name)).thenReturn(Optional.of(backup));
var result = reconciler.reconcile(new Reconciler.Request(name));
assertNotNull(result);
assertFalse(result.reEnqueue());
Mockito.verify(migrationService, never()).backup(any(Backup.class));
}
Backup createPureBackup(String name) {
var metadata = new Metadata();
metadata.setName(name);
var backup = new Backup();
backup.setMetadata(metadata);
return backup;
}
}

View File

@ -0,0 +1,202 @@
package run.halo.app.migration.impl;
import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.fasterxml.jackson.core.type.TypeReference;
import java.io.IOException;
import java.net.URISyntaxException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.time.Instant;
import java.util.List;
import java.util.zip.ZipInputStream;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import reactor.test.StepVerifier;
import run.halo.app.extension.Metadata;
import run.halo.app.extension.store.ExtensionStore;
import run.halo.app.extension.store.ExtensionStoreRepository;
import run.halo.app.infra.properties.HaloProperties;
import run.halo.app.infra.utils.FileUtils;
import run.halo.app.migration.Backup;
@ExtendWith(MockitoExtension.class)
class MigrationServiceImplTest {
@Mock
ExtensionStoreRepository repository;
@Mock
HaloProperties haloProperties;
@InjectMocks
MigrationServiceImpl migrationService;
@TempDir
Path tempDir;
@Test
void backupTest() throws IOException {
var startTimestamp = Instant.now();
var backup = createRunningBackup("fake-backup", startTimestamp);
Files.writeString(tempDir.resolve("fake-file"), "halo", StandardOpenOption.CREATE_NEW);
var extensionStores = List.of(
createExtensionStore("fake-extension-store", "fake-data")
);
when(repository.findAll()).thenReturn(Flux.fromIterable(extensionStores));
when(haloProperties.getWorkDir()).thenReturn(tempDir);
StepVerifier.create(migrationService.backup(backup))
.verifyComplete();
verify(repository).findAll();
// 1. backup workdir
// 2. package backup
verify(haloProperties, times(2)).getWorkDir();
var status = backup.getStatus();
var datetimePart = migrationService.getDateTimeFormatter().format(startTimestamp);
assertEquals(datetimePart + "-fake-backup.zip", status.getFilename());
var backupFile = migrationService.getBackupsRoot()
.resolve(status.getFilename());
assertTrue(Files.exists(backupFile));
assertEquals(Files.size(backupFile), status.getSize());
var target = tempDir.resolve("target");
try (var zis = new ZipInputStream(
Files.newInputStream(backupFile, StandardOpenOption.READ))) {
FileUtils.unzip(zis, tempDir.resolve("target"));
}
var extensionsFile = target.resolve("extensions.data");
var workdir = target.resolve("workdir");
assertTrue(Files.exists(extensionsFile));
assertTrue(Files.exists(workdir));
var objectMapper = migrationService.getObjectMapper();
var gotExtensionStores = objectMapper.readValue(extensionsFile.toFile(),
new TypeReference<List<ExtensionStore>>() {
});
assertEquals(gotExtensionStores, extensionStores);
assertEquals("halo", Files.readString(workdir.resolve("fake-file")));
}
@Test
void restoreTest() throws IOException, URISyntaxException {
var unpackedBackup =
getClass().getClassLoader().getResource("backups/backup-for-restoration");
assertNotNull(unpackedBackup);
var backupFile = tempDir.resolve("backups").resolve("fake-backup.zip");
Files.createDirectories(backupFile.getParent());
FileUtils.zip(Path.of(unpackedBackup.toURI()), backupFile);
var workdir = tempDir.resolve("workdir-for-restoration");
Files.createDirectory(workdir);
var expectStore = createExtensionStore("fake-extension-store", "fake-data");
expectStore.setVersion(null);
when(haloProperties.getWorkDir()).thenReturn(workdir);
when(repository.deleteAll(List.of(expectStore))).thenReturn(Mono.empty());
when(repository.saveAll(List.of(expectStore))).thenReturn(Flux.empty());
var content = DataBufferUtils.read(backupFile,
DefaultDataBufferFactory.sharedInstance,
2048,
StandardOpenOption.READ);
StepVerifier.create(migrationService.restore(content))
.verifyComplete();
verify(haloProperties).getWorkDir();
verify(repository).deleteAll(List.of(expectStore));
verify(repository).saveAll(List.of(expectStore));
// make sure the workdir is recovered.
var fakeFile = workdir.resolve("fake-file");
assertEquals("halo", Files.readString(fakeFile));
}
@Test
void cleanupBackupTest() throws IOException {
var backup = createSucceededBackup("fake-backup", "backup.zip");
var backupFile = tempDir.resolve("workdir").resolve("backups").resolve("backup.zip");
Files.createDirectories(backupFile.getParent());
Files.createFile(backupFile);
when(haloProperties.getWorkDir()).thenReturn(tempDir.resolve("workdir"));
StepVerifier.create(migrationService.cleanup(backup))
.verifyComplete();
verify(haloProperties).getWorkDir();
assertTrue(Files.notExists(backupFile));
}
@Test
void downloadBackupTest() throws IOException {
var backup = createSucceededBackup("fake-backup", "backup.zip");
var backupFile = tempDir.resolve("workdir").resolve("backups").resolve("backup.zip");
Files.createDirectories(backupFile.getParent());
Files.writeString(backupFile, "this is a backup file.", StandardOpenOption.CREATE_NEW);
when(haloProperties.getWorkDir()).thenReturn(tempDir.resolve("workdir"));
StepVerifier.create(migrationService.download(backup))
.assertNext(resource -> {
assertEquals("backup.zip", resource.getFilename());
try {
var content = resource.getContentAsString(UTF_8);
assertEquals("this is a backup file.", content);
} catch (IOException e) {
throw new RuntimeException(e);
}
})
.verifyComplete();
}
Backup createSucceededBackup(String name, String filename) {
var metadata = new Metadata();
metadata.setName(name);
var backup = new Backup();
backup.setMetadata(metadata);
var status = backup.getStatus();
status.setPhase(Backup.Phase.SUCCEEDED);
status.setCompletionTimestamp(Instant.now());
status.setFilename(filename);
status.setSize(1024L);
return backup;
}
Backup createRunningBackup(String name, Instant startTimestamp) {
var metadata = new Metadata();
metadata.setName(name);
var backup = new Backup();
backup.setMetadata(metadata);
var status = backup.getStatus();
status.setPhase(Backup.Phase.RUNNING);
status.setStartTimestamp(startTimestamp);
return backup;
}
ExtensionStore createExtensionStore(String name, String data) {
var store = new ExtensionStore();
store.setName(name);
store.setData(data.getBytes(UTF_8));
store.setVersion(1024L);
return store;
}
}

View File

@ -0,0 +1 @@
[{"name":"fake-extension-store","data":"ZmFrZS1kYXRh","version":1024}]

View File

@ -0,0 +1,43 @@
# 备份页面选项卡扩展点
## 原由
在 Halo 2.8 中提供了基础备份和恢复的功能,此扩展点是为了提供给插件开发者针对备份扩展更多功能,比如定时备份设置、备份到第三方云存储等。
## 定义方式
```ts
import { definePlugin } from "@halo-dev/console-shared";
import BackupStorage from "@/views/BackupStorage.vue";
import { markRaw } from "vue";
export default definePlugin({
components: {},
routes: [],
extensionPoints: {
"backup:tabs:create": () => {
return [
{
id: "storage",
label: "备份位置",
component: markRaw(BackupStorage),
},
];
},
},
});
```
BackupTab 类型:
```ts
import type { Component, Raw } from "vue";
export interface BackupTab {
id: string;
label: string;
component: Raw<Component>;
permissions?: string[];
}
```

View File

@ -12,6 +12,7 @@ api/api-console-halo-run-v1alpha1-single-page-api.ts
api/api-console-halo-run-v1alpha1-stats-api.ts
api/api-console-halo-run-v1alpha1-theme-api.ts
api/api-console-halo-run-v1alpha1-user-api.ts
api/api-console-migration-halo-run-v1alpha1-migration-api.ts
api/api-content-halo-run-v1alpha1-category-api.ts
api/api-content-halo-run-v1alpha1-post-api.ts
api/api-content-halo-run-v1alpha1-single-page-api.ts
@ -34,6 +35,7 @@ api/content-halo-run-v1alpha1-snapshot-api.ts
api/content-halo-run-v1alpha1-tag-api.ts
api/login-api.ts
api/metrics-halo-run-v1alpha1-counter-api.ts
api/migration-halo-run-v1alpha1-backup-api.ts
api/plugin-halo-run-v1alpha1-extension-definition-api.ts
api/plugin-halo-run-v1alpha1-extension-point-definition-api.ts
api/plugin-halo-run-v1alpha1-plugin-api.ts
@ -71,6 +73,10 @@ models/auth-provider-list.ts
models/auth-provider-spec.ts
models/auth-provider.ts
models/author.ts
models/backup-list.ts
models/backup-spec.ts
models/backup-status.ts
models/backup.ts
models/category-list.ts
models/category-spec.ts
models/category-status.ts

View File

@ -23,6 +23,7 @@ export * from "./api/api-console-halo-run-v1alpha1-single-page-api";
export * from "./api/api-console-halo-run-v1alpha1-stats-api";
export * from "./api/api-console-halo-run-v1alpha1-theme-api";
export * from "./api/api-console-halo-run-v1alpha1-user-api";
export * from "./api/api-console-migration-halo-run-v1alpha1-migration-api";
export * from "./api/api-content-halo-run-v1alpha1-category-api";
export * from "./api/api-content-halo-run-v1alpha1-post-api";
export * from "./api/api-content-halo-run-v1alpha1-single-page-api";
@ -45,6 +46,7 @@ export * from "./api/content-halo-run-v1alpha1-snapshot-api";
export * from "./api/content-halo-run-v1alpha1-tag-api";
export * from "./api/login-api";
export * from "./api/metrics-halo-run-v1alpha1-counter-api";
export * from "./api/migration-halo-run-v1alpha1-backup-api";
export * from "./api/plugin-halo-run-v1alpha1-extension-definition-api";
export * from "./api/plugin-halo-run-v1alpha1-extension-point-definition-api";
export * from "./api/plugin-halo-run-v1alpha1-plugin-api";

View File

@ -0,0 +1,210 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "../configuration";
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
import globalAxios from "axios";
// Some imports not used depending on template conditions
// @ts-ignore
import {
DUMMY_BASE_URL,
assertParamExists,
setApiKeyToObject,
setBasicAuthToObject,
setBearerAuthToObject,
setOAuthToObject,
setSearchParams,
serializeDataIfNeeded,
toPathString,
createRequestFunction,
} from "../common";
// @ts-ignore
import {
BASE_PATH,
COLLECTION_FORMATS,
RequestArgs,
BaseAPI,
RequiredError,
} from "../base";
/**
* ApiConsoleHaloRunV1alpha1RestorationApi - axios parameter creator
* @export
*/
export const ApiConsoleHaloRunV1alpha1RestorationApiAxiosParamCreator =
function (configuration?: Configuration) {
return {
/**
*
* @param {File} file
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreBackup: async (
file: File,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'file' is not null or undefined
assertParamExists("restoreBackup", "file", file);
const localVarPath = `/apis/api.console.halo.run/v1alpha1/restorations`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "POST",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
const localVarFormParams = new ((configuration &&
configuration.formDataCtor) ||
FormData)();
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
if (file !== undefined) {
localVarFormParams.append("file", file as any);
}
localVarHeaderParameter["Content-Type"] = "multipart/form-data";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = localVarFormParams;
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
};
};
/**
* ApiConsoleHaloRunV1alpha1RestorationApi - functional programming interface
* @export
*/
export const ApiConsoleHaloRunV1alpha1RestorationApiFp = function (
configuration?: Configuration
) {
const localVarAxiosParamCreator =
ApiConsoleHaloRunV1alpha1RestorationApiAxiosParamCreator(configuration);
return {
/**
*
* @param {File} file
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async restoreBackup(
file: File,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.restoreBackup(
file,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
/**
* ApiConsoleHaloRunV1alpha1RestorationApi - factory interface
* @export
*/
export const ApiConsoleHaloRunV1alpha1RestorationApiFactory = function (
configuration?: Configuration,
basePath?: string,
axios?: AxiosInstance
) {
const localVarFp = ApiConsoleHaloRunV1alpha1RestorationApiFp(configuration);
return {
/**
*
* @param {ApiConsoleHaloRunV1alpha1RestorationApiRestoreBackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreBackup(
requestParameters: ApiConsoleHaloRunV1alpha1RestorationApiRestoreBackupRequest,
options?: AxiosRequestConfig
): AxiosPromise<void> {
return localVarFp
.restoreBackup(requestParameters.file, options)
.then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for restoreBackup operation in ApiConsoleHaloRunV1alpha1RestorationApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1RestorationApiRestoreBackupRequest
*/
export interface ApiConsoleHaloRunV1alpha1RestorationApiRestoreBackupRequest {
/**
*
* @type {File}
* @memberof ApiConsoleHaloRunV1alpha1RestorationApiRestoreBackup
*/
readonly file: File;
}
/**
* ApiConsoleHaloRunV1alpha1RestorationApi - object-oriented interface
* @export
* @class ApiConsoleHaloRunV1alpha1RestorationApi
* @extends {BaseAPI}
*/
export class ApiConsoleHaloRunV1alpha1RestorationApi extends BaseAPI {
/**
*
* @param {ApiConsoleHaloRunV1alpha1RestorationApiRestoreBackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1RestorationApi
*/
public restoreBackup(
requestParameters: ApiConsoleHaloRunV1alpha1RestorationApiRestoreBackupRequest,
options?: AxiosRequestConfig
) {
return ApiConsoleHaloRunV1alpha1RestorationApiFp(this.configuration)
.restoreBackup(requestParameters.file, options)
.then((request) => request(this.axios, this.basePath));
}
}

View File

@ -0,0 +1,355 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "../configuration";
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
import globalAxios from "axios";
// Some imports not used depending on template conditions
// @ts-ignore
import {
DUMMY_BASE_URL,
assertParamExists,
setApiKeyToObject,
setBasicAuthToObject,
setBearerAuthToObject,
setOAuthToObject,
setSearchParams,
serializeDataIfNeeded,
toPathString,
createRequestFunction,
} from "../common";
// @ts-ignore
import {
BASE_PATH,
COLLECTION_FORMATS,
RequestArgs,
BaseAPI,
RequiredError,
} from "../base";
/**
* ApiConsoleMigrationHaloRunV1alpha1MigrationApi - axios parameter creator
* @export
*/
export const ApiConsoleMigrationHaloRunV1alpha1MigrationApiAxiosParamCreator =
function (configuration?: Configuration) {
return {
/**
*
* @param {string} name Backup name.
* @param {string} filename Backup filename.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadBackups: async (
name: string,
filename: string,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists("downloadBackups", "name", name);
// verify required parameter 'filename' is not null or undefined
assertParamExists("downloadBackups", "filename", filename);
const localVarPath =
`/apis/api.console.migration.halo.run/v1alpha1/backups/{name}/files/{filename}`
.replace(`{${"name"}}`, encodeURIComponent(String(name)))
.replace(`{${"filename"}}`, encodeURIComponent(String(filename)));
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "GET",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
*
* @param {File} file
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreBackup: async (
file: File,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'file' is not null or undefined
assertParamExists("restoreBackup", "file", file);
const localVarPath = `/apis/api.console.migration.halo.run/v1alpha1/restorations`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "POST",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
const localVarFormParams = new ((configuration &&
configuration.formDataCtor) ||
FormData)();
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
if (file !== undefined) {
localVarFormParams.append("file", file as any);
}
localVarHeaderParameter["Content-Type"] = "multipart/form-data";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = localVarFormParams;
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
};
};
/**
* ApiConsoleMigrationHaloRunV1alpha1MigrationApi - functional programming interface
* @export
*/
export const ApiConsoleMigrationHaloRunV1alpha1MigrationApiFp = function (
configuration?: Configuration
) {
const localVarAxiosParamCreator =
ApiConsoleMigrationHaloRunV1alpha1MigrationApiAxiosParamCreator(
configuration
);
return {
/**
*
* @param {string} name Backup name.
* @param {string} filename Backup filename.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async downloadBackups(
name: string,
filename: string,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.downloadBackups(
name,
filename,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
*
* @param {File} file
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async restoreBackup(
file: File,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.restoreBackup(
file,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
/**
* ApiConsoleMigrationHaloRunV1alpha1MigrationApi - factory interface
* @export
*/
export const ApiConsoleMigrationHaloRunV1alpha1MigrationApiFactory = function (
configuration?: Configuration,
basePath?: string,
axios?: AxiosInstance
) {
const localVarFp =
ApiConsoleMigrationHaloRunV1alpha1MigrationApiFp(configuration);
return {
/**
*
* @param {ApiConsoleMigrationHaloRunV1alpha1MigrationApiDownloadBackupsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
downloadBackups(
requestParameters: ApiConsoleMigrationHaloRunV1alpha1MigrationApiDownloadBackupsRequest,
options?: AxiosRequestConfig
): AxiosPromise<void> {
return localVarFp
.downloadBackups(
requestParameters.name,
requestParameters.filename,
options
)
.then((request) => request(axios, basePath));
},
/**
*
* @param {ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreBackup(
requestParameters: ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest,
options?: AxiosRequestConfig
): AxiosPromise<void> {
return localVarFp
.restoreBackup(requestParameters.file, options)
.then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for downloadBackups operation in ApiConsoleMigrationHaloRunV1alpha1MigrationApi.
* @export
* @interface ApiConsoleMigrationHaloRunV1alpha1MigrationApiDownloadBackupsRequest
*/
export interface ApiConsoleMigrationHaloRunV1alpha1MigrationApiDownloadBackupsRequest {
/**
* Backup name.
* @type {string}
* @memberof ApiConsoleMigrationHaloRunV1alpha1MigrationApiDownloadBackups
*/
readonly name: string;
/**
* Backup filename.
* @type {string}
* @memberof ApiConsoleMigrationHaloRunV1alpha1MigrationApiDownloadBackups
*/
readonly filename: string;
}
/**
* Request parameters for restoreBackup operation in ApiConsoleMigrationHaloRunV1alpha1MigrationApi.
* @export
* @interface ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest
*/
export interface ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest {
/**
*
* @type {File}
* @memberof ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackup
*/
readonly file: File;
}
/**
* ApiConsoleMigrationHaloRunV1alpha1MigrationApi - object-oriented interface
* @export
* @class ApiConsoleMigrationHaloRunV1alpha1MigrationApi
* @extends {BaseAPI}
*/
export class ApiConsoleMigrationHaloRunV1alpha1MigrationApi extends BaseAPI {
/**
*
* @param {ApiConsoleMigrationHaloRunV1alpha1MigrationApiDownloadBackupsRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleMigrationHaloRunV1alpha1MigrationApi
*/
public downloadBackups(
requestParameters: ApiConsoleMigrationHaloRunV1alpha1MigrationApiDownloadBackupsRequest,
options?: AxiosRequestConfig
) {
return ApiConsoleMigrationHaloRunV1alpha1MigrationApiFp(this.configuration)
.downloadBackups(
requestParameters.name,
requestParameters.filename,
options
)
.then((request) => request(this.axios, this.basePath));
}
/**
*
* @param {ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleMigrationHaloRunV1alpha1MigrationApi
*/
public restoreBackup(
requestParameters: ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest,
options?: AxiosRequestConfig
) {
return ApiConsoleMigrationHaloRunV1alpha1MigrationApiFp(this.configuration)
.restoreBackup(requestParameters.file, options)
.then((request) => request(this.axios, this.basePath));
}
}

View File

@ -0,0 +1,802 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
import type { Configuration } from "../configuration";
import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios";
import globalAxios from "axios";
// Some imports not used depending on template conditions
// @ts-ignore
import {
DUMMY_BASE_URL,
assertParamExists,
setApiKeyToObject,
setBasicAuthToObject,
setBearerAuthToObject,
setOAuthToObject,
setSearchParams,
serializeDataIfNeeded,
toPathString,
createRequestFunction,
} from "../common";
// @ts-ignore
import {
BASE_PATH,
COLLECTION_FORMATS,
RequestArgs,
BaseAPI,
RequiredError,
} from "../base";
// @ts-ignore
import { Backup } from "../models";
// @ts-ignore
import { BackupList } from "../models";
/**
* MigrationHaloRunV1alpha1BackupApi - axios parameter creator
* @export
*/
export const MigrationHaloRunV1alpha1BackupApiAxiosParamCreator = function (
configuration?: Configuration
) {
return {
/**
* Create migration.halo.run/v1alpha1/Backup
* @param {Backup} [backup] Fresh backup
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createmigrationHaloRunV1alpha1Backup: async (
backup?: Backup,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/apis/migration.halo.run/v1alpha1/backups`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "POST",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
localVarHeaderParameter["Content-Type"] = "application/json";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = serializeDataIfNeeded(
backup,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Delete migration.halo.run/v1alpha1/Backup
* @param {string} name Name of backup
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deletemigrationHaloRunV1alpha1Backup: async (
name: string,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists("deletemigrationHaloRunV1alpha1Backup", "name", name);
const localVarPath =
`/apis/migration.halo.run/v1alpha1/backups/{name}`.replace(
`{${"name"}}`,
encodeURIComponent(String(name))
);
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "DELETE",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Get migration.halo.run/v1alpha1/Backup
* @param {string} name Name of backup
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getmigrationHaloRunV1alpha1Backup: async (
name: string,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists("getmigrationHaloRunV1alpha1Backup", "name", name);
const localVarPath =
`/apis/migration.halo.run/v1alpha1/backups/{name}`.replace(
`{${"name"}}`,
encodeURIComponent(String(name))
);
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "GET",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* List migration.halo.run/v1alpha1/Backup
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Support sorting based on attribute name path.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listmigrationHaloRunV1alpha1Backup: async (
fieldSelector?: Array<string>,
labelSelector?: Array<string>,
page?: number,
size?: number,
sort?: Array<string>,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/apis/migration.halo.run/v1alpha1/backups`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "GET",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
if (fieldSelector) {
localVarQueryParameter["fieldSelector"] = fieldSelector;
}
if (labelSelector) {
localVarQueryParameter["labelSelector"] = labelSelector;
}
if (page !== undefined) {
localVarQueryParameter["page"] = page;
}
if (size !== undefined) {
localVarQueryParameter["size"] = size;
}
if (sort) {
localVarQueryParameter["sort"] = Array.from(sort);
}
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
/**
* Update migration.halo.run/v1alpha1/Backup
* @param {string} name Name of backup
* @param {Backup} [backup] Updated backup
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatemigrationHaloRunV1alpha1Backup: async (
name: string,
backup?: Backup,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'name' is not null or undefined
assertParamExists("updatemigrationHaloRunV1alpha1Backup", "name", name);
const localVarPath =
`/apis/migration.halo.run/v1alpha1/backups/{name}`.replace(
`{${"name"}}`,
encodeURIComponent(String(name))
);
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
let baseOptions;
if (configuration) {
baseOptions = configuration.baseOptions;
}
const localVarRequestOptions = {
method: "PUT",
...baseOptions,
...options,
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
// authentication BasicAuth required
// http basic authentication required
setBasicAuthToObject(localVarRequestOptions, configuration);
// authentication BearerAuth required
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
localVarHeaderParameter["Content-Type"] = "application/json";
setSearchParams(localVarUrlObj, localVarQueryParameter);
let headersFromBaseOptions =
baseOptions && baseOptions.headers ? baseOptions.headers : {};
localVarRequestOptions.headers = {
...localVarHeaderParameter,
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = serializeDataIfNeeded(
backup,
localVarRequestOptions,
configuration
);
return {
url: toPathString(localVarUrlObj),
options: localVarRequestOptions,
};
},
};
};
/**
* MigrationHaloRunV1alpha1BackupApi - functional programming interface
* @export
*/
export const MigrationHaloRunV1alpha1BackupApiFp = function (
configuration?: Configuration
) {
const localVarAxiosParamCreator =
MigrationHaloRunV1alpha1BackupApiAxiosParamCreator(configuration);
return {
/**
* Create migration.halo.run/v1alpha1/Backup
* @param {Backup} [backup] Fresh backup
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async createmigrationHaloRunV1alpha1Backup(
backup?: Backup,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Backup>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.createmigrationHaloRunV1alpha1Backup(
backup,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
* Delete migration.halo.run/v1alpha1/Backup
* @param {string} name Name of backup
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async deletemigrationHaloRunV1alpha1Backup(
name: string,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.deletemigrationHaloRunV1alpha1Backup(
name,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
* Get migration.halo.run/v1alpha1/Backup
* @param {string} name Name of backup
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async getmigrationHaloRunV1alpha1Backup(
name: string,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Backup>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.getmigrationHaloRunV1alpha1Backup(
name,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
* List migration.halo.run/v1alpha1/Backup
* @param {Array<string>} [fieldSelector] Field selector for filtering.
* @param {Array<string>} [labelSelector] Label selector for filtering.
* @param {number} [page] The page number. Zero indicates no page.
* @param {number} [size] Size of one page. Zero indicates no limit.
* @param {Array<string>} [sort] Sort property and direction of the list result. Support sorting based on attribute name path.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async listmigrationHaloRunV1alpha1Backup(
fieldSelector?: Array<string>,
labelSelector?: Array<string>,
page?: number,
size?: number,
sort?: Array<string>,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<BackupList>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.listmigrationHaloRunV1alpha1Backup(
fieldSelector,
labelSelector,
page,
size,
sort,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
* Update migration.halo.run/v1alpha1/Backup
* @param {string} name Name of backup
* @param {Backup} [backup] Updated backup
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async updatemigrationHaloRunV1alpha1Backup(
name: string,
backup?: Backup,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Backup>
> {
const localVarAxiosArgs =
await localVarAxiosParamCreator.updatemigrationHaloRunV1alpha1Backup(
name,
backup,
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
};
};
/**
* MigrationHaloRunV1alpha1BackupApi - factory interface
* @export
*/
export const MigrationHaloRunV1alpha1BackupApiFactory = function (
configuration?: Configuration,
basePath?: string,
axios?: AxiosInstance
) {
const localVarFp = MigrationHaloRunV1alpha1BackupApiFp(configuration);
return {
/**
* Create migration.halo.run/v1alpha1/Backup
* @param {MigrationHaloRunV1alpha1BackupApiCreatemigrationHaloRunV1alpha1BackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
createmigrationHaloRunV1alpha1Backup(
requestParameters: MigrationHaloRunV1alpha1BackupApiCreatemigrationHaloRunV1alpha1BackupRequest = {},
options?: AxiosRequestConfig
): AxiosPromise<Backup> {
return localVarFp
.createmigrationHaloRunV1alpha1Backup(requestParameters.backup, options)
.then((request) => request(axios, basePath));
},
/**
* Delete migration.halo.run/v1alpha1/Backup
* @param {MigrationHaloRunV1alpha1BackupApiDeletemigrationHaloRunV1alpha1BackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
deletemigrationHaloRunV1alpha1Backup(
requestParameters: MigrationHaloRunV1alpha1BackupApiDeletemigrationHaloRunV1alpha1BackupRequest,
options?: AxiosRequestConfig
): AxiosPromise<void> {
return localVarFp
.deletemigrationHaloRunV1alpha1Backup(requestParameters.name, options)
.then((request) => request(axios, basePath));
},
/**
* Get migration.halo.run/v1alpha1/Backup
* @param {MigrationHaloRunV1alpha1BackupApiGetmigrationHaloRunV1alpha1BackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
getmigrationHaloRunV1alpha1Backup(
requestParameters: MigrationHaloRunV1alpha1BackupApiGetmigrationHaloRunV1alpha1BackupRequest,
options?: AxiosRequestConfig
): AxiosPromise<Backup> {
return localVarFp
.getmigrationHaloRunV1alpha1Backup(requestParameters.name, options)
.then((request) => request(axios, basePath));
},
/**
* List migration.halo.run/v1alpha1/Backup
* @param {MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1BackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
listmigrationHaloRunV1alpha1Backup(
requestParameters: MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1BackupRequest = {},
options?: AxiosRequestConfig
): AxiosPromise<BackupList> {
return localVarFp
.listmigrationHaloRunV1alpha1Backup(
requestParameters.fieldSelector,
requestParameters.labelSelector,
requestParameters.page,
requestParameters.size,
requestParameters.sort,
options
)
.then((request) => request(axios, basePath));
},
/**
* Update migration.halo.run/v1alpha1/Backup
* @param {MigrationHaloRunV1alpha1BackupApiUpdatemigrationHaloRunV1alpha1BackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
updatemigrationHaloRunV1alpha1Backup(
requestParameters: MigrationHaloRunV1alpha1BackupApiUpdatemigrationHaloRunV1alpha1BackupRequest,
options?: AxiosRequestConfig
): AxiosPromise<Backup> {
return localVarFp
.updatemigrationHaloRunV1alpha1Backup(
requestParameters.name,
requestParameters.backup,
options
)
.then((request) => request(axios, basePath));
},
};
};
/**
* Request parameters for createmigrationHaloRunV1alpha1Backup operation in MigrationHaloRunV1alpha1BackupApi.
* @export
* @interface MigrationHaloRunV1alpha1BackupApiCreatemigrationHaloRunV1alpha1BackupRequest
*/
export interface MigrationHaloRunV1alpha1BackupApiCreatemigrationHaloRunV1alpha1BackupRequest {
/**
* Fresh backup
* @type {Backup}
* @memberof MigrationHaloRunV1alpha1BackupApiCreatemigrationHaloRunV1alpha1Backup
*/
readonly backup?: Backup;
}
/**
* Request parameters for deletemigrationHaloRunV1alpha1Backup operation in MigrationHaloRunV1alpha1BackupApi.
* @export
* @interface MigrationHaloRunV1alpha1BackupApiDeletemigrationHaloRunV1alpha1BackupRequest
*/
export interface MigrationHaloRunV1alpha1BackupApiDeletemigrationHaloRunV1alpha1BackupRequest {
/**
* Name of backup
* @type {string}
* @memberof MigrationHaloRunV1alpha1BackupApiDeletemigrationHaloRunV1alpha1Backup
*/
readonly name: string;
}
/**
* Request parameters for getmigrationHaloRunV1alpha1Backup operation in MigrationHaloRunV1alpha1BackupApi.
* @export
* @interface MigrationHaloRunV1alpha1BackupApiGetmigrationHaloRunV1alpha1BackupRequest
*/
export interface MigrationHaloRunV1alpha1BackupApiGetmigrationHaloRunV1alpha1BackupRequest {
/**
* Name of backup
* @type {string}
* @memberof MigrationHaloRunV1alpha1BackupApiGetmigrationHaloRunV1alpha1Backup
*/
readonly name: string;
}
/**
* Request parameters for listmigrationHaloRunV1alpha1Backup operation in MigrationHaloRunV1alpha1BackupApi.
* @export
* @interface MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1BackupRequest
*/
export interface MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1BackupRequest {
/**
* Field selector for filtering.
* @type {Array<string>}
* @memberof MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1Backup
*/
readonly fieldSelector?: Array<string>;
/**
* Label selector for filtering.
* @type {Array<string>}
* @memberof MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1Backup
*/
readonly labelSelector?: Array<string>;
/**
* The page number. Zero indicates no page.
* @type {number}
* @memberof MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1Backup
*/
readonly page?: number;
/**
* Size of one page. Zero indicates no limit.
* @type {number}
* @memberof MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1Backup
*/
readonly size?: number;
/**
* Sort property and direction of the list result. Support sorting based on attribute name path.
* @type {Array<string>}
* @memberof MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1Backup
*/
readonly sort?: Array<string>;
}
/**
* Request parameters for updatemigrationHaloRunV1alpha1Backup operation in MigrationHaloRunV1alpha1BackupApi.
* @export
* @interface MigrationHaloRunV1alpha1BackupApiUpdatemigrationHaloRunV1alpha1BackupRequest
*/
export interface MigrationHaloRunV1alpha1BackupApiUpdatemigrationHaloRunV1alpha1BackupRequest {
/**
* Name of backup
* @type {string}
* @memberof MigrationHaloRunV1alpha1BackupApiUpdatemigrationHaloRunV1alpha1Backup
*/
readonly name: string;
/**
* Updated backup
* @type {Backup}
* @memberof MigrationHaloRunV1alpha1BackupApiUpdatemigrationHaloRunV1alpha1Backup
*/
readonly backup?: Backup;
}
/**
* MigrationHaloRunV1alpha1BackupApi - object-oriented interface
* @export
* @class MigrationHaloRunV1alpha1BackupApi
* @extends {BaseAPI}
*/
export class MigrationHaloRunV1alpha1BackupApi extends BaseAPI {
/**
* Create migration.halo.run/v1alpha1/Backup
* @param {MigrationHaloRunV1alpha1BackupApiCreatemigrationHaloRunV1alpha1BackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof MigrationHaloRunV1alpha1BackupApi
*/
public createmigrationHaloRunV1alpha1Backup(
requestParameters: MigrationHaloRunV1alpha1BackupApiCreatemigrationHaloRunV1alpha1BackupRequest = {},
options?: AxiosRequestConfig
) {
return MigrationHaloRunV1alpha1BackupApiFp(this.configuration)
.createmigrationHaloRunV1alpha1Backup(requestParameters.backup, options)
.then((request) => request(this.axios, this.basePath));
}
/**
* Delete migration.halo.run/v1alpha1/Backup
* @param {MigrationHaloRunV1alpha1BackupApiDeletemigrationHaloRunV1alpha1BackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof MigrationHaloRunV1alpha1BackupApi
*/
public deletemigrationHaloRunV1alpha1Backup(
requestParameters: MigrationHaloRunV1alpha1BackupApiDeletemigrationHaloRunV1alpha1BackupRequest,
options?: AxiosRequestConfig
) {
return MigrationHaloRunV1alpha1BackupApiFp(this.configuration)
.deletemigrationHaloRunV1alpha1Backup(requestParameters.name, options)
.then((request) => request(this.axios, this.basePath));
}
/**
* Get migration.halo.run/v1alpha1/Backup
* @param {MigrationHaloRunV1alpha1BackupApiGetmigrationHaloRunV1alpha1BackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof MigrationHaloRunV1alpha1BackupApi
*/
public getmigrationHaloRunV1alpha1Backup(
requestParameters: MigrationHaloRunV1alpha1BackupApiGetmigrationHaloRunV1alpha1BackupRequest,
options?: AxiosRequestConfig
) {
return MigrationHaloRunV1alpha1BackupApiFp(this.configuration)
.getmigrationHaloRunV1alpha1Backup(requestParameters.name, options)
.then((request) => request(this.axios, this.basePath));
}
/**
* List migration.halo.run/v1alpha1/Backup
* @param {MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1BackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof MigrationHaloRunV1alpha1BackupApi
*/
public listmigrationHaloRunV1alpha1Backup(
requestParameters: MigrationHaloRunV1alpha1BackupApiListmigrationHaloRunV1alpha1BackupRequest = {},
options?: AxiosRequestConfig
) {
return MigrationHaloRunV1alpha1BackupApiFp(this.configuration)
.listmigrationHaloRunV1alpha1Backup(
requestParameters.fieldSelector,
requestParameters.labelSelector,
requestParameters.page,
requestParameters.size,
requestParameters.sort,
options
)
.then((request) => request(this.axios, this.basePath));
}
/**
* Update migration.halo.run/v1alpha1/Backup
* @param {MigrationHaloRunV1alpha1BackupApiUpdatemigrationHaloRunV1alpha1BackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof MigrationHaloRunV1alpha1BackupApi
*/
public updatemigrationHaloRunV1alpha1Backup(
requestParameters: MigrationHaloRunV1alpha1BackupApiUpdatemigrationHaloRunV1alpha1BackupRequest,
options?: AxiosRequestConfig
) {
return MigrationHaloRunV1alpha1BackupApiFp(this.configuration)
.updatemigrationHaloRunV1alpha1Backup(
requestParameters.name,
requestParameters.backup,
options
)
.then((request) => request(this.axios, this.basePath));
}
}

View File

@ -0,0 +1,79 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import { Backup } from "./backup";
/**
*
* @export
* @interface BackupList
*/
export interface BackupList {
/**
* Indicates whether current page is the first page.
* @type {boolean}
* @memberof BackupList
*/
first: boolean;
/**
* Indicates whether current page has previous page.
* @type {boolean}
* @memberof BackupList
*/
hasNext: boolean;
/**
* Indicates whether current page has previous page.
* @type {boolean}
* @memberof BackupList
*/
hasPrevious: boolean;
/**
* A chunk of items.
* @type {Array<Backup>}
* @memberof BackupList
*/
items: Array<Backup>;
/**
* Indicates whether current page is the last page.
* @type {boolean}
* @memberof BackupList
*/
last: boolean;
/**
* Page number, starts from 1. If not set or equal to 0, it means no pagination.
* @type {number}
* @memberof BackupList
*/
page: number;
/**
* Size of each page. If not set or equal to 0, it means no pagination.
* @type {number}
* @memberof BackupList
*/
size: number;
/**
* Total elements.
* @type {number}
* @memberof BackupList
*/
total: number;
/**
* Indicates total pages.
* @type {number}
* @memberof BackupList
*/
totalPages: number;
}

View File

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface BackupSpec
*/
export interface BackupSpec {
/**
*
* @type {string}
* @memberof BackupSpec
*/
expiresAt?: string;
/**
* Backup file format. Currently, only zip format is supported.
* @type {string}
* @memberof BackupSpec
*/
format?: string;
}

View File

@ -0,0 +1,73 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface BackupStatus
*/
export interface BackupStatus {
/**
*
* @type {string}
* @memberof BackupStatus
*/
completionTimestamp?: string;
/**
*
* @type {string}
* @memberof BackupStatus
*/
failureMessage?: string;
/**
*
* @type {string}
* @memberof BackupStatus
*/
failureReason?: string;
/**
*
* @type {string}
* @memberof BackupStatus
*/
filename?: string;
/**
*
* @type {string}
* @memberof BackupStatus
*/
phase?: BackupStatusPhaseEnum;
/**
*
* @type {number}
* @memberof BackupStatus
*/
size?: number;
/**
*
* @type {string}
* @memberof BackupStatus
*/
startTimestamp?: string;
}
export const BackupStatusPhaseEnum = {
Pending: "PENDING",
Running: "RUNNING",
Succeeded: "SUCCEEDED",
Failed: "FAILED",
} as const;
export type BackupStatusPhaseEnum =
(typeof BackupStatusPhaseEnum)[keyof typeof BackupStatusPhaseEnum];

View File

@ -0,0 +1,61 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
// May contain unused imports in some cases
// @ts-ignore
import { BackupSpec } from "./backup-spec";
// May contain unused imports in some cases
// @ts-ignore
import { BackupStatus } from "./backup-status";
// May contain unused imports in some cases
// @ts-ignore
import { Metadata } from "./metadata";
/**
*
* @export
* @interface Backup
*/
export interface Backup {
/**
*
* @type {string}
* @memberof Backup
*/
apiVersion: string;
/**
*
* @type {string}
* @memberof Backup
*/
kind: string;
/**
*
* @type {Metadata}
* @memberof Backup
*/
metadata: Metadata;
/**
*
* @type {BackupSpec}
* @memberof Backup
*/
spec?: BackupSpec;
/**
*
* @type {BackupStatus}
* @memberof Backup
*/
status?: BackupStatus;
}

View File

@ -9,6 +9,10 @@ export * from "./auth-provider";
export * from "./auth-provider-list";
export * from "./auth-provider-spec";
export * from "./author";
export * from "./backup";
export * from "./backup-list";
export * from "./backup-spec";
export * from "./backup-status";
export * from "./category";
export * from "./category-list";
export * from "./category-spec";

View File

@ -0,0 +1,33 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface Spec
*/
export interface Spec {
/**
*
* @type {string}
* @memberof Spec
*/
autoDeleteWhen?: string;
/**
*
* @type {string}
* @memberof Spec
*/
format?: string;
}

View File

@ -0,0 +1,73 @@
/* tslint:disable */
/* eslint-disable */
/**
* Halo Next API
* No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator)
*
* The version of the OpenAPI document: 2.0.0
*
*
* NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
* https://openapi-generator.tech
* Do not edit the class manually.
*/
/**
*
* @export
* @interface Status
*/
export interface Status {
/**
*
* @type {string}
* @memberof Status
*/
completionTimestamp?: string;
/**
*
* @type {string}
* @memberof Status
*/
failureMessage?: string;
/**
*
* @type {string}
* @memberof Status
*/
failureReason?: string;
/**
*
* @type {string}
* @memberof Status
*/
filename?: string;
/**
*
* @type {string}
* @memberof Status
*/
phase?: StatusPhaseEnum;
/**
*
* @type {number}
* @memberof Status
*/
size?: number;
/**
*
* @type {string}
* @memberof Status
*/
startTimestamp?: string;
}
export const StatusPhaseEnum = {
Pending: "PENDING",
Running: "RUNNING",
Succeeded: "SUCCEEDED",
Failed: "FAILED",
} as const;
export type StatusPhaseEnum =
(typeof StatusPhaseEnum)[keyof typeof StatusPhaseEnum];

View File

@ -183,7 +183,7 @@ watch(
justify-center
top-0
py-10;
z-index: 999;
z-index: 2000;
.modal-layer {
@apply flex-none

View File

@ -59,6 +59,7 @@ import IconArrowDownCircleLine from "~icons/ri/arrow-down-circle-line";
import IconTerminalBoxLine from "~icons/ri/terminal-box-line";
import IconClipboardLine from "~icons/ri/clipboard-line";
import IconLockPasswordLine from "~icons/ri/lock-password-line";
import IconServerLine from "~icons/ri/server-line";
import IconRiPencilFill from "~icons/ri/pencil-fill";
import IconZoomInLine from "~icons/ri/zoom-in-line";
import IconZoomOutLine from "~icons/ri/zoom-out-line";
@ -128,6 +129,7 @@ export {
IconTerminalBoxLine,
IconClipboardLine,
IconLockPasswordLine,
IconServerLine,
IconRiPencilFill,
IconZoomInLine,
IconZoomOutLine,

View File

@ -6,3 +6,4 @@ export * from "./states/attachment-selector";
export * from "./states/editor";
export * from "./states/plugin-tab";
export * from "./states/comment-subject-ref";
export * from "./states/backup";

View File

@ -0,0 +1,8 @@
import type { Component, Raw } from "vue";
export interface BackupTab {
id: string;
label: string;
component: Raw<Component>;
permissions?: string[];
}

View File

@ -5,6 +5,7 @@ import type { AttachmentSelectProvider } from "../states/attachment-selector";
import type { EditorProvider, PluginTab } from "..";
import type { AnyExtension } from "@tiptap/vue-3";
import type { CommentSubjectRefProvider } from "@/states/comment-subject-ref";
import type { BackupTab } from "@/states/backup";
export interface RouteRecordAppend {
parentName: RouteRecordName;
@ -23,11 +24,13 @@ export interface ExtensionPoint {
"plugin:self:tabs:create"?: () => PluginTab[] | Promise<PluginTab[]>;
"default:editor:extension:create": () =>
"default:editor:extension:create"?: () =>
| AnyExtension[]
| Promise<AnyExtension[]>;
"comment:subject-ref:create"?: () => CommentSubjectRefProvider[];
"backup:tabs:create"?: () => BackupTab[] | Promise<BackupTab[]>;
}
export interface PluginModule {

View File

@ -24,6 +24,8 @@ const props = withDefaults(
note?: string;
method?: "GET" | "POST" | "PUT" | "HEAD" | "get" | "post" | "put" | "head";
disabled?: boolean;
width?: string;
height?: string;
doneButtonHandler?: () => void;
}>(),
{
@ -35,6 +37,8 @@ const props = withDefaults(
note: undefined,
method: "post",
disabled: false,
width: "750px",
height: "550px",
doneButtonHandler: undefined,
}
);
@ -97,11 +101,14 @@ onUnmounted(() => {
<template>
<dashboard
class="w-full"
:uppy="uppy"
:props="{
theme: 'light',
disabled: disabled,
note: note,
width,
height,
doneButtonHandler: doneButtonHandler,
}"
/>

View File

@ -69,6 +69,7 @@ core:
users: Users
settings: Settings
actuator: Actuator
backup: Backup
operations:
logout:
button: Logout
@ -975,6 +976,43 @@ core:
os: "Operating system: {os}"
alert:
external_url_invalid: The external access url detected is inconsistent with the current access url, which may cause some links to fail to redirect properly. Please check the external access url settings.
backup:
title: Backup and Restore
tabs:
backup_list: Backups
restore: Restore
empty:
title: No backups have been created yet
message: You can click the button below to create a backup
actions:
create: Create backup
operations:
create:
button: Create backup
title: Create backup
description: Are you sure you want to create a backup? This operation may last for a long time.
toast_success: Requested to create a backup
delete:
title: Delete the backup
description: Are you sure you want to delete the backup?
restore:
title: Restore successfully
description: After successful restore, you need to restart Halo to load the system resources normally. After clicking OK, we will automatically stop running Halo.
shutdown:
toast_success: Requested to shutdown operation
list:
phases:
pending: Pending
running: Running
succeeded: Succeeded
failed: Failed
fields:
expiresAt: Expires {expiresAt}
restore:
tips:
first: 1. The restore process may last for a long time, please do not refresh the page during this period.
second: 2. During the restore process, although the existing data will not be cleaned up, if there is a conflict, the data will be overwritten.
third: 3. After the restore is completed, you will be prompted to stop running Halo, and you may need to run it manually after stopping.
exception:
not_found:
message: Page not found
@ -1047,6 +1085,8 @@ core:
Users Management: Users
User manage: User Manage
User View: User View
Migration Management: Backup and Restore
Migration Manage: Backup and Restore Manage
role-template-view-users: User View
role-template-change-password: Change Password
components:

View File

@ -69,6 +69,7 @@ core:
users: 用户
settings: 设置
actuator: 概览
backup: 备份
operations:
logout:
button: 退出登录
@ -975,6 +976,43 @@ core:
os: 操作系统:{os}
alert:
external_url_invalid: 检测到外部访问地址与当前访问地址不一致,可能会导致部分链接无法正常跳转,请检查外部访问地址设置。
backup:
title: 备份与恢复
tabs:
backup_list: 备份
restore: 恢复
empty:
title: 没有备份
message: 当前没有已创建的备份,你可以点击刷新或者创建新的备份
actions:
create: 创建备份
operations:
create:
button: 创建备份
title: 创建备份
description: 确定要创建备份吗,此操作可能会持续较长时间。
toast_success: 已请求创建备份
delete:
title: 删除备份
description: 确定要删除该备份吗?
restore:
title: 恢复成功
description: 恢复成功之后,需要重启一下 Halo 才能够正常加载系统资源,点击确定之后我们会自动停止运行 Halo
shutdown:
toast_success: 已请求停止运行
list:
phases:
pending: 准备中
running: 备份中
succeeded: 备份完成
failed: 备份失败
fields:
expiresAt: "{expiresAt}失效"
restore:
tips:
first: 1. 恢复过程可能会持续较长时间,期间请勿刷新页面。
second: 2. 在恢复的过程中,虽然已有的数据不会被清理掉,但如果有冲突的数据将被覆盖。
third: 3. 恢复完成之后会提示停止运行 Halo,停止之后可能需要手动运行。
exception:
not_found:
message: 没有找到该页面
@ -1047,6 +1085,8 @@ core:
Users Management: 用户
User manage: 用户管理
User View: 用户查看
Migration Management: 备份与恢复
Migration Manage: 备份与恢复管理
role-template-view-users: 用户查看
role-template-change-password: 修改密码
components:

View File

@ -69,6 +69,7 @@ core:
users: 用戶
settings: 設置
actuator: 概覽
backup: 備份
operations:
logout:
button: 登出
@ -975,6 +976,43 @@ core:
os: 操作系統:{os}
alert:
external_url_invalid: 檢測到外部訪問地址與當前訪問地址不一致,可能會導致部分連結無法正常跳轉,請檢查外部訪問地址設置。
backup:
title: 備份與還原
tabs:
backup_list: 備份
restore: 還原
empty:
title: 沒有備份
message: 目前沒有已建立的備份,您可以點擊重新整理或建立新的備份。
actions:
create: 建立備份
operations:
create:
button: 建立備份
title: 建立備份
description: 確定要建立備份嗎?此操作可能需要較長時間。
toast_success: 已請求建立備份
delete:
title: 刪除備份
description: 確定要刪除此備份嗎?
restore:
title: 還原成功
description: 還原成功後,需要重新啟動 Halo 才能正常載入系統資源,點擊確定後我們會自動停止運行 Halo。
shutdown:
toast_success: 已請求停止運行
list:
phases:
pending: 準備中
running: 備份中
succeeded: 備份完成
failed: 備份失敗
fields:
expiresAt: "{expiresAt}失效"
restore:
tips:
first: 1. 還原過程可能需要較長時間,期間請勿重新整理頁面。
second: 2. 在還原過程中,雖然已有的資料不會被清除,但若有衝突的資料將被覆蓋。
third: 3. 還原完成後會提示停止運行 Halo,停止後可能需要手動啟動。
exception:
not_found:
message: 沒有找到該頁面
@ -1047,6 +1085,8 @@ core:
Users Management: 用戶
User manage: 用戶管理
User View: 用戶查看
Migration Management: 備份與還原
Migration Manage: 備份與還原管理
role-template-view-users: 用戶查看
role-template-change-password: 修改密碼
components:

View File

@ -11,6 +11,7 @@ import roleModule from "./system/roles/module";
import settingModule from "./system/settings/module";
import actuatorModule from "./system/actuator/module";
import authProviderModule from "./system/auth-providers/module";
import backupModule from "./system/backup/module";
// const coreModules = [
// dashboardModule,
@ -40,6 +41,7 @@ const coreModules = [
userModule,
roleModule,
authProviderModule,
backupModule,
];
export { coreModules };

View File

@ -0,0 +1,90 @@
<script lang="ts" setup>
import {
VPageHeader,
VCard,
VButton,
VTabbar,
IconAddCircle,
IconServerLine,
} from "@halo-dev/components";
import { onMounted, shallowRef } from "vue";
import ListTab from "./tabs/List.vue";
import RestoreTab from "./tabs/Restore.vue";
import { useRouteQuery } from "@vueuse/router";
import { markRaw } from "vue";
import { useI18n } from "vue-i18n";
import { useBackup } from "./composables/use-backup";
import { usePluginModuleStore } from "@/stores/plugin";
import type { BackupTab } from "@halo-dev/console-shared";
const { t } = useI18n();
const tabs = shallowRef<BackupTab[]>([
{
id: "backups",
label: t("core.backup.tabs.backup_list"),
component: markRaw(ListTab),
},
{
id: "restore",
label: t("core.backup.tabs.restore"),
component: markRaw(RestoreTab),
},
]);
const activeTab = useRouteQuery<string>("tab", tabs.value[0].id);
const { handleCreate } = useBackup();
onMounted(() => {
const { pluginModules } = usePluginModuleStore();
pluginModules.forEach((pluginModule) => {
const { extensionPoints } = pluginModule;
if (!extensionPoints?.["backup:tabs:create"]) {
return;
}
const backupTabs = extensionPoints["backup:tabs:create"]() as BackupTab[];
if (backupTabs) {
tabs.value = tabs.value.concat(backupTabs);
}
});
});
</script>
<template>
<VPageHeader :title="$t('core.backup.title')">
<template #icon>
<IconServerLine class="mr-2 self-center" />
</template>
<template #actions>
<VButton type="secondary" @click="handleCreate">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.backup.operations.create.button") }}
</VButton>
</template>
</VPageHeader>
<div class="m-0 md:m-4">
<VCard :body-class="['!p-0']">
<template #header>
<VTabbar
v-model:active-id="activeTab"
:items="tabs.map((item) => ({ id: item.id, label: item.label }))"
class="w-full !rounded-none"
type="outline"
></VTabbar>
</template>
<div class="bg-white">
<template v-for="tab in tabs" :key="tab.id">
<component :is="tab.component" v-if="activeTab === tab.id" />
</template>
</div>
</VCard>
</div>
</template>

View File

@ -0,0 +1,171 @@
<script lang="ts" setup>
import {
Dialog,
Toast,
VDropdownItem,
VEntity,
VEntityField,
VSpace,
VStatusDot,
} from "@halo-dev/components";
import type { Backup } from "@halo-dev/api-client";
import { relativeTimeTo, formatDatetime } from "@/utils/date";
import { computed } from "vue";
import { apiClient } from "@/utils/api-client";
import { useQueryClient } from "@tanstack/vue-query";
import prettyBytes from "pretty-bytes";
import { useI18n } from "vue-i18n";
const queryClient = useQueryClient();
const { t } = useI18n();
const props = defineProps<{
backup: Backup;
}>();
type Phase = {
text: string;
state: "default" | "warning" | "success" | "error";
animate: boolean;
value: "PENDING" | "RUNNING" | "SUCCEEDED" | "FAILED";
};
const phases: Phase[] = [
{
text: t("core.backup.list.phases.pending"),
state: "default",
animate: false,
value: "PENDING",
},
{
text: t("core.backup.list.phases.running"),
state: "warning",
animate: true,
value: "RUNNING",
},
{
text: t("core.backup.list.phases.succeeded"),
state: "success",
animate: false,
value: "SUCCEEDED",
},
{
text: t("core.backup.list.phases.failed"),
state: "error",
animate: false,
value: "FAILED",
},
];
const getPhase = computed(() => {
if (!props.backup.status?.phase) {
return undefined;
}
return phases.find((phase) => phase.value === props.backup.status?.phase);
});
const getFailureMessage = computed(() => {
const { phase, failureMessage } = props.backup.status || {};
return phase === "FAILED" ? failureMessage : undefined;
});
function handleDownload() {
window.open(
`/apis/api.console.migration.halo.run/v1alpha1/backups/${props.backup.metadata.name}/files/${props.backup.status?.filename}`,
"_blank"
);
}
function handleDelete() {
Dialog.warning({
title: t("core.backup.operations.delete.title"),
description: t("core.backup.operations.delete.description"),
confirmType: "danger",
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await apiClient.extension.backup.deletemigrationHaloRunV1alpha1Backup({
name: props.backup.metadata.name,
});
queryClient.invalidateQueries({ queryKey: ["backups"] });
Toast.success(t("core.common.toast.delete_success"));
},
});
}
</script>
<template>
<VEntity>
<template #start>
<VEntityField
:title="backup.metadata.name"
:description="backup.status?.filename"
>
<template v-if="backup.status?.filename" #description>
<VSpace class="flex-wrap">
<span class="text-xs text-gray-500">
{{ backup.status?.filename }}
</span>
<span class="text-xs text-gray-500">
{{ prettyBytes(backup.status?.size || 0) }}
</span>
</VSpace>
</template>
</VEntityField>
</template>
<template #end>
<VEntityField v-if="getPhase">
<template #description>
<VStatusDot
v-tooltip="{ content: getFailureMessage }"
:state="getPhase.state"
:text="getPhase.text"
:animate="getPhase.animate"
/>
</template>
</VEntityField>
<VEntityField v-if="backup.metadata.deletionTimestamp">
<template #description>
<VStatusDot
v-tooltip="$t('core.common.status.deleting')"
state="warning"
animate
/>
</template>
</VEntityField>
<VEntityField
v-if="backup.spec?.expiresAt && backup.status?.phase === 'SUCCEEDED'"
>
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{
$t("core.backup.list.fields.expiresAt", {
expiresAt: relativeTimeTo(backup.spec?.expiresAt),
})
}}
</span>
</template>
</VEntityField>
<VEntityField v-if="backup.metadata.creationTimestamp">
<template #description>
<span class="truncate text-xs tabular-nums text-gray-500">
{{ formatDatetime(backup.metadata.creationTimestamp) }}
</span>
</template>
</VEntityField>
</template>
<template #dropdownItems>
<VDropdownItem
v-if="backup.status?.phase === 'SUCCEEDED'"
@click="handleDownload"
>
{{ $t("core.common.buttons.download") }}
</VDropdownItem>
<VDropdownItem type="danger" @click="handleDelete">
{{ $t("core.common.buttons.delete") }}
</VDropdownItem>
</template>
</VEntity>
</template>

View File

@ -0,0 +1,40 @@
import { apiClient } from "@/utils/api-client";
import { Dialog, Toast } from "@halo-dev/components";
import { useQueryClient } from "@tanstack/vue-query";
import dayjs from "dayjs";
import { useI18n } from "vue-i18n";
export function useBackup() {
const { t } = useI18n();
const queryClient = useQueryClient();
const handleCreate = async () => {
Dialog.info({
title: t("core.backup.operations.create.title"),
description: t("core.backup.operations.create.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await apiClient.extension.backup.createmigrationHaloRunV1alpha1Backup({
backup: {
apiVersion: "migration.halo.run/v1alpha1",
kind: "Backup",
metadata: {
generateName: "backup-",
name: "",
},
spec: {
expiresAt: dayjs().add(7, "day").toISOString(),
},
},
});
queryClient.invalidateQueries({ queryKey: ["backups"] });
Toast.success(t("core.backup.operations.create.toast_success"));
},
});
};
return { handleCreate };
}

View File

@ -0,0 +1,33 @@
import { definePlugin } from "@halo-dev/console-shared";
import BasicLayout from "@/layouts/BasicLayout.vue";
import Backups from "./Backups.vue";
import { IconServerLine } from "@halo-dev/components";
import { markRaw } from "vue";
export default definePlugin({
components: {},
routes: [
{
path: "/backup",
component: BasicLayout,
children: [
{
path: "",
name: "Backup",
component: Backups,
meta: {
title: "core.backup.title",
searchable: true,
permissions: ["system:migrations:manage"],
menu: {
name: "core.sidebar.menu.items.backup",
group: "system",
icon: markRaw(IconServerLine),
priority: 4,
},
},
},
],
},
],
});

View File

@ -0,0 +1,85 @@
<script lang="ts" setup>
import { apiClient } from "@/utils/api-client";
import { useQuery } from "@tanstack/vue-query";
import { BackupStatusPhaseEnum } from "@halo-dev/api-client";
import {
IconAddCircle,
VButton,
VEmpty,
VLoading,
VSpace,
} from "@halo-dev/components";
import BackupListItem from "../components/BackupListItem.vue";
import { useBackup } from "../composables/use-backup";
const {
data: backups,
isLoading,
isFetching,
refetch,
} = useQuery({
queryKey: ["backups"],
queryFn: async () => {
const { data } =
await apiClient.extension.backup.listmigrationHaloRunV1alpha1Backup({
sort: ["metadata.creationTimestamp,desc"],
});
return data;
},
refetchInterval(data) {
const deletingBackups = data?.items.filter((backup) => {
return !!backup.metadata.deletionTimestamp;
});
if (deletingBackups?.length) {
return 1000;
}
const pendingBackups = data?.items.filter((backup) => {
return (
backup.status?.phase === BackupStatusPhaseEnum.Pending ||
backup.status?.phase === BackupStatusPhaseEnum.Running
);
});
if (pendingBackups?.length) {
return 3000;
}
return false;
},
});
const { handleCreate } = useBackup();
</script>
<template>
<VLoading v-if="isLoading" />
<Transition v-else-if="!backups?.items?.length" appear name="fade">
<VEmpty
:message="$t('core.backup.empty.message')"
:title="$t('core.backup.empty.title')"
>
<template #actions>
<VSpace>
<VButton :loading="isFetching" @click="refetch()">
{{ $t("core.common.buttons.refresh") }}
</VButton>
<VButton type="secondary" @click="handleCreate">
<template #icon>
<IconAddCircle class="h-full w-full" />
</template>
{{ $t("core.backup.empty.actions.create") }}
</VButton>
</VSpace>
</template>
</VEmpty>
</Transition>
<Transition v-else appear name="fade">
<ul class="box-border h-full w-full divide-y divide-gray-100" role="list">
<li v-for="(backup, index) in backups?.items" :key="index">
<BackupListItem :backup="backup" />
</li>
</ul>
</Transition>
</template>

View File

@ -0,0 +1,89 @@
<script lang="ts" setup>
import UppyUpload from "@/components/upload/UppyUpload.vue";
import { Dialog, Toast, VAlert, VLoading } from "@halo-dev/components";
import { useQuery } from "@tanstack/vue-query";
import axios from "axios";
import { computed } from "vue";
import { ref } from "vue";
import { useI18n } from "vue-i18n";
const { t } = useI18n();
const complete = ref(false);
const onUploaded = () => {
Dialog.success({
title: t("core.backup.operations.restore.title"),
description: t("core.backup.operations.restore.description"),
confirmText: t("core.common.buttons.confirm"),
cancelText: t("core.common.buttons.cancel"),
async onConfirm() {
await handleShutdown();
},
async onCancel() {
await handleShutdown();
},
});
};
async function handleShutdown() {
await axios.post(`/actuator/shutdown`);
Toast.success(t("core.backup.operations.shutdown.toast_success"));
setTimeout(() => {
complete.value = true;
}, 1000);
}
useQuery({
queryKey: ["check-health"],
queryFn: async () => {
const { data } = await axios.get("/actuator/health");
return data;
},
onSuccess(data) {
if (data.status === "UP") {
window.location.reload();
}
},
retry: true,
retryDelay: 2000,
enabled: computed(() => complete.value),
});
</script>
<template>
<div v-if="!complete">
<div class="px-4 py-3">
<VAlert :title="$t('core.common.text.tip')" :closable="false">
<template #description>
<ul>
<li>{{ $t("core.backup.restore.tips.first") }}</li>
<li>
{{ $t("core.backup.restore.tips.second") }}
</li>
<li>
{{ $t("core.backup.restore.tips.third") }}
</li>
</ul>
</template>
</VAlert>
</div>
<div class="flex items-center justify-center px-4 py-3">
<UppyUpload
:restrictions="{
maxNumberOfFiles: 1,
allowedFileTypes: ['.zip'],
}"
endpoint="/apis/api.console.migration.halo.run/v1alpha1/restorations"
width="100%"
@uploaded="onUploaded"
/>
</div>
</div>
<div v-else class="flex h-72 flex-col items-center justify-center">
<VLoading />
<div class="text-xs text-gray-600">恢复完成等待重启...</div>
</div>
</template>

View File

@ -38,6 +38,8 @@ import {
AuthHaloRunV1alpha1AuthProviderApi,
AuthHaloRunV1alpha1UserConnectionApi,
ApiHaloRunV1alpha1UserApi,
MigrationHaloRunV1alpha1BackupApi,
ApiConsoleMigrationHaloRunV1alpha1MigrationApi,
} from "@halo-dev/api-client";
import type { AxiosError, AxiosInstance } from "axios";
import axios from "axios";
@ -180,6 +182,7 @@ function setupApiClient(axios: AxiosInstance) {
baseURL,
axios
),
backup: new MigrationHaloRunV1alpha1BackupApi(undefined, baseURL, axios),
},
// custom endpoints
user: new ApiConsoleHaloRunV1alpha1UserApi(undefined, baseURL, axios),
@ -210,6 +213,11 @@ function setupApiClient(axios: AxiosInstance) {
user: new ApiHaloRunV1alpha1UserApi(undefined, baseURL, axios),
},
cache: new V1alpha1CacheApi(undefined, baseURL, axios),
migration: new ApiConsoleMigrationHaloRunV1alpha1MigrationApi(
undefined,
baseURL,
axios
),
};
}

View File

@ -1,12 +1,21 @@
import { i18n } from "@/locales";
import dayjs from "dayjs";
import "dayjs/locale/zh-cn";
import "dayjs/locale/en";
import "dayjs/locale/zh-tw";
import timezone from "dayjs/plugin/timezone";
import utc from "dayjs/plugin/utc";
import relativeTime from "dayjs/plugin/relativeTime";
dayjs.extend(timezone);
dayjs.extend(utc);
dayjs.locale("zh-cn");
dayjs.extend(relativeTime);
const dayjsLocales = {
en: "en",
zh: "zh-cn",
"en-US": "en",
"zh-CN": "zh-cn",
"zh-TW": "zh-tw",
};
export function formatDatetime(
date: string | Date | undefined | null,
@ -35,3 +44,24 @@ export function toDatetimeLocal(
// see https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/datetime-local#the_y10k_problem_often_client-side
return dayjs(date).tz(tz).format("YYYY-MM-DDTHH:mm");
}
/**
* Get relative time to end date
*
* @param date end date
* @returns relative time to end date
*
* @example
*
* // now is 2020-12-01
* RelativeTimeTo("2021-01-01") // in 1 month
*/
export function relativeTimeTo(date: string | Date | undefined | null) {
dayjs.locale(dayjsLocales[i18n.global.locale.value] || dayjsLocales["zh-CN"]);
if (!date) {
return;
}
return dayjs().to(dayjs(date));
}

240
docs/backup-and-restore.md Normal file
View File

@ -0,0 +1,240 @@
# 备份和恢复 Proposal
## Motivation
目前,Halo 2.x 支持多种数据库:H2、MySQL、MariaDB、Microsoft SQL Server、Oracle 和
PostgreSQL,虽然数据库有备份和恢复的功能,但是仍然缺少应用级别的备份和恢复功能。Halo
的数据不仅限于数据库中的数据,还包含工作目录下的数据,例如主题、插件和日志等。
## Goals
- 全站备份,包括数据库中的数据和工作目录的数据。
- 全站恢复,包括恢复数据库中的数据和工作目录的数据。
- 用户可控制备份文件存储的时间。
- 对于工作目录的数据,用户可选择性备份和恢复。
- 用户可指定备份权限到任意用户。
## Non-Goals
- 仅备份部分自定义资源。
- 仅备份和恢复文章 Markdown。
- 定时备份。
- 加密备份文件。
- 备份文件自动上传至对象存储。
## Use Cases
- 从某种数据库(例如:H2)迁移至另外的数据库(例如:MySQL),不会因为 SQL 的兼容性而影响迁移。
- 定时完整备份 Halo,并存储至对象存储,一旦发生意外可随时恢复。
## Requirements
- 仅支持 2.8.x 及以上的 Halo。
- 恢复的数据的 creationTimestamp 可能会被当前时间覆盖。
## Draft
恢复数据之前需要完整备份当前 Halo,以便恢复过程中发生错误导致无法回滚。
备份文件将存储在 `${halo.work-dir}/backups/halo-full-backup-2023.07.03-17:52:59.zip`
备份整站可能需要大量的时间,所以我们需要创建自定义模型(Backup)用于保存用户创建备份的请求,并异步执行备份操作,最终将结果反馈至自定义模型数据中。
Backup 模型样例如下:
- 备份成功样例
```yaml
apiVersion: migration.halo.run/v1alpha1
kind: Backup
metadata:
name: halo-full-backup-xyz
creationTimestamp: 2023.07.04-10:25:30
spec:
format: zip
autoDeleteWhen: 2023.07.10-00:00:00Z
status:
phase: Succeeded
startTimestamp: 2023.07.04-10:25:31
completionTimestamp: 2023.07.04-10:26:30
filename: halo-full-backup-2023-07-04-10-25-30.zip
size: 1024 # data unit: bytes
```
- 备份失败样例
```yaml
apiVersion: migration.halo.run/v1alpha1
kind: Backup
metadata:
name: halo-full-backup-xyz
creationTimestamp: 2023.07.04-10:25:30
spec:
compressionFormat: zip | 7z | tar | tar.gz # 压缩格式
status:
startTimestamp: 2023.07.04-10:25:31
# Pending: 刚刚创建好 Backup 资源,等待 Reconciler reconcile。
# Running: Reconciler 正在备份 Halo。
# Succeeded: Reconciler 成功执行备份 Halo 操作。
# Failed: 备份 Halo 失败。
phase: Failed
failureReason: DatabaseConnectionReset | UnsupportedCompression # 机器可识别的信息
failureMessage: The database connection reset. # 人类可阅读的信息
```
同时,BackupReconciler 将负责备份操作,并更新 Backup 数据。
请求示例如下:
```text
POST /apis/migration.halo.run/v1alpha1/backups
Content-Type: application/json
```
### 备份
准备好所有的备份内容后,需要计算摘要并保存,以便后期恢复校验备份文件完整性使用。
#### 数据库备份和恢复
因为 Halo 的 [Extension 设计](https://github.com/halo-dev/rfcs/tree/main/extension),所以 Halo 的在数据库中的数据备份相对比较简单,只需要简单备份
ExtensionStore 即可。恢复同理。
#### 工作目录备份和恢复
Halo 工作目录样例如下所示:
```text
├── application.yaml
├── attachments
│   └── upload
│   └── image_2023-06-09_16-24-41.png
├── db
│   └── halo-next.mv.db
├── indices
│   └── posts
│   ├── _a.cfe
│   ├── _a.cfs
│   ├── _a.si
│   ├── segments_h
│   └── write.lock
├── keys
│   ├── id_rsa
│   └── id_rsa.pub
├── logs
│   ├── halo.log
│   ├── halo.log.2023-06-01.0.gz
│   ├── halo.log.2023-06-02.0.gz
│   ├── halo.log.2023-06-05.0.gz
│   └── halo.log.2023-06-26.0.gz
├── plugins
│   ├── PluginCommentWidget-1.5.0.jar
│   ├── PluginFeed-1.1.1.jar
│   ├── PluginSearchWidget-1.0.0.jar
│   ├── PluginSitemap-1.0.2.jar
│   └── configs
└── themes
├── theme-earth
│   ├── README.md
│   ├── settings.yaml
│   ├── templates
│   │   ├── archives.html
│   │   ├── assets
│   │   │   ├── dist
│   │   │   │   ├── main.iife.js
│   │   │   │   └── style.css
│   │   │   └── images
│   │   │   ├── default-avatar.svg
│   │   │   └── default-background.png
│   │   ├── author.html
│   │   ├── category.html
│   │   ├── error
│   │   │   └── error.html
│   │   ├── index.html
│   │   ├── links.html
│   │   ├── modules
│   │   │   ├── category-filter.html
│   │   │   ├── category-tree.html
│   │   │   ├── featured-post-card.html
│   │   │   ├── footer.html
│   │   │   ├── header.html
│   │   │   ├── hero.html
│   │   │   ├── layout.html
│   │   │   ├── post-card.html
│   │   │   ├── sidebar.html
│   │   │   ├── tag-filter.html
│   │   │   └── widgets
│   │   │   ├── categories.html
│   │   │   ├── latest-comments.html
│   │   │   ├── popular-posts.html
│   │   │   ├── profile.html
│   │   │   └── tags.html
│   │   ├── page.html
│   │   ├── post.html
│   │   ├── tag.html
│   │   └── tags.html
│   └── theme.yaml
```
备份时需要过滤 `db`、backups` 和 `indices` 目录。
#### 备份文件结构
备份文件主要包含自定义资源(`extensions.data`)和工作目录(`workdir.data`)的数据。
- `extensions.data`
前期可考虑使用 JSON 来存储所有的 ExtensionStore 数据。
- `workdir.data`
对工作目录进行 `ZIP` 压缩。
- config.yaml(备份配置)
主要用于描述 `extensions.data``workdir.data` 压缩格式,后续可扩展备份与恢复相关的配置。例如:
```yaml
compressions:
extensions: json | others
workdir: zip | others
```
前期可不实现该功能。
### 恢复
用户通过上传备份文件的方式进行恢复。当且仅当博客未初始化阶段才能进行恢复操作,否则可能会造成数据不一致。
请求示例如下:
```text
POST /apis/migration.halo.run/v1alpha1/restorations
Content-Type: multipart/form-data; boundary="boundary"
'''
--boundary
Content-Disposition: form-data; name="backupfile"; filename="halo-full-backup.zip"
Content-Type: application/zip
'''
```
恢复步骤如下:
1. 解压缩备份文件。
2. 校验备份文件的完整性。
2. 恢复所有 ExtensionStore。
3. 覆盖当前工作目录。
4. 备份完成。
> 需要注意内存占用问题。
## TBDs
- 数据备份期间可能会存在数据的创建、更新和删除。
我们将忽略这些数据变化。
- 是否支持在初始化博客后恢复数据?
支持。不过可能会覆盖掉已有的数据。