Merge branch 'main' into feat/deep-secret-resolution

This commit is contained in:
Jakub Trávník 2026-01-08 22:58:05 +01:00 committed by GitHub
commit 5e055263de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 274 additions and 217 deletions

View file

@ -1,6 +1,6 @@
{ {
"$schema": "./node_modules/oxlint/configuration_schema.json", "$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["unicorn", "typescript", "oxc"], "plugins": ["eslint", "unicorn", "typescript", "oxc", "import", "react", "react-perf", "node", "jsx-a11y"],
"categories": {}, "categories": {},
"rules": { "rules": {
"constructor-super": "warn", "constructor-super": "warn",
@ -112,7 +112,8 @@
"unicorn/no-useless-length-check": "warn", "unicorn/no-useless-length-check": "warn",
"unicorn/no-useless-spread": "warn", "unicorn/no-useless-spread": "warn",
"unicorn/prefer-set-size": "warn", "unicorn/prefer-set-size": "warn",
"unicorn/prefer-string-starts-ends-with": "warn" "unicorn/prefer-string-starts-ends-with": "warn",
"import/no-cycle": "error"
}, },
"settings": { "settings": {
"jsx-a11y": { "jsx-a11y": {

View file

@ -10,8 +10,9 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
VITE_RCLONE_VERSION=${RCLONE_VERSION} \ VITE_RCLONE_VERSION=${RCLONE_VERSION} \
VITE_SHOUTRRR_VERSION=${SHOUTRRR_VERSION} VITE_SHOUTRRR_VERSION=${SHOUTRRR_VERSION}
RUN apk upgrade --no-cache && \ RUN apk update --no-cache && \
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini nfs-utils cifs-utils apk upgrade --no-cache && \
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini nfs-utils cifs-utils util-linux
ENTRYPOINT ["/sbin/tini", "-s", "--"] ENTRYPOINT ["/sbin/tini", "-s", "--"]

View file

@ -489,18 +489,27 @@ interface ButtonProps {
const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => { const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]); const paddingLeft = useMemo(() => `${8 + depth * NODE_PADDING_LEFT}px`, [depth]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onClick?.();
}
},
[onClick],
);
return ( return (
// biome-ignore lint/a11y/noStaticElementInteractions: we handle click and hover manually <button
// biome-ignore lint/a11y/useKeyWithClickEvents: we handle click and hover manually
<div
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)} className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
style={{ paddingLeft }} style={{ paddingLeft }}
onClick={onClick} onClick={onClick}
onMouseEnter={onMouseEnter} onMouseEnter={onMouseEnter}
onKeyDown={handleKeyDown}
> >
{icon} {icon}
<div className="truncate w-full flex items-center gap-2">{children}</div> <div className="truncate w-full flex items-center gap-2">{children}</div>
</div> </button>
); );
}); });

View file

@ -158,7 +158,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
} }
setSelectedIds(newSelected); setSelectedIds(newSelected);
}} }}
aria-label={`Select snapshot ${snapshot.short_id}`} aria-label={`Select snapshot ${snapshot.short_id}` as string}
/> />
</TableCell> </TableCell>
<TableCell className="font-mono text-sm"> <TableCell className="font-mono text-sm">

View file

@ -27,9 +27,9 @@ const Alert = React.forwardRef<
)); ));
Alert.displayName = "Alert"; Alert.displayName = "Alert";
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>( const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
({ className, ...props }, ref) => ( ({ className, ...props }, ref) => (
<h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props} /> <h5 ref={ref} className={cn("mb-1 font-medium leading-none tracking-tight", className)} {...props}>{props.children}</h5>
), ),
); );
AlertTitle.displayName = "AlertTitle"; AlertTitle.displayName = "AlertTitle";

View file

@ -43,16 +43,17 @@ function BreadcrumbLink({
); );
} }
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) { function BreadcrumbPage({ className, children, ...props }: React.ComponentProps<"a">) {
return ( return (
<span <a
data-slot="breadcrumb-page" data-slot="breadcrumb-page"
role="link"
aria-disabled="true" aria-disabled="true"
aria-current="page" aria-current="page"
className={cn("text-foreground font-normal truncate", className)} className={cn("text-foreground font-normal truncate", className)}
{...props} {...props}
/> >
{children}
</a>
); );
} }

View file

@ -88,7 +88,6 @@ export default function DownloadRecoveryKeyPage() {
onChange={(e) => setPassword(e.target.value)} onChange={(e) => setPassword(e.target.value)}
placeholder="Enter your password" placeholder="Enter your password"
required required
autoFocus
disabled={downloadResticPassword.isPending} disabled={downloadResticPassword.isPending}
/> />
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p> <p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>

View file

@ -84,7 +84,7 @@ export default function LoginPage() {
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} autoFocus /> <Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} />
</FormControl> </FormControl>
<FormMessage /> <FormMessage />
</FormItem> </FormItem>

View file

@ -115,7 +115,7 @@ export default function OnboardingPage() {
<FormItem> <FormItem>
<FormLabel>Username</FormLabel> <FormLabel>Username</FormLabel>
<FormControl> <FormControl>
<Input {...field} type="text" placeholder="admin" disabled={submitting} autoFocus /> <Input {...field} type="text" placeholder="admin" disabled={submitting} />
</FormControl> </FormControl>
<FormDescription>Choose a username for the admin account</FormDescription> <FormDescription>Choose a username for the admin account</FormDescription>
<FormMessage /> <FormMessage />

View file

@ -393,7 +393,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
type="button" type="button"
onClick={() => handleRemovePath(path)} onClick={() => handleRemovePath(path)}
className="ml-1 hover:bg-destructive/20 rounded p-0.5 transition-colors" className="ml-1 hover:bg-destructive/20 rounded p-0.5 transition-colors"
aria-label={`Remove ${path}`} aria-label={`Remove ${path}` as string}
> >
<X className="h-3 w-3" /> <X className="h-3 w-3" />
</button> </button>

View file

@ -65,7 +65,7 @@ export const ScheduleSummary = (props: Props) => {
repositoryLabel: schedule.repositoryId || "No repository selected", repositoryLabel: schedule.repositoryId || "No repository selected",
retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy", retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy",
}; };
}, [schedule, schedule.volume.name]); }, [schedule]);
const handleConfirmDelete = () => { const handleConfirmDelete = () => {
setShowDeleteConfirm(false); setShowDeleteConfirm(false);

View file

@ -244,7 +244,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
onChange={(e) => setDownloadPassword(e.target.value)} onChange={(e) => setDownloadPassword(e.target.value)}
placeholder="Enter your password" placeholder="Enter your password"
required required
autoFocus
/> />
</div> </div>
</div> </div>

View file

@ -14,36 +14,66 @@ import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>; export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
export const auth = betterAuth({ const createBetterAuth = (secret: string) =>
secret: await cryptoUtils.deriveSecret("better-auth"), betterAuth({
hooks: { secret,
before: createAuthMiddleware(async (ctx) => { hooks: {
await ensureOnlyOneUser(ctx); before: createAuthMiddleware(async (ctx) => {
await convertLegacyUserOnFirstLogin(ctx); await ensureOnlyOneUser(ctx);
await convertLegacyUserOnFirstLogin(ctx);
}),
},
database: drizzleAdapter(db, {
provider: "sqlite",
}), }),
}, emailAndPassword: {
database: drizzleAdapter(db, { enabled: true,
provider: "sqlite", },
}), user: {
emailAndPassword: { modelName: "usersTable",
enabled: true, additionalFields: {
}, username: {
user: { type: "string",
modelName: "usersTable", returned: true,
additionalFields: { required: true,
username: { },
type: "string", hasDownloadedResticPassword: {
returned: true, type: "boolean",
required: true, returned: true,
}, },
hasDownloadedResticPassword: {
type: "boolean",
returned: true,
}, },
}, },
session: {
modelName: "sessionsTable",
},
plugins: [username({})],
advanced: {
disableOriginCheck: true,
},
});
type Auth = ReturnType<typeof createBetterAuth>;
let _auth: Auth | null = null;
const createAuth = async (): Promise<Auth> => {
if (_auth) return _auth;
_auth = createBetterAuth(await cryptoUtils.deriveSecret("better-auth"));
return _auth;
};
export const auth = new Proxy(
{},
{
get(_, prop, receiver) {
if (!_auth) {
throw new Error("Auth not initialized. Call initAuth() first.");
}
return Reflect.get(_auth, prop, receiver);
},
}, },
session: { ) as Auth;
modelName: "sessionsTable",
}, export const initAuth = createAuth;
plugins: [username({})],
});

View file

@ -5,5 +5,3 @@ export const DATABASE_URL = process.env.DATABASE_URL || "/var/lib/zerobyte/data/
export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass"; export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass";
export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE]; export const DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE];
export const REQUIRED_MIGRATIONS = []; // ["v0.21.1"] add this once re-tagging migration is removed

