feat: add support for provided shortId in volume, repository and backup and pass shortIds to service creation from config import

This commit is contained in:
Jakub Trávník 2026-01-02 09:54:52 +01:00
parent 37d04d3b61
commit cc9982b764
5 changed files with 103 additions and 25 deletions

View file

@ -14,7 +14,7 @@ import { notificationsService } from "../notifications/notifications.service";
import { repoMutex } from "../../core/repository-mutex";
import { checkMirrorCompatibility, getIncompatibleMirrorError } from "~/server/utils/backend-compatibility";
import path from "node:path";
import { generateShortId } from "~/server/utils/id";
import { generateShortId, isValidShortId } from "~/server/utils/id";
const runningBackups = new Map<number, AbortController>();
@ -82,7 +82,7 @@ const getSchedule = async (scheduleId: number) => {
return schedule;
};
const createSchedule = async (data: CreateBackupScheduleBody) => {
const createSchedule = async (data: CreateBackupScheduleBody, providedShortId?: string) => {
if (!cron.validate(data.cronExpression)) {
throw new BadRequestError("Invalid cron expression");
}
@ -95,6 +95,25 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
throw new ConflictError("A backup schedule with this name already exists");
}
// Use provided shortId if valid, otherwise generate a new one
let shortId: string;
if (providedShortId) {
if (!isValidShortId(providedShortId)) {
throw new BadRequestError(`Invalid shortId format: '${providedShortId}'. Must be 8 base64url characters.`);
}
const shortIdInUse = await db.query.backupSchedulesTable.findFirst({
where: eq(backupSchedulesTable.shortId, providedShortId),
});
if (shortIdInUse) {
throw new ConflictError(
`Schedule shortId '${providedShortId}' is already in use by schedule '${shortIdInUse.name}'`,
);
}
shortId = providedShortId;
} else {
shortId = generateShortId();
}
const volume = await db.query.volumesTable.findFirst({
where: eq(volumesTable.id, data.volumeId),
});
@ -127,7 +146,7 @@ const createSchedule = async (data: CreateBackupScheduleBody) => {
includePatterns: data.includePatterns ?? [],
oneFileSystem: data.oneFileSystem,
nextBackupAt: nextBackupAt,
shortId: generateShortId(),
shortId,
})
.returning();

View file

