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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "~/client/components/ui/select";
import { Checkbox } from "~/client/components/ui/checkbox";
import { notificationConfigSchema } from "~/schemas/notifications";
import { notificationConfigSchemaBase } from "~/schemas/notifications";
export const formSchema = type({
name: "2<=string<=32",
}).and(notificationConfigSchema);
}).and(notificationConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
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 { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
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 {
LocalRepositoryForm,
@ -36,7 +36,7 @@ import {
export const formSchema = type({
name: "2<=string<=32",
compressionMode: type.valueOf(COMPRESSION_MODES).optional(),
}).and(repositoryConfigSchema);
}).and(repositoryConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type RepositoryFormValues = typeof formSchema.inferIn;

View file

@ -18,7 +18,7 @@ import {
} from "../../../components/ui/form";
import { Input } from "../../../components/ui/input";
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 { Tooltip, TooltipContent, TooltipTrigger } from "../../../components/ui/tooltip";
import { useSystemInfo } from "~/client/hooks/use-system-info";
@ -26,7 +26,7 @@ import { DirectoryForm, NFSForm, SMBForm, WebDAVForm, RcloneForm } from "./volum
export const formSchema = type({
name: "2<=string<=32",
}).and(volumeConfigSchema);
}).and(volumeConfigSchemaBase);
const cleanSchema = type.pipe((d) => formSchema(deepClean(d)));
export type FormValues = typeof formSchema.inferIn;

View file

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

View file

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

View file

@ -55,7 +55,13 @@ export const rcloneConfigSchema = type({
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;

View file

@ -10,6 +10,34 @@ import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
import { RepositoryHealthCheckJob } from "../../jobs/repository-healthchecks";
import { BackupExecutionJob } from "../../jobs/backup-execution";
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 () => {
await Scheduler.start();
@ -19,6 +47,8 @@ export const startup = async () => {
logger.error(`Error ensuring restic passfile exists: ${err.message}`);
});
await ensureLatestConfigurationSchema();
const volumes = await db.query.volumesTable.findMany({
where: or(
eq(volumesTable.status, "mounted"),

View file

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

View file

@ -9,7 +9,13 @@ import { generateShortId } from "../../utils/id";
import { restic } from "../../utils/restic";
import { cryptoUtils } from "../../utils/crypto";
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 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");
}
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;
if (updates.name !== undefined && updates.name !== existing.name) {
const newSlug = slugify(updates.name, { lower: true, strict: true });
@ -445,6 +458,7 @@ const updateRepository = async (name: string, updates: { name?: string; compress
name: newName,
compressionMode: updates.compressionMode ?? existing.compressionMode,
updatedAt: Date.now(),
config: encryptedConfig,
})
.where(eq(repositoriesTable.id, existing.id))
.returning();

View file

@ -16,7 +16,8 @@ import type { UpdateVolumeBody } from "./volume.dto";
import { getVolumePath } from "./helpers";
import { logger } from "../../utils/logger";
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> {
switch (config.backend) {
@ -192,7 +193,12 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => {
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
.update(volumesTable)