View file

@ -2,14 +2,12 @@ import { createHonoServer } from "react-router-hono-server/bun";
import * as schema from "./db/schema"; import * as schema from "./db/schema";
import { setSchema, runDbMigrations } from "./db/db"; import { setSchema, runDbMigrations } from "./db/db";
import { startup } from "./modules/lifecycle/startup"; import { startup } from "./modules/lifecycle/startup";
import { retagSnapshots } from "./modules/lifecycle/migration";
import { logger } from "./utils/logger"; import { logger } from "./utils/logger";
import { shutdown } from "./modules/lifecycle/shutdown"; import { shutdown } from "./modules/lifecycle/shutdown";
import { REQUIRED_MIGRATIONS } from "./core/constants";
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
import { createApp } from "./app"; import { createApp } from "./app";
import { config } from "./core/config"; import { config } from "./core/config";
import { runCLI } from "./cli"; import { runCLI } from "./cli";
import { runMigrations } from "./modules/lifecycle/migrations";
setSchema(schema); setSchema(schema);
@ -18,13 +16,11 @@ if (cliRun) {
process.exit(0); process.exit(0);
} }
const app = createApp();
runDbMigrations(); runDbMigrations();
await retagSnapshots(); const app = createApp();
await validateRequiredMigrations(REQUIRED_MIGRATIONS);
await runMigrations();
await startup(); await startup();
export type AppType = typeof app; export type AppType = typeof app;

View file