@ -167,7 +167,9 @@ async function importVolumes(volumes: unknown[]): Promise<ImportResult> {
continue;
}
await volumeService.createVolume(v.name, v.config as BackendConfig);
// Pass shortId from config if provided (for IaC reproducibility)
const shortId = typeof v.shortId === "string" ? v.shortId : undefined;
await volumeService.createVolume(v.name, v.config as BackendConfig, shortId);
logger.info(`Initialized volume from config: ${v.name}`);
result.succeeded++;
@ -257,10 +259,13 @@ async function importRepositories(repositories: unknown[]): Promise<ImportResult
r.compressionMode === "auto" || r.compressionMode === "off" || r.compressionMode === "max"
? r.compressionMode
: undefined;
// Pass shortId from config if provided (for IaC reproducibility)
const shortId = typeof r.shortId === "string" ? r.shortId : undefined;
await repoServiceModule.repositoriesService.createRepository(
r.name,
r.config as RepositoryConfig,
compressionMode,
shortId,
);
logger.info(`Initialized repository from config: ${r.name}`);
result.succeeded++;
@ -514,18 +519,23 @@ async function importBackupSchedules(backupSchedules: unknown[]): Promise<Import
try {
const retentionPolicy = isRecord(s.retentionPolicy) ? (s.retentionPolicy as RetentionPolicy) : undefined;
const createdSchedule = await backupServiceModule.backupsService.createSchedule({
name: scheduleName,
volumeId: volume.id,
repositoryId: repository.id,
enabled: typeof s.enabled === "boolean" ? s.enabled : true,
cronExpression: s.cronExpression,
retentionPolicy,
excludePatterns: asStringArray(s.excludePatterns),
excludeIfPresent: asStringArray(s.excludeIfPresent),
includePatterns: asStringArray(s.includePatterns),
oneFileSystem: typeof s.oneFileSystem === "boolean" ? s.oneFileSystem : undefined,
});
// Pass shortId from config if provided (for IaC reproducibility)
const providedShortId = typeof s.shortId === "string" ? s.shortId : undefined;
const createdSchedule = await backupServiceModule.backupsService.createSchedule(
{
name: scheduleName,
volumeId: volume.id,
repositoryId: repository.id,
enabled: typeof s.enabled === "boolean" ? s.enabled : true,
cronExpression: s.cronExpression,
retentionPolicy,
excludePatterns: asStringArray(s.excludePatterns),
excludeIfPresent: asStringArray(s.excludeIfPresent),
includePatterns: asStringArray(s.includePatterns),
oneFileSystem: typeof s.oneFileSystem === "boolean" ? s.oneFileSystem : undefined,
},
providedShortId,
);
logger.info(`Initialized backup schedule from config: ${scheduleName}`);
result.succeeded++;
scheduleId = createdSchedule.id;
@ -568,7 +578,7 @@ async function attachScheduleMirrors(
scheduleId: number,
scheduleName: string,
mirrors: unknown[],
repoByName: Map<string, { id: string; name: string }>,
repoByName: Map<string, { id: string; name: string; config: RepositoryConfig }>,
backupServiceModule: typeof import("../backups/backups.service"),
): Promise<ImportResult> {
const result: ImportResult = { succeeded: 0, skipped: 0, warnings: 0, errors: 0 };
@ -576,7 +586,11 @@ async function attachScheduleMirrors(
const existingMirrors = await backupServiceModule.backupsService.getMirrors(scheduleId);
const existingRepoIds = new Set(existingMirrors.map((m) => m.repositoryId));
const mirrorConfigs: Array<{ repositoryId: string; repositoryName: string; enabled: boolean }> = [];
const mirrorConfigs: Array<{
repositoryId: string;
repositoryName: string;
enabled: boolean;
}> = [];
for (const m of mirrors) {
if (!isRecord(m)) continue;

View file

@ -4,7 +4,7 @@ import { ConflictError, InternalServerError, NotFoundError } from "http-errors-e
import { db } from "../../db/db";
import { repositoriesTable } from "../../db/schema";
import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id";
import { generateShortId, isValidShortId } from "../../utils/id";
import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto";
import { repoMutex } from "../../core/repository-mutex";
@ -62,9 +62,32 @@ const encryptConfig = async (config: RepositoryConfig): Promise<RepositoryConfig
return encryptedConfig as RepositoryConfig;
};
const createRepository = async (name: string, config: RepositoryConfig, compressionMode?: CompressionMode) => {
const createRepository = async (
name: string,
config: RepositoryConfig,
compressionMode?: CompressionMode,
providedShortId?: string,
) => {
const id = crypto.randomUUID();
const shortId = generateShortId();
// Use provided shortId if valid, otherwise generate a new one
let shortId: string;
if (providedShortId) {
if (!isValidShortId(providedShortId)) {
throw new Error(`Invalid shortId format: '${providedShortId}'. Must be 8 base64url characters.`);
}
const shortIdInUse = await db.query.repositoriesTable.findFirst({
where: eq(repositoriesTable.shortId, providedShortId),
});
if (shortIdInUse) {
throw new ConflictError(
`Repository shortId '${providedShortId}' is already in use by repository '${shortIdInUse.name}'`,
);
}
shortId = providedShortId;
} else {
shortId = generateShortId();
}
let processedConfig = config;
if (config.backend === "local" && !config.isExistingRepository) {

View file

@ -8,7 +8,7 @@ import { db } from "../../db/db";
import { volumesTable } from "../../db/schema";
import { cryptoUtils } from "../../utils/crypto";
import { toMessage } from "../../utils/errors";
import { generateShortId } from "../../utils/id";
import { generateShortId, isValidShortId } from "../../utils/id";
import { getStatFs, type StatFs } from "../../utils/mountinfo";
import { withTimeout } from "../../utils/timeout";
import { createVolumeBackend } from "../backends/backend";
@ -48,7 +48,7 @@ const listVolumes = async () => {
return volumes;
};
const createVolume = async (name: string, backendConfig: BackendConfig) => {
const createVolume = async (name: string, backendConfig: BackendConfig, providedShortId?: string) => {
const slug = slugify(name, { lower: true, strict: true });
const existing = await db.query.volumesTable.findFirst({
@ -59,7 +59,22 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => {
throw new ConflictError("Volume already exists");
}
const shortId = generateShortId();
// Use provided shortId if valid, otherwise generate a new one
let shortId: string;
if (providedShortId) {
if (!isValidShortId(providedShortId)) {
throw new Error(`Invalid shortId format: '${providedShortId}'. Must be 8 base64url characters.`);
}
const shortIdInUse = await db.query.volumesTable.findFirst({
where: eq(volumesTable.shortId, providedShortId),
});
if (shortIdInUse) {
throw new ConflictError(`Volume shortId '${providedShortId}' is already in use by volume '${shortIdInUse.name}'`);
}
shortId = providedShortId;
} else {
shortId = generateShortId();
}
const encryptedConfig = await encryptSensitiveFields(backendConfig);
const [created] = await db

View file

@ -1,6 +1,13 @@
import crypto from "node:crypto";
export const generateShortId = (length = 8): string => {
const SHORT_ID_LENGTH = 8;
export const generateShortId = (length = SHORT_ID_LENGTH): string => {
const bytesNeeded = Math.ceil((length * 3) / 4);
return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length);
};
export const isValidShortId = (value: string, length = SHORT_ID_LENGTH): boolean => {
const regex = new RegExp(`^[A-Za-z0-9_-]{${length}}$`);
return regex.test(value);
};