Files
HiveGrid-Wireframes/hivegrid-cloud/packages/cli/src/commands/backup/create.ts
T

178 lines
6.5 KiB
TypeScript

import { defineCommand } from "citty";
import consola from "consola";
import { randomBytes } from "node:crypto";
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { create as createTar } from "tar";
import { hostname } from "node:os";
import { S3Client } from "@aws-sdk/client-s3";
import {
BACKUP_MANIFEST_VERSION,
type BackupManifest,
classifyBackup,
serializeManifest,
} from "./manifest.js";
import { dumpPostgresToFile } from "./postgres.js";
import { backupStorageBucket } from "./storage.js";
import { maybeSnapshotClickhouse } from "./clickhouse.js";
import { encryptFile } from "./encryption.js";
import { uploadFileMultipart } from "./s3-upload.js";
import { resolveBackupConfig, type BackupEnv } from "./env.js";
export interface RunCreateBackupOptions {
env?: BackupEnv;
/** Inject a pre-built S3 client (tests). */
client?: S3Client;
/** Override now() for deterministic naming. */
now?: () => Date;
/** Skip storage component (CLI --skip-storage flag). */
skipStorage?: boolean;
/** Logger override (tests). */
logger?: { info: (msg: string) => void; warn: (msg: string) => void };
}
export interface RunCreateBackupResult {
backupId: string;
key: string;
sizeBytes: number;
manifest: BackupManifest;
}
/** Run the backup-create flow. Exposed as a function so other commands and tests can re-use it. */
export async function runCreateBackup(opts: RunCreateBackupOptions = {}): Promise<RunCreateBackupResult> {
const env = opts.env ?? (process.env as unknown as BackupEnv);
const logger = opts.logger ?? consola.create({ defaults: { tag: "backup:create" } });
const config = resolveBackupConfig(env);
const now = (opts.now ?? (() => new Date()))();
const createdAt = now.toISOString();
const shortUuid = randomBytes(4).toString("hex");
const backupId = `backup-${createdAt.replace(/[:.]/gu, "")}-${shortUuid}`;
const objectKey = `${backupId}.tar.gz${config.encryption.method === "none" ? "" : `.${config.encryption.method}`}`;
const tempRoot = await mkdtemp(join(tmpdir(), "backup-create-"));
try {
const stageDir = join(tempRoot, backupId);
await mkdir(stageDir, { recursive: true });
logger.info(`Dumping Postgres into ${stageDir}/postgres/dump.sql.gz`);
const pgDir = join(stageDir, "postgres");
await mkdir(pgDir, { recursive: true });
const pgPath = join(pgDir, "dump.sql.gz");
const pgResult = await dumpPostgresToFile({ connectionString: config.databaseUrl, outPath: pgPath });
let storageEntry: BackupManifest["components"]["storage"] | undefined;
if (!opts.skipStorage && config.appStorageBucket) {
logger.info(`Backing up storage bucket ${config.appStorageBucket}`);
const storageDir = join(stageDir, "storage");
const storageResult = await backupStorageBucket({
s3: config.s3,
bucket: config.appStorageBucket,
outDir: storageDir,
client: opts.client,
});
storageEntry = {
path: "storage/objects.tar",
sizeBytes: storageResult.totalBytes,
checksum: storageResult.checksum,
objectCount: storageResult.objectCount,
};
} else if (opts.skipStorage) {
logger.warn("--skip-storage flag set; skipping storage component");
} else {
logger.warn("STORAGE_BUCKET env not set; skipping storage component");
}
let clickhouseEntry: BackupManifest["components"]["clickhouse"] | undefined;
if (config.includeClickhouse) {
logger.info("Snapshotting ClickHouse");
const chDir = join(stageDir, "clickhouse");
const chResult = await maybeSnapshotClickhouse({ outDir: chDir, env: env as never });
if (chResult) {
clickhouseEntry = {
path: "clickhouse/snapshot.native.gz",
sizeBytes: chResult.sizeBytes,
checksum: chResult.checksum,
};
}
}
const manifest: BackupManifest = {
schemaVersion: BACKUP_MANIFEST_VERSION,
backupId,
createdAt,
creator: { cliVersion: "0.0.0", host: hostname() },
classification: classifyBackup(createdAt),
components: {
postgres: {
path: "postgres/dump.sql.gz",
sizeBytes: pgResult.sizeBytes,
checksum: pgResult.checksum,
},
...(storageEntry ? { storage: storageEntry } : {}),
...(clickhouseEntry ? { clickhouse: clickhouseEntry } : {}),
},
encryption: {
method: config.encryption.method,
recipient: config.encryption.recipient,
},
};
const manifestPath = join(stageDir, "manifest.json");
await writeFile(manifestPath, serializeManifest(manifest));
const archivePath = join(tempRoot, `${backupId}.tar.gz`);
logger.info(`Packaging ${archivePath}`);
await createTar(
{ gzip: true, file: archivePath, cwd: tempRoot, portable: true },
[backupId],
);
let uploadPath = archivePath;
if (config.encryption.method !== "none") {
const encryptedPath = `${archivePath}.${config.encryption.method}`;
logger.info(`Encrypting via ${config.encryption.method}`);
await encryptFile({ config: config.encryption, inputPath: archivePath, outputPath: encryptedPath });
uploadPath = encryptedPath;
}
const client = opts.client ?? new S3Client(config.s3);
logger.info(`Uploading to s3://${config.backupBucket}/${objectKey}`);
await uploadFileMultipart({
client,
bucket: config.backupBucket,
key: objectKey,
filePath: uploadPath,
contentType: "application/gzip",
metadata: {
"backup-id": backupId,
"manifest-version": String(BACKUP_MANIFEST_VERSION),
encryption: config.encryption.method,
classification: manifest.classification.join(","),
},
});
const uploadStat = await readFile(uploadPath).then((b) => b.length);
return { backupId, key: objectKey, sizeBytes: uploadStat, manifest };
} finally {
await rm(tempRoot, { recursive: true, force: true });
}
}
export const createBackupCommand = defineCommand({
meta: {
name: "create",
description: "Create a new backup (Postgres + Storage) and upload to S3.",
},
args: {
"skip-storage": {
type: "boolean",
description: "Do not include the application storage bucket.",
default: false,
},
},
async run({ args }) {
const result = await runCreateBackup({ skipStorage: Boolean(args["skip-storage"]) });
consola.success(`Created ${result.backupId} (${result.sizeBytes} bytes) at key ${result.key}`);
},
});