@ -1,5 +1,6 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as os from "node:os"; import * as os from "node:os";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
import { OPERATION_TIMEOUT } from "../../../core/constants"; import { OPERATION_TIMEOUT } from "../../../core/constants";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
@ -7,19 +8,24 @@ import { getMountForPath } from "../../../utils/mountinfo";
import { withTimeout } from "../../../utils/timeout"; import { withTimeout } from "../../../utils/timeout";
import type { VolumeBackend } from "../backend"; import type { VolumeBackend } from "../backend";
import { executeMount, executeUnmount } from "../utils/backend-utils"; import { executeMount, executeUnmount } from "../utils/backend-utils";
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
const mount = async (config: BackendConfig, path: string) => { const mount = async (config: BackendConfig, path: string) => {
logger.debug(`Mounting volume ${path}...`); logger.debug(`Mounting volume ${path}...`);
if (config.backend !== "nfs") { if (config.backend !== "nfs") {
logger.error("Provided config is not for NFS backend"); logger.error("Provided config is not for NFS backend");
return { status: BACKEND_STATUS.error, error: "Provided config is not for NFS backend" }; return {
status: BACKEND_STATUS.error,
error: "Provided config is not for NFS backend",
};
} }
if (os.platform() !== "linux") { if (os.platform() !== "linux") {
logger.error("NFS mounting is only supported on Linux hosts."); logger.error("NFS mounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "NFS mounting is only supported on Linux hosts." }; return {
status: BACKEND_STATUS.error,
error: "NFS mounting is only supported on Linux hosts.",
};
} }
const { status } = await checkHealth(path); const { status } = await checkHealth(path);
@ -37,6 +43,9 @@ const mount = async (config: BackendConfig, path: string) => {
const source = `${config.server}:${config.exportPath}`; const source = `${config.server}:${config.exportPath}`;
const options = [`vers=${config.version}`, `port=${config.port}`]; const options = [`vers=${config.version}`, `port=${config.port}`];
if (config.version === "3") {
options.push("nolock");
}
if (config.readOnly) { if (config.readOnly) {
options.push("ro"); options.push("ro");
} }
@ -47,6 +56,9 @@ const mount = async (config: BackendConfig, path: string) => {
await executeMount(args); await executeMount(args);
// Fallback with -i flag if the first mount fails using the mount helper
await executeMount(["-i", ...args]);
logger.info(`NFS volume at ${path} mounted successfully.`); logger.info(`NFS volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted }; return { status: BACKEND_STATUS.mounted };
}; };
@ -62,7 +74,10 @@ const mount = async (config: BackendConfig, path: string) => {
const unmount = async (path: string) => { const unmount = async (path: string) => {
if (os.platform() !== "linux") { if (os.platform() !== "linux") {
logger.error("NFS unmounting is only supported on Linux hosts."); logger.error("NFS unmounting is only supported on Linux hosts.");
return { status: BACKEND_STATUS.error, error: "NFS unmounting is only supported on Linux hosts." }; return {
status: BACKEND_STATUS.error,
error: "NFS unmounting is only supported on Linux hosts.",
};
} }
const run = async () => { const run = async () => {
@ -83,7 +98,10 @@ const unmount = async (path: string) => {
try { try {
return await withTimeout(run(), OPERATION_TIMEOUT, "NFS unmount"); return await withTimeout(run(), OPERATION_TIMEOUT, "NFS unmount");
} catch (err) { } catch (err) {
logger.error("Error unmounting NFS volume", { path, error: toMessage(err) }); logger.error("Error unmounting NFS volume", {
path,
error: toMessage(err),
});
return { status: BACKEND_STATUS.error, error: toMessage(err) }; return { status: BACKEND_STATUS.error, error: toMessage(err) };
} }
}; };

View file

@ -61,6 +61,9 @@ const mount = async (config: BackendConfig, path: string) => {
await executeMount(args); await executeMount(args);
// Fallback with -i flag if the first mount fails using the mount helper
await executeMount(["-i", ...args]);
logger.info(`SMB volume at ${path} mounted successfully.`); logger.info(`SMB volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted }; return { status: BACKEND_STATUS.mounted };
}; };

View file

@ -1,23 +1,30 @@
import * as fs from "node:fs/promises"; import * as fs from "node:fs/promises";
import * as npath from "node:path"; import * as npath from "node:path";
import { $ } from "bun";
import { toMessage } from "../../../utils/errors"; import { toMessage } from "../../../utils/errors";
import { logger } from "../../../utils/logger"; import { logger } from "../../../utils/logger";
import { $ } from "bun";
export const executeMount = async (args: string[]): Promise<void> => { export const executeMount = async (args: string[]): Promise<void> => {
let stderr: string | undefined; const shouldBeVerbose = process.env.LOG_LEVEL === "debug" || process.env.NODE_ENV !== "production";
const hasVerboseFlag = args.some((arg) => arg === "-v" || arg.startsWith("-vv"));
const effectiveArgs = shouldBeVerbose && !hasVerboseFlag ? ["-vvv", ...args] : args;
logger.debug(`Executing mount ${args.join(" ")}`); logger.debug(`Executing mount ${effectiveArgs.join(" ")}`);
const result = await $`mount ${args}`.nothrow(); const result = await $`mount ${effectiveArgs}`.nothrow();
stderr = result.stderr.toString();
if (stderr?.trim()) { const stdout = result.stdout.toString().trim();
logger.warn(stderr.trim()); const stderr = result.stderr.toString().trim();
if (result.exitCode === 0) {
if (stdout) logger.debug(stdout);
if (stderr) logger.debug(stderr);
return;
} }
if (result.exitCode !== 0) { if (stdout) logger.warn(stdout);
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`); if (stderr) logger.warn(stderr);
}
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr || stdout || "unknown error"}`);
}; };
export const executeUnmount = async (path: string): Promise<void> => { export const executeUnmount = async (path: string): Promise<void> => {

View file

@ -58,6 +58,9 @@ const mount = async (config: BackendConfig, path: string) => {
const args = ["-t", "davfs", "-o", options.join(","), source, path]; const args = ["-t", "davfs", "-o", options.join(","), source, path];
await executeMount(args); await executeMount(args);
// Fallback with -i flag if the first mount fails using the mount helper
await executeMount(["-i", ...args]);
logger.info(`WebDAV volume at ${path} mounted successfully.`); logger.info(`WebDAV volume at ${path} mounted successfully.`);
return { status: BACKEND_STATUS.mounted }; return { status: BACKEND_STATUS.mounted };
}; };

View file

@ -1,88 +0,0 @@
import { eq, sql } from "drizzle-orm";
import { db } from "../../db/db";
import { appMetadataTable, usersTable } from "../../db/schema";
import { logger } from "../../utils/logger";
const MIGRATION_KEY_PREFIX = "migration:";
export const recordMigrationCheckpoint = async (version: string): Promise<void> => {
const key = `${MIGRATION_KEY_PREFIX}${version}`;
const now = Date.now();
await db
.insert(appMetadataTable)
.values({
key,
value: JSON.stringify({ completedAt: new Date().toISOString() }),
createdAt: now,
updatedAt: now,
})
.onConflictDoUpdate({
target: appMetadataTable.key,
set: {
value: JSON.stringify({ completedAt: new Date().toISOString() }),
updatedAt: now,
},
});
logger.info(`Recorded migration checkpoint for ${version}`);
};
export const hasMigrationCheckpoint = async (version: string): Promise<boolean> => {
const key = `${MIGRATION_KEY_PREFIX}${version}`;
const result = await db.query.appMetadataTable.findFirst({
where: eq(appMetadataTable.key, key),
});
return result !== undefined;
};
export const validateRequiredMigrations = async (requiredVersions: string[]): Promise<void> => {
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
const isFreshInstall = userCount[0]?.count === 0;
if (isFreshInstall) {
logger.info("Fresh installation detected, skipping migration checkpoint validation.");
for (const version of requiredVersions) {
const hasCheckpoint = await hasMigrationCheckpoint(version);
if (!hasCheckpoint) {
await recordMigrationCheckpoint(version);
}
}
return;
}
for (const version of requiredVersions) {
const hasCheckpoint = await hasMigrationCheckpoint(version);
if (!hasCheckpoint) {
logger.error(`
================================================================================
MIGRATION ERROR: Required migration ${version} has not been run.
You are attempting to start a version of Zerobyte that requires migration
checkpoints from previous versions. This typically happens when you skip
versions during an upgrade.
To fix this:
1. First upgrade to version ${version} and run the application once
2. Validate that everything is still working correctly
3. Then upgrade to the current version
================================================================================
`);
process.exit(1);
}
}
};
export const getMigrationCheckpoints = async (): Promise<{ version: string; completedAt: string }[]> => {
const results = await db.query.appMetadataTable.findMany({
where: (table, { like }) => like(table.key, `${MIGRATION_KEY_PREFIX}%`),
});
return results.map((r) => ({
version: r.key.replace(MIGRATION_KEY_PREFIX, ""),
completedAt: JSON.parse(r.value).completedAt,
}));
};

View file

@ -0,0 +1,114 @@
import { db } from "~/server/db/db";
import { logger } from "../../utils/logger";
import { v00001 } from "./migrations/00001-retag-snapshots";
import { usersTable } from "~/server/db/schema";
import { sql } from "drizzle-orm";
import { eq } from "drizzle-orm";
import { appMetadataTable } from "../../db/schema";
const MIGRATION_KEY_PREFIX = "migration:";
const recordMigrationCheckpoint = async (version: string): Promise<void> => {
const key = `${MIGRATION_KEY_PREFIX}${version}`;
const now = Date.now();
await db
.insert(appMetadataTable)
.values({ key, value: JSON.stringify({ completedAt: new Date().toISOString() }), createdAt: now, updatedAt: now })
.onConflictDoUpdate({
target: appMetadataTable.key,
set: { value: JSON.stringify({ completedAt: new Date().toISOString() }), updatedAt: now },
});
logger.info(`Recorded migration checkpoint for ${version}`);
};
const hasMigrationCheckpoint = async (id: string): Promise<boolean> => {
const key = `${MIGRATION_KEY_PREFIX}${id}`;
const result = await db.query.appMetadataTable.findFirst({
where: eq(appMetadataTable.key, key),
});
return result !== undefined;
};
type MigrationEntity = {
execute: () => Promise<{ success: boolean; errors: Array<{ name: string; error: string }> }>;
id: string;
type: "maintenance" | "critical";
dependsOn?: string[];
};
const registry: MigrationEntity[] = [v00001];
export const runMigrations = async () => {
const userCount = await db.select({ count: sql<number>`count(*)` }).from(usersTable);
const isFreshInstall = userCount[0]?.count === 0;
if (isFreshInstall) {
logger.debug("Fresh installation detected, skipping migration checkpoint validation.");
for (const migration of registry) {
const hasCheckpoint = await hasMigrationCheckpoint(migration.id);
if (!hasCheckpoint) {
await recordMigrationCheckpoint(migration.id);
}
}
return;
}
for (const migration of registry) {
const alreadyMigrated = await hasMigrationCheckpoint(migration.id);
if (alreadyMigrated) {
logger.debug(`Migration ${migration.id} already completed, skipping.`);
continue;
}
if (migration.dependsOn) {
for (const dep of migration.dependsOn) {
const depCompleted = await hasMigrationCheckpoint(dep);
if (!depCompleted) {
const err = [
"================================================================================",
`🚨 MIGRATION ERROR: Migration ${migration.id} depends on migration ${dep}.`,
"The application cannot start until the required migration has successfully completed.",
"Please fix the issues and restart the application.",
"",
"Seek support by opening an issue on the Zerobyte GitHub repository if you need assistance.",
"================================================================================",
];
err.forEach((line) => logger.error(line));
process.exit(1);
}
}
}
logger.info(`Running migration: ${migration.id} (${migration.type})`);
const result = await migration.execute();
if (result.success) {
logger.info(`Migration ${migration.id} completed successfully.`);
await recordMigrationCheckpoint(migration.id);
} else {
logger.error(`Migration ${migration.id} completed with errors: ${result.errors.length} items failed.`);
for (const err of result.errors) {
logger.error(`Migration failure - ${err.name}: ${err.error}`);
}
if (migration.type === "critical") {
const err = [
"================================================================================",
`🚨 MIGRATION ERROR: Critical migration ${migration.id} failed.`,
"",
"The application cannot start until this migration has successfully completed.",
"",
"Please fix the issues and restart the application. Seek support by opening an issue",
"on the Zerobyte GitHub repository if you need assistance.",
"================================================================================",
];
err.forEach((line) => logger.error(line));
process.exit(1);
}
}
}
};

View file

@ -1,60 +1,11 @@
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import { db } from "../../db/db"; import { db } from "../../../db/db";
import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../db/schema"; import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../../db/schema";
import { logger } from "../../utils/logger"; import { logger } from "../../../utils/logger";
import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint";
import { toMessage } from "~/server/utils/errors"; import { toMessage } from "~/server/utils/errors";
import { safeSpawn } from "~/server/utils/spawn"; import { safeSpawn } from "~/server/utils/spawn";
import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic"; import { addCommonArgs, buildEnv, buildRepoUrl, cleanupTemporaryKeys } from "~/server/utils/restic";
const MIGRATION_VERSION = "v0.21.1";
interface MigrationResult {
success: boolean;
errors: Array<{ name: string; error: string }>;
}
export class MigrationError extends Error {
version: string;
failedItems: Array<{ name: string; error: string }>;
constructor(version: string, failedItems: Array<{ name: string; error: string }>) {
const itemNames = failedItems.map((e) => e.name).join(", ");
super(`Migration ${version} failed for: ${itemNames}`);
this.version = version;
this.failedItems = failedItems;
this.name = "MigrationError";
}
}
export const retagSnapshots = async () => {
const alreadyMigrated = await hasMigrationCheckpoint(MIGRATION_VERSION);
if (alreadyMigrated) {
logger.debug(`Migration ${MIGRATION_VERSION} already completed, skipping.`);
return;
}
logger.info(`Starting snapshots retagging migration (${MIGRATION_VERSION})...`);
const result = await migrateSnapshotsToShortIdTag();
const allErrors = [...result.errors];
if (allErrors.length > 0) {
logger.error(`Migration ${MIGRATION_VERSION} completed with errors: ${allErrors.length} items failed.`);
logger.error(
`Some snapshots could not be retagged. Please check the logs for details. Fix any repository in error state and re-start zerobyte to retry the migration for failed items.`,
);
for (const err of allErrors) {
logger.error(`Migration failure - ${err.name}: ${err.error}`);
}
return;
}
await recordMigrationCheckpoint(MIGRATION_VERSION);
logger.info(`Snapshots retagging migration (${MIGRATION_VERSION}) complete.`);
};
const migrateTag = async ( const migrateTag = async (
oldTag: string, oldTag: string,
newTag: string, newTag: string,
@ -81,7 +32,7 @@ const migrateTag = async (
return null; return null;
}; };
const migrateSnapshotsToShortIdTag = async (): Promise<MigrationResult> => { const execute = async () => {
const errors: Array<{ name: string; error: string }> = []; const errors: Array<{ name: string; error: string }> = [];
const backupSchedules = await db.query.backupSchedulesTable.findMany({}); const backupSchedules = await db.query.backupSchedulesTable.findMany({});
@ -134,3 +85,9 @@ const migrateSnapshotsToShortIdTag = async (): Promise<MigrationResult> => {
return { success: errors.length === 0, errors }; return { success: errors.length === 0, errors };
}; };
export const v00001 = {
execute,
id: "00001-retag-snapshots",
type: "maintenance" as const,
};

View file

@ -13,6 +13,8 @@ import { repositoriesService } from "../repositories/repositories.service";
import { notificationsService } from "../notifications/notifications.service"; import { notificationsService } from "../notifications/notifications.service";
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount"; import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
import { cache } from "~/server/utils/cache"; import { cache } from "~/server/utils/cache";
import { initAuth } from "~/lib/auth";
import { toMessage } from "~/server/utils/errors";
const ensureLatestConfigurationSchema = async () => { const ensureLatestConfigurationSchema = async () => {
const volumes = await db.query.volumesTable.findMany({}); const volumes = await db.query.volumesTable.findMany({});
@ -50,6 +52,11 @@ export const startup = async () => {
logger.error(`Error ensuring restic passfile exists: ${err.message}`); logger.error(`Error ensuring restic passfile exists: ${err.message}`);
}); });
await initAuth().catch((err) => {
logger.error(`Error initializing auth: ${toMessage(err)}`);
throw err;
});
await ensureLatestConfigurationSchema(); await ensureLatestConfigurationSchema();
const volumes = await db.query.volumesTable.findMany({ const volumes = await db.query.volumesTable.findMany({

View file

@ -27,7 +27,7 @@ const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) =>
return sanitizeSensitiveData(JSON.stringify(m, null, 2)); return sanitizeSensitiveData(JSON.stringify(m, null, 2));
} }
return sanitizeSensitiveData(String(JSON.stringify(m))); return sanitizeSensitiveData(String(m as string));
}); });
winstonLogger.log(level, stringMessages.join(" ")); winstonLogger.log(level, stringMessages.join(" "));

View file

@ -1,4 +1,4 @@
import "./setup.ts"; import "./setup.ts";
import { GlobalRegistrator } from "@happy-dom/global-registrator"; import { GlobalRegistrator } from "@happy-dom/global-registrator";
GlobalRegistrator.register(); GlobalRegistrator.register({ url: "http://localhost:3000" });

View file

@ -4,6 +4,7 @@ import path from "node:path";
import { cwd } from "node:process"; import { cwd } from "node:process";
import * as schema from "~/server/db/schema"; import * as schema from "~/server/db/schema";
import { db, setSchema } from "~/server/db/db"; import { db, setSchema } from "~/server/db/db";
import { initAuth } from "~/lib/auth";
setSchema(schema); setSchema(schema);
@ -27,4 +28,5 @@ void mock.module("~/server/utils/crypto", () => ({
beforeAll(async () => { beforeAll(async () => {
const migrationsFolder = path.join(cwd(), "app", "drizzle"); const migrationsFolder = path.join(cwd(), "app", "drizzle");
migrate(db, { migrationsFolder }); migrate(db, { migrationsFolder });
await initAuth();
}); });