refactor: use spawn & exec instead of bun $ (#356)
* refactor: use spawn & exec accordingly * chore: pr feedbacks
This commit is contained in:
parent
2cbd0d1021
commit
d3f9ced5dc
9 changed files with 108 additions and 109 deletions
|
|
@ -1,6 +1,5 @@
|
|||
import * as fs from "node:fs/promises";
|
||||
import * as os from "node:os";
|
||||
import { $ } from "bun";
|
||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
|
|
@ -9,6 +8,7 @@ import { withTimeout } from "../../../utils/timeout";
|
|||
import type { VolumeBackend } from "../backend";
|
||||
import { executeUnmount } from "../utils/backend-utils";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
import { exec } from "~/server/utils/spawn";
|
||||
|
||||
const mount = async (config: BackendConfig, path: string) => {
|
||||
logger.debug(`Mounting rclone volume ${path}...`);
|
||||
|
|
@ -50,7 +50,7 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||
logger.debug(`Mounting rclone volume ${path}...`);
|
||||
logger.info(`Executing rclone: rclone ${args.join(" ")}`);
|
||||
|
||||
const result = await $`rclone ${args}`.nothrow();
|
||||
const result = await exec({ command: "rclone", args });
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
const errorMsg = result.stderr.toString() || result.stdout.toString() || "Unknown error";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import * as fs from "node:fs/promises";
|
||||
import * as npath from "node:path";
|
||||
import { $ } from "bun";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { exec } from "~/server/utils/spawn";
|
||||
|
||||
export const executeMount = async (args: string[]): Promise<void> => {
|
||||
const shouldBeVerbose = process.env.LOG_LEVEL === "debug" || process.env.NODE_ENV !== "production";
|
||||
|
|
@ -10,7 +10,7 @@ export const executeMount = async (args: string[]): Promise<void> => {
|
|||
const effectiveArgs = shouldBeVerbose && !hasVerboseFlag ? ["-v", ...args] : args;
|
||||
|
||||
logger.debug(`Executing mount ${effectiveArgs.join(" ")}`);
|
||||
let result = await $`mount ${effectiveArgs}`.nothrow();
|
||||
const result = await exec({ command: "mount", args: effectiveArgs, timeout: 10000 });
|
||||
|
||||
const stdout = result.stdout.toString().trim();
|
||||
const stderr = result.stderr.toString().trim();
|
||||
|
|
@ -31,7 +31,8 @@ export const executeUnmount = async (path: string): Promise<void> => {
|
|||
let stderr: string | undefined;
|
||||
|
||||
logger.debug(`Executing umount -l ${path}`);
|
||||
const result = await $`umount -l ${path}`.nothrow();
|
||||
const result = await exec({ command: "umount", args: ["-l", path], timeout: 10000 });
|
||||
|
||||
stderr = result.stderr.toString();
|
||||
|
||||
if (stderr?.trim()) {
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { generateBackupOutput } from "~/test/helpers/restic";
|
|||
import { faker } from "@faker-js/faker";
|
||||
import * as spawnModule from "~/server/utils/spawn";
|
||||
|
||||
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }));
|
||||
const resticBackupMock = mock(() => Promise.resolve({ exitCode: 0, summary: "", error: "" }));
|
||||
|
||||
beforeEach(() => {
|
||||
resticBackupMock.mockClear();
|
||||
|
|
@ -31,7 +31,7 @@ describe("execute backup", () => {
|
|||
expect(schedule.nextBackupAt).toBeNull();
|
||||
|
||||
resticBackupMock.mockImplementationOnce(() =>
|
||||
Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }),
|
||||
Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }),
|
||||
);
|
||||
|
||||
// act
|
||||
|
|
@ -76,7 +76,7 @@ describe("execute backup", () => {
|
|||
});
|
||||
|
||||
resticBackupMock.mockImplementationOnce(() =>
|
||||
Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" }),
|
||||
Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" }),
|
||||
);
|
||||
|
||||
// act
|
||||
|
|
@ -97,7 +97,7 @@ describe("execute backup", () => {
|
|||
|
||||
resticBackupMock.mockImplementation(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
return Promise.resolve({ exitCode: 0, stdout: generateBackupOutput(), stderr: "" });
|
||||
return Promise.resolve({ exitCode: 0, summary: generateBackupOutput(), error: "" });
|
||||
});
|
||||
|
||||
// act
|
||||
|
|
@ -119,7 +119,7 @@ describe("execute backup", () => {
|
|||
});
|
||||
|
||||
resticBackupMock.mockImplementationOnce(() =>
|
||||
Promise.resolve({ exitCode: 3, stdout: generateBackupOutput(), stderr: "Some error occurred" }),
|
||||
Promise.resolve({ exitCode: 3, summary: generateBackupOutput(), error: "Some error occurred" }),
|
||||
);
|
||||
|
||||
// act
|
||||
|
|
@ -140,7 +140,7 @@ describe("execute backup", () => {
|
|||
});
|
||||
|
||||
resticBackupMock.mockImplementationOnce(() =>
|
||||
Promise.resolve({ exitCode: 1, stdout: generateBackupOutput(), stderr: "Some error occurred" }),
|
||||
Promise.resolve({ exitCode: 1, summary: generateBackupOutput(), error: "Some error occurred" }),
|
||||
);
|
||||
|
||||
// act
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { db } from "../../../db/db";
|
|||
import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../../db/schema";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { safeSpawn } from "~/server/utils/spawn";
|
||||
import { exec } from "~/server/utils/spawn";
|
||||
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic";
|
||||
|
||||
const migrateTag = async (
|
||||
|
|
@ -20,7 +20,7 @@ const migrateTag = async (
|
|||
addCommonArgs(args, env);
|
||||
|
||||
logger.info(`Migrating snapshots for schedule '${scheduleName}' from tag '${oldTag}' to '${newTag}'`);
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
import { $ } from "bun";
|
||||
import { logger } from "./logger";
|
||||
import { toMessage } from "./errors";
|
||||
import { exec } from "./spawn";
|
||||
|
||||
/**
|
||||
* List all configured rclone remotes
|
||||
* @returns Array of remote names
|
||||
*/
|
||||
export async function listRcloneRemotes(): Promise<string[]> {
|
||||
const result = await $`rclone listremotes`.nothrow();
|
||||
const result = await exec({ command: "rclone", args: ["listremotes"] });
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.error(`Failed to list rclone remotes: ${result.stderr.toString()}`);
|
||||
|
|
@ -34,7 +34,7 @@ export async function getRcloneRemoteInfo(
|
|||
remote: string,
|
||||
): Promise<{ type: string; config: Record<string, string> } | null> {
|
||||
try {
|
||||
const result = await $`rclone config show ${remote}`.quiet();
|
||||
const result = await exec({ command: "rclone", args: ["config", "show", remote] });
|
||||
|
||||
if (result.exitCode !== 0) {
|
||||
logger.error(`Failed to get info for remote ${remote}: ${result.stderr.toString()}`);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { config as appConfig } from "../core/config";
|
|||
import { logger } from "./logger";
|
||||
import { cryptoUtils } from "./crypto";
|
||||
import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
||||
import { safeSpawn } from "./spawn";
|
||||
import { safeSpawn, exec } from "./spawn";
|
||||
import type { CompressionMode, RepositoryConfig, OverwriteMode, BandwidthLimit } from "~/schemas/restic";
|
||||
import { ResticError } from "./errors";
|
||||
|
||||
|
|
@ -231,7 +231,7 @@ const init = async (config: RepositoryConfig) => {
|
|||
const args = ["init", "--repo", repoUrl];
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
|
|
@ -341,8 +341,6 @@ const backup = async (
|
|||
}
|
||||
}, 1000);
|
||||
|
||||
let stdout = "";
|
||||
|
||||
logger.debug(`Executing: restic ${args.join(" ")}`);
|
||||
const res = await safeSpawn({
|
||||
command: "restic",
|
||||
|
|
@ -350,37 +348,34 @@ const backup = async (
|
|||
env,
|
||||
signal: options?.signal,
|
||||
onStdout: (data) => {
|
||||
stdout = data;
|
||||
logData(data);
|
||||
|
||||
if (options?.onProgress) {
|
||||
streamProgress(data);
|
||||
}
|
||||
},
|
||||
finally: async () => {
|
||||
if (includeFile) await fs.unlink(includeFile).catch(() => {});
|
||||
if (excludeFile) await fs.unlink(excludeFile).catch(() => {});
|
||||
await cleanupTemporaryKeys(env);
|
||||
},
|
||||
});
|
||||
|
||||
if (includeFile) await fs.unlink(includeFile).catch(() => {});
|
||||
if (excludeFile) await fs.unlink(excludeFile).catch(() => {});
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (options?.signal?.aborted) {
|
||||
logger.warn("Restic backup was aborted by signal.");
|
||||
return { result: null, exitCode: res.exitCode };
|
||||
}
|
||||
|
||||
if (res.exitCode === 3) {
|
||||
logger.error(`Restic backup encountered read errors: ${res.stderr}`);
|
||||
logger.error(`Restic backup encountered read errors: ${res.error}`);
|
||||
}
|
||||
|
||||
if (res.exitCode !== 0 && res.exitCode !== 3) {
|
||||
logger.error(`Restic backup failed: ${res.stderr}`);
|
||||
logger.error(`Restic backup failed: ${res.error}`);
|
||||
logger.error(`Command executed: restic ${args.join(" ")}`);
|
||||
|
||||
throw new ResticError(res.exitCode, res.stderr);
|
||||
throw new ResticError(res.exitCode, res.error);
|
||||
}
|
||||
|
||||
const lastLine = (stdout || res.stdout).trim();
|
||||
const lastLine = res.summary.trim();
|
||||
let summaryLine = "";
|
||||
try {
|
||||
const resSummary = JSON.parse(lastLine ?? "{}");
|
||||
|
|
@ -390,6 +385,7 @@ const backup = async (
|
|||
summaryLine = "{}";
|
||||
}
|
||||
|
||||
logger.debug(`Restic backup output last line: ${summaryLine}`);
|
||||
const result = backupOutputSchema(summaryLine);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
|
|
@ -461,27 +457,22 @@ const restore = async (
|
|||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic restore failed: ${res.stderr}`);
|
||||
throw new ResticError(res.exitCode, res.stderr);
|
||||
logger.error(`Restic restore failed: ${res.error}`);
|
||||
throw new ResticError(res.exitCode, res.error);
|
||||
}
|
||||
|
||||
const outputLines = res.stdout.trim().split("\n");
|
||||
const lastLine = outputLines[outputLines.length - 1];
|
||||
|
||||
if (!lastLine) {
|
||||
logger.info(`Restic restore completed for snapshot ${snapshotId} to target ${target}`);
|
||||
return {
|
||||
message_type: "summary" as const,
|
||||
total_files: 0,
|
||||
files_restored: 0,
|
||||
files_skipped: 0,
|
||||
bytes_skipped: 0,
|
||||
};
|
||||
const lastLine = res.summary.trim();
|
||||
let summaryLine = "";
|
||||
try {
|
||||
const resSummary = JSON.parse(lastLine ?? "{}");
|
||||
summaryLine = resSummary;
|
||||
} catch (_) {
|
||||
logger.warn("Failed to parse restic backup output JSON summary.", lastLine);
|
||||
summaryLine = "{}";
|
||||
}
|
||||
|
||||
logger.debug(`Restic restore output last line: ${lastLine}`);
|
||||
const resSummary = JSON.parse(lastLine);
|
||||
const result = restoreOutputSchema(resSummary);
|
||||
logger.debug(`Restic restore output last line: ${summaryLine}`);
|
||||
const result = restoreOutputSchema(summaryLine);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
logger.warn(`Restic restore output validation failed: ${result.summary}`);
|
||||
|
|
@ -518,7 +509,7 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
|||
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
|
|
@ -567,7 +558,7 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
|||
args.push("--prune");
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
|
|
@ -589,7 +580,7 @@ const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[])
|
|||
const args: string[] = ["--repo", repoUrl, "forget", ...snapshotIds, "--prune"];
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
|
|
@ -638,7 +629,7 @@ const tagSnapshots = async (
|
|||
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
|
|
@ -688,7 +679,7 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
|||
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
|
|
@ -739,7 +730,7 @@ const unlock = async (config: RepositoryConfig) => {
|
|||
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
|
|
@ -763,7 +754,7 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
|||
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
const { stdout, stderr } = res;
|
||||
|
|
@ -796,7 +787,7 @@ const repairIndex = async (config: RepositoryConfig) => {
|
|||
const args = ["repair", "index", "--repo", repoUrl];
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
|
||||
const { stdout, stderr } = res;
|
||||
|
|
@ -863,7 +854,7 @@ const copy = async (
|
|||
logger.info(`Copying snapshots from ${sourceRepoUrl} to ${destRepoUrl}...`);
|
||||
logger.debug(`Executing: restic ${args.join(" ")}`);
|
||||
|
||||
const res = await safeSpawn({ command: "restic", args, env });
|
||||
const res = await exec({ command: "restic", args, env });
|
||||
|
||||
await cleanupTemporaryKeys(sourceEnv);
|
||||
await cleanupTemporaryKeys(destEnv);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { safeSpawn } from "./spawn";
|
||||
import { exec } from "./spawn";
|
||||
import { logger } from "./logger";
|
||||
import { toMessage } from "./errors";
|
||||
|
||||
|
|
@ -16,10 +16,7 @@ export async function sendNotification(params: SendNotificationParams) {
|
|||
|
||||
logger.debug(`Sending notification via Shoutrrr: ${title}`);
|
||||
|
||||
const result = await safeSpawn({
|
||||
command: "shoutrrr",
|
||||
args,
|
||||
});
|
||||
const result = await exec({ command: "shoutrrr", args });
|
||||
|
||||
if (result.exitCode === 0) {
|
||||
logger.debug(`Notification sent successfully: ${title}`);
|
||||
|
|
|
|||
|
|
@ -1,78 +1,88 @@
|
|||
import { spawn } from "node:child_process";
|
||||
import { spawn, execFile, type ExecException, type ExecFileOptions } from "node:child_process";
|
||||
import { createInterface } from "node:readline";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
type ExecProps = {
|
||||
command: string;
|
||||
args?: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
} & ExecFileOptions;
|
||||
|
||||
export const exec = async ({ command, args = [], env = {}, ...rest }: ExecProps) => {
|
||||
const options = {
|
||||
env: { ...process.env, ...env },
|
||||
};
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await promisify(execFile)(command, args, { ...options, ...rest, encoding: "utf8" });
|
||||
|
||||
return { exitCode: 0, stdout, stderr };
|
||||
} catch (error) {
|
||||
const execError = error as ExecException;
|
||||
|
||||
return {
|
||||
exitCode: typeof execError.code === "number" ? execError.code : 1,
|
||||
stdout: execError.stdout || "",
|
||||
stderr: execError.stderr || "",
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
export interface SafeSpawnParams {
|
||||
command: string;
|
||||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
signal?: AbortSignal;
|
||||
onStdout?: (data: string) => void;
|
||||
onStdout?: (line: string) => void;
|
||||
onStderr?: (error: string) => void;
|
||||
onError?: (error: Error) => Promise<void> | void;
|
||||
onClose?: (code: number | null) => Promise<void> | void;
|
||||
finally?: () => Promise<void> | void;
|
||||
}
|
||||
|
||||
type SpawnResult = {
|
||||
exitCode: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
summary: string;
|
||||
error: string;
|
||||
};
|
||||
|
||||
export const safeSpawn = (params: SafeSpawnParams) => {
|
||||
const { command, args, env = {}, signal, ...callbacks } = params;
|
||||
const { command, args, env = {}, signal, onStdout, onStderr } = params;
|
||||
|
||||
let lastStdout = "";
|
||||
let lastStderr = "";
|
||||
|
||||
return new Promise<SpawnResult>((resolve) => {
|
||||
let stdoutData = "";
|
||||
let stderrData = "";
|
||||
|
||||
const child = spawn(command, args, {
|
||||
env: { ...process.env, ...env },
|
||||
signal: signal,
|
||||
});
|
||||
|
||||
child.stdout.on("data", (data) => {
|
||||
if (callbacks.onStdout) {
|
||||
callbacks.onStdout(data.toString());
|
||||
} else {
|
||||
stdoutData += data.toString();
|
||||
child.stdout.setEncoding("utf8");
|
||||
child.stderr.setEncoding("utf8");
|
||||
|
||||
const rl = createInterface({ input: child.stdout });
|
||||
const rlErr = createInterface({ input: child.stderr });
|
||||
|
||||
rl.on("line", (line) => {
|
||||
if (onStdout) onStdout(line);
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length > 0) {
|
||||
lastStdout = line;
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (data) => {
|
||||
if (callbacks.onStderr) {
|
||||
callbacks.onStderr(data.toString());
|
||||
rlErr.on("line", (line) => {
|
||||
if (onStderr) onStderr(line);
|
||||
const trimmed = line.trim();
|
||||
if (trimmed.length > 0) {
|
||||
lastStderr = line;
|
||||
}
|
||||
stderrData += data.toString();
|
||||
});
|
||||
|
||||
child.on("error", async (error) => {
|
||||
if (callbacks.onError) {
|
||||
await callbacks.onError(error);
|
||||
}
|
||||
if (callbacks.finally) {
|
||||
await callbacks.finally();
|
||||
}
|
||||
|
||||
resolve({
|
||||
exitCode: -1,
|
||||
stdout: stdoutData,
|
||||
stderr: stderrData,
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
resolve({ exitCode: -1, summary: lastStdout, error: err.message || lastStderr });
|
||||
});
|
||||
|
||||
child.on("close", async (code) => {
|
||||
if (callbacks.onClose) {
|
||||
await callbacks.onClose(code);
|
||||
}
|
||||
if (callbacks.finally) {
|
||||
await callbacks.finally();
|
||||
}
|
||||
|
||||
resolve({
|
||||
exitCode: code === null ? -1 : code,
|
||||
stdout: stdoutData,
|
||||
stderr: stderrData,
|
||||
});
|
||||
child.on("close", (code) => {
|
||||
resolve({ exitCode: code ?? -1, summary: lastStdout, error: lastStderr });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export default defineConfig({
|
|||
},
|
||||
},
|
||||
server: {
|
||||
host: true,
|
||||
host: '0.0.0.0',
|
||||
port: 4096,
|
||||
},
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue