chore: Move all remaining actions into links (#37829)
The chain+link framework is now used for all the steps in the backup command. ## Automation /test sanity ### 🔍 Cypress test results <!-- This is an auto-generated comment: Cypress test results --> > [!TIP] > 🟢 🟢 🟢 All cypress tests have passed! 🎉 🎉 🎉 > Workflow run: <https://github.com/appsmithorg/appsmith/actions/runs/12080116829> > Commit: fcc016fd1fa0bbe9b4cae2478bf4f7750ee5459d > <a href="https://internal.appsmith.com/app/cypress-dashboard/rundetails-65890b3c81d7400d08fa9ee5?branch=master&workflowId=12080116829&attempt=1" target="_blank">Cypress dashboard</a>. > Tags: `@tag.Sanity` > Spec: > <hr>Fri, 29 Nov 2024 07:12:22 UTC <!-- end of auto-generated comment: Cypress test results --> ## Communication Should the DevRel and Marketing teams inform users about this change? - [ ] Yes - [x] No <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit ## Release Notes - **New Features** - Introduced new classes for managing backup processes, including `BackupFolderLink`, `DiskSpaceLink`, `EnvFileLink`, `GitStorageLink`, `MongoDumpLink`, and `ManifestLink`. - Added functionality for checking available disk space and managing temporary backup folders. - Enhanced encryption handling with a new password prompt mechanism. - **Bug Fixes** - Streamlined backup process by removing redundant functions and ensuring proper error handling. - **Documentation** - Updated comments and documentation for new classes and methods to improve clarity. - **Refactor** - Restructured backup functionality for improved modularity and maintainability. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
parent
e8cb73dc68
commit
6773f51d6a
|
|
@ -1,25 +1,19 @@
|
|||
import { getTimeStampInISO } from "./index";
|
||||
|
||||
export class BackupState {
|
||||
readonly args: string[];
|
||||
readonly initAt: string = getTimeStampInISO();
|
||||
readonly args: readonly string[];
|
||||
readonly initAt: string = new Date().toISOString().replace(/:/g, "-");
|
||||
readonly errors: string[] = [];
|
||||
|
||||
backupRootPath: string = "";
|
||||
archivePath: string = "";
|
||||
|
||||
encryptionPassword: string = "";
|
||||
isEncryptionEnabled: boolean = false;
|
||||
|
||||
constructor(args: string[]) {
|
||||
this.args = args;
|
||||
this.args = Object.freeze([...args]);
|
||||
|
||||
// We seal `this` so that no link in the chain can "add" new properties to the state. This is intentional. If any
|
||||
// link wants to save data in the `BackupState`, which shouldn't even be needed in most cases, it should do so by
|
||||
// explicitly declaring a property in this class. No surprises.
|
||||
Object.seal(this);
|
||||
}
|
||||
|
||||
isEncryptionEnabled() {
|
||||
return !!this.encryptionPassword;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,16 @@ import * as backup from ".";
|
|||
import * as Constants from "../constants";
|
||||
import * as utils from "../utils";
|
||||
import readlineSync from "readline-sync";
|
||||
import {
|
||||
checkAvailableBackupSpace,
|
||||
encryptBackupArchive,
|
||||
executeCopyCMD,
|
||||
executeMongoDumpCMD,
|
||||
getAvailableBackupSpaceInBytes,
|
||||
getEncryptionPasswordFromUser,
|
||||
getGitRoot,
|
||||
removeSensitiveEnvData,
|
||||
} from "./links";
|
||||
|
||||
jest.mock("../utils", () => ({
|
||||
...jest.requireActual("../utils"),
|
||||
|
|
@ -10,15 +20,8 @@ jest.mock("../utils", () => ({
|
|||
}));
|
||||
|
||||
describe("Backup Tests", () => {
|
||||
test("Timestamp string in ISO format", () => {
|
||||
console.log(backup.getTimeStampInISO());
|
||||
expect(backup.getTimeStampInISO()).toMatch(
|
||||
/(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})\.(\d{3})Z/,
|
||||
);
|
||||
});
|
||||
|
||||
test("Available Space in /appsmith-stacks volume in Bytes", async () => {
|
||||
const res = expect(await backup.getAvailableBackupSpaceInBytes("/"));
|
||||
const res = expect(await getAvailableBackupSpaceInBytes("/"));
|
||||
|
||||
res.toBeGreaterThan(1024 * 1024);
|
||||
});
|
||||
|
|
@ -32,14 +35,12 @@ describe("Backup Tests", () => {
|
|||
it("Should throw Error when the available size is below MIN_REQUIRED_DISK_SPACE_IN_BYTES", () => {
|
||||
const size = Constants.MIN_REQUIRED_DISK_SPACE_IN_BYTES - 1;
|
||||
|
||||
expect(() => backup.checkAvailableBackupSpace(size)).toThrow();
|
||||
expect(() => checkAvailableBackupSpace(size)).toThrow();
|
||||
});
|
||||
|
||||
it("Should not should throw Error when the available size is >= MIN_REQUIRED_DISK_SPACE_IN_BYTES", () => {
|
||||
expect(() => {
|
||||
backup.checkAvailableBackupSpace(
|
||||
Constants.MIN_REQUIRED_DISK_SPACE_IN_BYTES,
|
||||
);
|
||||
checkAvailableBackupSpace(Constants.MIN_REQUIRED_DISK_SPACE_IN_BYTES);
|
||||
}).not.toThrow(
|
||||
"Not enough space available at /appsmith-stacks. Please ensure availability of at least 5GB to backup successfully.",
|
||||
);
|
||||
|
|
@ -59,29 +60,29 @@ describe("Backup Tests", () => {
|
|||
const appsmithMongoURI = "mongodb://username:password@host/appsmith";
|
||||
const cmd =
|
||||
"mongodump --uri=mongodb://username:password@host/appsmith --archive=/dest/mongodb-data.gz --gzip";
|
||||
const res = await backup.executeMongoDumpCMD(dest, appsmithMongoURI);
|
||||
const res = await executeMongoDumpCMD(dest, appsmithMongoURI);
|
||||
|
||||
expect(res).toBe(cmd);
|
||||
console.log(res);
|
||||
});
|
||||
|
||||
test("Test get gitRoot path when APPSMITH_GIT_ROOT is '' ", () => {
|
||||
expect(backup.getGitRoot("")).toBe("/appsmith-stacks/git-storage");
|
||||
expect(getGitRoot("")).toBe("/appsmith-stacks/git-storage");
|
||||
});
|
||||
|
||||
test("Test get gitRoot path when APPSMITH_GIT_ROOT is null ", () => {
|
||||
expect(backup.getGitRoot()).toBe("/appsmith-stacks/git-storage");
|
||||
expect(getGitRoot()).toBe("/appsmith-stacks/git-storage");
|
||||
});
|
||||
|
||||
test("Test get gitRoot path when APPSMITH_GIT_ROOT is defined ", () => {
|
||||
expect(backup.getGitRoot("/my/git/storage")).toBe("/my/git/storage");
|
||||
expect(getGitRoot("/my/git/storage")).toBe("/my/git/storage");
|
||||
});
|
||||
|
||||
test("Test ln command generation", async () => {
|
||||
const gitRoot = "/appsmith-stacks/git-storage";
|
||||
const dest = "/destdir";
|
||||
const cmd = "ln -s /appsmith-stacks/git-storage /destdir/git-storage";
|
||||
const res = await backup.executeCopyCMD(gitRoot, dest);
|
||||
const res = await executeCopyCMD(gitRoot, dest);
|
||||
|
||||
expect(res).toBe(cmd);
|
||||
console.log(res);
|
||||
|
|
@ -102,7 +103,7 @@ describe("Backup Tests", () => {
|
|||
|
||||
test("If MONGODB and Encryption env values are being removed", () => {
|
||||
expect(
|
||||
backup.removeSensitiveEnvData(`APPSMITH_REDIS_URL=redis://127.0.0.1:6379\nAPPSMITH_DB_URL=mongodb://appsmith:pass@localhost:27017/appsmith\nAPPSMITH_MONGODB_USER=appsmith\nAPPSMITH_MONGODB_PASSWORD=pass\nAPPSMITH_INSTANCE_NAME=Appsmith\n
|
||||
removeSensitiveEnvData(`APPSMITH_REDIS_URL=redis://127.0.0.1:6379\nAPPSMITH_DB_URL=mongodb://appsmith:pass@localhost:27017/appsmith\nAPPSMITH_MONGODB_USER=appsmith\nAPPSMITH_MONGODB_PASSWORD=pass\nAPPSMITH_INSTANCE_NAME=Appsmith\n
|
||||
`),
|
||||
).toMatch(
|
||||
`APPSMITH_REDIS_URL=redis://127.0.0.1:6379\nAPPSMITH_INSTANCE_NAME=Appsmith\n`,
|
||||
|
|
@ -111,7 +112,7 @@ describe("Backup Tests", () => {
|
|||
|
||||
test("If MONGODB and Encryption env values are being removed", () => {
|
||||
expect(
|
||||
backup.removeSensitiveEnvData(`APPSMITH_REDIS_URL=redis://127.0.0.1:6379\nAPPSMITH_ENCRYPTION_PASSWORD=dummy-pass\nAPPSMITH_ENCRYPTION_SALT=dummy-salt\nAPPSMITH_DB_URL=mongodb://appsmith:pass@localhost:27017/appsmith\nAPPSMITH_MONGODB_USER=appsmith\nAPPSMITH_MONGODB_PASSWORD=pass\nAPPSMITH_INSTANCE_NAME=Appsmith\n
|
||||
removeSensitiveEnvData(`APPSMITH_REDIS_URL=redis://127.0.0.1:6379\nAPPSMITH_ENCRYPTION_PASSWORD=dummy-pass\nAPPSMITH_ENCRYPTION_SALT=dummy-salt\nAPPSMITH_DB_URL=mongodb://appsmith:pass@localhost:27017/appsmith\nAPPSMITH_MONGODB_USER=appsmith\nAPPSMITH_MONGODB_PASSWORD=pass\nAPPSMITH_INSTANCE_NAME=Appsmith\n
|
||||
`),
|
||||
).toMatch(
|
||||
`APPSMITH_REDIS_URL=redis://127.0.0.1:6379\nAPPSMITH_INSTANCE_NAME=Appsmith\n`,
|
||||
|
|
@ -199,7 +200,7 @@ describe("Backup Tests", () => {
|
|||
const password = "password#4321";
|
||||
|
||||
readlineSync.question = jest.fn().mockImplementation(() => password);
|
||||
const password_res = backup.getEncryptionPasswordFromUser();
|
||||
const password_res = getEncryptionPasswordFromUser();
|
||||
|
||||
expect(password_res).toEqual(password);
|
||||
});
|
||||
|
|
@ -215,13 +216,13 @@ describe("Backup Tests", () => {
|
|||
return password;
|
||||
});
|
||||
|
||||
expect(() => backup.getEncryptionPasswordFromUser()).toThrow();
|
||||
expect(() => getEncryptionPasswordFromUser()).toThrow();
|
||||
});
|
||||
|
||||
test("Get encrypted archive path", async () => {
|
||||
const archivePath = "/rootDir/appsmith-backup-0000-00-0T00-00-00.00Z";
|
||||
const encryptionPassword = "password#4321";
|
||||
const encArchivePath = await backup.encryptBackupArchive(
|
||||
const encArchivePath = await encryptBackupArchive(
|
||||
archivePath,
|
||||
encryptionPassword,
|
||||
);
|
||||
|
|
@ -234,10 +235,7 @@ describe("Backup Tests", () => {
|
|||
test("Test backup encryption function", async () => {
|
||||
const archivePath = "/rootDir/appsmith-backup-0000-00-0T00-00-00.00Z";
|
||||
const encryptionPassword = "password#123";
|
||||
const res = await backup.encryptBackupArchive(
|
||||
archivePath,
|
||||
encryptionPassword,
|
||||
);
|
||||
const res = await encryptBackupArchive(archivePath, encryptionPassword);
|
||||
|
||||
console.log(res);
|
||||
expect(res).toEqual("/rootDir/appsmith-backup-0000-00-0T00-00-00.00Z.enc");
|
||||
|
|
|
|||
|
|
@ -1,14 +1,10 @@
|
|||
import fsPromises from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import * as utils from "../utils";
|
||||
import * as Constants from "../constants";
|
||||
import * as logger from "../logger";
|
||||
import * as mailer from "../mailer";
|
||||
import readlineSync from "readline-sync";
|
||||
import { DiskSpaceLink } from "./links/DiskSpaceLink";
|
||||
import type { Link } from "./links";
|
||||
import { EncryptionLink, ManifestLink } from "./links";
|
||||
import * as linkClasses from "./links";
|
||||
import { BackupState } from "./BackupState";
|
||||
|
||||
export async function run(args: string[]) {
|
||||
|
|
@ -17,9 +13,16 @@ export async function run(args: string[]) {
|
|||
const state: BackupState = new BackupState(args);
|
||||
|
||||
const chain: Link[] = [
|
||||
new DiskSpaceLink(),
|
||||
new ManifestLink(state),
|
||||
new EncryptionLink(state),
|
||||
new linkClasses.BackupFolderLink(state),
|
||||
new linkClasses.DiskSpaceLink(),
|
||||
new linkClasses.ManifestLink(state),
|
||||
new linkClasses.MongoDumpLink(state),
|
||||
new linkClasses.GitStorageLink(state),
|
||||
new linkClasses.EnvFileLink(state),
|
||||
|
||||
// Encryption link is best placed last so if any of the above links fail, we don't ask the user for a password and
|
||||
// then do nothing with it.
|
||||
new linkClasses.EncryptionLink(state),
|
||||
];
|
||||
|
||||
try {
|
||||
|
|
@ -29,19 +32,6 @@ export async function run(args: string[]) {
|
|||
}
|
||||
|
||||
// BACKUP
|
||||
state.backupRootPath = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), "appsmithctl-backup-"),
|
||||
);
|
||||
|
||||
await exportDatabase(state.backupRootPath);
|
||||
|
||||
await createGitStorageArchive(state.backupRootPath);
|
||||
|
||||
await exportDockerEnvFile(
|
||||
state.backupRootPath,
|
||||
state.isEncryptionEnabled(),
|
||||
);
|
||||
|
||||
for (const link of chain) {
|
||||
await link.doBackup?.();
|
||||
}
|
||||
|
|
@ -58,23 +48,6 @@ export async function run(args: string[]) {
|
|||
|
||||
console.log("Post-backup done. Final archive at", state.archivePath);
|
||||
|
||||
if (!state.isEncryptionEnabled()) {
|
||||
console.log(
|
||||
"********************************************************* IMPORTANT!!! *************************************************************",
|
||||
);
|
||||
console.log(
|
||||
"*** Please ensure you have saved the APPSMITH_ENCRYPTION_SALT and APPSMITH_ENCRYPTION_PASSWORD variables from the docker.env file **",
|
||||
);
|
||||
console.log(
|
||||
"*** These values are not included in the backup export. **",
|
||||
);
|
||||
console.log(
|
||||
"************************************************************************************************************************************",
|
||||
);
|
||||
}
|
||||
|
||||
await fsPromises.rm(state.backupRootPath, { recursive: true, force: true });
|
||||
|
||||
await logger.backup_info(
|
||||
"Finished taking a backup at " + state.archivePath,
|
||||
);
|
||||
|
|
@ -116,118 +89,6 @@ export async function run(args: string[]) {
|
|||
}
|
||||
}
|
||||
|
||||
export async function encryptBackupArchive(
|
||||
archivePath: string,
|
||||
encryptionPassword: string,
|
||||
) {
|
||||
const encryptedArchivePath = archivePath + ".enc";
|
||||
|
||||
await utils.execCommand([
|
||||
"openssl",
|
||||
"enc",
|
||||
"-aes-256-cbc",
|
||||
"-pbkdf2",
|
||||
"-iter",
|
||||
"100000",
|
||||
"-in",
|
||||
archivePath,
|
||||
"-out",
|
||||
encryptedArchivePath,
|
||||
"-k",
|
||||
encryptionPassword,
|
||||
]);
|
||||
|
||||
return encryptedArchivePath;
|
||||
}
|
||||
|
||||
export function getEncryptionPasswordFromUser(): string {
|
||||
for (const attempt of [1, 2, 3]) {
|
||||
if (attempt > 1) {
|
||||
console.log("Retry attempt", attempt);
|
||||
}
|
||||
|
||||
const encryptionPwd1: string = readlineSync.question(
|
||||
"Enter a password to encrypt the backup archive: ",
|
||||
{ hideEchoBack: true },
|
||||
);
|
||||
const encryptionPwd2: string = readlineSync.question(
|
||||
"Enter the above password again: ",
|
||||
{ hideEchoBack: true },
|
||||
);
|
||||
|
||||
if (encryptionPwd1 === encryptionPwd2) {
|
||||
if (encryptionPwd1) {
|
||||
return encryptionPwd1;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Invalid input. Empty password is not allowed, please try again.",
|
||||
);
|
||||
} else {
|
||||
console.error("The passwords do not match, please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Aborting backup process, failed to obtain valid encryption password.",
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
"Backup process aborted because a valid encryption password could not be obtained from the user",
|
||||
);
|
||||
}
|
||||
|
||||
async function exportDatabase(destFolder: string) {
|
||||
console.log("Exporting database");
|
||||
await executeMongoDumpCMD(destFolder, utils.getDburl());
|
||||
console.log("Exporting database done.");
|
||||
}
|
||||
|
||||
async function createGitStorageArchive(destFolder: string) {
|
||||
console.log("Creating git-storage archive");
|
||||
|
||||
const gitRoot = getGitRoot(process.env.APPSMITH_GIT_ROOT);
|
||||
|
||||
await executeCopyCMD(gitRoot, destFolder);
|
||||
|
||||
console.log("Created git-storage archive");
|
||||
}
|
||||
|
||||
async function exportDockerEnvFile(
|
||||
destFolder: string,
|
||||
encryptArchive: boolean,
|
||||
) {
|
||||
console.log("Exporting docker environment file");
|
||||
const content = await fsPromises.readFile(
|
||||
"/appsmith-stacks/configuration/docker.env",
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
let cleaned_content = removeSensitiveEnvData(content);
|
||||
|
||||
if (encryptArchive) {
|
||||
cleaned_content +=
|
||||
"\nAPPSMITH_ENCRYPTION_SALT=" +
|
||||
process.env.APPSMITH_ENCRYPTION_SALT +
|
||||
"\nAPPSMITH_ENCRYPTION_PASSWORD=" +
|
||||
process.env.APPSMITH_ENCRYPTION_PASSWORD;
|
||||
}
|
||||
|
||||
await fsPromises.writeFile(destFolder + "/docker.env", cleaned_content);
|
||||
console.log("Exporting docker environment file done.");
|
||||
}
|
||||
|
||||
export async function executeMongoDumpCMD(
|
||||
destFolder: string,
|
||||
appsmithMongoURI: string,
|
||||
) {
|
||||
return await utils.execCommand([
|
||||
"mongodump",
|
||||
`--uri=${appsmithMongoURI}`,
|
||||
`--archive=${destFolder}/mongodb-data.gz`,
|
||||
"--gzip",
|
||||
]); // generate cmd
|
||||
}
|
||||
|
||||
async function createFinalArchive(destFolder: string, timestamp: string) {
|
||||
console.log("Creating final archive");
|
||||
|
||||
|
|
@ -260,23 +121,6 @@ async function postBackupCleanup() {
|
|||
console.log("Cleanup completed.");
|
||||
}
|
||||
|
||||
export async function executeCopyCMD(srcFolder: string, destFolder: string) {
|
||||
return await utils.execCommand([
|
||||
"ln",
|
||||
"-s",
|
||||
srcFolder,
|
||||
path.join(destFolder, "git-storage"),
|
||||
]);
|
||||
}
|
||||
|
||||
export function getGitRoot(gitRoot?: string | undefined) {
|
||||
if (gitRoot == null || gitRoot === "") {
|
||||
gitRoot = "/appsmith-stacks/git-storage";
|
||||
}
|
||||
|
||||
return gitRoot;
|
||||
}
|
||||
|
||||
export function getBackupContentsPath(
|
||||
backupRootPath: string,
|
||||
timestamp: string,
|
||||
|
|
@ -284,23 +128,6 @@ export function getBackupContentsPath(
|
|||
return backupRootPath + "/appsmith-backup-" + timestamp;
|
||||
}
|
||||
|
||||
export function removeSensitiveEnvData(content: string): string {
|
||||
// Remove encryption and Mongodb data from docker.env
|
||||
const output_lines = [];
|
||||
|
||||
content.split(/\r?\n/).forEach((line) => {
|
||||
if (
|
||||
!line.startsWith("APPSMITH_ENCRYPTION") &&
|
||||
!line.startsWith("APPSMITH_MONGODB") &&
|
||||
!line.startsWith("APPSMITH_DB_URL=")
|
||||
) {
|
||||
output_lines.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
return output_lines.join("\n");
|
||||
}
|
||||
|
||||
export function getBackupArchiveLimit(backupArchivesLimit?: number): number {
|
||||
return backupArchivesLimit || Constants.APPSMITH_DEFAULT_BACKUP_ARCHIVE_LIMIT;
|
||||
}
|
||||
|
|
@ -318,23 +145,3 @@ export async function removeOldBackups(
|
|||
.map(async (file) => fsPromises.rm(file)),
|
||||
);
|
||||
}
|
||||
|
||||
export function getTimeStampInISO() {
|
||||
return new Date().toISOString().replace(/:/g, "-");
|
||||
}
|
||||
|
||||
export async function getAvailableBackupSpaceInBytes(
|
||||
path: string,
|
||||
): Promise<number> {
|
||||
const stat = await fsPromises.statfs(path);
|
||||
|
||||
return stat.bsize * stat.bfree;
|
||||
}
|
||||
|
||||
export function checkAvailableBackupSpace(availSpaceInBytes: number) {
|
||||
if (availSpaceInBytes < Constants.MIN_REQUIRED_DISK_SPACE_IN_BYTES) {
|
||||
throw new Error(
|
||||
"Not enough space available at /appsmith-stacks. Please ensure availability of at least 2GB to backup successfully.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,26 @@
|
|||
import type { Link } from ".";
|
||||
import type { BackupState } from "../BackupState";
|
||||
import fsPromises from "fs/promises";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
|
||||
/**
|
||||
* Creates the backup folder in pre step, and deletes it in post step. The existence of the backup folder should only
|
||||
* be assumed in the "doBackup" step, and no other.
|
||||
*/
|
||||
export class BackupFolderLink implements Link {
|
||||
constructor(private readonly state: BackupState) {}
|
||||
|
||||
async preBackup() {
|
||||
this.state.backupRootPath = await fsPromises.mkdtemp(
|
||||
path.join(os.tmpdir(), "appsmithctl-backup-"),
|
||||
);
|
||||
}
|
||||
|
||||
async postBackup() {
|
||||
await fsPromises.rm(this.state.backupRootPath, {
|
||||
recursive: true,
|
||||
force: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,10 @@
|
|||
import { checkAvailableBackupSpace, getAvailableBackupSpaceInBytes } from "..";
|
||||
import type { Link } from ".";
|
||||
import * as Constants from "../../constants";
|
||||
import fsPromises from "fs/promises";
|
||||
|
||||
/**
|
||||
* Checks if there is enough space available at the backup location.
|
||||
*/
|
||||
export class DiskSpaceLink implements Link {
|
||||
async preBackup() {
|
||||
const availSpaceInBytes: number =
|
||||
|
|
@ -9,3 +13,19 @@ export class DiskSpaceLink implements Link {
|
|||
checkAvailableBackupSpace(availSpaceInBytes);
|
||||
}
|
||||
}
|
||||
|
||||
export async function getAvailableBackupSpaceInBytes(
|
||||
path: string,
|
||||
): Promise<number> {
|
||||
const stat = await fsPromises.statfs(path);
|
||||
|
||||
return stat.bsize * stat.bfree;
|
||||
}
|
||||
|
||||
export function checkAvailableBackupSpace(availSpaceInBytes: number) {
|
||||
if (availSpaceInBytes < Constants.MIN_REQUIRED_DISK_SPACE_IN_BYTES) {
|
||||
throw new Error(
|
||||
"Not enough space available at /appsmith-stacks. Please ensure availability of at least 2GB to backup successfully.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,17 @@
|
|||
import type { Link } from "./index";
|
||||
import type { Link } from ".";
|
||||
import tty from "tty";
|
||||
import fsPromises from "fs/promises";
|
||||
import { encryptBackupArchive, getEncryptionPasswordFromUser } from "../index";
|
||||
import type { BackupState } from "../BackupState";
|
||||
import readlineSync from "readline-sync";
|
||||
import * as utils from "../../utils";
|
||||
|
||||
/**
|
||||
* Asks the user for a password, and then encrypts the backup archive using openssl, with that password. If a TTY is not
|
||||
* available to ask for a password, then this feature is gracefully disabled, and encryption is not performed.
|
||||
*/
|
||||
export class EncryptionLink implements Link {
|
||||
#password: string = "";
|
||||
|
||||
constructor(private readonly state: BackupState) {}
|
||||
|
||||
async preBackup() {
|
||||
|
|
@ -12,12 +19,14 @@ export class EncryptionLink implements Link {
|
|||
!this.state.args.includes("--non-interactive") &&
|
||||
tty.isatty((process.stdout as any).fd)
|
||||
) {
|
||||
this.state.encryptionPassword = getEncryptionPasswordFromUser();
|
||||
this.#password = getEncryptionPasswordFromUser();
|
||||
}
|
||||
|
||||
this.state.isEncryptionEnabled = !!this.#password;
|
||||
}
|
||||
|
||||
async postBackup() {
|
||||
if (!this.state.isEncryptionEnabled()) {
|
||||
if (!this.#password) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -25,7 +34,7 @@ export class EncryptionLink implements Link {
|
|||
|
||||
this.state.archivePath = await encryptBackupArchive(
|
||||
unencryptedArchivePath,
|
||||
this.state.encryptionPassword,
|
||||
this.#password,
|
||||
);
|
||||
|
||||
await fsPromises.rm(unencryptedArchivePath, {
|
||||
|
|
@ -34,3 +43,64 @@ export class EncryptionLink implements Link {
|
|||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getEncryptionPasswordFromUser(): string {
|
||||
for (const attempt of [1, 2, 3]) {
|
||||
if (attempt > 1) {
|
||||
console.log("Retry attempt", attempt);
|
||||
}
|
||||
|
||||
const encryptionPwd1: string = readlineSync.question(
|
||||
"Enter a password to encrypt the backup archive: ",
|
||||
{ hideEchoBack: true },
|
||||
);
|
||||
const encryptionPwd2: string = readlineSync.question(
|
||||
"Enter the above password again: ",
|
||||
{ hideEchoBack: true },
|
||||
);
|
||||
|
||||
if (encryptionPwd1 === encryptionPwd2) {
|
||||
if (encryptionPwd1) {
|
||||
return encryptionPwd1;
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Invalid input. Empty password is not allowed, please try again.",
|
||||
);
|
||||
} else {
|
||||
console.error("The passwords do not match, please try again.");
|
||||
}
|
||||
}
|
||||
|
||||
console.error(
|
||||
"Aborting backup process, failed to obtain valid encryption password.",
|
||||
);
|
||||
|
||||
throw new Error(
|
||||
"Backup process aborted because a valid encryption password could not be obtained from the user",
|
||||
);
|
||||
}
|
||||
|
||||
export async function encryptBackupArchive(
|
||||
archivePath: string,
|
||||
encryptionPassword: string,
|
||||
) {
|
||||
const encryptedArchivePath = archivePath + ".enc";
|
||||
|
||||
await utils.execCommand([
|
||||
"openssl",
|
||||
"enc",
|
||||
"-aes-256-cbc",
|
||||
"-pbkdf2",
|
||||
"-iter",
|
||||
"100000",
|
||||
"-in",
|
||||
archivePath,
|
||||
"-out",
|
||||
encryptedArchivePath,
|
||||
"-k",
|
||||
encryptionPassword,
|
||||
]);
|
||||
|
||||
return encryptedArchivePath;
|
||||
}
|
||||
|
|
|
|||
65
app/client/packages/rts/src/ctl/backup/links/EnvFileLink.ts
Normal file
65
app/client/packages/rts/src/ctl/backup/links/EnvFileLink.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import type { Link } from ".";
|
||||
import type { BackupState } from "../BackupState";
|
||||
import fsPromises from "fs/promises";
|
||||
|
||||
const SECRETS_WARNING = `
|
||||
***************************** IMPORTANT!!! *****************************
|
||||
*** Please ensure you have saved the APPSMITH_ENCRYPTION_SALT and ***
|
||||
*** APPSMITH_ENCRYPTION_PASSWORD variables from the docker.env file. ***
|
||||
*** These values are not included in the backup export. ***
|
||||
************************************************************************
|
||||
`;
|
||||
|
||||
/**
|
||||
* Exports the docker environment file to the backup folder. If encryption is not enabled, sensitive information is
|
||||
* not written to the backup folder.
|
||||
*/
|
||||
export class EnvFileLink implements Link {
|
||||
constructor(private readonly state: BackupState) {}
|
||||
|
||||
async doBackup() {
|
||||
console.log("Exporting docker environment file");
|
||||
const content = await fsPromises.readFile(
|
||||
"/appsmith-stacks/configuration/docker.env",
|
||||
{ encoding: "utf8" },
|
||||
);
|
||||
let cleanedContent = removeSensitiveEnvData(content);
|
||||
|
||||
if (this.state.isEncryptionEnabled) {
|
||||
cleanedContent +=
|
||||
"\nAPPSMITH_ENCRYPTION_SALT=" +
|
||||
process.env.APPSMITH_ENCRYPTION_SALT +
|
||||
"\nAPPSMITH_ENCRYPTION_PASSWORD=" +
|
||||
process.env.APPSMITH_ENCRYPTION_PASSWORD;
|
||||
}
|
||||
|
||||
await fsPromises.writeFile(
|
||||
this.state.backupRootPath + "/docker.env",
|
||||
cleanedContent,
|
||||
);
|
||||
console.log("Exporting docker environment file done.");
|
||||
}
|
||||
|
||||
async postBackup() {
|
||||
if (!this.state.isEncryptionEnabled) {
|
||||
console.log(SECRETS_WARNING);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function removeSensitiveEnvData(content: string): string {
|
||||
// Remove encryption and Mongodb data from docker.env
|
||||
const output_lines = [];
|
||||
|
||||
content.split(/\r?\n/).forEach((line) => {
|
||||
if (
|
||||
!line.startsWith("APPSMITH_ENCRYPTION") &&
|
||||
!line.startsWith("APPSMITH_MONGODB") &&
|
||||
!line.startsWith("APPSMITH_DB_URL=")
|
||||
) {
|
||||
output_lines.push(line);
|
||||
}
|
||||
});
|
||||
|
||||
return output_lines.join("\n");
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
import type { Link } from ".";
|
||||
import type { BackupState } from "../BackupState";
|
||||
import * as utils from "../../utils";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Copies the `git-storage` folder to the backup folder.
|
||||
*/
|
||||
export class GitStorageLink implements Link {
|
||||
constructor(private readonly state: BackupState) {}
|
||||
|
||||
async doBackup() {
|
||||
console.log("Creating git-storage archive");
|
||||
|
||||
const gitRoot = getGitRoot(process.env.APPSMITH_GIT_ROOT);
|
||||
|
||||
await executeCopyCMD(gitRoot, this.state.backupRootPath);
|
||||
console.log("Created git-storage archive");
|
||||
}
|
||||
}
|
||||
|
||||
export function getGitRoot(gitRoot?: string | undefined) {
|
||||
if (gitRoot == null || gitRoot === "") {
|
||||
gitRoot = "/appsmith-stacks/git-storage";
|
||||
}
|
||||
|
||||
return gitRoot;
|
||||
}
|
||||
|
||||
export async function executeCopyCMD(srcFolder: string, destFolder: string) {
|
||||
return await utils.execCommand([
|
||||
"ln",
|
||||
"-s",
|
||||
srcFolder,
|
||||
path.join(destFolder, "git-storage"),
|
||||
]);
|
||||
}
|
||||
|
|
@ -1,9 +1,12 @@
|
|||
import type { Link } from "./index";
|
||||
import type { Link } from ".";
|
||||
import type { BackupState } from "../BackupState";
|
||||
import * as utils from "../../utils";
|
||||
import fsPromises from "fs/promises";
|
||||
import path from "path";
|
||||
|
||||
/**
|
||||
* Creates a manifest file that contains metadata about the backup.
|
||||
*/
|
||||
export class ManifestLink implements Link {
|
||||
constructor(private readonly state: BackupState) {}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,25 @@
|
|||
import type { Link } from ".";
|
||||
import type { BackupState } from "../BackupState";
|
||||
import * as utils from "../../utils";
|
||||
|
||||
/**
|
||||
* Exports the MongoDB database data using mongodump.
|
||||
*/
|
||||
export class MongoDumpLink implements Link {
|
||||
constructor(private readonly state: BackupState) {}
|
||||
|
||||
async doBackup() {
|
||||
console.log("Exporting database");
|
||||
await executeMongoDumpCMD(this.state.backupRootPath, utils.getDburl());
|
||||
console.log("Exporting database done.");
|
||||
}
|
||||
}
|
||||
|
||||
export async function executeMongoDumpCMD(destFolder: string, dbUrl: string) {
|
||||
return await utils.execCommand([
|
||||
"mongodump",
|
||||
`--uri=${dbUrl}`,
|
||||
`--archive=${destFolder}/mongodb-data.gz`,
|
||||
"--gzip",
|
||||
]);
|
||||
}
|
||||
|
|
@ -9,5 +9,10 @@ export interface Link {
|
|||
postBackup?(): Promise<void>;
|
||||
}
|
||||
|
||||
export { EncryptionLink } from "./EncryptionLink";
|
||||
export { ManifestLink } from "./ManifestLink";
|
||||
export * from "./BackupFolderLink";
|
||||
export * from "./DiskSpaceLink";
|
||||
export * from "./EncryptionLink";
|
||||
export * from "./EnvFileLink";
|
||||
export * from "./GitStorageLink";
|
||||
export * from "./ManifestLink";
|
||||
export * from "./MongoDumpLink";
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user