Merge branch 'main' into feat/deep-secret-resolution
This commit is contained in:
commit
5e055263de
26 changed files with 274 additions and 217 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||
"plugins": ["unicorn", "typescript", "oxc"],
|
||||
"plugins": ["eslint", "unicorn", "typescript", "oxc", "import", "react", "react-perf", "node", "jsx-a11y"],
|
||||
"categories": {},
|
||||
"rules": {
|
||||
"constructor-super": "warn",
|
||||
|
|
@ -112,7 +112,8 @@
|
|||
"unicorn/no-useless-length-check": "warn",
|
||||
"unicorn/no-useless-spread": "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": {
|
||||
"jsx-a11y": {
|
||||
|
|
|
|||
|
|
@ -10,8 +10,9 @@ ENV VITE_RESTIC_VERSION=${RESTIC_VERSION} \
|
|||
VITE_RCLONE_VERSION=${RCLONE_VERSION} \
|
||||
VITE_SHOUTRRR_VERSION=${SHOUTRRR_VERSION}
|
||||
|
||||
RUN apk upgrade --no-cache && \
|
||||
apk add --no-cache davfs2=1.6.1-r2 openssh-client fuse3 sshfs tini nfs-utils cifs-utils
|
||||
RUN apk update --no-cache && \
|
||||
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", "--"]
|
||||
|
||||
|
|
|
|||
|
|
@ -489,18 +489,27 @@ interface ButtonProps {
|
|||
const NodeButton = memo(({ depth, icon, onClick, onMouseEnter, className, children }: ButtonProps) => {
|
||||
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 (
|
||||
// biome-ignore lint/a11y/noStaticElementInteractions: we handle click and hover manually
|
||||
// biome-ignore lint/a11y/useKeyWithClickEvents: we handle click and hover manually
|
||||
<div
|
||||
<button
|
||||
className={cn("flex items-center gap-2 w-full pr-2 text-sm py-1.5 text-left", className)}
|
||||
style={{ paddingLeft }}
|
||||
onClick={onClick}
|
||||
onMouseEnter={onMouseEnter}
|
||||
onKeyDown={handleKeyDown}
|
||||
>
|
||||
{icon}
|
||||
<div className="truncate w-full flex items-center gap-2">{children}</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -158,7 +158,7 @@ export const SnapshotsTable = ({ snapshots, repositoryId, backups }: Props) => {
|
|||
}
|
||||
setSelectedIds(newSelected);
|
||||
}}
|
||||
aria-label={`Select snapshot ${snapshot.short_id}`}
|
||||
aria-label={`Select snapshot ${snapshot.short_id}` as string}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ const Alert = React.forwardRef<
|
|||
));
|
||||
Alert.displayName = "Alert";
|
||||
|
||||
const AlertTitle = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
const AlertTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
||||
({ 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";
|
||||
|
|
|
|||
|
|
@ -43,16 +43,17 @@ function BreadcrumbLink({
|
|||
);
|
||||
}
|
||||
|
||||
function BreadcrumbPage({ className, ...props }: React.ComponentProps<"span">) {
|
||||
function BreadcrumbPage({ className, children, ...props }: React.ComponentProps<"a">) {
|
||||
return (
|
||||
<span
|
||||
<a
|
||||
data-slot="breadcrumb-page"
|
||||
role="link"
|
||||
aria-disabled="true"
|
||||
aria-current="page"
|
||||
className={cn("text-foreground font-normal truncate", className)}
|
||||
{...props}
|
||||
/>
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -88,7 +88,6 @@ export default function DownloadRecoveryKeyPage() {
|
|||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoFocus
|
||||
disabled={downloadResticPassword.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">Enter your account password to download the recovery key</p>
|
||||
|
|
|
|||
|
|
@ -84,7 +84,7 @@ export default function LoginPage() {
|
|||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} autoFocus />
|
||||
<Input {...field} type="text" placeholder="admin" disabled={isLoggingIn} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
|
|
|
|||
|
|
@ -115,7 +115,7 @@ export default function OnboardingPage() {
|
|||
<FormItem>
|
||||
<FormLabel>Username</FormLabel>
|
||||
<FormControl>
|
||||
<Input {...field} type="text" placeholder="admin" disabled={submitting} autoFocus />
|
||||
<Input {...field} type="text" placeholder="admin" disabled={submitting} />
|
||||
</FormControl>
|
||||
<FormDescription>Choose a username for the admin account</FormDescription>
|
||||
<FormMessage />
|
||||
|
|
|
|||
|
|
@ -393,7 +393,7 @@ export const CreateScheduleForm = ({ initialValues, formId, onSubmit, volume }:
|
|||
type="button"
|
||||
onClick={() => handleRemovePath(path)}
|
||||
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" />
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export const ScheduleSummary = (props: Props) => {
|
|||
repositoryLabel: schedule.repositoryId || "No repository selected",
|
||||
retentionLabel: retentionParts.length > 0 ? retentionParts.join(" • ") : "No retention policy",
|
||||
};
|
||||
}, [schedule, schedule.volume.name]);
|
||||
}, [schedule]);
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
setShowDeleteConfirm(false);
|
||||
|
|
|
|||
|
|
@ -244,7 +244,6 @@ export default function Settings({ loaderData }: Route.ComponentProps) {
|
|||
onChange={(e) => setDownloadPassword(e.target.value)}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,36 +14,66 @@ import { ensureOnlyOneUser } from "./auth-middlewares/only-one-user";
|
|||
|
||||
export type AuthMiddlewareContext = MiddlewareContext<MiddlewareOptions, AuthContext<BetterAuthOptions>>;
|
||||
|
||||
export const auth = betterAuth({
|
||||
secret: await cryptoUtils.deriveSecret("better-auth"),
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
await ensureOnlyOneUser(ctx);
|
||||
await convertLegacyUserOnFirstLogin(ctx);
|
||||
const createBetterAuth = (secret: string) =>
|
||||
betterAuth({
|
||||
secret,
|
||||
hooks: {
|
||||
before: createAuthMiddleware(async (ctx) => {
|
||||
await ensureOnlyOneUser(ctx);
|
||||
await convertLegacyUserOnFirstLogin(ctx);
|
||||
}),
|
||||
},
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
}),
|
||||
},
|
||||
database: drizzleAdapter(db, {
|
||||
provider: "sqlite",
|
||||
}),
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
user: {
|
||||
modelName: "usersTable",
|
||||
additionalFields: {
|
||||
username: {
|
||||
type: "string",
|
||||
returned: true,
|
||||
required: true,
|
||||
},
|
||||
hasDownloadedResticPassword: {
|
||||
type: "boolean",
|
||||
returned: true,
|
||||
emailAndPassword: {
|
||||
enabled: true,
|
||||
},
|
||||
user: {
|
||||
modelName: "usersTable",
|
||||
additionalFields: {
|
||||
username: {
|
||||
type: "string",
|
||||
returned: true,
|
||||
required: 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: {
|
||||
modelName: "sessionsTable",
|
||||
},
|
||||
plugins: [username({})],
|
||||
});
|
||||
) as Auth;
|
||||
|
||||
export const initAuth = createAuth;
|
||||
|
|
|
|||
|
|
@ -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 DEFAULT_EXCLUDES = [DATABASE_URL, RESTIC_PASS_FILE, REPOSITORY_BASE];
|
||||
|
||||
export const REQUIRED_MIGRATIONS = []; // ["v0.21.1"] add this once re-tagging migration is removed
|
||||
|
|
|
|||
|
|
@ -2,14 +2,12 @@ import { createHonoServer } from "react-router-hono-server/bun";
|
|||
import * as schema from "./db/schema";
|
||||
import { setSchema, runDbMigrations } from "./db/db";
|
||||
import { startup } from "./modules/lifecycle/startup";
|
||||
import { retagSnapshots } from "./modules/lifecycle/migration";
|
||||
import { logger } from "./utils/logger";
|
||||
import { shutdown } from "./modules/lifecycle/shutdown";
|
||||
import { REQUIRED_MIGRATIONS } from "./core/constants";
|
||||
import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint";
|
||||
import { createApp } from "./app";
|
||||
import { config } from "./core/config";
|
||||
import { runCLI } from "./cli";
|
||||
import { runMigrations } from "./modules/lifecycle/migrations";
|
||||
|
||||
setSchema(schema);
|
||||
|
||||
|
|
@ -18,13 +16,11 @@ if (cliRun) {
|
|||
process.exit(0);
|
||||
}
|
||||
|
||||
const app = createApp();
|
||||
|
||||
runDbMigrations();
|
||||
|
||||
await retagSnapshots();
|
||||
await validateRequiredMigrations(REQUIRED_MIGRATIONS);
|
||||
const app = createApp();
|
||||
|
||||
await runMigrations();
|
||||
await startup();
|
||||
|
||||
export type AppType = typeof app;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import * as fs from "node:fs/promises";
|
||||
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";
|
||||
|
|
@ -7,19 +8,24 @@ import { getMountForPath } from "../../../utils/mountinfo";
|
|||
import { withTimeout } from "../../../utils/timeout";
|
||||
import type { VolumeBackend } from "../backend";
|
||||
import { executeMount, executeUnmount } from "../utils/backend-utils";
|
||||
import { BACKEND_STATUS, type BackendConfig } from "~/schemas/volumes";
|
||||
|
||||
const mount = async (config: BackendConfig, path: string) => {
|
||||
logger.debug(`Mounting volume ${path}...`);
|
||||
|
||||
if (config.backend !== "nfs") {
|
||||
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") {
|
||||
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);
|
||||
|
|
@ -37,6 +43,9 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||
|
||||
const source = `${config.server}:${config.exportPath}`;
|
||||
const options = [`vers=${config.version}`, `port=${config.port}`];
|
||||
if (config.version === "3") {
|
||||
options.push("nolock");
|
||||
}
|
||||
if (config.readOnly) {
|
||||
options.push("ro");
|
||||
}
|
||||
|
|
@ -47,6 +56,9 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||
|
||||
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.`);
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
};
|
||||
|
|
@ -62,7 +74,10 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||
const unmount = async (path: string) => {
|
||||
if (os.platform() !== "linux") {
|
||||
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 () => {
|
||||
|
|
@ -83,7 +98,10 @@ const unmount = async (path: string) => {
|
|||
try {
|
||||
return await withTimeout(run(), OPERATION_TIMEOUT, "NFS unmount");
|
||||
} 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) };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -61,6 +61,9 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||
|
||||
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.`);
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,23 +1,30 @@
|
|||
import * as fs from "node:fs/promises";
|
||||
import * as npath from "node:path";
|
||||
import { $ } from "bun";
|
||||
import { toMessage } from "../../../utils/errors";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { $ } from "bun";
|
||||
|
||||
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(" ")}`);
|
||||
const result = await $`mount ${args}`.nothrow();
|
||||
stderr = result.stderr.toString();
|
||||
logger.debug(`Executing mount ${effectiveArgs.join(" ")}`);
|
||||
const result = await $`mount ${effectiveArgs}`.nothrow();
|
||||
|
||||
if (stderr?.trim()) {
|
||||
logger.warn(stderr.trim());
|
||||
const stdout = result.stdout.toString().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) {
|
||||
throw new Error(`Mount command failed with exit code ${result.exitCode}: ${stderr?.trim()}`);
|
||||
}
|
||||
if (stdout) logger.warn(stdout);
|
||||
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> => {
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ const mount = async (config: BackendConfig, path: string) => {
|
|||
const args = ["-t", "davfs", "-o", options.join(","), source, path];
|
||||
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.`);
|
||||
return { status: BACKEND_STATUS.mounted };
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}));
|
||||
};
|
||||
114
app/server/modules/lifecycle/migrations.ts
Normal file
114
app/server/modules/lifecycle/migrations.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1,60 +1,11 @@
|
|||
import { eq } from "drizzle-orm";
|
||||
import { db } from "../../db/db";
|
||||
import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../db/schema";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint";
|
||||
import { db } from "../../../db/db";
|
||||
import { backupScheduleMirrorsTable, repositoriesTable, type Repository } from "../../../db/schema";
|
||||
import { logger } from "../../../utils/logger";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
import { safeSpawn } from "~/server/utils/spawn";
|
||||
import { 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 (
|
||||
oldTag: string,
|
||||
newTag: string,
|
||||
|
|
@ -81,7 +32,7 @@ const migrateTag = async (
|
|||
return null;
|
||||
};
|
||||
|
||||
const migrateSnapshotsToShortIdTag = async (): Promise<MigrationResult> => {
|
||||
const execute = async () => {
|
||||
const errors: Array<{ name: string; error: string }> = [];
|
||||
const backupSchedules = await db.query.backupSchedulesTable.findMany({});
|
||||
|
||||
|
|
@ -134,3 +85,9 @@ const migrateSnapshotsToShortIdTag = async (): Promise<MigrationResult> => {
|
|||
|
||||
return { success: errors.length === 0, errors };
|
||||
};
|
||||
|
||||
export const v00001 = {
|
||||
execute,
|
||||
id: "00001-retag-snapshots",
|
||||
type: "maintenance" as const,
|
||||
};
|
||||
|
|
@ -13,6 +13,8 @@ import { repositoriesService } from "../repositories/repositories.service";
|
|||
import { notificationsService } from "../notifications/notifications.service";
|
||||
import { VolumeAutoRemountJob } from "~/server/jobs/auto-remount";
|
||||
import { cache } from "~/server/utils/cache";
|
||||
import { initAuth } from "~/lib/auth";
|
||||
import { toMessage } from "~/server/utils/errors";
|
||||
|
||||
const ensureLatestConfigurationSchema = async () => {
|
||||
const volumes = await db.query.volumesTable.findMany({});
|
||||
|
|
@ -50,6 +52,11 @@ export const startup = async () => {
|
|||
logger.error(`Error ensuring restic passfile exists: ${err.message}`);
|
||||
});
|
||||
|
||||
await initAuth().catch((err) => {
|
||||
logger.error(`Error initializing auth: ${toMessage(err)}`);
|
||||
throw err;
|
||||
});
|
||||
|
||||
await ensureLatestConfigurationSchema();
|
||||
|
||||
const volumes = await db.query.volumesTable.findMany({
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ const log = (level: "info" | "warn" | "error" | "debug", messages: unknown[]) =>
|
|||
return sanitizeSensitiveData(JSON.stringify(m, null, 2));
|
||||
}
|
||||
|
||||
return sanitizeSensitiveData(String(JSON.stringify(m)));
|
||||
return sanitizeSensitiveData(String(m as string));
|
||||
});
|
||||
|
||||
winstonLogger.log(level, stringMessages.join(" "));
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import "./setup.ts";
|
||||
import { GlobalRegistrator } from "@happy-dom/global-registrator";
|
||||
|
||||
GlobalRegistrator.register();
|
||||
GlobalRegistrator.register({ url: "http://localhost:3000" });
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import path from "node:path";
|
|||
import { cwd } from "node:process";
|
||||
import * as schema from "~/server/db/schema";
|
||||
import { db, setSchema } from "~/server/db/db";
|
||||
import { initAuth } from "~/lib/auth";
|
||||
|
||||
setSchema(schema);
|
||||
|
||||
|
|
@ -27,4 +28,5 @@ void mock.module("~/server/utils/crypto", () => ({
|
|||
beforeAll(async () => {
|
||||
const migrationsFolder = path.join(cwd(), "app", "drizzle");
|
||||
migrate(db, { migrationsFolder });
|
||||
await initAuth();
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue