refactor: each org has its own restic password
This commit is contained in:
parent
5895bc3ae3
commit
207c88ef7e
13 changed files with 316 additions and 80 deletions
|
|
@ -1,4 +1,3 @@
|
||||||
-- Custom SQL migration file, put your code below! --
|
|
||||||
INSERT INTO organization (id, name, slug, created_at)
|
INSERT INTO organization (id, name, slug, created_at)
|
||||||
SELECT
|
SELECT
|
||||||
'default-org-' || u.id as id,
|
'default-org-' || u.id as id,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
-- Custom SQL migration file, put your code below! --
|
|
||||||
INSERT INTO member (id, organization_id, user_id, role, created_at)
|
INSERT INTO member (id, organization_id, user_id, role, created_at)
|
||||||
SELECT
|
SELECT
|
||||||
'default-mem-' || u.id as id,
|
'default-mem-' || u.id as id,
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,15 @@
|
||||||
-- Backfill organization_id for volumes, backups, notifications and repositories
|
|
||||||
-- Uses the first organization found in the database
|
|
||||||
|
|
||||||
-- Update volumes_table
|
|
||||||
UPDATE volumes_table
|
UPDATE volumes_table
|
||||||
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
||||||
WHERE organization_id IS NULL;
|
WHERE organization_id IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
-- Update backup_schedules_table
|
|
||||||
UPDATE backup_schedules_table
|
UPDATE backup_schedules_table
|
||||||
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
||||||
WHERE organization_id IS NULL;
|
WHERE organization_id IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
-- Update notification_destinations_table
|
|
||||||
UPDATE notification_destinations_table
|
UPDATE notification_destinations_table
|
||||||
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
||||||
WHERE organization_id IS NULL;
|
WHERE organization_id IS NULL;
|
||||||
|
--> statement-breakpoint
|
||||||
-- Update repositories_table
|
|
||||||
UPDATE repositories_table
|
UPDATE repositories_table
|
||||||
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
SET organization_id = (SELECT id FROM organization ORDER BY created_at ASC LIMIT 1)
|
||||||
WHERE organization_id IS NULL;
|
WHERE organization_id IS NULL;
|
||||||
|
|
|
||||||
|
|
@ -8,11 +8,11 @@ import {
|
||||||
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
import { drizzleAdapter } from "better-auth/adapters/drizzle";
|
||||||
import { admin, createAuthMiddleware, twoFactor, username, organization } from "better-auth/plugins";
|
import { admin, createAuthMiddleware, twoFactor, username, organization } from "better-auth/plugins";
|
||||||
import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user";
|
import { convertLegacyUserOnFirstLogin } from "./auth-middlewares/convert-legacy-user";
|
||||||
import { cryptoUtils } from "~/server/utils/crypto";
|
|
||||||
import { db } from "~/server/db/db";
|
|
||||||
import { member, organization as organizationTable } from "~/server/db/schema";
|
|
||||||
import { config } from "~/server/core/config";
|
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
|
import { config } from "../server/core/config";
|
||||||
|
import { db } from "../server/db/db";
|
||||||
|
import { cryptoUtils } from "../server/utils/crypto";
|
||||||
|
import { organization as organizationTable, member } from "../server/db/schema";
|
||||||
|
|
||||||
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||||
|
|
||||||
|
|
@ -45,6 +45,11 @@ const createBetterAuth = (secret: string) =>
|
||||||
after: async (user) => {
|
after: async (user) => {
|
||||||
const slug = user.email.split("@")[0] + "-" + Math.random().toString(36).slice(-4);
|
const slug = user.email.split("@")[0] + "-" + Math.random().toString(36).slice(-4);
|
||||||
|
|
||||||
|
const resticPassword = cryptoUtils.generateResticPassword();
|
||||||
|
const metadata = {
|
||||||
|
resticPassword: await cryptoUtils.sealSecret(resticPassword),
|
||||||
|
};
|
||||||
|
|
||||||
await db.transaction(async (tx) => {
|
await db.transaction(async (tx) => {
|
||||||
const orgId = Bun.randomUUIDv7();
|
const orgId = Bun.randomUUIDv7();
|
||||||
|
|
||||||
|
|
@ -53,6 +58,7 @@ const createBetterAuth = (secret: string) =>
|
||||||
slug: slug,
|
slug: slug,
|
||||||
id: orgId,
|
id: orgId,
|
||||||
createdAt: new Date(),
|
createdAt: new Date(),
|
||||||
|
metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
await tx.insert(member).values({
|
await tx.insert(member).values({
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,7 @@ const envSchema = type({
|
||||||
APP_VERSION: "string = 'dev'",
|
APP_VERSION: "string = 'dev'",
|
||||||
TRUSTED_ORIGINS: "string?",
|
TRUSTED_ORIGINS: "string?",
|
||||||
DISABLE_RATE_LIMITING: 'string = "false"',
|
DISABLE_RATE_LIMITING: 'string = "false"',
|
||||||
|
ZEROBYTE_APP_SECRET: "32 <= string <= 256",
|
||||||
}).pipe((s) => ({
|
}).pipe((s) => ({
|
||||||
__prod__: s.NODE_ENV === "production",
|
__prod__: s.NODE_ENV === "production",
|
||||||
environment: s.NODE_ENV,
|
environment: s.NODE_ENV,
|
||||||
|
|
@ -45,13 +46,35 @@ const envSchema = type({
|
||||||
appVersion: s.APP_VERSION,
|
appVersion: s.APP_VERSION,
|
||||||
trustedOrigins: s.TRUSTED_ORIGINS?.split(",").map((origin) => origin.trim()),
|
trustedOrigins: s.TRUSTED_ORIGINS?.split(",").map((origin) => origin.trim()),
|
||||||
disableRateLimiting: s.DISABLE_RATE_LIMITING === "true",
|
disableRateLimiting: s.DISABLE_RATE_LIMITING === "true",
|
||||||
|
appSecret: s.ZEROBYTE_APP_SECRET,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const parseConfig = (env: unknown) => {
|
const parseConfig = (env: unknown) => {
|
||||||
const result = envSchema(env);
|
const result = envSchema(env);
|
||||||
|
|
||||||
if (result instanceof type.errors) {
|
if (result instanceof type.errors) {
|
||||||
console.error(`Environment variable validation failed: ${result.toString()}`);
|
if (!process.env.ZEROBYTE_APP_SECRET) {
|
||||||
|
const errorMessage = [
|
||||||
|
"",
|
||||||
|
"================================================================================",
|
||||||
|
"ZEROBYTE_APP_SECRET is not configured.",
|
||||||
|
"",
|
||||||
|
"This secret is required for encrypting sensitive data in the database.",
|
||||||
|
"",
|
||||||
|
"To generate a new secret, run:",
|
||||||
|
" openssl rand -hex 32",
|
||||||
|
"",
|
||||||
|
"Then set the ZEROBYTE_APP_SECRET environment variable with the generated value.",
|
||||||
|
"",
|
||||||
|
"IMPORTANT: Store this secret securely and back it up. If lost, encrypted data",
|
||||||
|
"in the database will be unrecoverable.",
|
||||||
|
"================================================================================",
|
||||||
|
"",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
console.error(errorMessage);
|
||||||
|
}
|
||||||
|
console.error(`Environment variable validation failed: ${result.summary}`);
|
||||||
throw new Error("Invalid environment variables");
|
throw new Error("Invalid environment variables");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -59,3 +82,15 @@ const parseConfig = (env: unknown) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
export const config = parseConfig(process.env);
|
export const config = parseConfig(process.env);
|
||||||
|
|
||||||
|
export const getAppSecret = (): string => {
|
||||||
|
if (!config.appSecret) {
|
||||||
|
throw new Error("ZEROBYTE_APP_SECRET is not configured");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.appSecret.length < 32) {
|
||||||
|
throw new Error("ZEROBYTE_APP_SECRET must be at least 32 characters long");
|
||||||
|
}
|
||||||
|
|
||||||
|
return config.appSecret;
|
||||||
|
};
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,10 @@ export const verification = sqliteTable(
|
||||||
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
(table) => [index("verification_identifier_idx").on(table.identifier)],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export type OrganizationMetadata = {
|
||||||
|
resticPassword: string;
|
||||||
|
};
|
||||||
|
|
||||||
export const organization = sqliteTable(
|
export const organization = sqliteTable(
|
||||||
"organization",
|
"organization",
|
||||||
{
|
{
|
||||||
|
|
@ -120,7 +124,7 @@ export const organization = sqliteTable(
|
||||||
slug: text("slug").notNull().unique(),
|
slug: text("slug").notNull().unique(),
|
||||||
logo: text("logo"),
|
logo: text("logo"),
|
||||||
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
createdAt: integer("created_at", { mode: "timestamp_ms" }).notNull(),
|
||||||
metadata: text("metadata"),
|
metadata: text("metadata", { mode: "json" }).$type<OrganizationMetadata>(),
|
||||||
},
|
},
|
||||||
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
(table) => [uniqueIndex("organization_slug_uidx").on(table.slug)],
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -306,6 +306,7 @@ const executeBackup = async (scheduleId: number, organizationId: string, manual
|
||||||
const result = await restic.backup(repository.config, volumePath, {
|
const result = await restic.backup(repository.config, volumePath, {
|
||||||
...backupOptions,
|
...backupOptions,
|
||||||
compressionMode: repository.compressionMode ?? "auto",
|
compressionMode: repository.compressionMode ?? "auto",
|
||||||
|
organizationId,
|
||||||
onProgress: (progress) => {
|
onProgress: (progress) => {
|
||||||
serverEvents.emit("backup:progress", {
|
serverEvents.emit("backup:progress", {
|
||||||
scheduleId,
|
scheduleId,
|
||||||
|
|
@ -493,7 +494,7 @@ const runForget = async (scheduleId: number, organizationId: string, repositoryI
|
||||||
logger.info(`running retention policy (forget) for schedule ${scheduleId}`);
|
logger.info(`running retention policy (forget) for schedule ${scheduleId}`);
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`);
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, `forget:${scheduleId}`);
|
||||||
try {
|
try {
|
||||||
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId });
|
await restic.forget(repository.config, schedule.retentionPolicy, { tag: schedule.shortId, organizationId });
|
||||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
cache.delByPrefix(`snapshots:${repository.id}:`);
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock();
|
releaseLock();
|
||||||
|
|
@ -626,7 +627,7 @@ const copyToMirrors = async (
|
||||||
const releaseMirror = await repoMutex.acquireShared(mirror.repository.id, `mirror:${scheduleId}`);
|
const releaseMirror = await repoMutex.acquireShared(mirror.repository.id, `mirror:${scheduleId}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId });
|
await restic.copy(sourceRepository.config, mirror.repository.config, { tag: schedule.shortId, organizationId });
|
||||||
cache.delByPrefix(`snapshots:${mirror.repository.id}:`);
|
cache.delByPrefix(`snapshots:${mirror.repository.id}:`);
|
||||||
} finally {
|
} finally {
|
||||||
releaseSource();
|
releaseSource();
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,10 @@
|
||||||
import { db } from "~/server/db/db";
|
|
||||||
import { logger } from "../../utils/logger";
|
import { logger } from "../../utils/logger";
|
||||||
import { v00001 } from "./migrations/00001-retag-snapshots";
|
import { v00001 } from "./migrations/00001-retag-snapshots";
|
||||||
import { usersTable } from "~/server/db/schema";
|
import { v00002 } from "./migrations/00002-isolate-restic-passwords";
|
||||||
import { sql } from "drizzle-orm";
|
import { sql } from "drizzle-orm";
|
||||||
import { eq } from "drizzle-orm";
|
import { eq } from "drizzle-orm";
|
||||||
import { appMetadataTable } from "../../db/schema";
|
import { appMetadataTable, usersTable } from "../../db/schema";
|
||||||
|
import { db } from "../../db/db";
|
||||||
|
|
||||||
const MIGRATION_KEY_PREFIX = "migration:";
|
const MIGRATION_KEY_PREFIX = "migration:";
|
||||||
|
|
||||||
|
|
@ -38,7 +38,7 @@ type MigrationEntity = {
|
||||||
dependsOn?: string[];
|
dependsOn?: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const registry: MigrationEntity[] = [v00001];
|
const registry: MigrationEntity[] = [v00001, v00002];
|
||||||
|
|
||||||
export const runMigrations = async () => {
|
export const runMigrations = async () => {
|
||||||
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
|
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ const migrateTag = async (
|
||||||
scheduleName: string,
|
scheduleName: string,
|
||||||
): Promise<string | null> => {
|
): Promise<string | null> => {
|
||||||
const repoUrl = buildRepoUrl(repository.config);
|
const repoUrl = buildRepoUrl(repository.config);
|
||||||
const env = await buildEnv(repository.config);
|
const env = await buildEnv(repository.config, "org-id");
|
||||||
|
|
||||||
const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
|
const args = ["--repo", repoUrl, "tag", "--tag", oldTag, "--add", newTag, "--remove", oldTag];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,129 @@
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "../../../db/db";
|
||||||
|
import { organization, repositoriesTable, volumesTable, notificationDestinationsTable } from "../../../db/schema";
|
||||||
|
import { logger } from "../../../utils/logger";
|
||||||
|
import { toMessage } from "~/server/utils/errors";
|
||||||
|
import { cryptoUtils } from "~/server/utils/crypto";
|
||||||
|
import type { RepositoryConfig } from "~/schemas/restic";
|
||||||
|
import type { BackendConfig } from "~/schemas/volumes";
|
||||||
|
import type { NotificationConfig } from "~/schemas/notifications";
|
||||||
|
import { RESTIC_PASS_FILE } from "~/server/core/constants";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: Isolate Restic Passwords
|
||||||
|
*
|
||||||
|
* This migration performs two critical tasks:
|
||||||
|
* 1. Assigns unique restic passwords to each organization (using the legacy password for existing orgs)
|
||||||
|
* 2. Re-keys all encrypted secrets from the legacy restic passfile to use the new ZEROBYTE_APP_SECRET
|
||||||
|
*
|
||||||
|
* This allows per-organization encryption key isolation while ensuring
|
||||||
|
* database encryption is decoupled from restic repository passwords.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type MigrationError = { name: string; error: string };
|
||||||
|
|
||||||
|
const rekeySecrets = async (config: Record<string, unknown>): Promise<Record<string, unknown>> => {
|
||||||
|
const rekeyedConfig: Record<string, unknown> = { ...config };
|
||||||
|
|
||||||
|
for (const [key, value] of Object.entries(rekeyedConfig)) {
|
||||||
|
if (typeof value === "string" && cryptoUtils.isEncrypted(value)) {
|
||||||
|
const decrypted = await cryptoUtils.legacyDecrypt(value);
|
||||||
|
rekeyedConfig[key] = await cryptoUtils.sealSecret(decrypted);
|
||||||
|
} else if (typeof value === "object" && value !== null && !Array.isArray(value)) {
|
||||||
|
rekeyedConfig[key] = await rekeySecrets(value as Record<string, unknown>);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return rekeyedConfig;
|
||||||
|
};
|
||||||
|
|
||||||
|
const execute = async () => {
|
||||||
|
const errors: MigrationError[] = [];
|
||||||
|
|
||||||
|
// Step 1: Read the legacy restic passfile
|
||||||
|
const legacyPassword = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
|
||||||
|
|
||||||
|
if (!legacyPassword) {
|
||||||
|
logger.info("No legacy restic passfile found, skipping migration");
|
||||||
|
return { success: true, errors: [] };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Assign restic passwords to all existing organizations
|
||||||
|
const organizations = await db.query.organization.findMany({});
|
||||||
|
|
||||||
|
for (const org of organizations) {
|
||||||
|
try {
|
||||||
|
const currentMetadata = org.metadata;
|
||||||
|
|
||||||
|
if (!currentMetadata?.resticPassword) {
|
||||||
|
const newMetadata = {
|
||||||
|
...currentMetadata,
|
||||||
|
resticPassword: await cryptoUtils.sealSecret(legacyPassword),
|
||||||
|
};
|
||||||
|
|
||||||
|
await db.update(organization).set({ metadata: newMetadata }).where(eq(organization.id, org.id));
|
||||||
|
|
||||||
|
logger.info(`Assigned restic password to organization: ${org.name}`);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ name: `org:${org.name}`, error: toMessage(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Re-key all repository secrets
|
||||||
|
const repositories = await db.query.repositoriesTable.findMany({});
|
||||||
|
|
||||||
|
for (const repo of repositories) {
|
||||||
|
try {
|
||||||
|
const rekeyedConfig = (await rekeySecrets(repo.config)) as RepositoryConfig;
|
||||||
|
|
||||||
|
await db.update(repositoriesTable).set({ config: rekeyedConfig }).where(eq(repositoriesTable.id, repo.id));
|
||||||
|
|
||||||
|
logger.info(`Re-keyed secrets for repository: ${repo.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ name: `repo:${repo.name}`, error: toMessage(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Re-key all volume secrets
|
||||||
|
const volumes = await db.query.volumesTable.findMany({});
|
||||||
|
|
||||||
|
for (const volume of volumes) {
|
||||||
|
try {
|
||||||
|
const rekeyedConfig = (await rekeySecrets(volume.config)) as BackendConfig;
|
||||||
|
|
||||||
|
await db.update(volumesTable).set({ config: rekeyedConfig }).where(eq(volumesTable.id, volume.id));
|
||||||
|
|
||||||
|
logger.info(`Re-keyed secrets for volume: ${volume.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ name: `volume:${volume.name}`, error: toMessage(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: Re-key all notification secrets
|
||||||
|
const notifications = await db.query.notificationDestinationsTable.findMany({});
|
||||||
|
|
||||||
|
for (const notification of notifications) {
|
||||||
|
try {
|
||||||
|
const rekeyedConfig = (await rekeySecrets(notification.config)) as NotificationConfig;
|
||||||
|
|
||||||
|
await db
|
||||||
|
.update(notificationDestinationsTable)
|
||||||
|
.set({ config: rekeyedConfig })
|
||||||
|
.where(eq(notificationDestinationsTable.id, notification.id));
|
||||||
|
|
||||||
|
logger.info(`Re-keyed secrets for notification: ${notification.name}`);
|
||||||
|
} catch (err) {
|
||||||
|
errors.push({ name: `notification:${notification.name}`, error: toMessage(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { success: errors.length === 0, errors };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const v00002 = {
|
||||||
|
execute,
|
||||||
|
id: "00002-isolate-restic-passwords",
|
||||||
|
type: "critical" as const,
|
||||||
|
dependsOn: ["00001-retag-snapshots"],
|
||||||
|
};
|
||||||
|
|
@ -9,19 +9,19 @@ import { restic } from "../../utils/restic";
|
||||||
import { cryptoUtils } from "../../utils/crypto";
|
import { cryptoUtils } from "../../utils/crypto";
|
||||||
import { cache } from "../../utils/cache";
|
import { cache } from "../../utils/cache";
|
||||||
import { repoMutex } from "../../core/repository-mutex";
|
import { repoMutex } from "../../core/repository-mutex";
|
||||||
|
import { type } from "arktype";
|
||||||
import {
|
import {
|
||||||
repositoryConfigSchema,
|
repositoryConfigSchema,
|
||||||
type CompressionMode,
|
type CompressionMode,
|
||||||
type OverwriteMode,
|
type OverwriteMode,
|
||||||
type RepositoryConfig,
|
type RepositoryConfig,
|
||||||
} from "~/schemas/restic";
|
} from "~/schemas/restic";
|
||||||
import { type } from "arktype";
|
|
||||||
|
|
||||||
const findRepository = async (idOrShortId: string, organizationId: string) => {
|
const findRepository = async (idOrShortId: string, organizationId: string) => {
|
||||||
return await db.query.repositoriesTable.findFirst({
|
return await db.query.repositoriesTable.findFirst({
|
||||||
where: and(
|
where: and(
|
||||||
or(eq(repositoriesTable.id, idOrShortId), eq(repositoriesTable.shortId, idOrShortId)),
|
or(eq(repositoriesTable.id, idOrShortId), eq(repositoriesTable.shortId, idOrShortId)),
|
||||||
eq(repositoriesTable.organizationId, organizationId)
|
eq(repositoriesTable.organizationId, organizationId),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
@ -72,7 +72,12 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
|
||||||
return encryptedConfig as RepositoryConfig;
|
return encryptedConfig as RepositoryConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
const createRepository = async (name: string, config: RepositoryConfig, organizationId: string, compressionMode?: CompressionMode) => {
|
const createRepository = async (
|
||||||
|
name: string,
|
||||||
|
config: RepositoryConfig,
|
||||||
|
organizationId: string,
|
||||||
|
compressionMode?: CompressionMode,
|
||||||
|
) => {
|
||||||
const id = crypto.randomUUID();
|
const id = crypto.randomUUID();
|
||||||
const shortId = generateShortId();
|
const shortId = generateShortId();
|
||||||
|
|
||||||
|
|
@ -105,13 +110,13 @@ const createRepository = async (name: string, config: RepositoryConfig, organiza
|
||||||
|
|
||||||
if (config.isExistingRepository) {
|
if (config.isExistingRepository) {
|
||||||
const result = await restic
|
const result = await restic
|
||||||
.snapshots(encryptedConfig)
|
.snapshots(encryptedConfig, { organizationId })
|
||||||
.then(() => ({ error: null }))
|
.then(() => ({ error: null }))
|
||||||
.catch((error) => ({ error }));
|
.catch((error) => ({ error }));
|
||||||
|
|
||||||
error = result.error;
|
error = result.error;
|
||||||
} else {
|
} else {
|
||||||
const initResult = await restic.init(encryptedConfig);
|
const initResult = await restic.init(encryptedConfig, organizationId);
|
||||||
error = initResult.error;
|
error = initResult.error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,9 +186,9 @@ const listSnapshots = async (id: string, organizationId: string, backupId?: stri
|
||||||
let snapshots = [];
|
let snapshots = [];
|
||||||
|
|
||||||
if (backupId) {
|
if (backupId) {
|
||||||
snapshots = await restic.snapshots(repository.config, { tags: [backupId] });
|
snapshots = await restic.snapshots(repository.config, { tags: [backupId], organizationId });
|
||||||
} else {
|
} else {
|
||||||
snapshots = await restic.snapshots(repository.config);
|
snapshots = await restic.snapshots(repository.config, { organizationId });
|
||||||
}
|
}
|
||||||
|
|
||||||
cache.set(cacheKey, snapshots);
|
cache.set(cacheKey, snapshots);
|
||||||
|
|
@ -212,7 +217,7 @@ const listSnapshotFiles = async (id: string, organizationId: string, snapshotId:
|
||||||
|
|
||||||
const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`);
|
const releaseLock = await repoMutex.acquireShared(repository.id, `ls:${snapshotId}`);
|
||||||
try {
|
try {
|
||||||
const result = await restic.ls(repository.config, snapshotId, path);
|
const result = await restic.ls(repository.config, snapshotId, organizationId, path);
|
||||||
|
|
||||||
if (!result.snapshot) {
|
if (!result.snapshot) {
|
||||||
throw new NotFoundError("Snapshot not found or empty");
|
throw new NotFoundError("Snapshot not found or empty");
|
||||||
|
|
@ -260,7 +265,7 @@ const restoreSnapshot = async (
|
||||||
|
|
||||||
const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
|
const releaseLock = await repoMutex.acquireShared(repository.id, `restore:${snapshotId}`);
|
||||||
try {
|
try {
|
||||||
const result = await restic.restore(repository.config, snapshotId, target, options);
|
const result = await restic.restore(repository.config, snapshotId, target, { ...options, organizationId });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
|
|
@ -286,7 +291,7 @@ const getSnapshotDetails = async (id: string, organizationId: string, snapshotId
|
||||||
if (!snapshots) {
|
if (!snapshots) {
|
||||||
const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`);
|
const releaseLock = await repoMutex.acquireShared(repository.id, `snapshot_details:${snapshotId}`);
|
||||||
try {
|
try {
|
||||||
snapshots = await restic.snapshots(repository.config);
|
snapshots = await restic.snapshots(repository.config, { organizationId });
|
||||||
cache.set(cacheKey, snapshots);
|
cache.set(cacheKey, snapshots);
|
||||||
} finally {
|
} finally {
|
||||||
releaseLock();
|
releaseLock();
|
||||||
|
|
@ -311,7 +316,7 @@ const checkHealth = async (repositoryId: string, organizationId: string) => {
|
||||||
|
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, "check");
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, "check");
|
||||||
try {
|
try {
|
||||||
const { hasErrors, error } = await restic.check(repository.config);
|
const { hasErrors, error } = await restic.check(repository.config, { organizationId });
|
||||||
|
|
||||||
await db
|
await db
|
||||||
.update(repositoriesTable)
|
.update(repositoriesTable)
|
||||||
|
|
@ -337,7 +342,7 @@ const doctorRepository = async (id: string, organizationId: string) => {
|
||||||
|
|
||||||
const steps: Array<{ step: string; success: boolean; output: string | null; error: string | null }> = [];
|
const steps: Array<{ step: string; success: boolean; output: string | null; error: string | null }> = [];
|
||||||
|
|
||||||
const unlockResult = await restic.unlock(repository.config).then(
|
const unlockResult = await restic.unlock(repository.config, organizationId).then(
|
||||||
(result) => ({ success: true, message: result.message, error: null }),
|
(result) => ({ success: true, message: result.message, error: null }),
|
||||||
(error) => ({ success: false, message: null, error: toMessage(error) }),
|
(error) => ({ success: false, message: null, error: toMessage(error) }),
|
||||||
);
|
);
|
||||||
|
|
@ -351,7 +356,7 @@ const doctorRepository = async (id: string, organizationId: string) => {
|
||||||
|
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, "doctor");
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, "doctor");
|
||||||
try {
|
try {
|
||||||
const checkResult = await restic.check(repository.config, { readData: false }).then(
|
const checkResult = await restic.check(repository.config, { readData: false, organizationId }).then(
|
||||||
(result) => result,
|
(result) => result,
|
||||||
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||||
);
|
);
|
||||||
|
|
@ -364,7 +369,7 @@ const doctorRepository = async (id: string, organizationId: string) => {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (checkResult.hasErrors) {
|
if (checkResult.hasErrors) {
|
||||||
const repairResult = await restic.repairIndex(repository.config).then(
|
const repairResult = await restic.repairIndex(repository.config, organizationId).then(
|
||||||
(result) => ({ success: true, output: result.output, error: null }),
|
(result) => ({ success: true, output: result.output, error: null }),
|
||||||
(error) => ({ success: false, output: null, error: toMessage(error) }),
|
(error) => ({ success: false, output: null, error: toMessage(error) }),
|
||||||
);
|
);
|
||||||
|
|
@ -376,7 +381,7 @@ const doctorRepository = async (id: string, organizationId: string) => {
|
||||||
error: repairResult.error,
|
error: repairResult.error,
|
||||||
});
|
});
|
||||||
|
|
||||||
const recheckResult = await restic.check(repository.config, { readData: false }).then(
|
const recheckResult = await restic.check(repository.config, { readData: false, organizationId }).then(
|
||||||
(result) => result,
|
(result) => result,
|
||||||
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
(error) => ({ success: false, output: null, error: toMessage(error), hasErrors: true }),
|
||||||
);
|
);
|
||||||
|
|
@ -426,7 +431,7 @@ const deleteSnapshot = async (id: string, organizationId: string, snapshotId: st
|
||||||
|
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:${snapshotId}`);
|
||||||
try {
|
try {
|
||||||
await restic.deleteSnapshot(repository.config, snapshotId);
|
await restic.deleteSnapshot(repository.config, snapshotId, organizationId);
|
||||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
cache.delByPrefix(`snapshots:${repository.id}:`);
|
||||||
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -443,7 +448,7 @@ const deleteSnapshots = async (id: string, organizationId: string, snapshotIds:
|
||||||
|
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`);
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, `delete:bulk`);
|
||||||
try {
|
try {
|
||||||
await restic.deleteSnapshots(repository.config, snapshotIds);
|
await restic.deleteSnapshots(repository.config, snapshotIds, organizationId);
|
||||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
cache.delByPrefix(`snapshots:${repository.id}:`);
|
||||||
for (const snapshotId of snapshotIds) {
|
for (const snapshotId of snapshotIds) {
|
||||||
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
||||||
|
|
@ -467,7 +472,7 @@ const tagSnapshots = async (
|
||||||
|
|
||||||
const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`);
|
const releaseLock = await repoMutex.acquireExclusive(repository.id, `tag:bulk`);
|
||||||
try {
|
try {
|
||||||
await restic.tagSnapshots(repository.config, snapshotIds, tags);
|
await restic.tagSnapshots(repository.config, snapshotIds, tags, organizationId);
|
||||||
cache.delByPrefix(`snapshots:${repository.id}:`);
|
cache.delByPrefix(`snapshots:${repository.id}:`);
|
||||||
for (const snapshotId of snapshotIds) {
|
for (const snapshotId of snapshotIds) {
|
||||||
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
cache.delByPrefix(`ls:${repository.id}:${snapshotId}:`);
|
||||||
|
|
@ -477,7 +482,11 @@ const tagSnapshots = async (
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const updateRepository = async (id: string, organizationId: string, updates: { name?: string; compressionMode?: CompressionMode }) => {
|
const updateRepository = async (
|
||||||
|
id: string,
|
||||||
|
organizationId: string,
|
||||||
|
updates: { name?: string; compressionMode?: CompressionMode },
|
||||||
|
) => {
|
||||||
const existing = await findRepository(id, organizationId);
|
const existing = await findRepository(id, organizationId);
|
||||||
|
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import crypto from "node:crypto";
|
||||||
import fs from "node:fs/promises";
|
import fs from "node:fs/promises";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { RESTIC_PASS_FILE } from "../core/constants";
|
import { RESTIC_PASS_FILE } from "../core/constants";
|
||||||
|
import { getAppSecret } from "../core/config";
|
||||||
import { isNodeJSErrnoException } from "./fs";
|
import { isNodeJSErrnoException } from "./fs";
|
||||||
import { promisify } from "node:util";
|
import { promisify } from "node:util";
|
||||||
|
|
||||||
|
|
@ -84,7 +85,7 @@ const resolveFileSecret = async (ref: string): Promise<string> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given a string, encrypts it using a randomly generated salt.
|
* Given a string, encrypts it using a randomly generated salt and the APP_SECRET.
|
||||||
* Returns the input unchanged if it's empty or already encrypted.
|
* Returns the input unchanged if it's empty or already encrypted.
|
||||||
*/
|
*/
|
||||||
const encrypt = async (data: string) => {
|
const encrypt = async (data: string) => {
|
||||||
|
|
@ -96,7 +97,7 @@ const encrypt = async (data: string) => {
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
const secret = await Bun.file(RESTIC_PASS_FILE).text();
|
const secret = getAppSecret();
|
||||||
|
|
||||||
const salt = crypto.randomBytes(16);
|
const salt = crypto.randomBytes(16);
|
||||||
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
|
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
|
||||||
|
|
@ -110,7 +111,7 @@ const encrypt = async (data: string) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Given an encrypted string, decrypts it using the salt stored in the string.
|
* Given an encrypted string, decrypts it using the salt stored in the string and the APP_SECRET.
|
||||||
* Returns the input unchanged if it's not encrypted (for backward compatibility).
|
* Returns the input unchanged if it's not encrypted (for backward compatibility).
|
||||||
*/
|
*/
|
||||||
const decrypt = async (encryptedData: string) => {
|
const decrypt = async (encryptedData: string) => {
|
||||||
|
|
@ -118,6 +119,36 @@ const decrypt = async (encryptedData: string) => {
|
||||||
return encryptedData;
|
return encryptedData;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const secret = getAppSecret();
|
||||||
|
|
||||||
|
const parts = encryptedData.split(":").slice(1); // Remove prefix
|
||||||
|
const saltHex = parts.shift() as string;
|
||||||
|
const salt = Buffer.from(saltHex, "hex");
|
||||||
|
|
||||||
|
const key = crypto.pbkdf2Sync(secret, salt, 100000, keyLength, "sha256");
|
||||||
|
|
||||||
|
const iv = Buffer.from(parts.shift() as string, "hex");
|
||||||
|
const encrypted = Buffer.from(parts.shift() as string, "hex");
|
||||||
|
const tag = Buffer.from(parts.shift() as string, "hex");
|
||||||
|
const decipher = crypto.createDecipheriv(algorithm, key, iv);
|
||||||
|
|
||||||
|
decipher.setAuthTag(tag);
|
||||||
|
|
||||||
|
let decrypted = decipher.update(encrypted);
|
||||||
|
decrypted = Buffer.concat([decrypted, decipher.final()]);
|
||||||
|
|
||||||
|
return decrypted.toString();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decrypts data using the legacy restic passfile.
|
||||||
|
* Used during migration to re-key existing secrets.
|
||||||
|
*/
|
||||||
|
const legacyDecrypt = async (encryptedData: string): Promise<string> => {
|
||||||
|
if (!isEncrypted(encryptedData)) {
|
||||||
|
return encryptedData;
|
||||||
|
}
|
||||||
|
|
||||||
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
|
const secret = (await Bun.file(RESTIC_PASS_FILE).text()).trim();
|
||||||
|
|
||||||
const parts = encryptedData.split(":").slice(1); // Remove prefix
|
const parts = encryptedData.split(":").slice(1); // Remove prefix
|
||||||
|
|
@ -187,15 +218,22 @@ const sealSecret = async (value: string): Promise<string> => {
|
||||||
};
|
};
|
||||||
|
|
||||||
async function deriveSecret(label: string) {
|
async function deriveSecret(label: string) {
|
||||||
const masterSecret = await Bun.file(RESTIC_PASS_FILE).text();
|
const masterSecret = getAppSecret();
|
||||||
|
|
||||||
const derivedKey = await hkdf("sha256", masterSecret, "", label, 32);
|
const derivedKey = await hkdf("sha256", masterSecret, "", label, 32);
|
||||||
|
|
||||||
return Buffer.from(derivedKey).toString("hex");
|
return Buffer.from(derivedKey).toString("hex");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function generateResticPassword(): string {
|
||||||
|
return crypto.randomBytes(32).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
export const cryptoUtils = {
|
export const cryptoUtils = {
|
||||||
resolveSecret,
|
resolveSecret,
|
||||||
sealSecret,
|
sealSecret,
|
||||||
deriveSecret,
|
deriveSecret,
|
||||||
|
legacyDecrypt,
|
||||||
|
generateResticPassword,
|
||||||
|
isEncrypted,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
||||||
import os from "node:os";
|
import os from "node:os";
|
||||||
import { throttle } from "es-toolkit";
|
import { throttle } from "es-toolkit";
|
||||||
import { type } from "arktype";
|
import { type } from "arktype";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { REPOSITORY_BASE, RESTIC_PASS_FILE, DEFAULT_EXCLUDES, RESTIC_CACHE_DIR } from "../core/constants";
|
import { REPOSITORY_BASE, RESTIC_PASS_FILE, DEFAULT_EXCLUDES, RESTIC_CACHE_DIR } from "../core/constants";
|
||||||
import { config as appConfig } from "../core/config";
|
import { config as appConfig } from "../core/config";
|
||||||
import { logger } from "./logger";
|
import { logger } from "./logger";
|
||||||
|
|
@ -12,6 +13,8 @@ import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
||||||
import { safeSpawn, exec } from "./spawn";
|
import { safeSpawn, exec } from "./spawn";
|
||||||
import type { CompressionMode, RepositoryConfig, OverwriteMode, BandwidthLimit } from "~/schemas/restic";
|
import type { CompressionMode, RepositoryConfig, OverwriteMode, BandwidthLimit } from "~/schemas/restic";
|
||||||
import { ResticError } from "./errors";
|
import { ResticError } from "./errors";
|
||||||
|
import { db } from "../db/db";
|
||||||
|
import { organization } from "../db/schema";
|
||||||
|
|
||||||
const backupOutputSchema = type({
|
const backupOutputSchema = type({
|
||||||
message_type: "'summary'",
|
message_type: "'summary'",
|
||||||
|
|
@ -105,7 +108,7 @@ export const buildRepoUrl = (config: RepositoryConfig): string => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const buildEnv = async (config: RepositoryConfig) => {
|
export const buildEnv = async (config: RepositoryConfig, organizationId: string) => {
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
RESTIC_CACHE_DIR,
|
RESTIC_CACHE_DIR,
|
||||||
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
PATH: process.env.PATH || "/usr/local/bin:/usr/bin:/bin",
|
||||||
|
|
@ -118,7 +121,23 @@ export const buildEnv = async (config: RepositoryConfig) => {
|
||||||
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
|
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
|
||||||
env.RESTIC_PASSWORD_FILE = passwordFilePath;
|
env.RESTIC_PASSWORD_FILE = passwordFilePath;
|
||||||
} else {
|
} else {
|
||||||
env.RESTIC_PASSWORD_FILE = RESTIC_PASS_FILE;
|
const org = await db.query.organization.findFirst({
|
||||||
|
where: eq(organization.id, organizationId),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!org) {
|
||||||
|
throw new Error(`Organization ${organizationId} not found`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = org.metadata;
|
||||||
|
if (!metadata?.resticPassword) {
|
||||||
|
throw new Error(`Restic password not configured for organization ${organizationId}`);
|
||||||
|
} else {
|
||||||
|
const decryptedPassword = await cryptoUtils.resolveSecret(metadata.resticPassword);
|
||||||
|
const passwordFilePath = path.join("/tmp", `zerobyte-pass-${crypto.randomBytes(8).toString("hex")}.txt`);
|
||||||
|
await fs.writeFile(passwordFilePath, decryptedPassword, { mode: 0o600 });
|
||||||
|
env.RESTIC_PASSWORD_FILE = passwordFilePath;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (config.backend) {
|
switch (config.backend) {
|
||||||
|
|
@ -219,14 +238,14 @@ export const buildEnv = async (config: RepositoryConfig) => {
|
||||||
return env;
|
return env;
|
||||||
};
|
};
|
||||||
|
|
||||||
const init = async (config: RepositoryConfig) => {
|
const init = async (config: RepositoryConfig, organizationId: string) => {
|
||||||
await ensurePassfile();
|
await ensurePassfile();
|
||||||
|
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
|
|
||||||
logger.info(`Initializing restic repository at ${repoUrl}...`);
|
logger.info(`Initializing restic repository at ${repoUrl}...`);
|
||||||
|
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, organizationId);
|
||||||
|
|
||||||
const args = ["init", "--repo", repoUrl];
|
const args = ["init", "--repo", repoUrl];
|
||||||
addCommonArgs(args, env, config);
|
addCommonArgs(args, env, config);
|
||||||
|
|
@ -259,7 +278,8 @@ export type BackupProgress = typeof backupProgressSchema.infer;
|
||||||
const backup = async (
|
const backup = async (
|
||||||
config: RepositoryConfig,
|
config: RepositoryConfig,
|
||||||
source: string,
|
source: string,
|
||||||
options?: {
|
options: {
|
||||||
|
organizationId: string;
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
excludeIfPresent?: string[];
|
excludeIfPresent?: string[];
|
||||||
include?: string[];
|
include?: string[];
|
||||||
|
|
@ -271,7 +291,7 @@ const backup = async (
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, options?.organizationId);
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"];
|
const args: string[] = ["--repo", repoUrl, "backup", "--compression", options?.compressionMode ?? "auto"];
|
||||||
|
|
||||||
|
|
@ -410,7 +430,8 @@ const restore = async (
|
||||||
config: RepositoryConfig,
|
config: RepositoryConfig,
|
||||||
snapshotId: string,
|
snapshotId: string,
|
||||||
target: string,
|
target: string,
|
||||||
options?: {
|
options: {
|
||||||
|
organizationId: string;
|
||||||
include?: string[];
|
include?: string[];
|
||||||
exclude?: string[];
|
exclude?: string[];
|
||||||
excludeXattr?: string[];
|
excludeXattr?: string[];
|
||||||
|
|
@ -419,7 +440,7 @@ const restore = async (
|
||||||
},
|
},
|
||||||
) => {
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, options.organizationId);
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
|
const args: string[] = ["--repo", repoUrl, "restore", snapshotId, "--target", target];
|
||||||
|
|
||||||
|
|
@ -493,11 +514,11 @@ const restore = async (
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshots = async (config: RepositoryConfig, options: { tags?: string[] } = {}) => {
|
const snapshots = async (config: RepositoryConfig, options: { tags?: string[]; organizationId: string }) => {
|
||||||
const { tags } = options;
|
const { tags, organizationId } = options;
|
||||||
|
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, organizationId);
|
||||||
|
|
||||||
const args = ["--repo", repoUrl, "snapshots"];
|
const args = ["--repo", repoUrl, "snapshots"];
|
||||||
|
|
||||||
|
|
@ -527,9 +548,13 @@ const snapshots = async (config: RepositoryConfig, options: { tags?: string[] }
|
||||||
return result;
|
return result;
|
||||||
};
|
};
|
||||||
|
|
||||||
const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra: { tag: string }) => {
|
const forget = async (
|
||||||
|
config: RepositoryConfig,
|
||||||
|
options: RetentionPolicy,
|
||||||
|
extra: { tag: string; organizationId: string },
|
||||||
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, extra.organizationId);
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag];
|
const args: string[] = ["--repo", repoUrl, "forget", "--group-by", "tags", "--tag", extra.tag];
|
||||||
|
|
||||||
|
|
@ -569,9 +594,9 @@ const forget = async (config: RepositoryConfig, options: RetentionPolicy, extra:
|
||||||
return { success: true };
|
return { success: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[]) => {
|
const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[], organizationId: string) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, organizationId);
|
||||||
|
|
||||||
if (snapshotIds.length === 0) {
|
if (snapshotIds.length === 0) {
|
||||||
throw new Error("No snapshot IDs provided for deletion.");
|
throw new Error("No snapshot IDs provided for deletion.");
|
||||||
|
|
@ -591,17 +616,18 @@ const deleteSnapshots = async (config: RepositoryConfig, snapshotIds: string[])
|
||||||
return { success: true };
|
return { success: true };
|
||||||
};
|
};
|
||||||
|
|
||||||
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string) => {
|
const deleteSnapshot = async (config: RepositoryConfig, snapshotId: string, organizationId: string) => {
|
||||||
return deleteSnapshots(config, [snapshotId]);
|
return deleteSnapshots(config, [snapshotId], organizationId);
|
||||||
};
|
};
|
||||||
|
|
||||||
const tagSnapshots = async (
|
const tagSnapshots = async (
|
||||||
config: RepositoryConfig,
|
config: RepositoryConfig,
|
||||||
snapshotIds: string[],
|
snapshotIds: string[],
|
||||||
tags: { add?: string[]; remove?: string[]; set?: string[] },
|
tags: { add?: string[]; remove?: string[]; set?: string[] },
|
||||||
|
organizationId: string,
|
||||||
) => {
|
) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, organizationId);
|
||||||
|
|
||||||
if (snapshotIds.length === 0) {
|
if (snapshotIds.length === 0) {
|
||||||
throw new Error("No snapshot IDs provided for tagging.");
|
throw new Error("No snapshot IDs provided for tagging.");
|
||||||
|
|
@ -667,9 +693,9 @@ const lsSnapshotInfoSchema = type({
|
||||||
message_type: "'snapshot'",
|
message_type: "'snapshot'",
|
||||||
});
|
});
|
||||||
|
|
||||||
const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) => {
|
const ls = async (config: RepositoryConfig, snapshotId: string, organizationId: string, path?: string) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, organizationId);
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
|
const args: string[] = ["--repo", repoUrl, "ls", snapshotId, "--long"];
|
||||||
|
|
||||||
|
|
@ -723,9 +749,9 @@ const ls = async (config: RepositoryConfig, snapshotId: string, path?: string) =
|
||||||
return { snapshot, nodes };
|
return { snapshot, nodes };
|
||||||
};
|
};
|
||||||
|
|
||||||
const unlock = async (config: RepositoryConfig) => {
|
const unlock = async (config: RepositoryConfig, organizationId: string) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, organizationId);
|
||||||
|
|
||||||
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
const args = ["unlock", "--repo", repoUrl, "--remove-all"];
|
||||||
addCommonArgs(args, env, config);
|
addCommonArgs(args, env, config);
|
||||||
|
|
@ -742,9 +768,9 @@ const unlock = async (config: RepositoryConfig) => {
|
||||||
return { success: true, message: "Repository unlocked successfully" };
|
return { success: true, message: "Repository unlocked successfully" };
|
||||||
};
|
};
|
||||||
|
|
||||||
const check = async (config: RepositoryConfig, options?: { readData?: boolean }) => {
|
const check = async (config: RepositoryConfig, options: { readData?: boolean; organizationId: string }) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, options.organizationId);
|
||||||
|
|
||||||
const args: string[] = ["--repo", repoUrl, "check"];
|
const args: string[] = ["--repo", repoUrl, "check"];
|
||||||
|
|
||||||
|
|
@ -780,9 +806,9 @@ const check = async (config: RepositoryConfig, options?: { readData?: boolean })
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const repairIndex = async (config: RepositoryConfig) => {
|
const repairIndex = async (config: RepositoryConfig, organizationId: string) => {
|
||||||
const repoUrl = buildRepoUrl(config);
|
const repoUrl = buildRepoUrl(config);
|
||||||
const env = await buildEnv(config);
|
const env = await buildEnv(config, organizationId);
|
||||||
|
|
||||||
const args = ["repair", "index", "--repo", repoUrl];
|
const args = ["repair", "index", "--repo", repoUrl];
|
||||||
addCommonArgs(args, env, config);
|
addCommonArgs(args, env, config);
|
||||||
|
|
@ -808,16 +834,13 @@ const repairIndex = async (config: RepositoryConfig) => {
|
||||||
const copy = async (
|
const copy = async (
|
||||||
sourceConfig: RepositoryConfig,
|
sourceConfig: RepositoryConfig,
|
||||||
destConfig: RepositoryConfig,
|
destConfig: RepositoryConfig,
|
||||||
options: {
|
options: { organizationId: string; tag?: string; snapshotId?: string },
|
||||||
tag?: string;
|
|
||||||
snapshotId?: string;
|
|
||||||
},
|
|
||||||
) => {
|
) => {
|
||||||
const sourceRepoUrl = buildRepoUrl(sourceConfig);
|
const sourceRepoUrl = buildRepoUrl(sourceConfig);
|
||||||
const destRepoUrl = buildRepoUrl(destConfig);
|
const destRepoUrl = buildRepoUrl(destConfig);
|
||||||
|
|
||||||
const sourceEnv = await buildEnv(sourceConfig);
|
const sourceEnv = await buildEnv(sourceConfig, options.organizationId);
|
||||||
const destEnv = await buildEnv(destConfig);
|
const destEnv = await buildEnv(destConfig, options.organizationId);
|
||||||
|
|
||||||
const env: Record<string, string> = {
|
const env: Record<string, string> = {
|
||||||
...sourceEnv,
|
...sourceEnv,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue