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:
Nico 2026-03-11 21:56:07 +01:00 committed by GitHub
parent 9e616ca35a
commit 332e5bffda
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
121 changed files with 864 additions and 456 deletions

1
.gitignore vendored
View file

@ -42,3 +42,4 @@ tmp/
qa-output
.worktrees/
.turbo

View file

@ -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 = {

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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";

View file

@ -30,7 +30,7 @@ import {
restRepositoryConfigSchema,
s3RepositoryConfigSchema,
sftpRepositoryConfigSchema,
} from "~/schemas/restic";
} from "@zerobyte/core/restic";
import { Checkbox } from "../../../components/ui/checkbox";
import {
LocalRepositoryForm,

View file

@ -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>;

View file

@ -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 = {

View file

@ -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";

View file

@ -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"]);

View file

@ -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;

View file

@ -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";

View file

@ -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";

View file

@ -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 () => {

View file

@ -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;

View file

@ -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
View 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);

View file

@ -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>;

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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({

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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) => {

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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 {

View file

@ -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()

View file

@ -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>();

View file

@ -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();

View file

@ -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";

View file

@ -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";

View file

@ -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}`);

View file

@ -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";

View file

@ -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 () => {

View file

@ -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 };

View file

@ -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 () => {

View file

@ -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";

View file

@ -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";

View file

@ -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");

View file

@ -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) => {

View file

@ -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";

View file

@ -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, "");

View file

@ -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(),

View file

@ -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 };
};

View file

@ -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";

View file

@ -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 } });

View file

@ -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";

View file

@ -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";

View file

@ -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 () => {

View file

@ -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", () => {

View file

@ -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;

View file

@ -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", () => {

View file

@ -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";
}
}

View file

@ -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

View file

@ -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";

View file

@ -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,
};

View file

@ -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";

View file

@ -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 {

View file

@ -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: {

View file

@ -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", () => {

View file

@ -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=="],

View file

@ -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
View 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
View 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
View 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=="],
}
}

View 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"
}
}

View 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";

View file

@ -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);
});

View file

@ -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([]);

View file

@ -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.");

View file

@ -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.");

View file

@ -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;

View file

@ -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);
};

View file

@ -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;

View file

@ -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}`);

View file

@ -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 };
};

View file

@ -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}`);

View file

@ -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}`);

View file

@ -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.");

View file

@ -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}`);

View file

@ -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}`);

View file

@ -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}`);

View file

@ -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}`);

View file

@ -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.");

View 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";
}
}

View file

@ -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