From c416864e4bf4451aea6f863e223783adfbf4372e Mon Sep 17 00:00:00 2001 From: Nicolas Meienberger Date: Tue, 25 Nov 2025 21:31:54 +0100 Subject: [PATCH] refactor: use short ids to allow changing the name of volumes & repos --- README.md | 8 +- .../api-client/@tanstack/react-query.gen.ts | 21 +- app/client/api-client/sdk.gen.ts | 16 +- app/client/api-client/types.gen.ts | 117 ++++ app/client/modules/volumes/tabs/docker.tsx | 6 +- app/drizzle/0012_add_short_ids.sql | 17 + app/drizzle/0013_add_app_metadata.sql | 7 + app/drizzle/meta/0012_snapshot.json | 648 ++++++++++++++++++ app/drizzle/meta/_journal.json | 193 +++--- app/server/core/constants.ts | 2 + app/server/db/schema.ts | 14 + app/server/index.ts | 9 +- .../modules/driver/driver.controller.ts | 39 +- app/server/modules/lifecycle/checkpoint.ts | 89 +++ app/server/modules/lifecycle/migration.ts | 148 ++++ .../notifications/builders/pushover.ts | 4 +- .../notifications/notifications.service.ts | 6 +- .../repositories/repositories.controller.ts | 11 + .../modules/repositories/repositories.dto.ts | 36 + .../repositories/repositories.service.ts | 54 +- app/server/modules/volumes/helpers.ts | 2 +- app/server/modules/volumes/volume.dto.ts | 2 + app/server/modules/volumes/volume.service.ts | 31 +- app/server/utils/id.ts | 6 + 24 files changed, 1366 insertions(+), 120 deletions(-) create mode 100644 app/drizzle/0012_add_short_ids.sql create mode 100644 app/drizzle/0013_add_app_metadata.sql create mode 100644 app/drizzle/meta/0012_snapshot.json create mode 100644 app/server/modules/lifecycle/checkpoint.ts create mode 100644 app/server/modules/lifecycle/migration.ts create mode 100644 app/server/utils/id.ts diff --git a/README.md b/README.md index 498e8587..f2f3652e 100644 --- a/README.md +++ b/README.md @@ -265,7 +265,7 @@ docker compose up -d Your Zerobyte volumes will now be available as Docker volumes that you can mount into other containers using the `--volume` flag: ```bash -docker run -v im-nfs:/path/in/container nginx:latest +docker run -v zb-abc12:/path/in/container nginx:latest ``` Or using Docker Compose: @@ -275,13 +275,13 @@ services: myservice: image: nginx:latest volumes: - - im-nfs:/path/in/container + - zb-abc12:/path/in/container volumes: - im-nfs: + zb-abc12: external: true ``` -The volume name format is `im-` where `` is the name you assigned to the volume in Zerobyte. You can verify that the volume is available by running: +The volume name format is `zb-` where `` is the unique identifier shown on the volume's Docker tab in Zerobyte. This short ID remains stable even if you rename the volume. You can verify that the volume is available by running: ```bash docker volume ls diff --git a/app/client/api-client/@tanstack/react-query.gen.ts b/app/client/api-client/@tanstack/react-query.gen.ts index b747bfb8..5beda44b 100644 --- a/app/client/api-client/@tanstack/react-query.gen.ts +++ b/app/client/api-client/@tanstack/react-query.gen.ts @@ -3,8 +3,8 @@ import { type DefaultError, queryOptions, type UseMutationOptions } from '@tanstack/react-query'; import { client } from '../client.gen'; -import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateScheduleNotifications, updateVolume } from '../sdk.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; +import { browseFilesystem, changePassword, createBackupSchedule, createNotificationDestination, createRepository, createVolume, deleteBackupSchedule, deleteNotificationDestination, deleteRepository, deleteSnapshot, deleteVolume, doctorRepository, downloadResticPassword, getBackupSchedule, getBackupScheduleForVolume, getContainersUsingVolume, getMe, getNotificationDestination, getRepository, getScheduleNotifications, getSnapshotDetails, getStatus, getSystemInfo, getVolume, healthCheckVolume, listBackupSchedules, listFiles, listNotificationDestinations, listRcloneRemotes, listRepositories, listSnapshotFiles, listSnapshots, listVolumes, login, logout, mountVolume, type Options, register, restoreSnapshot, runBackupNow, runForget, stopBackup, testConnection, testNotificationDestination, unmountVolume, updateBackupSchedule, updateNotificationDestination, updateRepository, updateScheduleNotifications, updateVolume } from '../sdk.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponse, ChangePasswordData, ChangePasswordResponse, CreateBackupScheduleData, CreateBackupScheduleResponse, CreateNotificationDestinationData, CreateNotificationDestinationResponse, CreateRepositoryData, CreateRepositoryResponse, CreateVolumeData, CreateVolumeResponse, DeleteBackupScheduleData, DeleteBackupScheduleResponse, DeleteNotificationDestinationData, DeleteNotificationDestinationResponse, DeleteRepositoryData, DeleteRepositoryResponse, DeleteSnapshotData, DeleteSnapshotResponse, DeleteVolumeData, DeleteVolumeResponse, DoctorRepositoryData, DoctorRepositoryResponse, DownloadResticPasswordData, DownloadResticPasswordResponse, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponse, GetBackupScheduleResponse, GetContainersUsingVolumeData, GetContainersUsingVolumeResponse, GetMeData, GetMeResponse, GetNotificationDestinationData, GetNotificationDestinationResponse, GetRepositoryData, GetRepositoryResponse, GetScheduleNotificationsData, GetScheduleNotificationsResponse, GetSnapshotDetailsData, GetSnapshotDetailsResponse, GetStatusData, GetStatusResponse, GetSystemInfoData, GetSystemInfoResponse, GetVolumeData, GetVolumeResponse, HealthCheckVolumeData, HealthCheckVolumeResponse, ListBackupSchedulesData, ListBackupSchedulesResponse, ListFilesData, ListFilesResponse, ListNotificationDestinationsData, ListNotificationDestinationsResponse, ListRcloneRemotesData, ListRcloneRemotesResponse, ListRepositoriesData, ListRepositoriesResponse, ListSnapshotFilesData, ListSnapshotFilesResponse, ListSnapshotsData, ListSnapshotsResponse, ListVolumesData, ListVolumesResponse, LoginData, LoginResponse, LogoutData, LogoutResponse, MountVolumeData, MountVolumeResponse, RegisterData, RegisterResponse, RestoreSnapshotData, RestoreSnapshotResponse, RunBackupNowData, RunBackupNowResponse, RunForgetData, RunForgetResponse, StopBackupData, StopBackupResponse, TestConnectionData, TestConnectionResponse, TestNotificationDestinationData, TestNotificationDestinationResponse, UnmountVolumeData, UnmountVolumeResponse, UpdateBackupScheduleData, UpdateBackupScheduleResponse, UpdateNotificationDestinationData, UpdateNotificationDestinationResponse, UpdateRepositoryData, UpdateRepositoryResponse, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponse, UpdateVolumeData, UpdateVolumeResponse } from '../types.gen'; /** * Register a new user @@ -442,6 +442,23 @@ export const getRepositoryOptions = (options: Options) => que queryKey: getRepositoryQueryKey(options) }); +/** + * Update a repository's name or settings + */ +export const updateRepositoryMutation = (options?: Partial>): UseMutationOptions> => { + const mutationOptions: UseMutationOptions> = { + mutationFn: async (fnOptions) => { + const { data } = await updateRepository({ + ...options, + ...fnOptions, + throwOnError: true + }); + return data; + } + }; + return mutationOptions; +}; + export const listSnapshotsQueryKey = (options: Options) => createQueryKey("listSnapshots", options); /** diff --git a/app/client/api-client/sdk.gen.ts b/app/client/api-client/sdk.gen.ts index df149610..9f5afe46 100644 --- a/app/client/api-client/sdk.gen.ts +++ b/app/client/api-client/sdk.gen.ts @@ -2,7 +2,7 @@ import type { Client, Options as Options2, TDataShape } from './client'; import { client } from './client.gen'; -import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; +import type { BrowseFilesystemData, BrowseFilesystemResponses, ChangePasswordData, ChangePasswordResponses, CreateBackupScheduleData, CreateBackupScheduleResponses, CreateNotificationDestinationData, CreateNotificationDestinationResponses, CreateRepositoryData, CreateRepositoryResponses, CreateVolumeData, CreateVolumeResponses, DeleteBackupScheduleData, DeleteBackupScheduleResponses, DeleteNotificationDestinationData, DeleteNotificationDestinationErrors, DeleteNotificationDestinationResponses, DeleteRepositoryData, DeleteRepositoryResponses, DeleteSnapshotData, DeleteSnapshotResponses, DeleteVolumeData, DeleteVolumeResponses, DoctorRepositoryData, DoctorRepositoryResponses, DownloadResticPasswordData, DownloadResticPasswordResponses, GetBackupScheduleData, GetBackupScheduleForVolumeData, GetBackupScheduleForVolumeResponses, GetBackupScheduleResponses, GetContainersUsingVolumeData, GetContainersUsingVolumeErrors, GetContainersUsingVolumeResponses, GetMeData, GetMeResponses, GetNotificationDestinationData, GetNotificationDestinationErrors, GetNotificationDestinationResponses, GetRepositoryData, GetRepositoryResponses, GetScheduleNotificationsData, GetScheduleNotificationsResponses, GetSnapshotDetailsData, GetSnapshotDetailsResponses, GetStatusData, GetStatusResponses, GetSystemInfoData, GetSystemInfoResponses, GetVolumeData, GetVolumeErrors, GetVolumeResponses, HealthCheckVolumeData, HealthCheckVolumeErrors, HealthCheckVolumeResponses, ListBackupSchedulesData, ListBackupSchedulesResponses, ListFilesData, ListFilesResponses, ListNotificationDestinationsData, ListNotificationDestinationsResponses, ListRcloneRemotesData, ListRcloneRemotesResponses, ListRepositoriesData, ListRepositoriesResponses, ListSnapshotFilesData, ListSnapshotFilesResponses, ListSnapshotsData, ListSnapshotsResponses, ListVolumesData, ListVolumesResponses, LoginData, LoginResponses, LogoutData, LogoutResponses, MountVolumeData, MountVolumeResponses, RegisterData, RegisterResponses, RestoreSnapshotData, RestoreSnapshotResponses, RunBackupNowData, RunBackupNowResponses, RunForgetData, RunForgetResponses, StopBackupData, StopBackupErrors, StopBackupResponses, TestConnectionData, TestConnectionResponses, TestNotificationDestinationData, TestNotificationDestinationErrors, TestNotificationDestinationResponses, UnmountVolumeData, UnmountVolumeResponses, UpdateBackupScheduleData, UpdateBackupScheduleResponses, UpdateNotificationDestinationData, UpdateNotificationDestinationErrors, UpdateNotificationDestinationResponses, UpdateRepositoryData, UpdateRepositoryErrors, UpdateRepositoryResponses, UpdateScheduleNotificationsData, UpdateScheduleNotificationsResponses, UpdateVolumeData, UpdateVolumeErrors, UpdateVolumeResponses } from './types.gen'; export type Options = Options2 & { /** @@ -276,6 +276,20 @@ export const getRepository = (options: Opt }); }; +/** + * Update a repository's name or settings + */ +export const updateRepository = (options: Options) => { + return (options.client ?? client).patch({ + url: '/api/v1/repositories/{name}', + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers + } + }); +}; + /** * List all snapshots in a repository */ diff --git a/app/client/api-client/types.gen.ts b/app/client/api-client/types.gen.ts index fb477402..a6d86d1c 100644 --- a/app/client/api-client/types.gen.ts +++ b/app/client/api-client/types.gen.ts @@ -189,6 +189,7 @@ export type ListVolumesResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -279,6 +280,7 @@ export type CreateVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -422,6 +424,7 @@ export type GetVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -465,6 +468,7 @@ export type UpdateVolumeData = { ssl?: boolean; username?: string; }; + name?: string; }; path: { name: string; @@ -522,6 +526,7 @@ export type UpdateVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -771,6 +776,7 @@ export type ListRepositoriesResponses = { lastChecked: number | null; lastError: string | null; name: string; + shortId: string; status: 'error' | 'healthy' | 'unknown' | null; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; @@ -985,6 +991,7 @@ export type GetRepositoryResponses = { lastChecked: number | null; lastError: string | null; name: string; + shortId: string; status: 'error' | 'healthy' | 'unknown' | null; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; @@ -993,6 +1000,110 @@ export type GetRepositoryResponses = { export type GetRepositoryResponse = GetRepositoryResponses[keyof GetRepositoryResponses]; +export type UpdateRepositoryData = { + body?: { + compressionMode?: 'auto' | 'better' | 'fastest' | 'max' | 'off'; + name?: string; + }; + path: { + name: string; + }; + query?: never; + url: '/api/v1/repositories/{name}'; +}; + +export type UpdateRepositoryErrors = { + /** + * Repository not found + */ + 404: unknown; + /** + * Repository with this name already exists + */ + 409: unknown; +}; + +export type UpdateRepositoryResponses = { + /** + * Repository updated successfully + */ + 200: { + compressionMode: 'auto' | 'better' | 'fastest' | 'max' | 'off' | null; + config: { + accessKeyId: string; + backend: 'r2'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accessKeyId: string; + backend: 's3'; + bucket: string; + endpoint: string; + secretAccessKey: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + accountKey: string; + accountName: string; + backend: 'azure'; + container: string; + customPassword?: string; + endpointSuffix?: string; + isExistingRepository?: boolean; + } | { + backend: 'gcs'; + bucket: string; + credentialsJson: string; + projectId: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'local'; + name: string; + customPassword?: string; + isExistingRepository?: boolean; + path?: string; + } | { + backend: 'rclone'; + path: string; + remote: string; + customPassword?: string; + isExistingRepository?: boolean; + } | { + backend: 'rest'; + url: string; + customPassword?: string; + isExistingRepository?: boolean; + password?: string; + path?: string; + username?: string; + } | { + backend: 'sftp'; + host: string; + path: string; + privateKey: string; + user: string; + port?: number; + customPassword?: string; + isExistingRepository?: boolean; + }; + createdAt: number; + id: string; + lastChecked: number | null; + lastError: string | null; + name: string; + shortId: string; + status: 'error' | 'healthy' | 'unknown' | null; + type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; + updatedAt: number; + }; +}; + +export type UpdateRepositoryResponse = UpdateRepositoryResponses[keyof UpdateRepositoryResponses]; + export type ListSnapshotsData = { body?: never; path: { @@ -1251,6 +1362,7 @@ export type ListBackupSchedulesResponses = { lastChecked: number | null; lastError: string | null; name: string; + shortId: string; status: 'error' | 'healthy' | 'unknown' | null; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; @@ -1304,6 +1416,7 @@ export type ListBackupSchedulesResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -1482,6 +1595,7 @@ export type GetBackupScheduleResponses = { lastChecked: number | null; lastError: string | null; name: string; + shortId: string; status: 'error' | 'healthy' | 'unknown' | null; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; @@ -1535,6 +1649,7 @@ export type GetBackupScheduleResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; @@ -1694,6 +1809,7 @@ export type GetBackupScheduleForVolumeResponses = { lastChecked: number | null; lastError: string | null; name: string; + shortId: string; status: 'error' | 'healthy' | 'unknown' | null; type: 'azure' | 'gcs' | 'local' | 'r2' | 'rclone' | 'rest' | 's3' | 'sftp'; updatedAt: number; @@ -1747,6 +1863,7 @@ export type GetBackupScheduleForVolumeResponses = { lastError: string | null; lastHealthCheck: number; name: string; + shortId: string; status: 'error' | 'mounted' | 'unmounted'; type: 'directory' | 'nfs' | 'smb' | 'webdav'; updatedAt: number; diff --git a/app/client/modules/volumes/tabs/docker.tsx b/app/client/modules/volumes/tabs/docker.tsx index b9424798..c03e5687 100644 --- a/app/client/modules/volumes/tabs/docker.tsx +++ b/app/client/modules/volumes/tabs/docker.tsx @@ -16,17 +16,17 @@ export const DockerTabContent = ({ volume }: Props) => { services: { nginx: { image: "nginx:latest", - volumes: [`im-${volume.name}:/path/in/container`], + volumes: [`zb-${volume.shortId}:/path/in/container`], }, }, volumes: { - [`im-${volume.name}`]: { + [`zb-${volume.shortId}`]: { external: true, }, }, }); - const dockerRunCommand = `docker run -v im-${volume.name}:/path/in/container nginx:latest`; + const dockerRunCommand = `docker run -v zb-${volume.shortId}:/path/in/container nginx:latest`; const { data: containersData, diff --git a/app/drizzle/0012_add_short_ids.sql b/app/drizzle/0012_add_short_ids.sql new file mode 100644 index 00000000..3278119e --- /dev/null +++ b/app/drizzle/0012_add_short_ids.sql @@ -0,0 +1,17 @@ +-- Add short_id column to repositories_table (nullable initially) +ALTER TABLE `repositories_table` ADD `short_id` text;--> statement-breakpoint + +-- Populate short_id for existing repositories using random hex string +UPDATE `repositories_table` SET `short_id` = lower(hex(randomblob(3))) WHERE `short_id` IS NULL;--> statement-breakpoint + +-- Create unique index on repositories short_id +CREATE UNIQUE INDEX `repositories_table_short_id_unique` ON `repositories_table` (`short_id`);--> statement-breakpoint + +-- Add short_id column to volumes_table (nullable initially) +ALTER TABLE `volumes_table` ADD `short_id` text;--> statement-breakpoint + +-- Populate short_id for existing volumes using random hex string +UPDATE `volumes_table` SET `short_id` = lower(hex(randomblob(3))) WHERE `short_id` IS NULL;--> statement-breakpoint + +-- Create unique index on volumes short_id +CREATE UNIQUE INDEX `volumes_table_short_id_unique` ON `volumes_table` (`short_id`); \ No newline at end of file diff --git a/app/drizzle/0013_add_app_metadata.sql b/app/drizzle/0013_add_app_metadata.sql new file mode 100644 index 00000000..6fdc0f43 --- /dev/null +++ b/app/drizzle/0013_add_app_metadata.sql @@ -0,0 +1,7 @@ +-- App metadata table for storing key-value pairs (e.g., migration checkpoints) +CREATE TABLE `app_metadata` ( + `key` text PRIMARY KEY NOT NULL, + `value` text NOT NULL, + `created_at` integer DEFAULT (unixepoch()) NOT NULL, + `updated_at` integer DEFAULT (unixepoch()) NOT NULL +); diff --git a/app/drizzle/meta/0012_snapshot.json b/app/drizzle/meta/0012_snapshot.json new file mode 100644 index 00000000..02bff3eb --- /dev/null +++ b/app/drizzle/meta/0012_snapshot.json @@ -0,0 +1,648 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "bbca8451-3894-4556-9824-c309b5105628", + "prevId": "67552135-fa49-478f-9333-107d3dbd7610", + "tables": { + "backup_schedule_notifications_table": { + "name": "backup_schedule_notifications_table", + "columns": { + "schedule_id": { + "name": "schedule_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "destination_id": { + "name": "destination_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "notify_on_start": { + "name": "notify_on_start", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_success": { + "name": "notify_on_success", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "notify_on_failure": { + "name": "notify_on_failure", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk": { + "name": "backup_schedule_notifications_table_schedule_id_backup_schedules_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "backup_schedules_table", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk": { + "name": "backup_schedule_notifications_table_destination_id_notification_destinations_table_id_fk", + "tableFrom": "backup_schedule_notifications_table", + "tableTo": "notification_destinations_table", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "backup_schedule_notifications_table_schedule_id_destination_id_pk": { + "columns": [ + "schedule_id", + "destination_id" + ], + "name": "backup_schedule_notifications_table_schedule_id_destination_id_pk" + } + }, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "backup_schedules_table": { + "name": "backup_schedules_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "volume_id": { + "name": "volume_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repository_id": { + "name": "repository_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "retention_policy": { + "name": "retention_policy", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "exclude_patterns": { + "name": "exclude_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "include_patterns": { + "name": "include_patterns", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'[]'" + }, + "last_backup_at": { + "name": "last_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_status": { + "name": "last_backup_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_backup_error": { + "name": "last_backup_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "next_backup_at": { + "name": "next_backup_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "backup_schedules_table_volume_id_volumes_table_id_fk": { + "name": "backup_schedules_table_volume_id_volumes_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "volumes_table", + "columnsFrom": [ + "volume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "backup_schedules_table_repository_id_repositories_table_id_fk": { + "name": "backup_schedules_table_repository_id_repositories_table_id_fk", + "tableFrom": "backup_schedules_table", + "tableTo": "repositories_table", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "notification_destinations_table": { + "name": "notification_destinations_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "notification_destinations_table_name_unique": { + "name": "notification_destinations_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "repositories_table": { + "name": "repositories_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "compression_mode": { + "name": "compression_mode", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'auto'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false, + "default": "'unknown'" + }, + "last_checked": { + "name": "last_checked", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "repositories_table_short_id_unique": { + "name": "repositories_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "repositories_table_name_unique": { + "name": "repositories_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "sessions_table": { + "name": "sessions_table", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "user_id": { + "name": "user_id", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "expires_at": { + "name": "expires_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_table_user_id_users_table_id_fk": { + "name": "sessions_table_user_id_users_table_id_fk", + "tableFrom": "sessions_table", + "tableTo": "users_table", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "users_table": { + "name": "users_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "password_hash": { + "name": "password_hash", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "has_downloaded_restic_password": { + "name": "has_downloaded_restic_password", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + } + }, + "indexes": { + "users_table_username_unique": { + "name": "users_table_username_unique", + "columns": [ + "username" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "volumes_table": { + "name": "volumes_table", + "columns": { + "id": { + "name": "id", + "type": "integer", + "primaryKey": true, + "notNull": true, + "autoincrement": true + }, + "short_id": { + "name": "short_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'unmounted'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "last_health_check": { + "name": "last_health_check", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "(unixepoch())" + }, + "config": { + "name": "config", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "auto_remount": { + "name": "auto_remount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": true + } + }, + "indexes": { + "volumes_table_short_id_unique": { + "name": "volumes_table_short_id_unique", + "columns": [ + "short_id" + ], + "isUnique": true + }, + "volumes_table_name_unique": { + "name": "volumes_table_name_unique", + "columns": [ + "name" + ], + "isUnique": true + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/app/drizzle/meta/_journal.json b/app/drizzle/meta/_journal.json index 6310637b..af437b2a 100644 --- a/app/drizzle/meta/_journal.json +++ b/app/drizzle/meta/_journal.json @@ -1,90 +1,105 @@ { - "version": "7", - "dialect": "sqlite", - "entries": [ - { - "idx": 0, - "version": "6", - "when": 1755765658194, - "tag": "0000_known_madelyne_pryor", - "breakpoints": true - }, - { - "idx": 1, - "version": "6", - "when": 1755775437391, - "tag": "0001_far_frank_castle", - "breakpoints": true - }, - { - "idx": 2, - "version": "6", - "when": 1756930554198, - "tag": "0002_cheerful_randall", - "breakpoints": true - }, - { - "idx": 3, - "version": "6", - "when": 1758653407064, - "tag": "0003_mature_hellcat", - "breakpoints": true - }, - { - "idx": 4, - "version": "6", - "when": 1758961535488, - "tag": "0004_wealthy_tomas", - "breakpoints": true - }, - { - "idx": 5, - "version": "6", - "when": 1759416698274, - "tag": "0005_simple_alice", - "breakpoints": true - }, - { - "idx": 6, - "version": "6", - "when": 1760734377440, - "tag": "0006_secret_micromacro", - "breakpoints": true - }, - { - "idx": 7, - "version": "6", - "when": 1761224911352, - "tag": "0007_watery_sersi", - "breakpoints": true - }, - { - "idx": 8, - "version": "6", - "when": 1761414054481, - "tag": "0008_silent_lady_bullseye", - "breakpoints": true - }, - { - "idx": 9, - "version": "6", - "when": 1762095226041, - "tag": "0009_little_adam_warlock", - "breakpoints": true - }, - { - "idx": 10, - "version": "6", - "when": 1762610065889, - "tag": "0010_perfect_proemial_gods", - "breakpoints": true - }, - { - "idx": 11, - "version": "6", - "when": 1763644043601, - "tag": "0011_familiar_stone_men", - "breakpoints": true - } - ] -} \ No newline at end of file + "version": "7", + "dialect": "sqlite", + "entries": [ + { + "idx": 0, + "version": "6", + "when": 1755765658194, + "tag": "0000_known_madelyne_pryor", + "breakpoints": true + }, + { + "idx": 1, + "version": "6", + "when": 1755775437391, + "tag": "0001_far_frank_castle", + "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1756930554198, + "tag": "0002_cheerful_randall", + "breakpoints": true + }, + { + "idx": 3, + "version": "6", + "when": 1758653407064, + "tag": "0003_mature_hellcat", + "breakpoints": true + }, + { + "idx": 4, + "version": "6", + "when": 1758961535488, + "tag": "0004_wealthy_tomas", + "breakpoints": true + }, + { + "idx": 5, + "version": "6", + "when": 1759416698274, + "tag": "0005_simple_alice", + "breakpoints": true + }, + { + "idx": 6, + "version": "6", + "when": 1760734377440, + "tag": "0006_secret_micromacro", + "breakpoints": true + }, + { + "idx": 7, + "version": "6", + "when": 1761224911352, + "tag": "0007_watery_sersi", + "breakpoints": true + }, + { + "idx": 8, + "version": "6", + "when": 1761414054481, + "tag": "0008_silent_lady_bullseye", + "breakpoints": true + }, + { + "idx": 9, + "version": "6", + "when": 1762095226041, + "tag": "0009_little_adam_warlock", + "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1762610065889, + "tag": "0010_perfect_proemial_gods", + "breakpoints": true + }, + { + "idx": 11, + "version": "6", + "when": 1763644043601, + "tag": "0011_familiar_stone_men", + "breakpoints": true + }, + { + "idx": 12, + "version": "6", + "when": 1764100562084, + "tag": "0012_add_short_ids", + "breakpoints": true + }, + { + "idx": 13, + "version": "6", + "when": 1764100562085, + "tag": "0013_add_app_metadata", + "breakpoints": true + } + ] +} + diff --git a/app/server/core/constants.ts b/app/server/core/constants.ts index 5b970ffc..f0e54235 100644 --- a/app/server/core/constants.ts +++ b/app/server/core/constants.ts @@ -4,3 +4,5 @@ export const REPOSITORY_BASE = "/var/lib/zerobyte/repositories"; export const DATABASE_URL = "/var/lib/zerobyte/data/ironmount.db"; export const RESTIC_PASS_FILE = "/var/lib/zerobyte/data/restic.pass"; export const SOCKET_PATH = "/run/docker/plugins/zerobyte.sock"; + +export const REQUIRED_MIGRATIONS = ["v0.14.0"]; diff --git a/app/server/db/schema.ts b/app/server/db/schema.ts index f4842d6b..686c4354 100644 --- a/app/server/db/schema.ts +++ b/app/server/db/schema.ts @@ -9,6 +9,7 @@ import type { NotificationType, notificationConfigSchema } from "~/schemas/notif */ export const volumesTable = sqliteTable("volumes_table", { id: int().primaryKey({ autoIncrement: true }), + shortId: text("short_id").notNull().unique(), name: text().notNull().unique(), type: text().$type().notNull(), status: text().$type().notNull().default("unmounted"), @@ -48,6 +49,7 @@ export type Session = typeof sessionsTable.$inferSelect; */ export const repositoriesTable = sqliteTable("repositories_table", { id: text().primaryKey(), + shortId: text("short_id").notNull().unique(), name: text().notNull().unique(), type: text().$type().notNull(), config: text("config", { mode: "json" }).$type().notNull(), @@ -151,3 +153,15 @@ export const backupScheduleNotificationRelations = relations(backupScheduleNotif }), })); export type BackupScheduleNotification = typeof backupScheduleNotificationsTable.$inferSelect; + +/** + * App Metadata Table + * Used for storing key-value pairs like migration checkpoints + */ +export const appMetadataTable = sqliteTable("app_metadata", { + key: text().primaryKey(), + value: text().notNull(), + createdAt: int("created_at", { mode: "number" }).notNull().default(sql`(unixepoch())`), + updatedAt: int("updated_at", { mode: "number" }).notNull().default(sql`(unixepoch())`), +}); +export type AppMetadata = typeof appMetadataTable.$inferSelect; diff --git a/app/server/index.ts b/app/server/index.ts index cf741e4c..c31c2835 100644 --- a/app/server/index.ts +++ b/app/server/index.ts @@ -10,6 +10,7 @@ import { authController } from "./modules/auth/auth.controller"; import { requireAuth } from "./modules/auth/auth.middleware"; import { driverController } from "./modules/driver/driver.controller"; import { startup } from "./modules/lifecycle/startup"; +import { migrateToShortIds } from "./modules/lifecycle/migration"; import { repositoriesController } from "./modules/repositories/repositories.controller"; import { systemController } from "./modules/system/system.controller"; import { volumeController } from "./modules/volumes/volume.controller"; @@ -19,7 +20,8 @@ import { notificationsController } from "./modules/notifications/notifications.c import { handleServiceError } from "./utils/errors"; import { logger } from "./utils/logger"; import { shutdown } from "./modules/lifecycle/shutdown"; -import { SOCKET_PATH } from "./core/constants"; +import { REQUIRED_MIGRATIONS, SOCKET_PATH } from "./core/constants"; +import { validateRequiredMigrations } from "./modules/lifecycle/checkpoint"; export const generalDescriptor = (app: Hono) => openAPIRouteHandler(app, { @@ -68,6 +70,11 @@ app.onError((err, c) => { runDbMigrations(); +await migrateToShortIds(); +await validateRequiredMigrations(REQUIRED_MIGRATIONS); + +await validateRequiredMigrations(["v0.14.0"]); + const { docker } = await getCapabilities(); if (docker) { diff --git a/app/server/modules/driver/driver.controller.ts b/app/server/modules/driver/driver.controller.ts index 6418d95f..72a1feb6 100644 --- a/app/server/modules/driver/driver.controller.ts +++ b/app/server/modules/driver/driver.controller.ts @@ -1,6 +1,9 @@ import { Hono } from "hono"; import { volumeService } from "../volumes/volume.service"; import { getVolumePath } from "../volumes/helpers"; +import { eq } from "drizzle-orm"; +import { db } from "../../db/db"; +import { volumesTable } from "../../db/schema"; export const driverController = new Hono() .post("/VolumeDriver.Capabilities", (c) => { @@ -30,10 +33,18 @@ export const driverController = new Hono() return c.json({ Err: "Volume name is required" }, 400); } - const volumeName = body.Name.replace(/^zb-/, ""); + const shortId = body.Name.replace(/^zb-/, ""); + + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.shortId, shortId), + }); + + if (!volume) { + return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404); + } return c.json({ - Mountpoint: getVolumePath(volumeName), + Mountpoint: getVolumePath(volume), }); }) .post("/VolumeDriver.Unmount", (c) => { @@ -48,7 +59,15 @@ export const driverController = new Hono() return c.json({ Err: "Volume name is required" }, 400); } - const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, "")); + const shortId = body.Name.replace(/^zb-/, ""); + + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.shortId, shortId), + }); + + if (!volume) { + return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404); + } return c.json({ Mountpoint: getVolumePath(volume), @@ -61,11 +80,19 @@ export const driverController = new Hono() return c.json({ Err: "Volume name is required" }, 400); } - const { volume } = await volumeService.getVolume(body.Name.replace(/^zb-/, "")); + const shortId = body.Name.replace(/^zb-/, ""); + + const volume = await db.query.volumesTable.findFirst({ + where: eq(volumesTable.shortId, shortId), + }); + + if (!volume) { + return c.json({ Err: `Volume with shortId ${shortId} not found` }, 404); + } return c.json({ Volume: { - Name: `zb-${volume.name}`, + Name: `zb-${volume.shortId}`, Mountpoint: getVolumePath(volume), Status: {}, }, @@ -76,7 +103,7 @@ export const driverController = new Hono() const volumes = await volumeService.listVolumes(); const res = volumes.map((volume) => ({ - Name: `zb-${volume.name}`, + Name: `zb-${volume.shortId}`, Mountpoint: getVolumePath(volume), Status: {}, })); diff --git a/app/server/modules/lifecycle/checkpoint.ts b/app/server/modules/lifecycle/checkpoint.ts new file mode 100644 index 00000000..65e14d03 --- /dev/null +++ b/app/server/modules/lifecycle/checkpoint.ts @@ -0,0 +1,89 @@ +import { eq, sql } from "drizzle-orm"; +import { db } from "../../db/db"; +import { appMetadataTable, usersTable } from "../../db/schema"; +import { logger } from "../../utils/logger"; +import { REQUIRED_MIGRATIONS } from "~/server/core/constants"; + +const MIGRATION_KEY_PREFIX = "migration:"; + +export const recordMigrationCheckpoint = async (version: string): Promise => { + const key = `${MIGRATION_KEY_PREFIX}${version}`; + const now = Math.floor(Date.now() / 1000); + + 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 => { + 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 => { + const userCount = await db.select({ count: sql`count(*)` }).from(usersTable); + const isFreshInstall = userCount[0]?.count === 0; + + if (isFreshInstall) { + logger.info("Fresh installation detected, skipping migration checkpoint validation."); + + REQUIRED_MIGRATIONS.forEach(async (version) => { + 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, + })); +}; diff --git a/app/server/modules/lifecycle/migration.ts b/app/server/modules/lifecycle/migration.ts new file mode 100644 index 00000000..2ff74518 --- /dev/null +++ b/app/server/modules/lifecycle/migration.ts @@ -0,0 +1,148 @@ +import * as fs from "node:fs/promises"; +import * as path from "node:path"; +import { eq } from "drizzle-orm"; +import { db } from "../../db/db"; +import { repositoriesTable } from "../../db/schema"; +import { VOLUME_MOUNT_BASE, REPOSITORY_BASE } from "../../core/constants"; +import { logger } from "../../utils/logger"; +import { hasMigrationCheckpoint, recordMigrationCheckpoint } from "./checkpoint"; +import type { RepositoryConfig } from "~/schemas/restic"; + +const MIGRATION_VERSION = "v0.14.0"; + +export const migrateToShortIds = async () => { + const alreadyMigrated = await hasMigrationCheckpoint(MIGRATION_VERSION); + if (alreadyMigrated) { + logger.debug(`Migration ${MIGRATION_VERSION} already completed, skipping.`); + return; + } + + logger.info(`Starting short ID migration (${MIGRATION_VERSION})...`); + + await migrateVolumeFolders(); + await migrateRepositoryFolders(); + + await recordMigrationCheckpoint(MIGRATION_VERSION); + + logger.info(`Short ID migration (${MIGRATION_VERSION}) complete.`); +}; + +const migrateVolumeFolders = async () => { + const volumes = await db.query.volumesTable.findMany({}); + + for (const volume of volumes) { + if (volume.config.backend === "directory") { + continue; + } + + const oldPath = path.join(VOLUME_MOUNT_BASE, volume.name); + const newPath = path.join(VOLUME_MOUNT_BASE, volume.shortId); + + const oldExists = await pathExists(oldPath); + const newExists = await pathExists(newPath); + + if (oldExists && !newExists) { + try { + logger.info(`Migrating volume folder: ${oldPath} -> ${newPath}`); + await fs.rename(oldPath, newPath); + logger.info(`Successfully migrated volume folder for "${volume.name}"`); + } catch (error) { + logger.error(`Failed to migrate volume folder for "${volume.name}": ${error}`); + } + } else if (oldExists && newExists) { + logger.warn( + `Both old (${oldPath}) and new (${newPath}) paths exist for volume "${volume.name}". Manual intervention may be required.`, + ); + } + } +}; + +const migrateRepositoryFolders = async () => { + const repositories = await db.query.repositoriesTable.findMany({}); + + for (const repo of repositories) { + if (repo.config.backend !== "local") { + continue; + } + + const config = repo.config as Extract; + + if (config.name === repo.shortId) { + continue; + } + + const basePath = config.path || REPOSITORY_BASE; + const oldPath = path.join(basePath, config.name); + const newPath = path.join(basePath, repo.shortId); + + const oldExists = await pathExists(oldPath); + const newExists = await pathExists(newPath); + + if (oldExists && !newExists) { + try { + logger.info(`Migrating repository folder: ${oldPath} -> ${newPath}`); + await fs.rename(oldPath, newPath); + + const updatedConfig: RepositoryConfig = { + ...config, + name: repo.shortId, + }; + + await db + .update(repositoriesTable) + .set({ + config: updatedConfig, + updatedAt: Math.floor(Date.now() / 1000), + }) + .where(eq(repositoriesTable.id, repo.id)); + + logger.info(`Successfully migrated repository folder and config for "${repo.name}"`); + } catch (error) { + logger.error(`Failed to migrate repository folder for "${repo.name}": ${error}`); + } + } else if (oldExists && newExists) { + logger.warn( + `Both old (${oldPath}) and new (${newPath}) paths exist for repository "${repo.name}". Manual intervention may be required.`, + ); + } else if (!oldExists && !newExists) { + logger.info(`Updating config.name for repository "${repo.name}" (no folder exists yet)`); + + const updatedConfig: RepositoryConfig = { + ...config, + name: repo.shortId, + }; + + await db + .update(repositoriesTable) + .set({ + config: updatedConfig, + updatedAt: Math.floor(Date.now() / 1000), + }) + .where(eq(repositoriesTable.id, repo.id)); + } else if (newExists && !oldExists && config.name !== repo.shortId) { + logger.info(`Folder already at new path, updating config.name for repository "${repo.name}"`); + + const updatedConfig: RepositoryConfig = { + ...config, + name: repo.shortId, + }; + + await db + .update(repositoriesTable) + .set({ + config: updatedConfig, + updatedAt: Math.floor(Date.now() / 1000), + }) + .where(eq(repositoriesTable.id, repo.id)); + } + } +}; + +const pathExists = async (p: string): Promise => { + try { + await fs.access(p); + return true; + } catch { + return false; + } +}; diff --git a/app/server/modules/notifications/builders/pushover.ts b/app/server/modules/notifications/builders/pushover.ts index 5f735a09..75c04929 100644 --- a/app/server/modules/notifications/builders/pushover.ts +++ b/app/server/modules/notifications/builders/pushover.ts @@ -1,8 +1,6 @@ import type { NotificationConfig } from "~/schemas/notifications"; -export function buildPushoverShoutrrrUrl( - config: Extract, -): string { +export function buildPushoverShoutrrrUrl(config: Extract): string { const params = new URLSearchParams(); if (config.devices) { diff --git a/app/server/modules/notifications/notifications.service.ts b/app/server/modules/notifications/notifications.service.ts index a3a83c58..13932fac 100644 --- a/app/server/modules/notifications/notifications.service.ts +++ b/app/server/modules/notifications/notifications.service.ts @@ -1,4 +1,4 @@ -import { eq, and } from "drizzle-orm"; +import { eq, and, ne } from "drizzle-orm"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import slugify from "slugify"; import { db } from "../../db/db"; @@ -164,10 +164,10 @@ const updateDestination = async ( const slug = slugify(updates.name, { lower: true, strict: true }); const conflict = await db.query.notificationDestinationsTable.findFirst({ - where: and(eq(notificationDestinationsTable.name, slug), eq(notificationDestinationsTable.id, id)), + where: and(eq(notificationDestinationsTable.name, slug), ne(notificationDestinationsTable.id, id)), }); - if (conflict && conflict.id !== id) { + if (conflict) { throw new ConflictError("Notification destination with this name already exists"); } updateData.name = slug; diff --git a/app/server/modules/repositories/repositories.controller.ts b/app/server/modules/repositories/repositories.controller.ts index e4ed4fc3..c629deca 100644 --- a/app/server/modules/repositories/repositories.controller.ts +++ b/app/server/modules/repositories/repositories.controller.ts @@ -16,6 +16,8 @@ import { listSnapshotsFilters, restoreSnapshotBody, restoreSnapshotDto, + updateRepositoryBody, + updateRepositoryDto, type DeleteRepositoryDto, type DeleteSnapshotDto, type DoctorRepositoryDto, @@ -25,6 +27,7 @@ import { type ListSnapshotFilesDto, type ListSnapshotsDto, type RestoreSnapshotDto, + type UpdateRepositoryDto, } from "./repositories.dto"; import { repositoriesService } from "./repositories.service"; import { getRcloneRemoteInfo, listRcloneRemotes } from "../../utils/rclone"; @@ -152,4 +155,12 @@ export const repositoriesController = new Hono() await repositoriesService.deleteSnapshot(name, snapshotId); return c.json({ message: "Snapshot deleted" }, 200); + }) + .patch("/:name", updateRepositoryDto, validator("json", updateRepositoryBody), async (c) => { + const { name } = c.req.param(); + const body = c.req.valid("json"); + + const res = await repositoriesService.updateRepository(name, body); + + return c.json(res.repository, 200); }); diff --git a/app/server/modules/repositories/repositories.dto.ts b/app/server/modules/repositories/repositories.dto.ts index 8d85808f..e54af333 100644 --- a/app/server/modules/repositories/repositories.dto.ts +++ b/app/server/modules/repositories/repositories.dto.ts @@ -4,6 +4,7 @@ import { COMPRESSION_MODES, REPOSITORY_BACKENDS, REPOSITORY_STATUS, repositoryCo export const repositorySchema = type({ id: "string", + shortId: "string", name: "string", type: type.valueOf(REPOSITORY_BACKENDS), config: repositoryConfigSchema, @@ -123,6 +124,41 @@ export const deleteRepositoryDto = describeRoute({ }, }); +/** + * Update a repository + */ +export const updateRepositoryBody = type({ + name: "string?", + compressionMode: type.valueOf(COMPRESSION_MODES).optional(), +}); + +export type UpdateRepositoryBody = typeof updateRepositoryBody.infer; + +export const updateRepositoryResponse = repositorySchema; +export type UpdateRepositoryDto = typeof updateRepositoryResponse.infer; + +export const updateRepositoryDto = describeRoute({ + description: "Update a repository's name or settings", + tags: ["Repositories"], + operationId: "updateRepository", + responses: { + 200: { + description: "Repository updated successfully", + content: { + "application/json": { + schema: resolver(updateRepositoryResponse), + }, + }, + }, + 404: { + description: "Repository not found", + }, + 409: { + description: "Repository with this name already exists", + }, + }, +}); + /** * List snapshots in a repository */ diff --git a/app/server/modules/repositories/repositories.service.ts b/app/server/modules/repositories/repositories.service.ts index 4c711d57..3ce1be25 100644 --- a/app/server/modules/repositories/repositories.service.ts +++ b/app/server/modules/repositories/repositories.service.ts @@ -1,10 +1,11 @@ import crypto from "node:crypto"; -import { eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import slugify from "slugify"; import { db } from "../../db/db"; import { repositoriesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; +import { generateShortId } from "../../utils/id"; import { restic } from "../../utils/restic"; import { cryptoUtils } from "../../utils/crypto"; import type { CompressionMode, RepositoryConfig } from "~/schemas/restic"; @@ -61,13 +62,20 @@ const createRepository = async (name: string, config: RepositoryConfig, compress } const id = crypto.randomUUID(); + const shortId = generateShortId(); - const encryptedConfig = await encryptConfig(config); + let processedConfig = config; + if (config.backend === "local") { + processedConfig = { ...config, name: shortId }; + } + + const encryptedConfig = await encryptConfig(processedConfig); const [created] = await db .insert(repositoriesTable) .values({ id, + shortId, name: slug, type: config.backend, config: encryptedConfig, @@ -350,11 +358,53 @@ const deleteSnapshot = async (name: string, snapshotId: string) => { await restic.deleteSnapshot(repository.config, snapshotId); }; +const updateRepository = async (name: string, updates: { name?: string; compressionMode?: CompressionMode }) => { + const existing = await db.query.repositoriesTable.findFirst({ + where: eq(repositoriesTable.name, name), + }); + + if (!existing) { + throw new NotFoundError("Repository not found"); + } + + let newName = existing.name; + if (updates.name !== undefined && updates.name !== existing.name) { + const newSlug = slugify(updates.name, { lower: true, strict: true }); + + const conflict = await db.query.repositoriesTable.findFirst({ + where: and(eq(repositoriesTable.name, newSlug), ne(repositoriesTable.id, existing.id)), + }); + + if (conflict) { + throw new ConflictError("A repository with this name already exists"); + } + + newName = newSlug; + } + + const [updated] = await db + .update(repositoriesTable) + .set({ + name: newName, + compressionMode: updates.compressionMode ?? existing.compressionMode, + updatedAt: Math.floor(Date.now() / 1000), + }) + .where(eq(repositoriesTable.id, existing.id)) + .returning(); + + if (!updated) { + throw new InternalServerError("Failed to update repository"); + } + + return { repository: updated }; +}; + export const repositoriesService = { listRepositories, createRepository, getRepository, deleteRepository, + updateRepository, listSnapshots, listSnapshotFiles, restoreSnapshot, diff --git a/app/server/modules/volumes/helpers.ts b/app/server/modules/volumes/helpers.ts index bb272651..a9f8207a 100644 --- a/app/server/modules/volumes/helpers.ts +++ b/app/server/modules/volumes/helpers.ts @@ -6,5 +6,5 @@ export const getVolumePath = (volume: Volume) => { return volume.config.path; } - return `${VOLUME_MOUNT_BASE}/${volume.name}/_data`; + return `${VOLUME_MOUNT_BASE}/${volume.shortId}/_data`; }; diff --git a/app/server/modules/volumes/volume.dto.ts b/app/server/modules/volumes/volume.dto.ts index 7f2ea400..03d61987 100644 --- a/app/server/modules/volumes/volume.dto.ts +++ b/app/server/modules/volumes/volume.dto.ts @@ -4,6 +4,7 @@ import { BACKEND_STATUS, BACKEND_TYPES, volumeConfigSchema } from "~/schemas/vol export const volumeSchema = type({ id: "number", + shortId: "string", name: "string", type: type.valueOf(BACKEND_TYPES), status: type.valueOf(BACKEND_STATUS), @@ -128,6 +129,7 @@ export const getVolumeDto = describeRoute({ * Update a volume */ export const updateVolumeBody = type({ + name: "string?", autoRemount: "boolean?", config: volumeConfigSchema.optional(), }); diff --git a/app/server/modules/volumes/volume.service.ts b/app/server/modules/volumes/volume.service.ts index 269b40fc..7169d6fc 100644 --- a/app/server/modules/volumes/volume.service.ts +++ b/app/server/modules/volumes/volume.service.ts @@ -2,13 +2,14 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import Docker from "dockerode"; -import { eq } from "drizzle-orm"; +import { and, eq, ne } from "drizzle-orm"; import { ConflictError, InternalServerError, NotFoundError } from "http-errors-enhanced"; import slugify from "slugify"; import { getCapabilities } from "../../core/capabilities"; import { db } from "../../db/db"; import { volumesTable } from "../../db/schema"; import { toMessage } from "../../utils/errors"; +import { generateShortId } from "../../utils/id"; import { getStatFs, type StatFs } from "../../utils/mountinfo"; import { withTimeout } from "../../utils/timeout"; import { createVolumeBackend } from "../backends/backend"; @@ -35,9 +36,12 @@ const createVolume = async (name: string, backendConfig: BackendConfig) => { throw new ConflictError("Volume already exists"); } + const shortId = generateShortId(); + const [created] = await db .insert(volumesTable) .values({ + shortId, name: slug, config: backendConfig, type: backendConfig.backend, @@ -147,6 +151,21 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => { throw new NotFoundError("Volume not found"); } + let newName = existing.name; + if (volumeData.name !== undefined && volumeData.name !== existing.name) { + const newSlug = slugify(volumeData.name, { lower: true, strict: true }); + + const conflict = await db.query.volumesTable.findFirst({ + where: and(eq(volumesTable.name, newSlug), ne(volumesTable.id, existing.id)), + }); + + if (conflict) { + throw new ConflictError("A volume with this name already exists"); + } + + newName = newSlug; + } + const configChanged = JSON.stringify(existing.config) !== JSON.stringify(volumeData.config) && volumeData.config !== undefined; @@ -159,12 +178,13 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => { const [updated] = await db .update(volumesTable) .set({ + name: newName, config: volumeData.config, type: volumeData.config?.backend, autoRemount: volumeData.autoRemount, updatedAt: Date.now(), }) - .where(eq(volumesTable.name, name)) + .where(eq(volumesTable.id, existing.id)) .returning(); if (!updated) { @@ -177,9 +197,9 @@ const updateVolume = async (name: string, volumeData: UpdateVolumeBody) => { await db .update(volumesTable) .set({ status, lastError: error ?? null, lastHealthCheck: Date.now() }) - .where(eq(volumesTable.name, name)); + .where(eq(volumesTable.id, existing.id)); - serverEvents.emit("volume:updated", { volumeName: name }); + serverEvents.emit("volume:updated", { volumeName: updated.name }); } return { volume: updated }; @@ -190,6 +210,7 @@ const testConnection = async (backendConfig: BackendConfig) => { const mockVolume = { id: 0, + shortId: "test", name: "test-connection", path: tempDir, config: backendConfig, @@ -264,7 +285,7 @@ const getContainersUsingVolume = async (name: string) => { const container = docker.getContainer(info.Id); const inspect = await container.inspect(); const mounts = inspect.Mounts || []; - const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `im-${volume.name}`); + const usesVolume = mounts.some((mount) => mount.Type === "volume" && mount.Name === `zb-${volume.shortId}`); if (usesVolume) { usingContainers.push({ id: inspect.Id, diff --git a/app/server/utils/id.ts b/app/server/utils/id.ts new file mode 100644 index 00000000..4ecfb662 --- /dev/null +++ b/app/server/utils/id.ts @@ -0,0 +1,6 @@ +import crypto from "node:crypto"; + +export const generateShortId = (length = 5): string => { + const bytesNeeded = Math.ceil((length * 3) / 4); + return crypto.randomBytes(bytesNeeded).toString("base64url").slice(0, length); +};