Support restoring with downloadable URL or backup name (#4474)

#### What type of PR is this?

/kind feature
/area core
/milestone 2.9.x

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

Currently, we only support restoring by uploading backup file. Downloading and uploading larger backup files can be cumbersome for users.

This PR supports restoring with downloadable URL or backup name as well.

#### Special notes for your reviewer:

```bash
# Replace ${BACKUP_NAME} by yourself.
curl -u admin:admin 'http://localhost:8090/apis/api.console.migration.halo.run/v1alpha1/restorations' \
  -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundary3Al7pC6AbBNfB1js' \
  --data-raw $'------WebKitFormBoundary3Al7pC6AbBNfB1js\r\nContent-Disposition: form-data; name="backupName"\r\n\r\n${BACKUP_NAME}\r\n------WebKitFormBoundary3Al7pC6AbBNfB1js--\r\n'
```

```bash
# Replace ${DOWNLOAD_LINK} by yourself.
curl -u admin:admin 'http://localhost:8090/apis/api.console.migration.halo.run/v1alpha1/restorations' \
  -H 'Content-Type: multipart/form-data; boundary=----WebKitFormBoundarytv6cqgmANkCpSuZm' \
  --data-raw $'------WebKitFormBoundarytv6cqgmANkCpSuZm\r\nContent-Disposition: form-data; name="downloadUrl"\r\n\r\n${DOWNLOAD_LINK}\r\n------WebKitFormBoundarytv6cqgmANkCpSuZm--\r\n'
```

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

```release-note
新增提供下载链接或者备份名进行系统恢复的功能。
```
pull/4482/head
John Niang 2023-08-25 23:22:11 +08:00 committed by GitHub
parent 2aeeb3e463
commit 7603b21dd2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 324 additions and 67 deletions

View File

@ -1,6 +1,6 @@
package run.halo.app.migration;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED;
import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_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;
@ -8,19 +8,31 @@ 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 java.net.MalformedURLException;
import java.net.URISyntaxException;
import java.net.URL;
import java.util.Optional;
import org.springdoc.webflux.core.fn.SpringdocRouteBuilder;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.core.io.buffer.DataBufferUtils;
import org.springframework.core.io.buffer.DefaultDataBufferFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.codec.multipart.FilePart;
import org.springframework.http.codec.multipart.FormFieldPart;
import org.springframework.http.codec.multipart.Part;
import org.springframework.stereotype.Component;
import org.springframework.util.MultiValueMap;
import org.springframework.util.StreamUtils;
import org.springframework.web.reactive.function.server.RouterFunction;
import org.springframework.web.reactive.function.server.ServerResponse;
import org.springframework.web.server.ServerWebInputException;
import reactor.core.publisher.Flux;
import reactor.core.publisher.Mono;
import run.halo.app.core.extension.endpoint.CustomEndpoint;
import run.halo.app.extension.GroupVersion;
import run.halo.app.extension.ReactiveExtensionClient;
import run.halo.app.infra.ReactiveUrlDataBufferFetcher;
@Component
public class MigrationEndpoint implements CustomEndpoint {
@ -29,9 +41,14 @@ public class MigrationEndpoint implements CustomEndpoint {
private final ReactiveExtensionClient client;
public MigrationEndpoint(MigrationService migrationService, ReactiveExtensionClient client) {
private final ReactiveUrlDataBufferFetcher dataBufferFetcher;
public MigrationEndpoint(MigrationService migrationService,
ReactiveExtensionClient client,
ReactiveUrlDataBufferFetcher dataBufferFetcher) {
this.migrationService = migrationService;
this.client = client;
this.dataBufferFetcher = dataBufferFetcher;
}
@Override
@ -65,11 +82,20 @@ public class MigrationEndpoint implements CustomEndpoint {
.build())
.POST("/restorations", request -> request.multipartData()
.map(RestoreRequest::new)
.flatMap(restoreRequest -> migrationService.restore(
restoreRequest.getFile().content()))
.flatMap(v -> ServerResponse.ok().bodyValue("Restored successfully!")),
.flatMap(restoreRequest -> {
var content = getContent(restoreRequest)
.switchIfEmpty(Mono.error(() -> new ServerWebInputException(
"Please upload a file "
+ "or provide a download link or backup name.")));
return migrationService.restore(content);
})
.then(Mono.defer(
() -> ServerResponse.ok().bodyValue("Restored successfully!")
)),
builder -> builder
.tag(tag)
.description("Restore backup by uploading file "
+ "or providing download link or backup name.")
.operationId("RestoreBackup")
.requestBody(requestBodyBuilder()
.required(true)
@ -82,6 +108,38 @@ public class MigrationEndpoint implements CustomEndpoint {
.build();
}
private Flux<DataBuffer> getContent(RestoreRequest request) {
var downloadContent = request.getDownloadUrl()
.map(downloadURL -> {
try {
var url = new URL(downloadURL);
return dataBufferFetcher.fetch(url.toURI());
} catch (MalformedURLException e) {
return Flux.<DataBuffer>error(new ServerWebInputException(
"Invalid download URL: " + downloadURL));
} catch (URISyntaxException e) {
// Should never happen
return Flux.<DataBuffer>error(e);
}
})
.orElseGet(Flux::empty);
var uploadContent = request.getFile()
.map(Part::content)
.orElseGet(Flux::empty);
var backupFileContent = request.getBackupName()
.map(backupName -> client.get(Backup.class, backupName)
.flatMap(migrationService::download)
.flatMapMany(resource -> DataBufferUtils.read(resource,
DefaultDataBufferFactory.sharedInstance,
StreamUtils.BUFFER_SIZE)))
.orElseGet(Flux::empty);
return uploadContent
.switchIfEmpty(downloadContent)
.switchIfEmpty(backupFileContent);
}
public static class RestoreRequest {
private final MultiValueMap<String, Part> multipart;
@ -89,13 +147,35 @@ public class MigrationEndpoint implements CustomEndpoint {
this.multipart = multipart;
}
@Schema(requiredMode = REQUIRED, name = "file", description = "Backup file.")
public FilePart getFile() {
@Schema(requiredMode = NOT_REQUIRED, name = "file", description = "Backup file.")
public Optional<FilePart> getFile() {
var part = multipart.getFirst("file");
if (part instanceof FilePart filePart) {
return filePart;
return Optional.of(filePart);
}
throw new ServerWebInputException("Invalid file part");
return Optional.empty();
}
@Schema(requiredMode = NOT_REQUIRED,
name = "downloadUrl",
description = "Remote backup HTTP URL.")
public Optional<String> getDownloadUrl() {
var part = multipart.getFirst("downloadUrl");
if (part instanceof FormFieldPart downloadUrlPart) {
return Optional.of(downloadUrlPart.value());
}
return Optional.empty();
}
@Schema(requiredMode = NOT_REQUIRED,
name = "backupName",
description = "Backup metadata name.")
public Optional<String> getBackupName() {
var part = multipart.getFirst("backupName");
if (part instanceof FormFieldPart backupNamePart) {
return Optional.of(backupNamePart.value());
}
return Optional.empty();
}
}

View File

@ -57,6 +57,98 @@ export const ApiConsoleHaloRunV1alpha1PluginApiAxiosParamCreator = function (
configuration?: Configuration
) {
return {
/**
* Merge all CSS bundles of enabled plugins into one.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
fetchCssBundle: async (
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.css`;
// 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,
};
},
/**
* Merge all JS bundles of enabled plugins into one.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
fetchJsBundle: async (
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
const localVarPath = `/apis/api.console.halo.run/v1alpha1/plugins/-/bundle.js`;
// 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,
};
},
/**
* Fetch configMap of plugin by configured configMapName.
* @param {string} name
@ -763,6 +855,46 @@ export const ApiConsoleHaloRunV1alpha1PluginApiFp = function (
const localVarAxiosParamCreator =
ApiConsoleHaloRunV1alpha1PluginApiAxiosParamCreator(configuration);
return {
/**
* Merge all CSS bundles of enabled plugins into one.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async fetchCssBundle(
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<string>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.fetchCssBundle(
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
* Merge all JS bundles of enabled plugins into one.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async fetchJsBundle(
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<string>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.fetchJsBundle(
options
);
return createRequestFunction(
localVarAxiosArgs,
globalAxios,
BASE_PATH,
configuration
);
},
/**
* Fetch configMap of plugin by configured configMapName.
* @param {string} name
@ -1062,6 +1194,26 @@ export const ApiConsoleHaloRunV1alpha1PluginApiFactory = function (
) {
const localVarFp = ApiConsoleHaloRunV1alpha1PluginApiFp(configuration);
return {
/**
* Merge all CSS bundles of enabled plugins into one.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
fetchCssBundle(options?: AxiosRequestConfig): AxiosPromise<string> {
return localVarFp
.fetchCssBundle(options)
.then((request) => request(axios, basePath));
},
/**
* Merge all JS bundles of enabled plugins into one.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
fetchJsBundle(options?: AxiosRequestConfig): AxiosPromise<string> {
return localVarFp
.fetchJsBundle(options)
.then((request) => request(axios, basePath));
},
/**
* Fetch configMap of plugin by configured configMapName.
* @param {ApiConsoleHaloRunV1alpha1PluginApiFetchPluginConfigRequest} requestParameters Request parameters.
@ -1483,6 +1635,30 @@ export interface ApiConsoleHaloRunV1alpha1PluginApiUpgradePluginFromUriRequest {
* @extends {BaseAPI}
*/
export class ApiConsoleHaloRunV1alpha1PluginApi extends BaseAPI {
/**
* Merge all CSS bundles of enabled plugins into one.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1PluginApi
*/
public fetchCssBundle(options?: AxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1PluginApiFp(this.configuration)
.fetchCssBundle(options)
.then((request) => request(this.axios, this.basePath));
}
/**
* Merge all JS bundles of enabled plugins into one.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1PluginApi
*/
public fetchJsBundle(options?: AxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1PluginApiFp(this.configuration)
.fetchJsBundle(options)
.then((request) => request(this.axios, this.basePath));
}
/**
* Fetch configMap of plugin by configured configMapName.
* @param {ApiConsoleHaloRunV1alpha1PluginApiFetchPluginConfigRequest} requestParameters Request parameters.

View File

@ -267,16 +267,12 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiAxiosParamCreator = function (
},
/**
* Install a theme by uploading a zip file.
* @param {File} file
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
installTheme: async (
file: File,
options: AxiosRequestConfig = {}
): Promise<RequestArgs> => {
// verify required parameter 'file' is not null or undefined
assertParamExists("installTheme", "file", file);
const localVarPath = `/apis/api.console.halo.run/v1alpha1/themes/install`;
// use dummy base URL string because the URL constructor only accepts absolute URLs.
const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL);
@ -292,9 +288,6 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiAxiosParamCreator = function (
};
const localVarHeaderParameter = {} as any;
const localVarQueryParameter = {} as any;
const localVarFormParams = new ((configuration &&
configuration.formDataCtor) ||
FormData)();
// authentication BasicAuth required
// http basic authentication required
@ -304,12 +297,6 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiAxiosParamCreator = function (
// 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 : {};
@ -318,7 +305,6 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiAxiosParamCreator = function (
...headersFromBaseOptions,
...options.headers,
};
localVarRequestOptions.data = localVarFormParams;
return {
url: toPathString(localVarUrlObj),
@ -873,18 +859,15 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiFp = function (
},
/**
* Install a theme by uploading a zip file.
* @param {File} file
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async installTheme(
file: File,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<Theme>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.installTheme(
file,
options
);
return createRequestFunction(
@ -1145,16 +1128,12 @@ export const ApiConsoleHaloRunV1alpha1ThemeApiFactory = function (
},
/**
* Install a theme by uploading a zip file.
* @param {ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
installTheme(
requestParameters: ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeRequest,
options?: AxiosRequestConfig
): AxiosPromise<Theme> {
installTheme(options?: AxiosRequestConfig): AxiosPromise<Theme> {
return localVarFp
.installTheme(requestParameters.file, options)
.installTheme(options)
.then((request) => request(axios, basePath));
},
/**
@ -1315,20 +1294,6 @@ export interface ApiConsoleHaloRunV1alpha1ThemeApiFetchThemeSettingRequest {
readonly name: string;
}
/**
* Request parameters for installTheme operation in ApiConsoleHaloRunV1alpha1ThemeApi.
* @export
* @interface ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeRequest
*/
export interface ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeRequest {
/**
*
* @type {File}
* @memberof ApiConsoleHaloRunV1alpha1ThemeApiInstallTheme
*/
readonly file: File;
}
/**
* Request parameters for installThemeFromUri operation in ApiConsoleHaloRunV1alpha1ThemeApi.
* @export
@ -1545,17 +1510,13 @@ export class ApiConsoleHaloRunV1alpha1ThemeApi extends BaseAPI {
/**
* Install a theme by uploading a zip file.
* @param {ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleHaloRunV1alpha1ThemeApi
*/
public installTheme(
requestParameters: ApiConsoleHaloRunV1alpha1ThemeApiInstallThemeRequest,
options?: AxiosRequestConfig
) {
public installTheme(options?: AxiosRequestConfig) {
return ApiConsoleHaloRunV1alpha1ThemeApiFp(this.configuration)
.installTheme(requestParameters.file, options)
.installTheme(options)
.then((request) => request(this.axios, this.basePath));
}

View File

@ -102,17 +102,19 @@ export const ApiConsoleMigrationHaloRunV1alpha1MigrationApiAxiosParamCreator =
};
},
/**
*
* @param {File} file
* Restore backup by uploading file or providing download link or backup name.
* @param {string} [backupName] Backup metadata name.
* @param {string} [downloadUrl] Remote backup HTTP URL.
* @param {File} [file]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreBackup: async (
file: File,
backupName?: string,
downloadUrl?: string,
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);
@ -140,6 +142,14 @@ export const ApiConsoleMigrationHaloRunV1alpha1MigrationApiAxiosParamCreator =
// http bearer authentication required
await setBearerAuthToObject(localVarHeaderParameter, configuration);
if (backupName !== undefined) {
localVarFormParams.append("backupName", backupName as any);
}
if (downloadUrl !== undefined) {
localVarFormParams.append("downloadUrl", downloadUrl as any);
}
if (file !== undefined) {
localVarFormParams.append("file", file as any);
}
@ -203,18 +213,24 @@ export const ApiConsoleMigrationHaloRunV1alpha1MigrationApiFp = function (
);
},
/**
*
* @param {File} file
* Restore backup by uploading file or providing download link or backup name.
* @param {string} [backupName] Backup metadata name.
* @param {string} [downloadUrl] Remote backup HTTP URL.
* @param {File} [file]
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
async restoreBackup(
file: File,
backupName?: string,
downloadUrl?: string,
file?: File,
options?: AxiosRequestConfig
): Promise<
(axios?: AxiosInstance, basePath?: string) => AxiosPromise<void>
> {
const localVarAxiosArgs = await localVarAxiosParamCreator.restoreBackup(
backupName,
downloadUrl,
file,
options
);
@ -259,17 +275,22 @@ export const ApiConsoleMigrationHaloRunV1alpha1MigrationApiFactory = function (
.then((request) => request(axios, basePath));
},
/**
*
* Restore backup by uploading file or providing download link or backup name.
* @param {ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
*/
restoreBackup(
requestParameters: ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest,
requestParameters: ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest = {},
options?: AxiosRequestConfig
): AxiosPromise<void> {
return localVarFp
.restoreBackup(requestParameters.file, options)
.restoreBackup(
requestParameters.backupName,
requestParameters.downloadUrl,
requestParameters.file,
options
)
.then((request) => request(axios, basePath));
},
};
@ -302,12 +323,26 @@ export interface ApiConsoleMigrationHaloRunV1alpha1MigrationApiDownloadBackupsRe
* @interface ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest
*/
export interface ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest {
/**
* Backup metadata name.
* @type {string}
* @memberof ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackup
*/
readonly backupName?: string;
/**
* Remote backup HTTP URL.
* @type {string}
* @memberof ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackup
*/
readonly downloadUrl?: string;
/**
*
* @type {File}
* @memberof ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackup
*/
readonly file: File;
readonly file?: File;
}
/**
@ -338,18 +373,23 @@ export class ApiConsoleMigrationHaloRunV1alpha1MigrationApi extends BaseAPI {
}
/**
*
* Restore backup by uploading file or providing download link or backup name.
* @param {ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest} requestParameters Request parameters.
* @param {*} [options] Override http request option.
* @throws {RequiredError}
* @memberof ApiConsoleMigrationHaloRunV1alpha1MigrationApi
*/
public restoreBackup(
requestParameters: ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest,
requestParameters: ApiConsoleMigrationHaloRunV1alpha1MigrationApiRestoreBackupRequest = {},
options?: AxiosRequestConfig
) {
return ApiConsoleMigrationHaloRunV1alpha1MigrationApiFp(this.configuration)
.restoreBackup(requestParameters.file, options)
.restoreBackup(
requestParameters.backupName,
requestParameters.downloadUrl,
requestParameters.file,
options
)
.then((request) => request(this.axios, this.basePath));
}
}