refactor: extract restic in core package (#651)
* refactor: extract restic in core package * chore: add turbo task runner * refactor: split server utils * chore: simplify withDeps signature and fix non-null assertion
This commit is contained in:
parent
9e616ca35a
commit
332e5bffda
121 changed files with 864 additions and 456 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
|
@ -42,3 +42,4 @@ tmp/
|
|||
qa-output
|
||||
|
||||
.worktrees/
|
||||
.turbo
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Card, CardContent } from "~/client/components/ui/card";
|
||||
import { ByteSize } from "~/client/components/bytes-size";
|
||||
import type { ResticSnapshotSummaryDto } from "~/schemas/restic-dto";
|
||||
import type { ResticSnapshotSummaryDto } from "@zerobyte/core/restic";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
|
||||
type Props = {
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { browseFilesystemOptions } from "~/client/api-client/@tanstack/react-que
|
|||
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { normalizeAbsolutePath } from "~/utils/path";
|
||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||
|
||||
type LocalFileBrowserProps = FileBrowserUiProps & {
|
||||
initialPath?: string;
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import { listSnapshotFilesOptions } from "~/client/api-client/@tanstack/react-qu
|
|||
import { FileBrowser, type FileBrowserUiProps } from "~/client/components/file-browsers/file-browser";
|
||||
import { useFileBrowser } from "~/client/hooks/use-file-browser";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { normalizeAbsolutePath } from "~/utils/path";
|
||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||
|
||||
type SnapshotTreeBrowserProps = FileBrowserUiProps & {
|
||||
repositoryId: string;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Database, HardDrive, Cloud, Server } from "lucide-react";
|
||||
import type { RepositoryBackend } from "~/schemas/restic";
|
||||
import type { RepositoryBackend } from "@zerobyte/core/restic";
|
||||
|
||||
type Props = {
|
||||
backend: RepositoryBackend;
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ import { SnapshotTreeBrowser } from "~/client/components/file-browsers/snapshot-
|
|||
import { RestoreProgress } from "~/client/components/restore-progress";
|
||||
import { restoreSnapshotMutation } from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import { type RestoreCompletedEvent, useServerEvents } from "~/client/hooks/use-server-events";
|
||||
import { OVERWRITE_MODES, type OverwriteMode } from "~/schemas/restic";
|
||||
import { OVERWRITE_MODES, type OverwriteMode } from "@zerobyte/core/restic";
|
||||
import type { Repository } from "~/client/lib/types";
|
||||
import { handleRepositoryError } from "~/client/lib/errors";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import {
|
|||
restRepositoryConfigSchema,
|
||||
s3RepositoryConfigSchema,
|
||||
sftpRepositoryConfigSchema,
|
||||
} from "~/schemas/restic";
|
||||
} from "@zerobyte/core/restic";
|
||||
import { Checkbox } from "../../../components/ui/checkbox";
|
||||
import {
|
||||
LocalRepositoryForm,
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "../../../../components/
|
|||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "../../../../components/ui/collapsible";
|
||||
import type { RepositoryFormValues } from "../create-repository-form";
|
||||
import { cn } from "~/client/lib/utils";
|
||||
import { BANDWIDTH_UNITS } from "~/schemas/restic";
|
||||
import { BANDWIDTH_UNITS } from "@zerobyte/core/restic";
|
||||
|
||||
type Props = {
|
||||
form: UseFormReturn<RepositoryFormValues>;
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { Button } from "~/client/components/ui/button";
|
|||
import { Card, CardContent, CardHeader, CardTitle } from "~/client/components/ui/card";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { Alert, AlertDescription } from "~/client/components/ui/alert";
|
||||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
const riskyLocationFieldsByBackend = {
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import {
|
|||
startDoctorMutation,
|
||||
unlockRepositoryMutation,
|
||||
} from "~/client/api-client/@tanstack/react-query.gen";
|
||||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||
import { DoctorReport } from "../components/doctor-report";
|
||||
import { parseError } from "~/client/lib/errors";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { z } from "zod";
|
||||
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "~/schemas/restic-dto";
|
||||
import { resticBackupProgressMetricsSchema, resticBackupRunSummarySchema } from "@zerobyte/core/restic";
|
||||
|
||||
export const backupEventStatusSchema = z.enum(["success", "error", "stopped", "warning"]);
|
||||
export const restoreEventStatusSchema = z.enum(["success", "error"]);
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import type {
|
|||
ServerRestoreProgressEventDto,
|
||||
ServerRestoreStartedEventDto,
|
||||
} from "~/schemas/events-dto";
|
||||
import type { DoctorResult } from "~/schemas/restic";
|
||||
import type { DoctorResult } from "@zerobyte/core/restic";
|
||||
|
||||
const payload = <T>() => undefined as unknown as T;
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "./server/utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { shutdown } from "./server/modules/lifecycle/shutdown";
|
||||
import { runCLI } from "./server/cli";
|
||||
import { createStartHandler, defaultStreamHandler, defineHandlerCallback } from "@tanstack/react-start/server";
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ import { backupScheduleController } from "./modules/backups/backups.controller";
|
|||
import { eventsController } from "./modules/events/events.controller";
|
||||
import { notificationsController } from "./modules/notifications/notifications.controller";
|
||||
import { handleServiceError } from "./utils/errors";
|
||||
import { logger } from "./utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { config } from "./core/config";
|
||||
import { auth } from "~/server/lib/auth";
|
||||
import { db } from "./db/db";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { afterEach, describe, expect, mock, spyOn, test, vi } from "bun:test";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { Job, Scheduler } from "../scheduler";
|
||||
|
||||
const flushMicrotasks = async () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as fs from "node:fs/promises";
|
||||
import { RCLONE_CONFIG_DIR } from "./constants";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
|
||||
export type SystemCapabilities = {
|
||||
rclone: boolean;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
|
||||
export type LockType = "shared" | "exclusive";
|
||||
|
||||
|
|
|
|||
32
app/server/core/restic.ts
Normal file
32
app/server/core/restic.ts
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { createRestic } from "@zerobyte/core/restic/server";
|
||||
import type { ResticDeps } from "@zerobyte/core/restic";
|
||||
import { DEFAULT_EXCLUDES, RESTIC_CACHE_DIR, RESTIC_PASS_FILE } from "./constants";
|
||||
import { config } from "./config";
|
||||
import { cryptoUtils } from "../utils/crypto";
|
||||
import { db } from "../db/db";
|
||||
|
||||
export const resticDeps: ResticDeps = {
|
||||
resolveSecret: cryptoUtils.resolveSecret,
|
||||
getOrganizationResticPassword: async (organizationId: string) => {
|
||||
const org = await db.query.organization.findFirst({
|
||||
where: { id: organizationId },
|
||||
});
|
||||
|
||||
if (!org) {
|
||||
throw new Error(`Organization ${organizationId} not found`);
|
||||
}
|
||||
|
||||
const metadata = org.metadata as { resticPassword?: string } | null;
|
||||
if (!metadata?.resticPassword) {
|
||||
throw new Error(`Restic password not configured for organization ${organizationId}`);
|
||||
}
|
||||
|
||||
return metadata.resticPassword;
|
||||
},
|
||||
resticCacheDir: RESTIC_CACHE_DIR,
|
||||
resticPassFile: RESTIC_PASS_FILE,
|
||||
defaultExcludes: DEFAULT_EXCLUDES,
|
||||
hostname: config.resticHostname,
|
||||
};
|
||||
|
||||
export const restic = createRestic(resticDeps);
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import CronExpressionParser from "cron-parser";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
|
||||
export abstract class Job {
|
||||
abstract run(): Promise<unknown>;
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@ import type {
|
|||
RepositoryStatus,
|
||||
BandwidthUnit,
|
||||
DoctorResult,
|
||||
} from "~/schemas/restic";
|
||||
import type { ResticStatsDto } from "~/schemas/restic-dto";
|
||||
ResticStatsDto,
|
||||
} from "@zerobyte/core/restic";
|
||||
import type { BackendConfig, BackendStatus, BackendType } from "~/schemas/volumes";
|
||||
import type { NotificationConfig, NotificationType } from "~/schemas/notifications";
|
||||
import type { ShortId } from "~/server/utils/branded";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Job } from "../core/scheduler";
|
||||
import { volumeService } from "../modules/volumes/volume.service";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { db } from "../db/db";
|
||||
import { withContext } from "../core/request-context";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Job } from "../core/scheduler";
|
||||
import { backupsExecutionService } from "../modules/backups/backups.execution";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { db } from "../db/db";
|
||||
import { withContext } from "../core/request-context";
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import fs from "node:fs/promises";
|
|||
import { volumeService } from "../modules/volumes/volume.service";
|
||||
import { readMountInfo } from "../utils/mountinfo";
|
||||
import { getVolumePath } from "../modules/volumes/helpers";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { executeUnmount } from "../modules/backends/utils/backend-utils";
|
||||
import { toMessage } from "../utils/errors";
|
||||
import { VOLUME_MOUNT_BASE } from "../core/constants";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Job } from "../core/scheduler";
|
||||
import { volumeService } from "../modules/volumes/volume.service";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { db } from "../db/db";
|
||||
import { withContext } from "../core/request-context";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Job } from "../core/scheduler";
|
||||
import { repositoriesService } from "../modules/repositories/repositories.service";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { db } from "../db/db";
|
||||
import { withContext } from "../core/request-context";
|
||||
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ import { createAuthMiddleware } from "better-auth/api";
|
|||
import { config } from "../core/config";
|
||||
import { db } from "../db/db";
|
||||
import { cryptoUtils } from "../utils/crypto";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { authService } from "../modules/auth/auth.service";
|
||||
import { tanstackStartCookies } from "better-auth/tanstack-start";
|
||||
import { isValidUsername, normalizeUsername } from "~/lib/username";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { UnauthorizedError } from "http-errors-enhanced";
|
|||
import { db } from "~/server/db/db";
|
||||
import { member, organization, type User } from "~/server/db/schema";
|
||||
import { cryptoUtils } from "~/server/utils/crypto";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
|
||||
export async function findMembershipWithOrganization(userId: string, organizationId?: string) {
|
||||
const membership = await db.query.member.findFirst({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { db } from "~/server/db/db";
|
||||
import type { AuthMiddlewareContext } from "~/server/lib/auth";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { ForbiddenError } from "http-errors-enhanced";
|
||||
import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import * as fs from "node:fs/promises";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as os from "node:os";
|
|||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
import { withTimeout } from "../../../utils/timeout";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ import * as fs from "node:fs/promises";
|
|||
import * as os from "node:os";
|
||||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
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 { safeExec } from "~/server/utils/spawn";
|
||||
import { safeExec } from "@zerobyte/core/node";
|
||||
import { config as zbConfig } from "~/server/core/config";
|
||||
|
||||
const mount = async (config: BackendConfig, path: string) => {
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { spawn } from "node:child_process";
|
|||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { cryptoUtils } from "../../../utils/crypto";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
import { withTimeout } from "../../../utils/timeout";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as os from "node:os";
|
|||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { cryptoUtils } from "../../../utils/crypto";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
import { withTimeout } from "../../../utils/timeout";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import * as fs from "node:fs/promises";
|
||||
import * as npath from "node:path";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { safeExec } from "@zerobyte/core/node";
|
||||
|
||||
export const executeMount = async (args: string[]): Promise<void> => {
|
||||
const shouldBeVerbose = process.env.LOG_LEVEL === "debug" || process.env.NODE_ENV !== "production";
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import * as os from "node:os";
|
|||
import { OPERATION_TIMEOUT } from "../../../core/constants";
|
||||
import { cryptoUtils } from "../../../utils/crypto";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { getMountForPath } from "../../../utils/mountinfo";
|
||||
import { withTimeout } from "../../../utils/timeout";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
|
|
|
|||
|
|
@ -9,8 +9,8 @@ import { createTestBackupScheduleMirror } from "~/test/helpers/backup-mirror";
|
|||
import { generateBackupOutput } from "~/test/helpers/restic";
|
||||
import { TEST_ORG_ID } from "~/test/helpers/organization";
|
||||
import * as context from "~/server/core/request-context";
|
||||
import * as spawnModule from "~/server/utils/spawn";
|
||||
import { restic } from "~/server/utils/restic";
|
||||
import * as spawnModule from "@zerobyte/core/node";
|
||||
import { restic } from "~/server/core/restic";
|
||||
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
|
||||
import { scheduleQueries } from "../backups.queries";
|
||||
import { fromAny } from "@total-typescript/shoehorn";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { createTestBackupSchedule } from "~/test/helpers/backup";
|
|||
import { createTestRepository } from "~/test/helpers/repository";
|
||||
import { generateBackupOutput } from "~/test/helpers/restic";
|
||||
import { faker } from "@faker-js/faker";
|
||||
import * as spawnModule from "~/server/utils/spawn";
|
||||
import * as spawnModule from "@zerobyte/core/node";
|
||||
import { db } from "~/server/db/db";
|
||||
import { backupScheduleMirrorsTable, repositoriesTable, volumesTable } from "~/server/db/schema";
|
||||
import { TEST_ORG_ID } from "~/test/helpers/organization";
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import CronExpressionParser from "cron-parser";
|
|||
import path from "node:path";
|
||||
import type { BackupSchedule } from "~/server/db/schema";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
|
||||
export const calculateNextRun = (cronExpression: string) => {
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -45,7 +45,7 @@ import {
|
|||
import { notificationsService } from "../notifications/notifications.service";
|
||||
import { requireAuth } from "../auth/auth.middleware";
|
||||
import { backupsExecutionService } from "./backups.execution";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { asShortId } from "~/server/utils/branded";
|
||||
|
||||
export const backupScheduleController = new Hono()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { NotFoundError, BadRequestError, ConflictError } from "http-errors-enhanced";
|
||||
import type { BackupSchedule, Volume, Repository } from "../../db/schema";
|
||||
import { restic } from "../../utils/restic";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { restic } from "../../core/restic";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { cache, cacheKeys } from "../../utils/cache";
|
||||
import { getVolumePath } from "../volumes/helpers";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
|
|
@ -12,7 +12,7 @@ import { repositoriesService } from "../repositories/repositories.service";
|
|||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
import { scheduleQueries, mirrorQueries, repositoryQueries } from "./backups.queries";
|
||||
import { calculateNextRun, createBackupOptions } from "./backup.helpers";
|
||||
import type { ResticBackupOutputDto } from "~/schemas/restic-dto";
|
||||
import type { ResticBackupOutputDto } from "@zerobyte/core/restic";
|
||||
import type { BackupProgressEventDto } from "~/schemas/events-dto";
|
||||
|
||||
const runningBackups = new Map<number, AbortController>();
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import { generateShortId } from "~/server/utils/id";
|
|||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
import { calculateNextRun } from "./backup.helpers";
|
||||
import { asShortId, type ShortId } from "~/server/utils/branded";
|
||||
import { validateCustomResticParams } from "~/server/utils/restic/helpers/validate-custom-params";
|
||||
import { validateCustomResticParams } from "@zerobyte/core/restic/server";
|
||||
|
||||
const listSchedules = async () => {
|
||||
const organizationId = getOrganizationId();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { Hono } from "hono";
|
||||
import { streamSSE } from "hono/streaming";
|
||||
import { serverEvents } from "../../core/events";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { requireAuth } from "../auth/auth.middleware";
|
||||
import type { ServerEventPayloadMap } from "~/schemas/server-events";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { logger } from "../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { v00001 } from "./migrations/00001-retag-snapshots";
|
||||
import { v00002 } from "./migrations/00002-isolate-restic-passwords";
|
||||
import { v00003 } from "./migrations/00003-assign-organization";
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../../../db/db";
|
||||
import { backupScheduleMirrorsTable, type Repository } from "../../../db/schema";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic";
|
||||
import { safeExec } from "@zerobyte/core/node";
|
||||
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "@zerobyte/core/restic/server";
|
||||
import { resticDeps } from "~/server/core/restic";
|
||||
|
||||
const migrateTag = async (
|
||||
oldTag: string,
|
||||
|
|
@ -13,7 +14,7 @@ const migrateTag = async (
|
|||
scheduleName: string,
|
||||
): Promise<string | null> => {
|
||||
const repoUrl = buildRepoUrl(repository.config);
|
||||
const env = await buildEnv(repository.config, repository.organizationId);
|
||||
const env = await buildEnv(repository.config, repository.organizationId, resticDeps);
|
||||
|
||||
const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
|
||||
|
||||
|
|
@ -21,7 +22,7 @@ const migrateTag = async (
|
|||
|
||||
logger.info(`Migrating snapshots for schedule '${scheduleName}' from tag '${oldTag}' to '${newTag}'`);
|
||||
const res = await safeExec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, resticDeps);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic tag failed: ${res.stderr}`);
|
||||
|
|
|
|||
|
|
@ -9,10 +9,10 @@ import {
|
|||
notificationDestinationsTable,
|
||||
twoFactor,
|
||||
} from "../../../db/schema";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { cryptoUtils } from "~/server/utils/crypto";
|
||||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||
import type { BackendConfig } from "~/schemas/volumes";
|
||||
import type { NotificationConfig } from "~/schemas/notifications";
|
||||
import { RESTIC_PASS_FILE } from "~/server/core/constants";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { db } from "../../../db/db";
|
||||
import { member } from "../../../db/schema";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
|
||||
const execute = async () => {
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
import { and, eq } from "drizzle-orm";
|
||||
import { db } from "../../../db/db";
|
||||
import { repositoriesTable } from "../../../db/schema";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { REPOSITORY_BASE } from "~/server/core/constants";
|
||||
import { repositoryConfigSchema } from "~/schemas/restic";
|
||||
import { repositoryConfigSchema } from "@zerobyte/core/restic";
|
||||
|
||||
type MigrationError = { name: string; error: string };
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Scheduler } from "../../core/scheduler";
|
||||
import { db } from "../../db/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { createVolumeBackend } from "../backends/backend";
|
||||
|
||||
export const shutdown = async () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { Scheduler } from "../../core/scheduler";
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../../db/db";
|
||||
import { backupSchedulesTable } from "../../db/schema";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { volumeService } from "../volumes/volume.service";
|
||||
import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
|
||||
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import {
|
|||
type NotificationDestination,
|
||||
} from "../../db/schema";
|
||||
import { cryptoUtils } from "../../utils/crypto";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { sendNotification } from "../../utils/shoutrrr";
|
||||
import { formatDuration } from "~/utils/utils";
|
||||
import { buildShoutrrrUrl } from "./builders";
|
||||
import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
|
||||
import type { ResticBackupRunSummaryDto } from "~/schemas/restic-dto";
|
||||
import type { ResticBackupRunSummaryDto } from "@zerobyte/core/restic";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
import { formatBytes } from "~/utils/format-bytes";
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { db } from "~/server/db/db";
|
|||
import { repositoriesTable } from "~/server/db/schema";
|
||||
import { generateShortId } from "~/server/utils/id";
|
||||
import { createTestSession, getAuthHeaders } from "~/test/helpers/auth";
|
||||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||
|
||||
const app = createApp();
|
||||
|
||||
|
|
@ -279,8 +279,8 @@ describe("repositories updates", () => {
|
|||
const { headers, organizationId } = await createTestSession();
|
||||
const repository = await createRepositoryRecord(organizationId);
|
||||
|
||||
const { restic } = await import("~/server/utils/restic");
|
||||
const { ResticError } = await import("~/server/utils/errors");
|
||||
const { restic } = await import("~/server/core/restic");
|
||||
const { ResticError } = await import("@zerobyte/core/restic");
|
||||
|
||||
const deleteSnapshotSpy = spyOn(restic, "deleteSnapshot").mockImplementation(async () => {
|
||||
throw new ResticError(1, "Fatal: unexpected HTTP response (403): 403 Forbidden");
|
||||
|
|
|
|||
|
|
@ -1,18 +1,18 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { Readable } from "node:stream";
|
||||
import { afterEach, beforeEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||
import { REPOSITORY_BASE } from "~/server/core/constants";
|
||||
import { serverEvents } from "~/server/core/events";
|
||||
import { withContext } from "~/server/core/request-context";
|
||||
import { db } from "~/server/db/db";
|
||||
import { repositoriesTable } from "~/server/db/schema";
|
||||
import { generateShortId } from "~/server/utils/id";
|
||||
import { restic } from "~/server/utils/restic";
|
||||
import { restic } from "~/server/core/restic";
|
||||
import { createTestSession } from "~/test/helpers/auth";
|
||||
import { createTestBackupSchedule } from "~/test/helpers/backup";
|
||||
import { cache, cacheKeys } from "~/server/utils/cache";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { ResticError } from "@zerobyte/core/restic/server";
|
||||
import { repositoriesService } from "../repositories.service";
|
||||
|
||||
const createTestRepository = async (organizationId: string) => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "~/schemas/restic";
|
||||
import { type DoctorStep, type DoctorResult, type RepositoryConfig } from "@zerobyte/core/restic";
|
||||
import { z } from "zod";
|
||||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
import { restic } from "~/server/utils/restic";
|
||||
import { restic } from "~/server/core/restic";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { safeJsonParse } from "~/server/utils/json";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeJsonParse } from "@zerobyte/core/utils";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { db } from "~/server/db/db";
|
||||
import { repositoriesTable } from "~/server/db/schema";
|
||||
import { repoMutex } from "~/server/core/repository-mutex";
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { BadRequestError } from "http-errors-enhanced";
|
||||
import path from "node:path";
|
||||
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||
import { normalizeAbsolutePath } from "~/utils/path";
|
||||
import { findCommonAncestor, normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||
|
||||
const sanitizeFilenamePart = (value: string): string => {
|
||||
const sanitized = value.replace(/[^a-zA-Z0-9._-]/g, "_").replace(/^_+|_+$/g, "");
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@ import {
|
|||
REPOSITORY_STATUS,
|
||||
repositoryConfigSchema,
|
||||
doctorResultSchema,
|
||||
} from "~/schemas/restic";
|
||||
import { resticSnapshotSummarySchema, resticStatsSchema } from "~/schemas/restic-dto";
|
||||
resticSnapshotSummarySchema,
|
||||
resticStatsSchema,
|
||||
} from "@zerobyte/core/restic";
|
||||
|
||||
export const repositorySchema = z.object({
|
||||
id: z.string(),
|
||||
|
|
|
|||
|
|
@ -6,13 +6,13 @@ import {
|
|||
type CompressionMode,
|
||||
type OverwriteMode,
|
||||
type RepositoryConfig,
|
||||
type ResticStatsDto,
|
||||
repositoryConfigSchema,
|
||||
} from "~/schemas/restic";
|
||||
import type { ResticStatsDto } from "~/schemas/restic-dto";
|
||||
} from "@zerobyte/core/restic";
|
||||
import { config as appConfig } from "~/server/core/config";
|
||||
import { serverEvents } from "~/server/core/events";
|
||||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { parseRetentionCategories, type RetentionCategory } from "~/server/utils/retention-categories";
|
||||
import { repoMutex } from "../../core/repository-mutex";
|
||||
import { db } from "../../db/db";
|
||||
|
|
@ -21,11 +21,12 @@ import { cache, cacheKeys } from "../../utils/cache";
|
|||
import { cryptoUtils } from "../../utils/crypto";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
import { generateShortId } from "../../utils/id";
|
||||
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys, restic } from "../../utils/restic";
|
||||
import { safeSpawn } from "../../utils/spawn";
|
||||
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "@zerobyte/core/restic/server";
|
||||
import { restic, resticDeps } from "../../core/restic";
|
||||
import { safeSpawn } from "@zerobyte/core/node";
|
||||
import { backupsService } from "../backups/backups.service";
|
||||
import type { DumpPathKind, UpdateRepositoryBody } from "./repositories.dto";
|
||||
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||
import { findCommonAncestor } from "@zerobyte/core/utils";
|
||||
import { prepareSnapshotDump } from "./helpers/dump";
|
||||
import { executeDoctor } from "./helpers/doctor";
|
||||
import type { ShortId } from "~/server/utils/branded";
|
||||
|
|
@ -832,7 +833,7 @@ const execResticCommand = async (
|
|||
}
|
||||
|
||||
const repoUrl = buildRepoUrl(repository.config);
|
||||
const env = await buildEnv(repository.config, organizationId);
|
||||
const env = await buildEnv(repository.config, organizationId, resticDeps);
|
||||
|
||||
const resticArgs: string[] = ["--repo", repoUrl, command];
|
||||
if (args && args.length > 0) {
|
||||
|
|
@ -842,7 +843,7 @@ const execResticCommand = async (
|
|||
|
||||
const result = await safeSpawn({ command: "restic", args: resticArgs, env, signal, onStdout, onStderr });
|
||||
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, resticDeps);
|
||||
|
||||
return { exitCode: result.exitCode };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { APIError } from "better-auth/api";
|
||||
import type { GenericEndpointContext } from "better-auth";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { extractProviderIdFromContext } from "~/server/modules/sso/utils/sso-context";
|
||||
import { ssoService } from "~/server/modules/sso/sso.service";
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { requireSsoInvitation } from "./middlewares/require-invitation";
|
|||
import { resolveTrustedProvidersForRequest } from "./middlewares/trust-provider-for-linking";
|
||||
import { isSsoCallbackRequest, extractProviderIdFromContext, normalizeEmail } from "./utils/sso-context";
|
||||
import { findMembershipWithOrganization } from "~/server/lib/auth/helpers/create-default-org";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
|
||||
async function resolveOrgMembership(userId: string, ctx: GenericEndpointContext | null) {
|
||||
const user = await db.query.usersTable.findFirst({ where: { id: userId } });
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { config } from "../../core/config";
|
|||
import type { UpdateInfoDto } from "./system.dto";
|
||||
import semver from "semver";
|
||||
import { cache, cacheKeys } from "../../utils/cache";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { db } from "../../db/db";
|
||||
import { appMetadataTable } from "../../db/schema";
|
||||
import { REGISTRATION_ENABLED_KEY } from "~/server/core/constants";
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import { withTimeout } from "../../utils/timeout";
|
|||
import { createVolumeBackend } from "../backends/backend";
|
||||
import type { UpdateVolumeBody } from "./volume.dto";
|
||||
import { getVolumePath } from "./helpers";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { serverEvents } from "../../core/events";
|
||||
import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes";
|
||||
import { getOrganizationId } from "~/server/core/request-context";
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { definePlugin } from "nitro";
|
||||
import { bootstrapApplication } from "../modules/lifecycle/bootstrap";
|
||||
import { logger } from "../utils/logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { toMessage } from "../utils/errors";
|
||||
|
||||
export default definePlugin(async () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { safeExec, safeSpawn } from "../spawn";
|
||||
import { safeExec, safeSpawn } from "@zerobyte/core/node";
|
||||
|
||||
describe("safeExec", () => {
|
||||
describe("successful commands", () => {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import type { RepositoryConfig } from "@zerobyte/core/restic";
|
||||
import { cryptoUtils } from "./crypto";
|
||||
|
||||
type BackendConflictGroup = "s3" | "gcs" | "azure" | "rest" | "sftp" | null;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||
import { findCommonAncestor } from "@zerobyte/core/utils";
|
||||
|
||||
describe("findCommonAncestor", () => {
|
||||
test("returns root for empty path lists", () => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { HttpError } from "http-errors-enhanced";
|
||||
import { sanitizeSensitiveData } from "./sanitize";
|
||||
import { sanitizeSensitiveData } from "@zerobyte/core/node";
|
||||
|
||||
export const handleServiceError = (error: unknown) => {
|
||||
if (error instanceof HttpError) {
|
||||
|
|
@ -13,25 +13,3 @@ export const toMessage = (err: unknown): string => {
|
|||
const message = err instanceof Error ? err.message : String(err);
|
||||
return sanitizeSensitiveData(message);
|
||||
};
|
||||
|
||||
const resticErrorCodes: Record<number, string> = {
|
||||
1: "Command failed: An error occurred while executing the command.",
|
||||
2: "Go runtime error: A runtime error occurred in the Go program.",
|
||||
3: "Backup could not read all files: Some files could not be read during backup.",
|
||||
10: "Repository not found: The specified repository could not be found.",
|
||||
11: "Failed to lock repository: Unable to acquire a lock on the repository. Try to run doctor on the repository.",
|
||||
12: "Wrong repository password: The provided password for the repository is incorrect.",
|
||||
130: "Backup interrupted: The backup process was interrupted.",
|
||||
};
|
||||
|
||||
export class ResticError extends Error {
|
||||
code: number;
|
||||
|
||||
constructor(code: number, stderr: string) {
|
||||
const message = resticErrorCodes[code] || `Unknown restic error with code ${code}`;
|
||||
super(`${message}\n${stderr}`);
|
||||
|
||||
this.code = code;
|
||||
this.name = "ResticError";
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { logger } from "./logger";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { toMessage } from "./errors";
|
||||
import { safeExec } from "./spawn";
|
||||
import { safeExec } from "@zerobyte/core/node";
|
||||
|
||||
/**
|
||||
* List all configured rclone remotes
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
export { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys, restic } from "./restic/index";
|
||||
export type { RestoreProgress } from "./restic/index";
|
||||
export type { ForgetGroup, ForgetReason, ResticDumpStream, ResticForgetResponse, Snapshot } from "./restic/index";
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
import { backup } from "./commands/backup";
|
||||
import { check } from "./commands/check";
|
||||
import { copy } from "./commands/copy";
|
||||
import { deleteSnapshot, deleteSnapshots } from "./commands/delete-snapshots";
|
||||
import { dump } from "./commands/dump";
|
||||
import { forget } from "./commands/forget";
|
||||
import { init } from "./commands/init";
|
||||
import { keyAdd } from "./commands/key-add";
|
||||
import { ls } from "./commands/ls";
|
||||
import { repairIndex } from "./commands/repair-index";
|
||||
import { restore } from "./commands/restore";
|
||||
import { snapshots } from "./commands/snapshots";
|
||||
import { stats } from "./commands/stats";
|
||||
import { tagSnapshots } from "./commands/tag-snapshots";
|
||||
import { unlock } from "./commands/unlock";
|
||||
|
||||
export { addCommonArgs } from "./helpers/add-common-args";
|
||||
export { buildEnv } from "./helpers/build-env";
|
||||
export { buildRepoUrl } from "./helpers/build-repo-url";
|
||||
export { cleanupTemporaryKeys } from "./helpers/cleanup-temporary-keys";
|
||||
|
||||
export type { RestoreProgress } from "./commands/restore";
|
||||
export type { ForgetGroup, ForgetReason, ResticDumpStream, ResticForgetResponse, Snapshot } from "./types";
|
||||
|
||||
export const restic = {
|
||||
init,
|
||||
keyAdd,
|
||||
backup,
|
||||
restore,
|
||||
dump,
|
||||
snapshots,
|
||||
stats,
|
||||
forget,
|
||||
deleteSnapshot,
|
||||
deleteSnapshots,
|
||||
tagSnapshots,
|
||||
unlock,
|
||||
ls,
|
||||
check,
|
||||
repairIndex,
|
||||
copy,
|
||||
};
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { ResticForgetResponse } from "./restic";
|
||||
import type { ResticForgetResponse } from "@zerobyte/core/restic";
|
||||
|
||||
export type RetentionCategory = "last" | "hourly" | "daily" | "weekly" | "monthly" | "yearly";
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { safeExec } from "./spawn";
|
||||
import { logger } from "./logger";
|
||||
import { safeExec } from "@zerobyte/core/node";
|
||||
import { logger } from "@zerobyte/core/node";
|
||||
import { toMessage } from "./errors";
|
||||
|
||||
export interface SendNotificationParams {
|
||||
|
|
|
|||
|
|
@ -4,14 +4,19 @@ import path from "node:path";
|
|||
import { cwd } from "node:process";
|
||||
import { db } from "~/server/db/db";
|
||||
|
||||
void mock.module("~/server/utils/logger", () => ({
|
||||
logger: {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
}));
|
||||
import * as utils from "@zerobyte/core/node";
|
||||
|
||||
void mock.module("@zerobyte/core/node", () => {
|
||||
return {
|
||||
...utils,
|
||||
logger: {
|
||||
debug: () => {},
|
||||
info: () => {},
|
||||
warn: () => {},
|
||||
error: () => {},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
void mock.module("~/server/utils/crypto", () => ({
|
||||
cryptoUtils: {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import { normalizeAbsolutePath } from "../path";
|
||||
import { normalizeAbsolutePath } from "@zerobyte/core/utils";
|
||||
|
||||
describe("normalizeAbsolutePath", () => {
|
||||
test("handles undefined and empty inputs", () => {
|
||||
|
|
|
|||
26
bun.lock
26
bun.lock
|
|
@ -100,6 +100,7 @@
|
|||
"oxlint-tsgolint": "^0.16.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"turbo": "^2.8.16",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
|
|
@ -108,6 +109,15 @@
|
|||
"wait-for-expect": "^4.0.0",
|
||||
},
|
||||
},
|
||||
"packages/core": {
|
||||
"name": "@zerobyte/core",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"overrides": {
|
||||
"esbuild": "^0.27.2",
|
||||
|
|
@ -957,6 +967,8 @@
|
|||
|
||||
"@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="],
|
||||
|
||||
"@zerobyte/core": ["@zerobyte/core@workspace:packages/core"],
|
||||
|
||||
"abort-controller": ["abort-controller@3.0.0", "", { "dependencies": { "event-target-shim": "^5.0.0" } }, "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg=="],
|
||||
|
||||
"acorn": ["acorn@8.16.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw=="],
|
||||
|
|
@ -1797,6 +1809,20 @@
|
|||
|
||||
"tsx": ["tsx@4.21.0", "", { "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "bin": { "tsx": "dist/cli.mjs" } }, "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw=="],
|
||||
|
||||
"turbo": ["turbo@2.8.16", "", { "optionalDependencies": { "turbo-darwin-64": "2.8.16", "turbo-darwin-arm64": "2.8.16", "turbo-linux-64": "2.8.16", "turbo-linux-arm64": "2.8.16", "turbo-windows-64": "2.8.16", "turbo-windows-arm64": "2.8.16" }, "bin": { "turbo": "bin/turbo" } }, "sha512-u6e9e3cTTpE2adQ1DYm3A3r8y3LAONEx1jYvJx6eIgSY4bMLxIxs0riWzI0Z/IK903ikiUzRPZ2c1Ph5lVLkhA=="],
|
||||
|
||||
"turbo-darwin-64": ["turbo-darwin-64@2.8.16", "", { "os": "darwin", "cpu": "x64" }, "sha512-KWa4hUMWrpADC6Q/wIHRkBLw6X6MV9nx6X7hSXbTrrMz0KdaKhmfudUZ3sS76bJFmgArBU25cSc0AUyyrswYxg=="],
|
||||
|
||||
"turbo-darwin-arm64": ["turbo-darwin-arm64@2.8.16", "", { "os": "darwin", "cpu": "arm64" }, "sha512-NBgaqBDLQSZlJR4D5XCkQq6noaO0RvIgwm5eYFJYL3bH5dNu8o0UBpq7C5DYnQI8+ybyoHFjT5/icN4LeUYLow=="],
|
||||
|
||||
"turbo-linux-64": ["turbo-linux-64@2.8.16", "", { "os": "linux", "cpu": "x64" }, "sha512-VYPdcCRevI9kR/hr1H1xwXy7QQt/jNKiim1e1mjANBXD2E9VZWMkIL74J1Huad5MbU3/jw7voHOqDPLJPC2p6w=="],
|
||||
|
||||
"turbo-linux-arm64": ["turbo-linux-arm64@2.8.16", "", { "os": "linux", "cpu": "arm64" }, "sha512-beq8tgUVI3uwkQkXJMiOr/hfxQRw54M3elpBwqgYFfemiK5LhCjjcwO0DkE8GZZfElBIlk+saMAQOZy3885wNQ=="],
|
||||
|
||||
"turbo-windows-64": ["turbo-windows-64@2.8.16", "", { "os": "win32", "cpu": "x64" }, "sha512-Ig7b46iUgiOIkea/D3Z7H+zNzvzSnIJcLYFpZLA0RxbUTrbLhv9qIPwv3pT9p/abmu0LXVKHxaOo+p26SuDhzw=="],
|
||||
|
||||
"turbo-windows-arm64": ["turbo-windows-arm64@2.8.16", "", { "os": "win32", "cpu": "arm64" }, "sha512-fOWjbEA2PiE2HEnFQrwNZKYEdjewyPc2no9GmrXklZnTCuMsxeCN39aVlKpKpim03Zq/ykIuvApGwq8ZbfS2Yw=="],
|
||||
|
||||
"tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="],
|
||||
|
||||
"type-fest": ["type-fest@5.4.4", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-JnTrzGu+zPV3aXIUhnyWJj4z/wigMsdYajGLIYakqyOW1nPllzXEJee0QQbHj+CTIQtXGlAjuK0UY+2xTyjVAw=="],
|
||||
|
|
|
|||
10
package.json
10
package.json
|
|
@ -1,6 +1,10 @@
|
|||
{
|
||||
"name": "zerobyte",
|
||||
"private": true,
|
||||
"workspaces": [
|
||||
"apps/*",
|
||||
"packages/*"
|
||||
],
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"lint": "oxlint --type-aware",
|
||||
|
|
@ -10,7 +14,7 @@
|
|||
"preview": "bunx --bun vite preview",
|
||||
"cli:dev": "bun run app/server/cli/main.ts",
|
||||
"cli": "ZEROBYTE_CLI=1 bun .output/server/_ssr/index.mjs",
|
||||
"tsc": "tsc --noEmit",
|
||||
"tsc": "tsc --noEmit && turbo run tsc",
|
||||
"start:dev": "docker compose down && docker compose up --build zerobyte-dev",
|
||||
"start:prod": "docker compose down && docker compose up --build zerobyte-prod",
|
||||
"start:e2e": "docker compose down && rm -rf playwright/data/* playwright/.auth/user.json playwright/restic.pass && mkdir -p playwright/temp && rm -rf playwright/temp/* && docker compose up --build zerobyte-e2e",
|
||||
|
|
@ -20,7 +24,7 @@
|
|||
"studio": "drizzle-kit studio",
|
||||
"test:server": "dotenv -e .env.test -- bun test app/server --preload ./app/test/setup.ts",
|
||||
"test:client": "dotenv -e .env.test -- bun test app/client --preload ./app/test/setup-client.ts",
|
||||
"test": "bun run test:server && bun run test:client",
|
||||
"test": "bun run test:server && bun run test:client && turbo run test",
|
||||
"test:e2e": "NODE_ENV=test dotenv -e .env.local -- playwright test",
|
||||
"test:e2e:ui": "NODE_ENV=test dotenv -e .env.local -- playwright test --ui",
|
||||
"test:codegen": "playwright codegen localhost:4096"
|
||||
|
|
@ -55,6 +59,7 @@
|
|||
"@tanstack/react-router": "^1.166.7",
|
||||
"@tanstack/react-router-ssr-query": "^1.166.7",
|
||||
"@tanstack/react-start": "^1.166.7",
|
||||
"@zerobyte/core": "workspace:*",
|
||||
"better-auth": "^1.5.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
@ -121,6 +126,7 @@
|
|||
"oxlint-tsgolint": "^0.16.0",
|
||||
"tailwindcss": "^4.2.1",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"turbo": "^2.8.16",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^7.3.1",
|
||||
|
|
|
|||
34
packages/core/.gitignore
vendored
Normal file
34
packages/core/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# dependencies (bun install)
|
||||
node_modules
|
||||
|
||||
# output
|
||||
out
|
||||
dist
|
||||
*.tgz
|
||||
|
||||
# code coverage
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# logs
|
||||
logs
|
||||
_.log
|
||||
report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.development.local
|
||||
.env.test.local
|
||||
.env.production.local
|
||||
.env.local
|
||||
|
||||
# caches
|
||||
.eslintcache
|
||||
.cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# IntelliJ based IDEs
|
||||
.idea
|
||||
|
||||
# Finder (MacOS) folder config
|
||||
.DS_Store
|
||||
15
packages/core/README.md
Normal file
15
packages/core/README.md
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# core
|
||||
|
||||
To install dependencies:
|
||||
|
||||
```bash
|
||||
bun install
|
||||
```
|
||||
|
||||
To run:
|
||||
|
||||
```bash
|
||||
bun run index.ts
|
||||
```
|
||||
|
||||
This project was created using `bun init` in bun v1.3.10. [Bun](https://bun.com) is a fast all-in-one JavaScript runtime.
|
||||
26
packages/core/bun.lock
Normal file
26
packages/core/bun.lock
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
{
|
||||
"lockfileVersion": 1,
|
||||
"configVersion": 1,
|
||||
"workspaces": {
|
||||
"": {
|
||||
"name": "core",
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest",
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5",
|
||||
},
|
||||
},
|
||||
},
|
||||
"packages": {
|
||||
"@types/bun": ["@types/bun@1.3.10", "", { "dependencies": { "bun-types": "1.3.10" } }, "sha512-0+rlrUrOrTSskibryHbvQkDOWRJwJZqZlxrUs1u4oOoTln8+WIXBPmAuCF35SWB2z4Zl3E84Nl/D0P7803nigQ=="],
|
||||
|
||||
"@types/node": ["@types/node@25.4.0", "", { "dependencies": { "undici-types": "~7.18.0" } }, "sha512-9wLpoeWuBlcbBpOY3XmzSTG3oscB6xjBEEtn+pYXTfhyXhIxC5FsBer2KTopBlvKEiW9l13po9fq+SJY/5lkhw=="],
|
||||
|
||||
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
||||
|
||||
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
|
||||
|
||||
"undici-types": ["undici-types@7.18.2", "", {}, "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w=="],
|
||||
}
|
||||
}
|
||||
37
packages/core/package.json
Normal file
37
packages/core/package.json
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
{
|
||||
"name": "@zerobyte/core",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./restic": {
|
||||
"types": "./src/restic/index.ts",
|
||||
"import": "./src/restic/index.ts",
|
||||
"default": "./src/restic/index.ts"
|
||||
},
|
||||
"./restic/server": {
|
||||
"types": "./src/restic/server.ts",
|
||||
"import": "./src/restic/server.ts",
|
||||
"default": "./src/restic/server.ts"
|
||||
},
|
||||
"./utils": {
|
||||
"types": "./src/utils/index.ts",
|
||||
"import": "./src/utils/index.ts",
|
||||
"default": "./src/utils/index.ts"
|
||||
},
|
||||
"./node": {
|
||||
"types": "./src/node/index.ts",
|
||||
"import": "./src/node/index.ts",
|
||||
"default": "./src/node/index.ts"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
"tsc": "tsc --noEmit",
|
||||
"test": "bun test --preload ./test/setup.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/bun": "latest"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
4
packages/core/src/node/index.ts
Normal file
4
packages/core/src/node/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { safeSpawn, safeExec } from "../utils/spawn.js";
|
||||
export type { SafeSpawnParams, SafeSpawnParamsLines, SafeSpawnParamsRaw, SpawnResult } from "../utils/spawn.js";
|
||||
export { logger } from "../utils/logger.js";
|
||||
export { sanitizeSensitiveData } from "../utils/sanitize.js";
|
||||
|
|
@ -1,8 +1,19 @@
|
|||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import * as cleanupModule from "~/server/utils/restic/helpers/cleanup-temporary-keys";
|
||||
import * as spawnModule from "~/server/utils/spawn";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import * as cleanupModule from "../../helpers/cleanup-temporary-keys";
|
||||
import * as spawnModule from "../../../utils/spawn";
|
||||
import { ResticError } from "../../error";
|
||||
import { backup } from "../backup";
|
||||
import type { ResticDeps } from "../../types";
|
||||
import type { SafeSpawnParams, SpawnResult } from "../../../utils/spawn";
|
||||
|
||||
const mockDeps: ResticDeps = {
|
||||
resolveSecret: async (s) => s,
|
||||
getOrganizationResticPassword: async () => "org-restic-password",
|
||||
resticCacheDir: "/tmp/restic-cache",
|
||||
resticPassFile: "/tmp/restic.pass",
|
||||
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
|
||||
hostname: "zerobyte",
|
||||
};
|
||||
|
||||
const VALID_SUMMARY = JSON.stringify({
|
||||
message_type: "summary",
|
||||
|
|
@ -39,8 +50,8 @@ const config = {
|
|||
};
|
||||
|
||||
type SetupOptions = {
|
||||
spawnResult?: Partial<spawnModule.SpawnResult>;
|
||||
onSpawnCall?: (params: spawnModule.SafeSpawnParams) => void;
|
||||
spawnResult?: Partial<SpawnResult>;
|
||||
onSpawnCall?: (params: SafeSpawnParams) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -81,7 +92,7 @@ describe("backup command", () => {
|
|||
describe("argument construction", () => {
|
||||
test("passes source path as positional arg when no include list is given", async () => {
|
||||
const { getArgs, hasFlag } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
expect(getArgs()).toContain("/mnt/data");
|
||||
expect(hasFlag("--files-from")).toBe(false);
|
||||
|
|
@ -89,10 +100,15 @@ describe("backup command", () => {
|
|||
|
||||
test("uses --files-from instead of source path when include list is provided", async () => {
|
||||
const { hasFlag, getArgs } = setup();
|
||||
await backup(config, "/mnt/data", {
|
||||
organizationId: "org-1",
|
||||
include: ["/mnt/data/docs", "/mnt/data/photos"],
|
||||
});
|
||||
await backup(
|
||||
config,
|
||||
"/mnt/data",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
include: ["/mnt/data/docs", "/mnt/data/photos"],
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(hasFlag("--files-from")).toBe(true);
|
||||
expect(getArgs()).not.toContain("/mnt/data");
|
||||
|
|
@ -100,86 +116,101 @@ describe("backup command", () => {
|
|||
|
||||
test("adds --tag for each entry in options.tags", async () => {
|
||||
const { getOptionValues } = setup();
|
||||
await backup(config, "/mnt/data", {
|
||||
organizationId: "org-1",
|
||||
tags: ["tag-a", "tag-b"],
|
||||
});
|
||||
await backup(
|
||||
config,
|
||||
"/mnt/data",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
tags: ["tag-a", "tag-b"],
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(getOptionValues("--tag")).toEqual(["tag-a", "tag-b"]);
|
||||
});
|
||||
|
||||
test("omits --tag when tags list is empty", async () => {
|
||||
const { hasFlag } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1", tags: [] });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1", tags: [] }, mockDeps);
|
||||
|
||||
expect(hasFlag("--tag")).toBe(false);
|
||||
});
|
||||
|
||||
test("passes provided compressionMode to --compression", async () => {
|
||||
const { getOptionValues } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1", compressionMode: "max" });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1", compressionMode: "max" }, mockDeps);
|
||||
|
||||
expect(getOptionValues("--compression")).toEqual(["max"]);
|
||||
});
|
||||
|
||||
test("defaults --compression to auto when compressionMode is omitted", async () => {
|
||||
const { getOptionValues } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
expect(getOptionValues("--compression")).toEqual(["auto"]);
|
||||
});
|
||||
|
||||
test("adds --one-file-system when oneFileSystem is true", async () => {
|
||||
const { hasFlag } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: true });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: true }, mockDeps);
|
||||
|
||||
expect(hasFlag("--one-file-system")).toBe(true);
|
||||
});
|
||||
|
||||
test("omits --one-file-system when oneFileSystem is false", async () => {
|
||||
const { hasFlag } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: false });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1", oneFileSystem: false }, mockDeps);
|
||||
|
||||
expect(hasFlag("--one-file-system")).toBe(false);
|
||||
});
|
||||
|
||||
test("adds --exclude-file when exclude list is provided", async () => {
|
||||
const { hasFlag } = setup();
|
||||
await backup(config, "/mnt/data", {
|
||||
organizationId: "org-1",
|
||||
exclude: ["/mnt/data/tmp", "/mnt/data/cache"],
|
||||
});
|
||||
await backup(
|
||||
config,
|
||||
"/mnt/data",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
exclude: ["/mnt/data/tmp", "/mnt/data/cache"],
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(hasFlag("--exclude-file")).toBe(true);
|
||||
});
|
||||
|
||||
test("omits --exclude-file when exclude list is empty", async () => {
|
||||
const { hasFlag } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1", exclude: [] });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1", exclude: [] }, mockDeps);
|
||||
|
||||
expect(hasFlag("--exclude-file")).toBe(false);
|
||||
});
|
||||
|
||||
test("adds --exclude-if-present for each entry in excludeIfPresent", async () => {
|
||||
const { getOptionValues } = setup();
|
||||
await backup(config, "/mnt/data", {
|
||||
organizationId: "org-1",
|
||||
excludeIfPresent: [".nobackup", ".gitignore"],
|
||||
});
|
||||
await backup(
|
||||
config,
|
||||
"/mnt/data",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
excludeIfPresent: [".nobackup", ".gitignore"],
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(getOptionValues("--exclude-if-present")).toEqual([".nobackup", ".gitignore"]);
|
||||
});
|
||||
|
||||
test("always includes DEFAULT_EXCLUDES as --exclude args", async () => {
|
||||
const { getOptionValues } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
expect(getOptionValues("--exclude").length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("includes --host arg from config", async () => {
|
||||
const { hasFlag } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
expect(hasFlag("--host")).toBe(true);
|
||||
});
|
||||
|
|
@ -188,7 +219,7 @@ describe("backup command", () => {
|
|||
describe("exit code handling", () => {
|
||||
test("returns parsed result on exit code 0", async () => {
|
||||
setup();
|
||||
const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" });
|
||||
const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
expect(exitCode).toBe(0);
|
||||
expect(result?.snapshot_id).toBe("abcd1234");
|
||||
|
|
@ -196,7 +227,7 @@ describe("backup command", () => {
|
|||
|
||||
test("returns result without throwing on exit code 3 (partial read errors)", async () => {
|
||||
setup({ spawnResult: { exitCode: 3 } });
|
||||
const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" });
|
||||
const { result, exitCode } = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
expect(exitCode).toBe(3);
|
||||
expect(result).not.toBeNull();
|
||||
|
|
@ -205,13 +236,15 @@ describe("backup command", () => {
|
|||
test("throws ResticError on non-zero, non-3 exit codes", async () => {
|
||||
setup({ spawnResult: { exitCode: 1, summary: "", error: "fatal error" } });
|
||||
|
||||
await expect(backup(config, "/mnt/data", { organizationId: "org-1" })).rejects.toBeInstanceOf(ResticError);
|
||||
await expect(backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps)).rejects.toBeInstanceOf(
|
||||
ResticError,
|
||||
);
|
||||
});
|
||||
|
||||
test("preserves the exit code inside the thrown ResticError", async () => {
|
||||
setup({ spawnResult: { exitCode: 12, summary: "", error: "wrong password" } });
|
||||
|
||||
const error = await backup(config, "/mnt/data", { organizationId: "org-1" }).catch((e) => e);
|
||||
const error = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps).catch((e) => e);
|
||||
expect(error).toBeInstanceOf(ResticError);
|
||||
expect((error as ResticError).code).toBe(12);
|
||||
});
|
||||
|
|
@ -223,10 +256,15 @@ describe("backup command", () => {
|
|||
spawnResult: { exitCode: 130, summary: "", error: "" },
|
||||
});
|
||||
|
||||
const { result, exitCode } = await backup(config, "/mnt/data", {
|
||||
organizationId: "org-1",
|
||||
signal: controller.signal,
|
||||
});
|
||||
const { result, exitCode } = await backup(
|
||||
config,
|
||||
"/mnt/data",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
signal: controller.signal,
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(result).toBeNull();
|
||||
expect(exitCode).toBe(130);
|
||||
|
|
@ -236,7 +274,7 @@ describe("backup command", () => {
|
|||
describe("output parsing", () => {
|
||||
test("returns a fully parsed summary object on valid output", async () => {
|
||||
setup();
|
||||
const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" });
|
||||
const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
message_type: "summary",
|
||||
|
|
@ -247,14 +285,14 @@ describe("backup command", () => {
|
|||
|
||||
test("returns { result: null } when summary line is not valid JSON", async () => {
|
||||
setup({ spawnResult: { summary: "not-json" } });
|
||||
const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" });
|
||||
const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns { result: null } when summary JSON does not satisfy the schema", async () => {
|
||||
setup({ spawnResult: { summary: JSON.stringify({ message_type: "summary" }) } });
|
||||
const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" });
|
||||
const { result } = await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
|
@ -265,10 +303,15 @@ describe("backup command", () => {
|
|||
const progressUpdates: unknown[] = [];
|
||||
setup({ onSpawnCall: (params) => params.onStdout?.(VALID_PROGRESS_LINE) });
|
||||
|
||||
await backup(config, "/mnt/data", {
|
||||
organizationId: "org-1",
|
||||
onProgress: (p) => progressUpdates.push(p),
|
||||
});
|
||||
await backup(
|
||||
config,
|
||||
"/mnt/data",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
onProgress: (p) => progressUpdates.push(p),
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(progressUpdates.length).toBeGreaterThan(0);
|
||||
expect(progressUpdates[0]).toMatchObject({
|
||||
|
|
@ -287,7 +330,7 @@ describe("backup command", () => {
|
|||
});
|
||||
|
||||
await expect(
|
||||
backup(config, "/mnt/data", { organizationId: "org-1", onProgress: () => {} }),
|
||||
backup(config, "/mnt/data", { organizationId: "org-1", onProgress: () => {} }, mockDeps),
|
||||
).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
|
|
@ -297,10 +340,15 @@ describe("backup command", () => {
|
|||
onSpawnCall: (params) => params.onStdout?.(JSON.stringify({ message_type: "verbose_status", action: "scan" })),
|
||||
});
|
||||
|
||||
await backup(config, "/mnt/data", {
|
||||
organizationId: "org-1",
|
||||
onProgress: (p) => progressUpdates.push(p),
|
||||
});
|
||||
await backup(
|
||||
config,
|
||||
"/mnt/data",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
onProgress: (p) => progressUpdates.push(p),
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(progressUpdates).toHaveLength(0);
|
||||
});
|
||||
|
|
@ -309,14 +357,14 @@ describe("backup command", () => {
|
|||
describe("cleanup", () => {
|
||||
test("calls cleanupTemporaryKeys after a successful backup", async () => {
|
||||
const { cleanupSpy } = setup();
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps);
|
||||
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test("calls cleanupTemporaryKeys even when the command fails", async () => {
|
||||
const { cleanupSpy } = setup({ spawnResult: { exitCode: 1, summary: "", error: "fail" } });
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" }).catch(() => {});
|
||||
await backup(config, "/mnt/data", { organizationId: "org-1" }, mockDeps).catch(() => {});
|
||||
|
||||
expect(cleanupSpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
|
@ -1,6 +1,16 @@
|
|||
import { afterEach, describe, expect, mock, spyOn, test } from "bun:test";
|
||||
import * as spawnModule from "~/server/utils/spawn";
|
||||
import * as spawnModule from "../../../utils/spawn";
|
||||
import { restore } from "../restore";
|
||||
import type { ResticDeps } from "../../types";
|
||||
import type { SafeSpawnParams } from "../../../utils/spawn";
|
||||
|
||||
const mockDeps: ResticDeps = {
|
||||
resolveSecret: async (s) => s,
|
||||
getOrganizationResticPassword: async () => "org-restic-password",
|
||||
resticCacheDir: "/tmp/restic-cache",
|
||||
resticPassFile: "/tmp/restic.pass",
|
||||
defaultExcludes: ["/tmp/restic.pass", "/var/lib/zerobyte/repositories"],
|
||||
};
|
||||
|
||||
const successfulRestoreSummary = JSON.stringify({
|
||||
message_type: "summary",
|
||||
|
|
@ -23,7 +33,7 @@ const config = {
|
|||
const setup = () => {
|
||||
let capturedArgs: string[] = [];
|
||||
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation((params) => {
|
||||
spyOn(spawnModule, "safeSpawn").mockImplementation((params: SafeSpawnParams) => {
|
||||
capturedArgs = params.args;
|
||||
return Promise.resolve({ exitCode: 0, summary: successfulRestoreSummary, error: "" });
|
||||
});
|
||||
|
|
@ -60,13 +70,19 @@ afterEach(() => {
|
|||
describe("restore command", () => {
|
||||
test("keeps snapshot restore arg and absolute include paths when target is root", async () => {
|
||||
const { getRestoreArg, getOptionValues } = setup();
|
||||
await restore(config, "snapshot-123", "/", {
|
||||
organizationId: "org-1",
|
||||
include: [
|
||||
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
|
||||
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
|
||||
],
|
||||
});
|
||||
await restore(
|
||||
config,
|
||||
"snapshot-123",
|
||||
"/",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
include: [
|
||||
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
|
||||
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
|
||||
],
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(getRestoreArg()).toBe("snapshot-123");
|
||||
expect(getOptionValues("--include")).toEqual([
|
||||
|
|
@ -77,13 +93,19 @@ describe("restore command", () => {
|
|||
|
||||
test("restores from common ancestor and strips include paths for non-root targets", async () => {
|
||||
const { getRestoreArg, getOptionValues } = setup();
|
||||
await restore(config, "snapshot-456", "/tmp/restore-target", {
|
||||
organizationId: "org-1",
|
||||
include: [
|
||||
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
|
||||
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
|
||||
],
|
||||
});
|
||||
await restore(
|
||||
config,
|
||||
"snapshot-456",
|
||||
"/tmp/restore-target",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
include: [
|
||||
"/var/lib/zerobyte/volumes/vol123/_data/Documents/report.pdf",
|
||||
"/var/lib/zerobyte/volumes/vol123/_data/Photos/summer.jpg",
|
||||
],
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(getRestoreArg()).toBe("snapshot-456:/var/lib/zerobyte/volumes/vol123/_data");
|
||||
expect(getOptionValues("--include")).toEqual(["Documents/report.pdf", "Photos/summer.jpg"]);
|
||||
|
|
@ -91,10 +113,16 @@ describe("restore command", () => {
|
|||
|
||||
test("uses base path for non-root restore when includes are omitted", async () => {
|
||||
const { getRestoreArg, getOptionValues } = setup();
|
||||
await restore(config, "snapshot-789", "/tmp/restore-target", {
|
||||
organizationId: "org-1",
|
||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||
});
|
||||
await restore(
|
||||
config,
|
||||
"snapshot-789",
|
||||
"/tmp/restore-target",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
basePath: "/var/lib/zerobyte/volumes/vol123/_data",
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(getRestoreArg()).toBe("snapshot-789:/var/lib/zerobyte/volumes/vol123/_data");
|
||||
expect(getOptionValues("--include")).toEqual([]);
|
||||
|
|
@ -108,7 +136,7 @@ describe("restore command", () => {
|
|||
selectedItemKind: "file",
|
||||
};
|
||||
|
||||
await restore(config, "snapshot-single-file", "/tmp/restore-target", options);
|
||||
await restore(config, "snapshot-single-file", "/tmp/restore-target", options, mockDeps);
|
||||
|
||||
expect(getRestoreArg()).toBe("snapshot-single-file:/var/lib/zerobyte/volumes/vol123/_data/archive");
|
||||
expect(getOptionValues("--include")).toEqual(["backup.20260301-233001.7z"]);
|
||||
|
|
@ -116,11 +144,17 @@ describe("restore command", () => {
|
|||
|
||||
test("does not pass an empty include when include equals restore root", async () => {
|
||||
const { getArgs, getRestoreArg, getOptionValues } = setup();
|
||||
await restore(config, "snapshot-7202d8cc", "/Users/nicolas/Documents/restore", {
|
||||
organizationId: "org-1",
|
||||
include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"],
|
||||
overwrite: "always",
|
||||
});
|
||||
await restore(
|
||||
config,
|
||||
"snapshot-7202d8cc",
|
||||
"/Users/nicolas/Documents/restore",
|
||||
{
|
||||
organizationId: "org-1",
|
||||
include: ["/Users/nicolas/Developer/zerobyte/tmp/deep/test/files"],
|
||||
overwrite: "always",
|
||||
},
|
||||
mockDeps,
|
||||
);
|
||||
|
||||
expect(getRestoreArg()).toBe("snapshot-7202d8cc:/Users/nicolas/Developer/zerobyte/tmp/deep/test/files");
|
||||
expect(getOptionValues("--include")).toEqual([]);
|
||||
|
|
@ -2,22 +2,16 @@ import fs from "node:fs/promises";
|
|||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { throttle } from "es-toolkit";
|
||||
import type { CompressionMode, RepositoryConfig } from "~/schemas/restic";
|
||||
import {
|
||||
type ResticBackupProgressDto,
|
||||
resticBackupOutputSchema,
|
||||
resticBackupProgressSchema,
|
||||
} from "~/schemas/restic-dto";
|
||||
import { DEFAULT_EXCLUDES } from "~/server/core/constants";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeSpawn } from "~/server/utils/spawn";
|
||||
import { config as appConfig } from "~/server/core/config";
|
||||
import type { CompressionMode, RepositoryConfig } from "../schemas";
|
||||
import { type ResticBackupProgressDto, resticBackupOutputSchema, resticBackupProgressSchema } from "../restic-dto";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import { validateCustomResticParams } from "../helpers/validate-custom-params";
|
||||
import { ResticError } from "../error";
|
||||
import { logger, safeSpawn } from "../../node";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
export const backup = async (
|
||||
config: RepositoryConfig,
|
||||
|
|
@ -34,9 +28,10 @@ export const backup = async (
|
|||
onProgress?: (progress: ResticBackupProgressDto) => void;
|
||||
customResticParams?: string[];
|
||||
},
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, options.organizationId);
|
||||
const env = await buildEnv(config, options.organizationId, deps);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "backup", "--compression", options.compressionMode ?? "auto"];
|
||||
|
||||
|
|
@ -44,8 +39,8 @@ export const backup = async (
|
|||
args.push("--one-file-system");
|
||||
}
|
||||
|
||||
if (appConfig.resticHostname) {
|
||||
args.push("--host", appConfig.resticHostname);
|
||||
if (deps.hostname) {
|
||||
args.push("--host", deps.hostname);
|
||||
}
|
||||
|
||||
if (options.tags && options.tags.length > 0) {
|
||||
|
|
@ -66,7 +61,7 @@ export const backup = async (
|
|||
args.push(source);
|
||||
}
|
||||
|
||||
for (const exclude of DEFAULT_EXCLUDES) {
|
||||
for (const exclude of deps.defaultExcludes) {
|
||||
args.push("--exclude", exclude);
|
||||
}
|
||||
|
||||
|
|
@ -143,7 +138,7 @@ export const backup = async (
|
|||
if (excludeFile) {
|
||||
await fs.unlink(excludeFile).catch(() => {});
|
||||
}
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
logger.warn("Restic backup was aborted by signal.");
|
||||
|
|
@ -1,10 +1,10 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
export const check = async (
|
||||
config: RepositoryConfig,
|
||||
|
|
@ -13,9 +13,10 @@ export const check = async (
|
|||
signal?: AbortSignal;
|
||||
organizationId: string;
|
||||
},
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, options.organizationId);
|
||||
const env = await buildEnv(config, options.organizationId, deps);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "check"];
|
||||
|
||||
|
|
@ -31,7 +32,7 @@ export const check = async (
|
|||
env,
|
||||
signal: options.signal,
|
||||
});
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
logger.warn("Restic check was aborted by signal.");
|
||||
|
|
@ -1,28 +1,29 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { formatBandwidthLimit } from "../helpers/bandwidth";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import { ResticError } from "../error";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
export const copy = async (
|
||||
sourceConfig: RepositoryConfig,
|
||||
destConfig: RepositoryConfig,
|
||||
options: { organizationId: string; tag?: string; snapshotId?: string },
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const sourceRepoUrl = buildRepoUrl(sourceConfig);
|
||||
const destRepoUrl = buildRepoUrl(destConfig);
|
||||
|
||||
const sourceEnv = await buildEnv(sourceConfig, options.organizationId);
|
||||
const destEnv = await buildEnv(destConfig, options.organizationId);
|
||||
const sourceEnv = await buildEnv(sourceConfig, options.organizationId, deps);
|
||||
const destEnv = await buildEnv(destConfig, options.organizationId, deps);
|
||||
|
||||
const env: Record<string, string> = {
|
||||
...sourceEnv,
|
||||
...destEnv,
|
||||
RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE,
|
||||
RESTIC_FROM_PASSWORD_FILE: sourceEnv.RESTIC_PASSWORD_FILE!,
|
||||
};
|
||||
|
||||
const args: string[] = ["--repo", destRepoUrl, "copy", "--from-repo", sourceRepoUrl];
|
||||
|
|
@ -55,8 +56,8 @@ export const copy = async (
|
|||
|
||||
const res = await safeExec({ command: "restic", args, env });
|
||||
|
||||
await cleanupTemporaryKeys(sourceEnv);
|
||||
await cleanupTemporaryKeys(destEnv);
|
||||
await cleanupTemporaryKeys(sourceEnv, deps);
|
||||
await cleanupTemporaryKeys(destEnv, deps);
|
||||
|
||||
const { stdout, stderr } = res;
|
||||
|
||||
|
|
@ -1,15 +1,20 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import { ResticError } from "../error";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
export const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[], organizationId: string) => {
|
||||
export const deleteSnapshots = async (
|
||||
config: RepositoryConfig,
|
||||
snapshotIds: string[],
|
||||
organizationId: string,
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
const env = await buildEnv(config, organizationId, deps);
|
||||
|
||||
if (snapshotIds.length === 0) {
|
||||
throw new Error("No snapshot IDs provided for deletion.");
|
||||
|
|
@ -19,7 +24,7 @@ export const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: str
|
|||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeExec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshot deletion failed: ${res.stderr}`);
|
||||
|
|
@ -29,6 +34,11 @@ export const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: str
|
|||
return { success: true };
|
||||
};
|
||||
|
||||
export const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string, organizationId: string) => {
|
||||
return deleteSnapshots(config, [snapshotId], organizationId);
|
||||
export const deleteSnapshot = async (
|
||||
config: RepositoryConfig,
|
||||
snapshotId: string,
|
||||
organizationId: string,
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
return deleteSnapshots(config, [snapshotId], organizationId, deps);
|
||||
};
|
||||
|
|
@ -1,14 +1,13 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { normalizeAbsolutePath } from "~/utils/path";
|
||||
import { normalizeAbsolutePath } from "../../utils/path";
|
||||
import type { Readable } from "node:stream";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { safeSpawn } from "~/server/utils/spawn";
|
||||
import type { ResticDumpStream } from "../types";
|
||||
import type { ResticDeps, ResticDumpStream } from "../types";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import { logger, safeSpawn } from "../../node";
|
||||
import { ResticError } from "../error";
|
||||
|
||||
const normalizeDumpPath = (pathToDump?: string): string => {
|
||||
const trimmedPath = pathToDump?.trim();
|
||||
|
|
@ -27,9 +26,10 @@ export const dump = async (
|
|||
path?: string;
|
||||
archive?: false;
|
||||
},
|
||||
deps: ResticDeps,
|
||||
): Promise<ResticDumpStream> => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, options.organizationId);
|
||||
const env = await buildEnv(config, options.organizationId, deps);
|
||||
const pathToDump = normalizeDumpPath(options.path);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "dump", snapshotRef, pathToDump];
|
||||
|
|
@ -49,7 +49,7 @@ export const dump = async (
|
|||
}
|
||||
|
||||
didCleanup = true;
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
};
|
||||
|
||||
let stream: Readable | null = null;
|
||||
|
|
@ -1,22 +1,21 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import type { RetentionPolicy } from "~/server/modules/backups/backups.dto";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { safeJsonParse } from "~/server/utils/json";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import type { ResticForgetResponse } from "../types";
|
||||
import type { ResticDeps, RetentionPolicy, ResticForgetResponse } from "../types";
|
||||
import { safeJsonParse } from "../../utils/json";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import { ResticError } from "../error";
|
||||
|
||||
export const forget = async (
|
||||
config: RepositoryConfig,
|
||||
options: RetentionPolicy,
|
||||
extra: { tag: string; organizationId: string; dryRun?: boolean },
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, extra.organizationId);
|
||||
const env = await buildEnv(config, extra.organizationId, deps);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag];
|
||||
|
||||
|
|
@ -53,7 +52,7 @@ export const forget = async (
|
|||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeExec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic forget failed: ${res.stderr}`);
|
||||
|
|
@ -1,19 +1,28 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { config as appConfig } from "~/server/core/config";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import { keyAdd } from "./key-add";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
const addDefaultKey = async (config: RepositoryConfig, organizationId: string, options?: { timeoutMs?: number }) => {
|
||||
if (appConfig.resticHostname) {
|
||||
const keyResult = await keyAdd(config, organizationId, {
|
||||
host: appConfig.resticHostname,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
});
|
||||
const addDefaultKey = async (
|
||||
config: RepositoryConfig,
|
||||
organizationId: string,
|
||||
options: { timeoutMs?: number },
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
if (deps?.hostname) {
|
||||
const keyResult = await keyAdd(
|
||||
config,
|
||||
organizationId,
|
||||
{
|
||||
host: deps.hostname,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
if (!keyResult.success) {
|
||||
logger.warn(`Repository initialized but failed to add key with hostname: ${keyResult.error}`);
|
||||
|
|
@ -21,18 +30,23 @@ const addDefaultKey = async (config: RepositoryConfig, organizationId: string, o
|
|||
}
|
||||
};
|
||||
|
||||
export const init = async (config: RepositoryConfig, organizationId: string, options?: { timeoutMs?: number }) => {
|
||||
export const init = async (
|
||||
config: RepositoryConfig,
|
||||
organizationId: string,
|
||||
options: { timeoutMs?: number } | undefined,
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
|
||||
logger.info(`Initializing restic repository at ${repoUrl}...`);
|
||||
|
||||
const env = await buildEnv(config, organizationId);
|
||||
const env = await buildEnv(config, organizationId, deps);
|
||||
|
||||
const args = ["init", "--repo", repoUrl];
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeExec({ command: "restic", args, env, timeout: options?.timeoutMs ?? 60000 });
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic init failed: ${res.stderr}`);
|
||||
|
|
@ -41,7 +55,14 @@ export const init = async (config: RepositoryConfig, organizationId: string, opt
|
|||
|
||||
logger.info(`Restic repository initialized: ${repoUrl}`);
|
||||
|
||||
void addDefaultKey(config, organizationId, { timeoutMs: options?.timeoutMs });
|
||||
void addDefaultKey(
|
||||
config,
|
||||
organizationId,
|
||||
{
|
||||
timeoutMs: options?.timeoutMs,
|
||||
},
|
||||
deps,
|
||||
);
|
||||
|
||||
return { success: true, error: null };
|
||||
};
|
||||
|
|
@ -1,21 +1,22 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
export const keyAdd = async (
|
||||
config: RepositoryConfig,
|
||||
organizationId: string,
|
||||
options: { host: string; timeoutMs?: number },
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
|
||||
logger.info(`Adding restic key with host "${options.host}" for repository at ${repoUrl}...`);
|
||||
|
||||
const env = await buildEnv(config, organizationId);
|
||||
const env = await buildEnv(config, organizationId, deps);
|
||||
|
||||
const args = [
|
||||
"key",
|
||||
|
|
@ -26,11 +27,12 @@ export const keyAdd = async (
|
|||
options.host,
|
||||
"--new-password-file",
|
||||
env.RESTIC_PASSWORD_FILE,
|
||||
];
|
||||
].filter((e) => e !== undefined);
|
||||
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeExec({ command: "restic", args, env, timeout: options.timeoutMs ?? 60000 });
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic key add failed: ${res.stderr}`);
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { z } from "zod";
|
||||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeSpawn } from "~/server/utils/spawn";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import { logger, safeSpawn } from "../../node";
|
||||
import { ResticError } from "../error";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
const lsNodeSchema = z.object({
|
||||
name: z.string(),
|
||||
|
|
@ -53,11 +53,12 @@ export const ls = async (
|
|||
config: RepositoryConfig,
|
||||
snapshotId: string,
|
||||
organizationId: string,
|
||||
path?: string,
|
||||
options?: { offset?: number; limit?: number },
|
||||
path: string | undefined,
|
||||
options: { offset?: number; limit?: number } | undefined,
|
||||
deps: ResticDeps,
|
||||
): Promise<ResticLsResult> => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
const env = await buildEnv(config, organizationId, deps);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
|
||||
|
||||
|
|
@ -118,7 +119,7 @@ export const ls = async (
|
|||
},
|
||||
});
|
||||
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic ls failed: ${res.error}`);
|
||||
|
|
@ -1,18 +1,19 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import { ResticError } from "../error";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
export const repairIndex = async (
|
||||
config: RepositoryConfig,
|
||||
options: { signal?: AbortSignal; organizationId: string },
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, options.organizationId);
|
||||
const env = await buildEnv(config, options.organizationId, deps);
|
||||
|
||||
const args = ["repair", "index", "--repo", repoUrl];
|
||||
addCommonArgs(args, env, config);
|
||||
|
|
@ -23,7 +24,7 @@ export const repairIndex = async (
|
|||
env,
|
||||
signal: options.signal,
|
||||
});
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
logger.warn("Restic repair index was aborted by signal.");
|
||||
|
|
@ -1,17 +1,16 @@
|
|||
import path from "node:path";
|
||||
import { z } from "zod";
|
||||
import { throttle } from "es-toolkit";
|
||||
import type { OverwriteMode, RepositoryConfig } from "~/schemas/restic";
|
||||
import type { ResticRestoreOutputDto } from "~/schemas/restic-dto";
|
||||
import { resticRestoreOutputSchema } from "~/schemas/restic-dto";
|
||||
import { findCommonAncestor } from "~/utils/common-ancestor";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeSpawn } from "~/server/utils/spawn";
|
||||
import { findCommonAncestor } from "../../utils/common-ancestor";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import { type RepositoryConfig, type OverwriteMode } from "../schemas";
|
||||
import { logger, safeSpawn } from "../../node";
|
||||
import { ResticError } from "../error";
|
||||
import { resticRestoreOutputSchema, type ResticRestoreOutputDto } from "../restic-dto";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
const restoreProgressSchema = z.object({
|
||||
message_type: z.enum(["status", "summary"]),
|
||||
|
|
@ -41,9 +40,10 @@ export const restore = async (
|
|||
onProgress?: (progress: RestoreProgress) => void;
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, options.organizationId);
|
||||
const env = await buildEnv(config, options.organizationId, deps);
|
||||
|
||||
let restoreArg = snapshotId;
|
||||
|
||||
|
|
@ -129,7 +129,7 @@ export const restore = async (
|
|||
},
|
||||
});
|
||||
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic restore failed: ${res.error}`);
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import { z } from "zod";
|
||||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { resticSnapshotSummarySchema } from "~/schemas/restic-dto";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import { resticSnapshotSummarySchema } from "../restic-dto";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
const snapshotInfoSchema = z.object({
|
||||
gid: z.number().optional(),
|
||||
|
|
@ -23,11 +23,15 @@ const snapshotInfoSchema = z.object({
|
|||
summary: resticSnapshotSummarySchema.optional(),
|
||||
});
|
||||
|
||||
export const snapshots = async (config: RepositoryConfig, options: { tags?: string[]; organizationId: string }) => {
|
||||
export const snapshots = async (
|
||||
config: RepositoryConfig,
|
||||
options: { tags?: string[]; organizationId: string },
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const { tags, organizationId } = options;
|
||||
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
const env = await buildEnv(config, organizationId, deps);
|
||||
|
||||
const args = ["--repo", repoUrl, "snapshots"];
|
||||
|
||||
|
|
@ -40,7 +44,7 @@ export const snapshots = async (config: RepositoryConfig, options: { tags?: stri
|
|||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeExec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshots retrieval failed: ${res.stderr}`);
|
||||
|
|
@ -1,23 +1,23 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { resticStatsSchema } from "~/schemas/restic-dto";
|
||||
import { safeJsonParse } from "~/server/utils/json";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { safeJsonParse } from "../../utils/json";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import { ResticError } from "../error";
|
||||
import { resticStatsSchema } from "../restic-dto";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
export const stats = async (config: RepositoryConfig, options: { organizationId: string }) => {
|
||||
export const stats = async (config: RepositoryConfig, options: { organizationId: string }, deps: ResticDeps) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, options.organizationId);
|
||||
const env = await buildEnv(config, options.organizationId, deps);
|
||||
|
||||
const args = ["--repo", repoUrl, "stats", "--mode", "raw-data"];
|
||||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeExec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic stats retrieval failed: ${res.stderr}`);
|
||||
|
|
@ -1,24 +1,25 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import { ResticError } from "../error";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
export const tagSnapshots = async (
|
||||
config: RepositoryConfig,
|
||||
snapshotIds: string[],
|
||||
tags: { add?: string[]; remove?: string[]; set?: string[] },
|
||||
organizationId: string,
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
if (snapshotIds.length === 0) {
|
||||
throw new Error("No snapshot IDs provided for tagging.");
|
||||
}
|
||||
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, organizationId);
|
||||
const env = await buildEnv(config, organizationId, deps);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "tag", ...snapshotIds];
|
||||
|
||||
|
|
@ -43,7 +44,7 @@ export const tagSnapshots = async (
|
|||
addCommonArgs(args, env, config);
|
||||
|
||||
const res = await safeExec({ command: "restic", args, env });
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic snapshot tagging failed: ${res.stderr}`);
|
||||
|
|
@ -1,15 +1,19 @@
|
|||
import type { RepositoryConfig } from "~/schemas/restic";
|
||||
import { ResticError } from "~/server/utils/errors";
|
||||
import { logger } from "~/server/utils/logger";
|
||||
import { safeExec } from "~/server/utils/spawn";
|
||||
import { logger, safeExec } from "../../node";
|
||||
import { ResticError } from "../error";
|
||||
import { addCommonArgs } from "../helpers/add-common-args";
|
||||
import { buildEnv } from "../helpers/build-env";
|
||||
import { buildRepoUrl } from "../helpers/build-repo-url";
|
||||
import { cleanupTemporaryKeys } from "../helpers/cleanup-temporary-keys";
|
||||
import type { RepositoryConfig } from "../schemas";
|
||||
import type { ResticDeps } from "../types";
|
||||
|
||||
export const unlock = async (config: RepositoryConfig, options: { signal?: AbortSignal; organizationId: string }) => {
|
||||
export const unlock = async (
|
||||
config: RepositoryConfig,
|
||||
options: { signal?: AbortSignal; organizationId: string },
|
||||
deps: ResticDeps,
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config, options.organizationId);
|
||||
const env = await buildEnv(config, options.organizationId, deps);
|
||||
|
||||
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
||||
addCommonArgs(args, env, config);
|
||||
|
|
@ -20,7 +24,7 @@ export const unlock = async (config: RepositoryConfig, options: { signal?: Abort
|
|||
env,
|
||||
signal: options.signal,
|
||||
});
|
||||
await cleanupTemporaryKeys(env);
|
||||
await cleanupTemporaryKeys(env, deps);
|
||||
|
||||
if (options.signal?.aborted) {
|
||||
logger.warn("Restic unlock was aborted by signal.");
|
||||
21
packages/core/src/restic/error.ts
Normal file
21
packages/core/src/restic/error.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
const resticErrorCodes: Record<number, string> = {
|
||||
1: "Command failed: An error occurred while executing the command.",
|
||||
2: "Go runtime error: A runtime error occurred in the Go program.",
|
||||
3: "Backup could not read all files: Some files could not be read during backup.",
|
||||
10: "Repository not found: The specified repository could not be found.",
|
||||
11: "Failed to lock repository: Unable to acquire a lock on the repository. Try to run doctor on the repository.",
|
||||
12: "Wrong repository password: The provided password for the repository is incorrect.",
|
||||
130: "Backup interrupted: The backup process was interrupted.",
|
||||
};
|
||||
|
||||
export class ResticError extends Error {
|
||||
code: number;
|
||||
|
||||
constructor(code: number, stderr: string) {
|
||||
const message = resticErrorCodes[code] || `Unknown restic error with code ${code}`;
|
||||
super(`${message}\n${stderr}`);
|
||||
|
||||
this.code = code;
|
||||
this.name = "ResticError";
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,19 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { afterEach, describe, expect, test } from "bun:test";
|
||||
import { db } from "~/server/db/db";
|
||||
import { organization } from "~/server/db/schema";
|
||||
import { RESTIC_CACHE_DIR } from "~/server/core/constants";
|
||||
import { buildEnv } from "../build-env";
|
||||
import type { ResticDeps } from "../../types";
|
||||
|
||||
const RESTIC_CACHE_DIR = "/tmp/restic-cache";
|
||||
const RESTIC_PASS_FILE = "/tmp/restic.pass";
|
||||
|
||||
const makeDeps = (overrides: Partial<ResticDeps> = {}): ResticDeps => ({
|
||||
resolveSecret: async (s) => s,
|
||||
getOrganizationResticPassword: async () => "org-restic-password",
|
||||
resticCacheDir: RESTIC_CACHE_DIR,
|
||||
resticPassFile: RESTIC_PASS_FILE,
|
||||
defaultExcludes: [RESTIC_PASS_FILE, "/var/lib/zerobyte/repositories"],
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const withCustomPassword = <T extends object>(config: T) => ({
|
||||
...config,
|
||||
|
|
@ -12,19 +21,6 @@ const withCustomPassword = <T extends object>(config: T) => ({
|
|||
customPassword: "test-password",
|
||||
});
|
||||
|
||||
const createTestOrg = async (overrides: Partial<typeof organization.$inferInsert> = {}) => {
|
||||
const id = randomUUID();
|
||||
await db.insert(organization).values({
|
||||
id,
|
||||
name: "Build-env test org",
|
||||
slug: `build-env-test-${id}`,
|
||||
createdAt: new Date(),
|
||||
metadata: { resticPassword: "org-restic-password" },
|
||||
...overrides,
|
||||
});
|
||||
return id;
|
||||
};
|
||||
|
||||
const PLAIN_PRIVATE_KEY =
|
||||
"-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXktdjEAAAAA\n-----END OPENSSH PRIVATE KEY-----";
|
||||
|
||||
|
|
@ -37,10 +33,11 @@ const trackTempFile = (filePath: string | undefined) => {
|
|||
};
|
||||
|
||||
const buildEnvForTest = async (
|
||||
config: Parameters<typeof import("../build-env").buildEnv>[0],
|
||||
config: Parameters<typeof buildEnv>[0],
|
||||
organizationId: string,
|
||||
): Promise<ReturnType<typeof import("../build-env").buildEnv>> => {
|
||||
const env = await buildEnv(config, organizationId);
|
||||
deps: ResticDeps = makeDeps(),
|
||||
) => {
|
||||
const env = await buildEnv(config, organizationId, deps);
|
||||
|
||||
// Automatically track all temp file paths created by buildEnv
|
||||
trackTempFile(env.RESTIC_PASSWORD_FILE);
|
||||
|
|
@ -94,9 +91,11 @@ describe("buildEnv", () => {
|
|||
});
|
||||
|
||||
test("writes a password file from the organization's resticPassword when no customPassword is given", async () => {
|
||||
const orgId = await createTestOrg();
|
||||
const deps = makeDeps({
|
||||
getOrganizationResticPassword: async () => "org-restic-password",
|
||||
});
|
||||
|
||||
const env = await buildEnvForTest({ backend: "local" as const, path: "/tmp/repo" }, orgId);
|
||||
const env = await buildEnvForTest({ backend: "local" as const, path: "/tmp/repo" }, "org-1", deps);
|
||||
|
||||
const passwordFilePath = env.RESTIC_PASSWORD_FILE;
|
||||
expect(passwordFilePath).toBeDefined();
|
||||
|
|
@ -108,17 +107,27 @@ describe("buildEnv", () => {
|
|||
expect(fileContent).toBe("org-restic-password");
|
||||
});
|
||||
|
||||
test("throws when the organization does not exist", async () => {
|
||||
await expect(buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "non-existent-org")).rejects.toThrow(
|
||||
"Organization non-existent-org not found",
|
||||
);
|
||||
test("throws when getOrganizationResticPassword throws (organization not found)", async () => {
|
||||
const deps = makeDeps({
|
||||
getOrganizationResticPassword: async () => {
|
||||
throw new Error("Organization non-existent-org not found");
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "non-existent-org", deps),
|
||||
).rejects.toThrow("Organization non-existent-org not found");
|
||||
});
|
||||
|
||||
test("throws when the organization has no resticPassword configured", async () => {
|
||||
const orgId = await createTestOrg({ metadata: null });
|
||||
test("throws when getOrganizationResticPassword throws (no password configured)", async () => {
|
||||
const deps = makeDeps({
|
||||
getOrganizationResticPassword: async (id) => {
|
||||
throw new Error(`Restic password not configured for organization ${id}`);
|
||||
},
|
||||
});
|
||||
|
||||
await expect(buildEnv({ backend: "local" as const, path: "/tmp/repo" }, orgId)).rejects.toThrow(
|
||||
`Restic password not configured for organization ${orgId}`,
|
||||
await expect(buildEnv({ backend: "local" as const, path: "/tmp/repo" }, "org-no-pass", deps)).rejects.toThrow(
|
||||
"Restic password not configured for organization org-no-pass",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -264,6 +273,7 @@ describe("buildEnv", () => {
|
|||
});
|
||||
|
||||
test("throws for passphrase-protected private keys", async () => {
|
||||
const deps = makeDeps();
|
||||
await expect(
|
||||
buildEnv(
|
||||
{
|
||||
|
|
@ -271,14 +281,16 @@ describe("buildEnv", () => {
|
|||
privateKey: "-----BEGIN RSA PRIVATE KEY-----\nProc-Type: 4,ENCRYPTED\n-----END RSA PRIVATE KEY-----",
|
||||
},
|
||||
"org-1",
|
||||
deps,
|
||||
),
|
||||
).rejects.toThrow("Passphrase-protected SSH keys are not supported");
|
||||
});
|
||||
|
||||
test("succeeds when the private key has CRLF line endings", async () => {
|
||||
const crlfKey = PLAIN_PRIVATE_KEY.replace(/\n/g, "\r\n");
|
||||
const deps = makeDeps();
|
||||
|
||||
await expect(buildEnv({ ...baseSftpConfig, privateKey: crlfKey }, "org-1")).resolves.toBeDefined();
|
||||
await expect(buildEnv({ ...baseSftpConfig, privateKey: crlfKey }, "org-1", deps)).resolves.toBeDefined();
|
||||
});
|
||||
|
||||
test("uses StrictHostKeyChecking=no when skipHostKeyCheck is true", async () => {
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue