refactor(startup): ensure entities always use the latest configuration format (#173)

* refactor(startup): ensure entities always use the latest configuration schema

* refactor: await config updates to avoid race condition on later mount
This commit is contained in:
Nico 2025-12-18 18:11:39 +01:00 committed by GitHub
parent 7a507354ad
commit a17da2562f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 81 additions and 17 deletions

View file

@ -17,11 +17,11 @@ import { Input } from "~/client/components/ui/input";
import { SecretInput } from "~/client/components/ui/secret-input"; import { SecretInput } from "~/client/components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox"; import { Checkbox } from "~/client/components/ui/checkbox";
import { notificationConfigSchema } from "~/schemas/notifications"; import { notificationConfigSchemaBase } from "~/schemas/notifications";
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
}).and(notificationConfigSchema); }).and(notificationConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type NotificationFormValues = typeof formSchema.inferIn; export type NotificationFormValues = typeof formSchema.inferIn;

View file

@ -20,7 +20,7 @@ import { SecretInput } from "../../../components/ui/secret-input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
import { COMPRESSION_MODES, repositoryConfigSchema } from "~/schemas/restic"; import { COMPRESSION_MODES, repositoryConfigSchemaBase } from "~/schemas/restic";
import { Checkbox } from "../../../components/ui/checkbox"; import { Checkbox } from "../../../components/ui/checkbox";
import { import {
LocalRepositoryForm, LocalRepositoryForm,
@ -36,7 +36,7 @@ import {
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(), compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchema); }).and(repositoryConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn; export type RepositoryFormValues = typeof formSchema.inferIn;

View file

@ -18,7 +18,7 @@ import {
} from "../../../components/ui/form"; } from "../../../components/ui/form";
import { Input } from "../../../components/ui/input"; import { Input } from "../../../components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../../components/ui/select";
import { volumeConfigSchema } from "~/schemas/volumes"; import { volumeConfigSchemaBase } from "~/schemas/volumes";
import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen"; import { testConnectionMutation } from "../../../api-client/@tanstack/react-query.gen";
import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip"; import { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info"; import { useSystemInfo } from "~/client/hooks/use-system-info";
@ -26,7 +26,7 @@ import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm } from "./volum
export const formSchema = type({ export const formSchema = type({
name: "2<=string<=32", name: "2<=string<=32",
}).and(volumeConfigSchema); }).and(volumeConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d))); const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type FormValues = typeof formSchema.inferIn; export type FormValues = typeof formSchema.inferIn;

View file

@ -76,7 +76,7 @@ export const customNotificationConfigSchema = type({
shoutrrrUrl: "string", shoutrrrUrl: "string",
}); });
export const notificationConfigSchema = emailNotificationConfigSchema export const notificationConfigSchemaBase = emailNotificationConfigSchema
.or(slackNotificationConfigSchema) .or(slackNotificationConfigSchema)
.or(discordNotificationConfigSchema) .or(discordNotificationConfigSchema)
.or(gotifyNotificationConfigSchema) .or(gotifyNotificationConfigSchema)
@ -85,6 +85,8 @@ export const notificationConfigSchema = emailNotificationConfigSchema
.or(telegramNotificationConfigSchema) .or(telegramNotificationConfigSchema)
.or(customNotificationConfigSchema); .or(customNotificationConfigSchema);
export const notificationConfigSchema = notificationConfigSchemaBase.onUndeclaredKey("delete");
export type NotificationConfig = typeof notificationConfigSchema.infer; export type NotificationConfig = typeof notificationConfigSchema.infer;
export const NOTIFICATION_EVENTS = { export const NOTIFICATION_EVENTS = {

View file

@ -79,7 +79,7 @@ export const sftpRepositoryConfigSchema = type({
privateKey: "string", privateKey: "string",
}).and(baseRepositoryConfigSchema); }).and(baseRepositoryConfigSchema);
export const repositoryConfigSchema = s3RepositoryConfigSchema export const repositoryConfigSchemaBase = s3RepositoryConfigSchema
.or(r2RepositoryConfigSchema) .or(r2RepositoryConfigSchema)
.or(localRepositoryConfigSchema) .or(localRepositoryConfigSchema)
.or(gcsRepositoryConfigSchema) .or(gcsRepositoryConfigSchema)
@ -88,6 +88,8 @@ export const repositoryConfigSchema = s3RepositoryConfigSchema
.or(restRepositoryConfigSchema) .or(restRepositoryConfigSchema)
.or(sftpRepositoryConfigSchema); .or(sftpRepositoryConfigSchema);
export const repositoryConfigSchema = repositoryConfigSchemaBase.onUndeclaredKey("delete");
export type RepositoryConfig = typeof repositoryConfigSchema.infer; export type RepositoryConfig = typeof repositoryConfigSchema.infer;
export const COMPRESSION_MODES = { export const COMPRESSION_MODES = {

View file

@ -55,7 +55,13 @@ export const rcloneConfigSchema = type({
readOnly: "boolean?", readOnly: "boolean?",
}); });
export const volumeConfigSchema = nfsConfigSchema.or(smbConfigSchema).or(webdavConfigSchema).or(directoryConfigSchema).or(rcloneConfigSchema); export const volumeConfigSchemaBase = nfsConfigSchema
.or(smbConfigSchema)
.or(webdavConfigSchema)
.or(directoryConfigSchema)
.or(rcloneConfigSchema);
export const volumeConfigSchema = volumeConfigSchemaBase.onUndeclaredKey("delete");
export type BackendConfig = typeof volumeConfigSchema.infer; export type BackendConfig = typeof volumeConfigSchema.infer;

View file

@ -10,6 +10,34 @@ import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks"; import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks";
import { BackupExecutionJob } from "../../jobs/backup-execution"; import { BackupExecutionJob } from "../../jobs/backup-execution";
import { CleanupSessionsJob } from "../../jobs/cleanup-sessions"; import { CleanupSessionsJob } from "../../jobs/cleanup-sessions";
import { repositoriesService } from "../repositories/repositories.service";
import { notificationsService } from "../notifications/notifications.service";
const ensureLatestConfigurationSchema = async () => {
const volumes = await db.query.volumesTable.findMany({});
for (const volume of volumes) {
await volumeService.updateVolume(volume.name, volume).catch((err) => {
logger.error(`Failed to update volume ${volume.name}: ${err}`);
});
}
const repositories = await db.query.repositoriesTable.findMany({});
for (const repo of repositories) {
await repositoriesService.updateRepository(repo.name, {}).catch((err) => {
logger.error(`Failed to update repository ${repo.name}: ${err}`);
});
}
const notifications = await db.query.notificationDestinationsTable.findMany({});
for (const notification of notifications) {
await notificationsService.updateDestination(notification.id, notification).catch((err) => {
logger.error(`Failed to update notification destination ${notification.id}: ${err}`);
});
}
};
export const startup = async () => { export const startup = async () => {
await Scheduler.start(); await Scheduler.start();
@ -19,6 +47,8 @@ export const startup = async () => {
logger.error(`Error ensuring restic passfile exists: ${err.message}`); logger.error(`Error ensuring restic passfile exists: ${err.message}`);
}); });
await ensureLatestConfigurationSchema();
const volumes = await db.query.volumesTable.findMany({ const volumes = await db.query.volumesTable.findMany({
where: or( where: or(
eq(volumesTable.status, "mounted"), eq(volumesTable.status, "mounted"),

View file

@ -11,8 +11,9 @@ import { cryptoUtils } from "../../utils/crypto";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { sendNotification } from "../../utils/shoutrrr"; import { sendNotification } from "../../utils/shoutrrr";
import { buildShoutrrrUrl } from "./builders"; import { buildShoutrrrUrl } from "./builders";
import type { NotificationConfig, NotificationEvent } from "~/schemas/notifications"; import { notificationConfigSchema, type NotificationConfig, type NotificationEvent } from "~/schemas/notifications";
import { toMessage } from "../../utils/errors"; import { toMessage } from "../../utils/errors";
import { type } from "arktype";
const listDestinations = async () => { const listDestinations = async () => {
const destinations = await db.query.notificationDestinationsTable.findMany({ const destinations = await db.query.notificationDestinationsTable.findMany({
@ -187,12 +188,15 @@ const updateDestination = async (
updateData.enabled = updates.enabled; updateData.enabled = updates.enabled;
} }
if (updates.config !== undefined) { const newConfig = notificationConfigSchema(updates.config || existing.config);
const encryptedConfig = await encryptSensitiveFields(updates.config); if (newConfig instanceof type.errors) {
updateData.config = encryptedConfig; throw new InternalServerError("Invalid notification configuration");
updateData.type = updates.config.type;
} }
const encryptedConfig = await encryptSensitiveFields(newConfig);
updateData.config = encryptedConfig;
updateData.type = newConfig.type;
const [updated] = await db const [updated] = await db
.update(notificationDestinationsTable) .update(notificationDestinationsTable)
.set(updateData) .set(updateData)

View file

@ -9,7 +9,13 @@ import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic"; import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto"; import { cryptoUtils } from "../../utils/crypto";
import { repoMutex } from "../../core/repository-mutex"; import { repoMutex } from "../../core/repository-mutex";
import type { CompressionMode, OverwriteMode, RepositoryConfig } from "~/schemas/restic"; import {
repositoryConfigSchema,
type CompressionMode,
type OverwriteMode,
type RepositoryConfig,
} from "~/schemas/restic";
import { type } from "arktype";
const listRepositories = async () => { const listRepositories = async () => {
const repositories = await db.query.repositoriesTable.findMany({}); const repositories = await db.query.repositoriesTable.findMany({});
@ -424,6 +430,13 @@ const updateRepository = async (name: string, updates: { name?: string; compress
throw new ConflictError("Cannot rename an imported local repository"); throw new ConflictError("Cannot rename an imported local repository");
} }
const newConfig = repositoryConfigSchema(existing.config);
if (newConfig instanceof type.errors) {
throw new InternalServerError("Invalid repository configuration");
}
const encryptedConfig = await encryptConfig(newConfig);
let newName = existing.name; let newName = existing.name;
if (updates.name !== undefined && updates.name !== existing.name) { if (updates.name !== undefined && updates.name !== existing.name) {
const newSlug = slugify(updates.name, { lower: true, strict: true }); const newSlug = slugify(updates.name, { lower: true, strict: true });
@ -445,6 +458,7 @@ const updateRepository = async (name: string, updates: { name?: string; compress
name: newName, name: newName,
compressionMode: updates.compressionMode ?? existing.compressionMode, compressionMode: updates.compressionMode ?? existing.compressionMode,
updatedAt: Date.now(), updatedAt: Date.now(),
config: encryptedConfig,
}) })
.where(eq(repositoriesTable.id, existing.id)) .where(eq(repositoriesTable.id, existing.id))
.returning(); .returning();

View file

@ -16,7 +16,8 @@ import type { UpdateVolumeBody } from "./volume.dto";
import { getVolumePath } from "./helpers"; import { getVolumePath } from "./helpers";
import { logger } from "../../utils/logger"; import { logger } from "../../utils/logger";
import { serverEvents } from "../../core/events"; import { serverEvents } from "../../core/events";
import type { BackendConfig } from "~/schemas/volumes"; import { volumeConfigSchema, type BackendConfig } from "~/schemas/volumes";
import { type } from "arktype";
async function encryptSensitiveFields(config: BackendConfig): Promise<BackendConfig> { async function encryptSensitiveFields(config: BackendConfig): Promise<BackendConfig> {
switch (config.backend) { switch (config.backend) {
@ -192,7 +193,12 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
await backend.unmount(); await backend.unmount();
} }
const encryptedConfig = volumeData.config ? await encryptSensitiveFields(volumeData.config) : undefined; const newConfig = volumeConfigSchema(volumeData.config || existing.config);
if (newConfig instanceof type.errors) {
throw new InternalServerError("Invalid volume configuration");
}
const encryptedConfig = await encryptSensitiveFields(newConfig);
const [updated] = await db const [updated] = await db
.update(volumesTable) .update(volumesTable)