mirror of
https://github.com/nicotsx/ironmount.git
synced 2025-12-10 12:10:51 +01:00
feat(backend): backup service with retention policy
This commit is contained in:
@@ -23,6 +23,12 @@ import {
|
||||
deleteRepository,
|
||||
getRepository,
|
||||
listSnapshots,
|
||||
listBackupSchedules,
|
||||
createBackupSchedule,
|
||||
deleteBackupSchedule,
|
||||
getBackupSchedule,
|
||||
updateBackupSchedule,
|
||||
runBackupNow,
|
||||
} from "../sdk.gen";
|
||||
import { queryOptions, type UseMutationOptions, type DefaultError } from "@tanstack/react-query";
|
||||
import type {
|
||||
@@ -59,6 +65,16 @@ import type {
|
||||
DeleteRepositoryResponse,
|
||||
GetRepositoryData,
|
||||
ListSnapshotsData,
|
||||
ListBackupSchedulesData,
|
||||
CreateBackupScheduleData,
|
||||
CreateBackupScheduleResponse,
|
||||
DeleteBackupScheduleData,
|
||||
DeleteBackupScheduleResponse,
|
||||
GetBackupScheduleData,
|
||||
UpdateBackupScheduleData,
|
||||
UpdateBackupScheduleResponse,
|
||||
RunBackupNowData,
|
||||
RunBackupNowResponse,
|
||||
} from "../types.gen";
|
||||
import { client as _heyApiClient } from "../client.gen";
|
||||
|
||||
@@ -693,3 +709,174 @@ export const listSnapshotsOptions = (options: Options<ListSnapshotsData>) => {
|
||||
queryKey: listSnapshotsQueryKey(options),
|
||||
});
|
||||
};
|
||||
|
||||
export const listBackupSchedulesQueryKey = (options?: Options<ListBackupSchedulesData>) =>
|
||||
createQueryKey("listBackupSchedules", options);
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
export const listBackupSchedulesOptions = (options?: Options<ListBackupSchedulesData>) => {
|
||||
return queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await listBackupSchedules({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: listBackupSchedulesQueryKey(options),
|
||||
});
|
||||
};
|
||||
|
||||
export const createBackupScheduleQueryKey = (options?: Options<CreateBackupScheduleData>) =>
|
||||
createQueryKey("createBackupSchedule", options);
|
||||
|
||||
/**
|
||||
* Create a new backup schedule for a volume
|
||||
*/
|
||||
export const createBackupScheduleOptions = (options?: Options<CreateBackupScheduleData>) => {
|
||||
return queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await createBackupSchedule({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: createBackupScheduleQueryKey(options),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new backup schedule for a volume
|
||||
*/
|
||||
export const createBackupScheduleMutation = (
|
||||
options?: Partial<Options<CreateBackupScheduleData>>,
|
||||
): UseMutationOptions<CreateBackupScheduleResponse, DefaultError, Options<CreateBackupScheduleData>> => {
|
||||
const mutationOptions: UseMutationOptions<
|
||||
CreateBackupScheduleResponse,
|
||||
DefaultError,
|
||||
Options<CreateBackupScheduleData>
|
||||
> = {
|
||||
mutationFn: async (localOptions) => {
|
||||
const { data } = await createBackupSchedule({
|
||||
...options,
|
||||
...localOptions,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a backup schedule
|
||||
*/
|
||||
export const deleteBackupScheduleMutation = (
|
||||
options?: Partial<Options<DeleteBackupScheduleData>>,
|
||||
): UseMutationOptions<DeleteBackupScheduleResponse, DefaultError, Options<DeleteBackupScheduleData>> => {
|
||||
const mutationOptions: UseMutationOptions<
|
||||
DeleteBackupScheduleResponse,
|
||||
DefaultError,
|
||||
Options<DeleteBackupScheduleData>
|
||||
> = {
|
||||
mutationFn: async (localOptions) => {
|
||||
const { data } = await deleteBackupSchedule({
|
||||
...options,
|
||||
...localOptions,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const getBackupScheduleQueryKey = (options: Options<GetBackupScheduleData>) =>
|
||||
createQueryKey("getBackupSchedule", options);
|
||||
|
||||
/**
|
||||
* Get a backup schedule by ID
|
||||
*/
|
||||
export const getBackupScheduleOptions = (options: Options<GetBackupScheduleData>) => {
|
||||
return queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await getBackupSchedule({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: getBackupScheduleQueryKey(options),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a backup schedule
|
||||
*/
|
||||
export const updateBackupScheduleMutation = (
|
||||
options?: Partial<Options<UpdateBackupScheduleData>>,
|
||||
): UseMutationOptions<UpdateBackupScheduleResponse, DefaultError, Options<UpdateBackupScheduleData>> => {
|
||||
const mutationOptions: UseMutationOptions<
|
||||
UpdateBackupScheduleResponse,
|
||||
DefaultError,
|
||||
Options<UpdateBackupScheduleData>
|
||||
> = {
|
||||
mutationFn: async (localOptions) => {
|
||||
const { data } = await updateBackupSchedule({
|
||||
...options,
|
||||
...localOptions,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
export const runBackupNowQueryKey = (options: Options<RunBackupNowData>) => createQueryKey("runBackupNow", options);
|
||||
|
||||
/**
|
||||
* Trigger a backup immediately for a schedule
|
||||
*/
|
||||
export const runBackupNowOptions = (options: Options<RunBackupNowData>) => {
|
||||
return queryOptions({
|
||||
queryFn: async ({ queryKey, signal }) => {
|
||||
const { data } = await runBackupNow({
|
||||
...options,
|
||||
...queryKey[0],
|
||||
signal,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
queryKey: runBackupNowQueryKey(options),
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger a backup immediately for a schedule
|
||||
*/
|
||||
export const runBackupNowMutation = (
|
||||
options?: Partial<Options<RunBackupNowData>>,
|
||||
): UseMutationOptions<RunBackupNowResponse, DefaultError, Options<RunBackupNowData>> => {
|
||||
const mutationOptions: UseMutationOptions<RunBackupNowResponse, DefaultError, Options<RunBackupNowData>> = {
|
||||
mutationFn: async (localOptions) => {
|
||||
const { data } = await runBackupNow({
|
||||
...options,
|
||||
...localOptions,
|
||||
throwOnError: true,
|
||||
});
|
||||
return data;
|
||||
},
|
||||
};
|
||||
return mutationOptions;
|
||||
};
|
||||
|
||||
@@ -54,6 +54,18 @@ import type {
|
||||
GetRepositoryResponses,
|
||||
ListSnapshotsData,
|
||||
ListSnapshotsResponses,
|
||||
ListBackupSchedulesData,
|
||||
ListBackupSchedulesResponses,
|
||||
CreateBackupScheduleData,
|
||||
CreateBackupScheduleResponses,
|
||||
DeleteBackupScheduleData,
|
||||
DeleteBackupScheduleResponses,
|
||||
GetBackupScheduleData,
|
||||
GetBackupScheduleResponses,
|
||||
UpdateBackupScheduleData,
|
||||
UpdateBackupScheduleResponses,
|
||||
RunBackupNowData,
|
||||
RunBackupNowResponses,
|
||||
} from "./types.gen";
|
||||
import { client as _heyApiClient } from "./client.gen";
|
||||
|
||||
@@ -335,3 +347,83 @@ export const listSnapshots = <ThrowOnError extends boolean = false>(
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
export const listBackupSchedules = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<ListBackupSchedulesData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).get<ListBackupSchedulesResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Create a new backup schedule for a volume
|
||||
*/
|
||||
export const createBackupSchedule = <ThrowOnError extends boolean = false>(
|
||||
options?: Options<CreateBackupScheduleData, ThrowOnError>,
|
||||
) => {
|
||||
return (options?.client ?? _heyApiClient).post<CreateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options?.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete a backup schedule
|
||||
*/
|
||||
export const deleteBackupSchedule = <ThrowOnError extends boolean = false>(
|
||||
options: Options<DeleteBackupScheduleData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? _heyApiClient).delete<DeleteBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups/{scheduleId}",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Get a backup schedule by ID
|
||||
*/
|
||||
export const getBackupSchedule = <ThrowOnError extends boolean = false>(
|
||||
options: Options<GetBackupScheduleData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? _heyApiClient).get<GetBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups/{scheduleId}",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Update a backup schedule
|
||||
*/
|
||||
export const updateBackupSchedule = <ThrowOnError extends boolean = false>(
|
||||
options: Options<UpdateBackupScheduleData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? _heyApiClient).patch<UpdateBackupScheduleResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups/{scheduleId}",
|
||||
...options,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...options.headers,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Trigger a backup immediately for a schedule
|
||||
*/
|
||||
export const runBackupNow = <ThrowOnError extends boolean = false>(
|
||||
options: Options<RunBackupNowData, ThrowOnError>,
|
||||
) => {
|
||||
return (options.client ?? _heyApiClient).post<RunBackupNowResponses, unknown, ThrowOnError>({
|
||||
url: "/api/v1/backups/{scheduleId}/run",
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -804,6 +804,258 @@ export type ListSnapshotsResponses = {
|
||||
|
||||
export type ListSnapshotsResponse = ListSnapshotsResponses[keyof ListSnapshotsResponses];
|
||||
|
||||
export type ListBackupSchedulesData = {
|
||||
body?: never;
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: "/api/v1/backups";
|
||||
};
|
||||
|
||||
export type ListBackupSchedulesResponses = {
|
||||
/**
|
||||
* List of backup schedules
|
||||
*/
|
||||
200: {
|
||||
schedules: Array<{
|
||||
createdAt: number;
|
||||
cronExpression: string;
|
||||
enabled: boolean;
|
||||
excludePatterns: Array<string>;
|
||||
id: number;
|
||||
includePatterns: Array<string>;
|
||||
lastBackupAt: number | null;
|
||||
lastBackupError: string | null;
|
||||
lastBackupStatus: "error" | "success" | null;
|
||||
nextBackupAt: number | null;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
retentionPolicy: {
|
||||
keepDaily?: number;
|
||||
keepHourly?: number;
|
||||
keepLast?: number;
|
||||
keepMonthly?: number;
|
||||
keepWeekly?: number;
|
||||
keepWithinDuration?: string;
|
||||
keepYearly?: number;
|
||||
} | null;
|
||||
updatedAt: number;
|
||||
volumeId: number;
|
||||
volumeName: string;
|
||||
}>;
|
||||
};
|
||||
};
|
||||
|
||||
export type ListBackupSchedulesResponse = ListBackupSchedulesResponses[keyof ListBackupSchedulesResponses];
|
||||
|
||||
export type CreateBackupScheduleData = {
|
||||
body?: {
|
||||
cronExpression: string;
|
||||
enabled: boolean;
|
||||
repositoryId: string;
|
||||
volumeId: number;
|
||||
excludePatterns?: Array<string>;
|
||||
includePatterns?: Array<string>;
|
||||
retentionPolicy?: {
|
||||
keepDaily?: number;
|
||||
keepHourly?: number;
|
||||
keepLast?: number;
|
||||
keepMonthly?: number;
|
||||
keepWeekly?: number;
|
||||
keepWithinDuration?: string;
|
||||
keepYearly?: number;
|
||||
};
|
||||
tags?: Array<string>;
|
||||
};
|
||||
path?: never;
|
||||
query?: never;
|
||||
url: "/api/v1/backups";
|
||||
};
|
||||
|
||||
export type CreateBackupScheduleResponses = {
|
||||
/**
|
||||
* Backup schedule created successfully
|
||||
*/
|
||||
201: {
|
||||
message: string;
|
||||
schedule: {
|
||||
createdAt: number;
|
||||
cronExpression: string;
|
||||
enabled: boolean;
|
||||
excludePatterns: Array<string>;
|
||||
id: number;
|
||||
includePatterns: Array<string>;
|
||||
lastBackupAt: number | null;
|
||||
lastBackupError: string | null;
|
||||
lastBackupStatus: "error" | "success" | null;
|
||||
nextBackupAt: number | null;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
retentionPolicy: {
|
||||
keepDaily?: number;
|
||||
keepHourly?: number;
|
||||
keepLast?: number;
|
||||
keepMonthly?: number;
|
||||
keepWeekly?: number;
|
||||
keepWithinDuration?: string;
|
||||
keepYearly?: number;
|
||||
} | null;
|
||||
updatedAt: number;
|
||||
volumeId: number;
|
||||
volumeName: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type CreateBackupScheduleResponse = CreateBackupScheduleResponses[keyof CreateBackupScheduleResponses];
|
||||
|
||||
export type DeleteBackupScheduleData = {
|
||||
body?: never;
|
||||
path: {
|
||||
scheduleId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/api/v1/backups/{scheduleId}";
|
||||
};
|
||||
|
||||
export type DeleteBackupScheduleResponses = {
|
||||
/**
|
||||
* Backup schedule deleted successfully
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type DeleteBackupScheduleResponse = DeleteBackupScheduleResponses[keyof DeleteBackupScheduleResponses];
|
||||
|
||||
export type GetBackupScheduleData = {
|
||||
body?: never;
|
||||
path: {
|
||||
scheduleId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/api/v1/backups/{scheduleId}";
|
||||
};
|
||||
|
||||
export type GetBackupScheduleResponses = {
|
||||
/**
|
||||
* Backup schedule details
|
||||
*/
|
||||
200: {
|
||||
schedule: {
|
||||
createdAt: number;
|
||||
cronExpression: string;
|
||||
enabled: boolean;
|
||||
excludePatterns: Array<string>;
|
||||
id: number;
|
||||
includePatterns: Array<string>;
|
||||
lastBackupAt: number | null;
|
||||
lastBackupError: string | null;
|
||||
lastBackupStatus: "error" | "success" | null;
|
||||
nextBackupAt: number | null;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
retentionPolicy: {
|
||||
keepDaily?: number;
|
||||
keepHourly?: number;
|
||||
keepLast?: number;
|
||||
keepMonthly?: number;
|
||||
keepWeekly?: number;
|
||||
keepWithinDuration?: string;
|
||||
keepYearly?: number;
|
||||
} | null;
|
||||
updatedAt: number;
|
||||
volumeId: number;
|
||||
volumeName: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type GetBackupScheduleResponse = GetBackupScheduleResponses[keyof GetBackupScheduleResponses];
|
||||
|
||||
export type UpdateBackupScheduleData = {
|
||||
body?: {
|
||||
cronExpression?: string;
|
||||
enabled?: boolean;
|
||||
excludePatterns?: Array<string>;
|
||||
includePatterns?: Array<string>;
|
||||
repositoryId?: string;
|
||||
retentionPolicy?: {
|
||||
keepDaily?: number;
|
||||
keepHourly?: number;
|
||||
keepLast?: number;
|
||||
keepMonthly?: number;
|
||||
keepWeekly?: number;
|
||||
keepWithinDuration?: string;
|
||||
keepYearly?: number;
|
||||
};
|
||||
tags?: Array<string>;
|
||||
};
|
||||
path: {
|
||||
scheduleId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/api/v1/backups/{scheduleId}";
|
||||
};
|
||||
|
||||
export type UpdateBackupScheduleResponses = {
|
||||
/**
|
||||
* Backup schedule updated successfully
|
||||
*/
|
||||
200: {
|
||||
message: string;
|
||||
schedule: {
|
||||
createdAt: number;
|
||||
cronExpression: string;
|
||||
enabled: boolean;
|
||||
excludePatterns: Array<string>;
|
||||
id: number;
|
||||
includePatterns: Array<string>;
|
||||
lastBackupAt: number | null;
|
||||
lastBackupError: string | null;
|
||||
lastBackupStatus: "error" | "success" | null;
|
||||
nextBackupAt: number | null;
|
||||
repositoryId: string;
|
||||
repositoryName: string;
|
||||
retentionPolicy: {
|
||||
keepDaily?: number;
|
||||
keepHourly?: number;
|
||||
keepLast?: number;
|
||||
keepMonthly?: number;
|
||||
keepWeekly?: number;
|
||||
keepWithinDuration?: string;
|
||||
keepYearly?: number;
|
||||
} | null;
|
||||
updatedAt: number;
|
||||
volumeId: number;
|
||||
volumeName: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export type UpdateBackupScheduleResponse = UpdateBackupScheduleResponses[keyof UpdateBackupScheduleResponses];
|
||||
|
||||
export type RunBackupNowData = {
|
||||
body?: never;
|
||||
path: {
|
||||
scheduleId: string;
|
||||
};
|
||||
query?: never;
|
||||
url: "/api/v1/backups/{scheduleId}/run";
|
||||
};
|
||||
|
||||
export type RunBackupNowResponses = {
|
||||
/**
|
||||
* Backup started successfully
|
||||
*/
|
||||
200: {
|
||||
backupStarted: boolean;
|
||||
message: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type RunBackupNowResponse = RunBackupNowResponses[keyof RunBackupNowResponses];
|
||||
|
||||
export type ClientOptions = {
|
||||
baseUrl: "http://192.168.2.42:4096" | (string & {});
|
||||
};
|
||||
|
||||
20
apps/server/drizzle/0008_silent_lady_bullseye.sql
Normal file
20
apps/server/drizzle/0008_silent_lady_bullseye.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
CREATE TABLE IF NOT EXISTS `backup_schedules_table` (
|
||||
`id` integer PRIMARY KEY AUTOINCREMENT NOT NULL,
|
||||
`volume_id` integer NOT NULL,
|
||||
`repository_id` text NOT NULL,
|
||||
`enabled` integer DEFAULT true NOT NULL,
|
||||
`cron_expression` text NOT NULL,
|
||||
`retention_policy` text,
|
||||
`exclude_patterns` text DEFAULT '[]',
|
||||
`include_patterns` text DEFAULT '[]',
|
||||
`last_backup_at` integer,
|
||||
`last_backup_status` text,
|
||||
`last_backup_error` text,
|
||||
`next_backup_at` integer,
|
||||
`created_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
`updated_at` integer DEFAULT (unixepoch()) NOT NULL,
|
||||
FOREIGN KEY (`volume_id`) REFERENCES `volumes_table`(`id`) ON UPDATE no action ON DELETE cascade,
|
||||
FOREIGN KEY (`repository_id`) REFERENCES `repositories_table`(`id`) ON UPDATE no action ON DELETE cascade
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS `backup_schedules_table_volume_id_unique` ON `backup_schedules_table` (`volume_id`);
|
||||
459
apps/server/drizzle/meta/0008_snapshot.json
Normal file
459
apps/server/drizzle/meta/0008_snapshot.json
Normal file
@@ -0,0 +1,459 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "6e35f329-5431-47fd-8862-8fb06b0afe4b",
|
||||
"prevId": "866b1d3b-454b-4cf7-9835-a0f60d048b6e",
|
||||
"tables": {
|
||||
"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": {
|
||||
"backup_schedules_table_volume_id_unique": {
|
||||
"name": "backup_schedules_table_volume_id_unique",
|
||||
"columns": [
|
||||
"volume_id"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"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": {}
|
||||
},
|
||||
"repositories_table": {
|
||||
"name": "repositories_table",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"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_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
|
||||
},
|
||||
"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
|
||||
},
|
||||
"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_name_unique": {
|
||||
"name": "volumes_table_name_unique",
|
||||
"columns": [
|
||||
"name"
|
||||
],
|
||||
"isUnique": true
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,13 @@
|
||||
"when": 1761224911352,
|
||||
"tag": "0007_watery_sersi",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 8,
|
||||
"version": "6",
|
||||
"when": 1761414054481,
|
||||
"tag": "0008_silent_lady_bullseye",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
"@ironmount/schemas": "workspace:*",
|
||||
"@scalar/hono-api-reference": "^0.9.13",
|
||||
"arktype": "^2.1.23",
|
||||
"cron-parser": "^5.4.0",
|
||||
"dockerode": "^4.0.8",
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
|
||||
@@ -58,3 +58,35 @@ export const repositoriesTable = sqliteTable("repositories_table", {
|
||||
});
|
||||
|
||||
export type Repository = typeof repositoriesTable.$inferSelect;
|
||||
|
||||
export const backupSchedulesTable = sqliteTable("backup_schedules_table", {
|
||||
id: int().primaryKey({ autoIncrement: true }),
|
||||
volumeId: int("volume_id")
|
||||
.notNull()
|
||||
.unique()
|
||||
.references(() => volumesTable.id, { onDelete: "cascade" }),
|
||||
repositoryId: text("repository_id")
|
||||
.notNull()
|
||||
.references(() => repositoriesTable.id, { onDelete: "cascade" }),
|
||||
enabled: int("enabled", { mode: "boolean" }).notNull().default(true),
|
||||
cronExpression: text("cron_expression").notNull(),
|
||||
retentionPolicy: text("retention_policy", { mode: "json" }).$type<{
|
||||
keepLast?: number;
|
||||
keepHourly?: number;
|
||||
keepDaily?: number;
|
||||
keepWeekly?: number;
|
||||
keepMonthly?: number;
|
||||
keepYearly?: number;
|
||||
keepWithinDuration?: string;
|
||||
}>(),
|
||||
excludePatterns: text("exclude_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||
includePatterns: text("include_patterns", { mode: "json" }).$type<string[]>().default([]),
|
||||
lastBackupAt: int("last_backup_at", { mode: "timestamp" }),
|
||||
lastBackupStatus: text("last_backup_status").$type<"success" | "error">(),
|
||||
lastBackupError: text("last_backup_error"),
|
||||
nextBackupAt: int("next_backup_at", { mode: "timestamp" }),
|
||||
createdAt: int("created_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||
updatedAt: int("updated_at", { mode: "timestamp" }).notNull().default(sql`(unixepoch())`),
|
||||
});
|
||||
|
||||
export type BackupSchedule = typeof backupSchedulesTable.$inferSelect;
|
||||
|
||||
@@ -11,6 +11,7 @@ import { driverController } from "./modules/driver/driver.controller";
|
||||
import { startup } from "./modules/lifecycle/startup";
|
||||
import { repositoriesController } from "./modules/repositories/repositories.controller";
|
||||
import { volumeController } from "./modules/volumes/volume.controller";
|
||||
import { backupScheduleController } from "./modules/backups/backups.controller";
|
||||
import { handleServiceError } from "./utils/errors";
|
||||
import { logger } from "./utils/logger";
|
||||
|
||||
@@ -39,6 +40,7 @@ const app = new Hono()
|
||||
.route("/api/v1/auth", authController.basePath("/api/v1"))
|
||||
.route("/api/v1/volumes", volumeController.use(requireAuth))
|
||||
.route("/api/v1/repositories", repositoriesController.use(requireAuth))
|
||||
.route("/api/v1/backups", backupScheduleController.use(requireAuth))
|
||||
.get("/assets/*", serveStatic({ root: "./assets/frontend" }))
|
||||
.get("*", serveStatic({ path: "./assets/frontend/index.html" }));
|
||||
|
||||
|
||||
29
apps/server/src/jobs/backup-execution.ts
Normal file
29
apps/server/src/jobs/backup-execution.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Job } from "../core/scheduler";
|
||||
import { backupsService } from "../modules/backups/backups.service";
|
||||
import { toMessage } from "../utils/errors";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class BackupExecutionJob extends Job {
|
||||
async run() {
|
||||
logger.debug("Checking for backup schedules to execute...");
|
||||
|
||||
const scheduleIds = await backupsService.getSchedulesToExecute();
|
||||
|
||||
if (scheduleIds.length === 0) {
|
||||
logger.debug("No backup schedules to execute");
|
||||
return { done: true, timestamp: new Date(), executed: 0 };
|
||||
}
|
||||
|
||||
logger.info(`Found ${scheduleIds.length} backup schedule(s) to execute`);
|
||||
|
||||
for (const scheduleId of scheduleIds) {
|
||||
try {
|
||||
await backupsService.executeBackup(scheduleId);
|
||||
} catch (error) {
|
||||
logger.error(`Failed to execute backup for schedule ${scheduleId}: ${toMessage(error)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { done: true, timestamp: new Date(), executed: scheduleIds.length };
|
||||
}
|
||||
}
|
||||
64
apps/server/src/modules/backups/backups.controller.ts
Normal file
64
apps/server/src/modules/backups/backups.controller.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Hono } from "hono";
|
||||
import { validator } from "hono-openapi";
|
||||
import {
|
||||
createBackupScheduleBody,
|
||||
createBackupScheduleDto,
|
||||
deleteBackupScheduleDto,
|
||||
getBackupScheduleDto,
|
||||
listBackupSchedulesDto,
|
||||
runBackupNowDto,
|
||||
updateBackupScheduleBody,
|
||||
updateBackupScheduleDto,
|
||||
} from "./backups.dto";
|
||||
import { backupsService } from "./backups.service";
|
||||
|
||||
export const backupScheduleController = new Hono()
|
||||
.get("/", listBackupSchedulesDto, async (c) => {
|
||||
const schedules = await backupsService.listSchedules();
|
||||
|
||||
return c.json({ schedules }, 200);
|
||||
})
|
||||
.get("/:scheduleId", getBackupScheduleDto, async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
|
||||
const schedule = await backupsService.getSchedule(Number(scheduleId));
|
||||
|
||||
return c.json({ schedule }, 200);
|
||||
})
|
||||
.post("/", createBackupScheduleDto, validator("json", createBackupScheduleBody), async (c) => {
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const schedule = await backupsService.createSchedule(body);
|
||||
|
||||
return c.json({ message: "Backup schedule created successfully", schedule }, 201);
|
||||
})
|
||||
.patch("/:scheduleId", updateBackupScheduleDto, validator("json", updateBackupScheduleBody), async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
const body = c.req.valid("json");
|
||||
|
||||
const schedule = await backupsService.updateSchedule(Number(scheduleId), body);
|
||||
|
||||
return c.json({ message: "Backup schedule updated successfully", schedule }, 200);
|
||||
})
|
||||
.delete("/:scheduleId", deleteBackupScheduleDto, async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
|
||||
await backupsService.deleteSchedule(Number(scheduleId));
|
||||
|
||||
return c.json({ message: "Backup schedule deleted successfully" }, 200);
|
||||
})
|
||||
.post("/:scheduleId/run", runBackupNowDto, async (c) => {
|
||||
const scheduleId = c.req.param("scheduleId");
|
||||
|
||||
backupsService.executeBackup(Number(scheduleId)).catch((error) => {
|
||||
console.error("Backup execution failed:", error);
|
||||
});
|
||||
|
||||
return c.json(
|
||||
{
|
||||
message: "Backup started",
|
||||
backupStarted: true,
|
||||
},
|
||||
200,
|
||||
);
|
||||
});
|
||||
205
apps/server/src/modules/backups/backups.dto.ts
Normal file
205
apps/server/src/modules/backups/backups.dto.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { type } from "arktype";
|
||||
import { describeRoute, resolver } from "hono-openapi";
|
||||
|
||||
const retentionPolicySchema = type({
|
||||
keepLast: "number?",
|
||||
keepHourly: "number?",
|
||||
keepDaily: "number?",
|
||||
keepWeekly: "number?",
|
||||
keepMonthly: "number?",
|
||||
keepYearly: "number?",
|
||||
keepWithinDuration: "string?",
|
||||
});
|
||||
|
||||
export type RetentionPolicy = typeof retentionPolicySchema.infer;
|
||||
|
||||
const backupScheduleSchema = type({
|
||||
id: "number",
|
||||
volumeId: "number",
|
||||
volumeName: "string",
|
||||
repositoryId: "string",
|
||||
repositoryName: "string",
|
||||
enabled: "boolean",
|
||||
cronExpression: "string",
|
||||
retentionPolicy: retentionPolicySchema.or("null"),
|
||||
excludePatterns: "string[]",
|
||||
includePatterns: "string[]",
|
||||
lastBackupAt: "number | null",
|
||||
lastBackupStatus: "'success' | 'error' | null",
|
||||
lastBackupError: "string | null",
|
||||
nextBackupAt: "number | null",
|
||||
createdAt: "number",
|
||||
updatedAt: "number",
|
||||
});
|
||||
|
||||
export type BackupScheduleDto = typeof backupScheduleSchema.infer;
|
||||
|
||||
/**
|
||||
* List all backup schedules
|
||||
*/
|
||||
export const listBackupSchedulesResponse = type({
|
||||
schedules: backupScheduleSchema.array(),
|
||||
});
|
||||
|
||||
export type ListBackupSchedulesResponseDto = typeof listBackupSchedulesResponse.infer;
|
||||
|
||||
export const listBackupSchedulesDto = describeRoute({
|
||||
description: "List all backup schedules",
|
||||
tags: ["Backups"],
|
||||
operationId: "listBackupSchedules",
|
||||
responses: {
|
||||
200: {
|
||||
description: "List of backup schedules",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(listBackupSchedulesResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Get a single backup schedule
|
||||
*/
|
||||
export const getBackupScheduleResponse = type({
|
||||
schedule: backupScheduleSchema,
|
||||
});
|
||||
|
||||
export type GetBackupScheduleResponseDto = typeof getBackupScheduleResponse.infer;
|
||||
|
||||
export const getBackupScheduleDto = describeRoute({
|
||||
description: "Get a backup schedule by ID",
|
||||
tags: ["Backups"],
|
||||
operationId: "getBackupSchedule",
|
||||
responses: {
|
||||
200: {
|
||||
description: "Backup schedule details",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(getBackupScheduleResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Create a new backup schedule
|
||||
*/
|
||||
export const createBackupScheduleBody = type({
|
||||
volumeId: "number",
|
||||
repositoryId: "string",
|
||||
enabled: "boolean",
|
||||
cronExpression: "string",
|
||||
retentionPolicy: retentionPolicySchema.optional(),
|
||||
excludePatterns: "string[]?",
|
||||
includePatterns: "string[]?",
|
||||
tags: "string[]?",
|
||||
});
|
||||
|
||||
export type CreateBackupScheduleBody = typeof createBackupScheduleBody.infer;
|
||||
|
||||
export const createBackupScheduleResponse = type({
|
||||
message: "string",
|
||||
schedule: backupScheduleSchema,
|
||||
});
|
||||
|
||||
export const createBackupScheduleDto = describeRoute({
|
||||
description: "Create a new backup schedule for a volume",
|
||||
operationId: "createBackupSchedule",
|
||||
tags: ["Backups"],
|
||||
responses: {
|
||||
201: {
|
||||
description: "Backup schedule created successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(createBackupScheduleResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Update a backup schedule
|
||||
*/
|
||||
export const updateBackupScheduleBody = type({
|
||||
repositoryId: "string?",
|
||||
enabled: "boolean?",
|
||||
cronExpression: "string?",
|
||||
retentionPolicy: retentionPolicySchema.optional(),
|
||||
excludePatterns: "string[]?",
|
||||
includePatterns: "string[]?",
|
||||
tags: "string[]?",
|
||||
});
|
||||
|
||||
export type UpdateBackupScheduleBody = typeof updateBackupScheduleBody.infer;
|
||||
|
||||
export const updateBackupScheduleResponse = type({
|
||||
message: "string",
|
||||
schedule: backupScheduleSchema,
|
||||
});
|
||||
|
||||
export const updateBackupScheduleDto = describeRoute({
|
||||
description: "Update a backup schedule",
|
||||
operationId: "updateBackupSchedule",
|
||||
tags: ["Backups"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Backup schedule updated successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(updateBackupScheduleResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Delete a backup schedule
|
||||
*/
|
||||
export const deleteBackupScheduleResponse = type({
|
||||
message: "string",
|
||||
});
|
||||
|
||||
export const deleteBackupScheduleDto = describeRoute({
|
||||
description: "Delete a backup schedule",
|
||||
operationId: "deleteBackupSchedule",
|
||||
tags: ["Backups"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Backup schedule deleted successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(deleteBackupScheduleResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Run a backup immediately
|
||||
*/
|
||||
export const runBackupNowResponse = type({
|
||||
message: "string",
|
||||
backupStarted: "boolean",
|
||||
});
|
||||
|
||||
export const runBackupNowDto = describeRoute({
|
||||
description: "Trigger a backup immediately for a schedule",
|
||||
operationId: "runBackupNow",
|
||||
tags: ["Backups"],
|
||||
responses: {
|
||||
200: {
|
||||
description: "Backup started successfully",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: resolver(runBackupNowResponse),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
258
apps/server/src/modules/backups/backups.service.ts
Normal file
258
apps/server/src/modules/backups/backups.service.ts
Normal file
@@ -0,0 +1,258 @@
|
||||
import { eq } from "drizzle-orm";
|
||||
import cron from "node-cron";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import { NotFoundError, BadRequestError } from "http-errors-enhanced";
|
||||
import { db } from "../../db/db";
|
||||
import { backupSchedulesTable, repositoriesTable, volumesTable } from "../../db/schema";
|
||||
import { restic } from "../../utils/restic";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { getVolumePath } from "../volumes/helpers";
|
||||
import type { CreateBackupScheduleBody, UpdateBackupScheduleBody } from "./backups.dto";
|
||||
import { toMessage } from "../../utils/errors";
|
||||
|
||||
const calculateNextRun = (cronExpression: string): Date => {
|
||||
try {
|
||||
const interval = CronExpressionParser.parse(cronExpression, {
|
||||
currentDate: new Date(),
|
||||
tz: "UTC",
|
||||
});
|
||||
|
||||
return interval.next().toDate();
|
||||
} catch (error) {
|
||||
logger.error(`Failed to parse cron expression "${cronExpression}": ${error}`);
|
||||
const fallback = new Date();
|
||||
fallback.setMinutes(fallback.getMinutes() + 1);
|
||||
return fallback;
|
||||
}
|
||||
};
|
||||
|
||||
const listSchedules = async () => {
|
||||
const schedules = await db.query.backupSchedulesTable.findMany({});
|
||||
return schedules;
|
||||
};
|
||||
|
||||
const getSchedule = async (scheduleId: number) => {
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(volumesTable.id, scheduleId),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
return schedule;
|
||||
};
|
||||
|
||||
const createSchedule = async (data: CreateBackupScheduleBody) => {
|
||||
if (!cron.validate(data.cronExpression)) {
|
||||
throw new BadRequestError("Invalid cron expression");
|
||||
}
|
||||
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.id, data.volumeId),
|
||||
});
|
||||
|
||||
if (!volume) {
|
||||
throw new NotFoundError("Volume not found");
|
||||
}
|
||||
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, data.repositoryId),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
const existingSchedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.volumeId, data.volumeId),
|
||||
});
|
||||
|
||||
if (existingSchedule) {
|
||||
throw new BadRequestError("Volume already has a backup schedule");
|
||||
}
|
||||
|
||||
const nextBackupAt = calculateNextRun(data.cronExpression);
|
||||
|
||||
const [newSchedule] = await db
|
||||
.insert(backupSchedulesTable)
|
||||
.values({
|
||||
volumeId: data.volumeId,
|
||||
repositoryId: data.repositoryId,
|
||||
enabled: data.enabled,
|
||||
cronExpression: data.cronExpression,
|
||||
retentionPolicy: data.retentionPolicy ?? null,
|
||||
excludePatterns: data.excludePatterns ?? [],
|
||||
includePatterns: data.includePatterns ?? [],
|
||||
nextBackupAt: nextBackupAt,
|
||||
})
|
||||
.returning();
|
||||
|
||||
if (!newSchedule) {
|
||||
throw new Error("Failed to create backup schedule");
|
||||
}
|
||||
|
||||
return newSchedule;
|
||||
};
|
||||
|
||||
const updateSchedule = async (scheduleId: number, data: UpdateBackupScheduleBody) => {
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
if (data.cronExpression && !cron.validate(data.cronExpression)) {
|
||||
throw new BadRequestError("Invalid cron expression");
|
||||
}
|
||||
|
||||
if (data.repositoryId) {
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, data.repositoryId),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
}
|
||||
|
||||
const cronExpression = data.cronExpression ?? schedule.cronExpression;
|
||||
const nextBackupAt = data.cronExpression ? calculateNextRun(cronExpression) : schedule.nextBackupAt;
|
||||
|
||||
const [updated] = await db
|
||||
.update(backupSchedulesTable)
|
||||
.set({ ...data, nextBackupAt, updatedAt: new Date() })
|
||||
.where(eq(backupSchedulesTable.id, scheduleId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error("Failed to update backup schedule");
|
||||
}
|
||||
|
||||
return updated;
|
||||
};
|
||||
|
||||
const deleteSchedule = async (scheduleId: number) => {
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
await db.delete(backupSchedulesTable).where(eq(backupSchedulesTable.id, scheduleId));
|
||||
};
|
||||
|
||||
const executeBackup = async (scheduleId: number) => {
|
||||
const schedule = await db.query.backupSchedulesTable.findFirst({
|
||||
where: eq(backupSchedulesTable.id, scheduleId),
|
||||
});
|
||||
|
||||
if (!schedule) {
|
||||
throw new NotFoundError("Backup schedule not found");
|
||||
}
|
||||
|
||||
const volume = await db.query.volumesTable.findFirst({
|
||||
where: eq(volumesTable.id, schedule.volumeId),
|
||||
});
|
||||
|
||||
if (!volume) {
|
||||
throw new NotFoundError("Volume not found");
|
||||
}
|
||||
|
||||
const repository = await db.query.repositoriesTable.findFirst({
|
||||
where: eq(repositoriesTable.id, schedule.repositoryId),
|
||||
});
|
||||
|
||||
if (!repository) {
|
||||
throw new NotFoundError("Repository not found");
|
||||
}
|
||||
|
||||
if (volume.status !== "mounted") {
|
||||
throw new BadRequestError("Volume is not mounted");
|
||||
}
|
||||
|
||||
logger.info(`Starting backup for volume ${volume.name} to repository ${repository.name}`);
|
||||
|
||||
try {
|
||||
const volumePath = getVolumePath(volume.name);
|
||||
|
||||
const backupOptions: {
|
||||
exclude?: string[];
|
||||
include?: string[];
|
||||
tags?: string[];
|
||||
} = {};
|
||||
|
||||
if (schedule.excludePatterns && schedule.excludePatterns.length > 0) {
|
||||
backupOptions.exclude = schedule.excludePatterns;
|
||||
}
|
||||
|
||||
if (schedule.includePatterns && schedule.includePatterns.length > 0) {
|
||||
backupOptions.include = schedule.includePatterns;
|
||||
}
|
||||
|
||||
await restic.backup(repository.config, volumePath, backupOptions);
|
||||
|
||||
if (schedule.retentionPolicy) {
|
||||
await restic.forget(repository.config, schedule.retentionPolicy);
|
||||
}
|
||||
|
||||
const nextBackupAt = calculateNextRun(schedule.cronExpression);
|
||||
await db
|
||||
.update(backupSchedulesTable)
|
||||
.set({
|
||||
lastBackupAt: new Date(),
|
||||
lastBackupStatus: "success",
|
||||
lastBackupError: null,
|
||||
nextBackupAt: nextBackupAt,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||
|
||||
logger.info(`Backup completed successfully for volume ${volume.name} to repository ${repository.name}`);
|
||||
} catch (error) {
|
||||
logger.error(`Backup failed for volume ${volume.name} to repository ${repository.name}: ${toMessage(error)}`);
|
||||
|
||||
await db
|
||||
.update(backupSchedulesTable)
|
||||
.set({
|
||||
lastBackupAt: new Date(),
|
||||
lastBackupStatus: "error",
|
||||
lastBackupError: toMessage(error),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(backupSchedulesTable.id, scheduleId));
|
||||
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
const getSchedulesToExecute = async () => {
|
||||
const now = new Date();
|
||||
const schedules = await db.query.backupSchedulesTable.findMany({
|
||||
where: eq(backupSchedulesTable.enabled, true),
|
||||
});
|
||||
|
||||
const schedulesToRun: number[] = [];
|
||||
|
||||
for (const schedule of schedules) {
|
||||
if (!schedule.nextBackupAt || schedule.nextBackupAt <= now) {
|
||||
schedulesToRun.push(schedule.id);
|
||||
}
|
||||
}
|
||||
|
||||
return schedulesToRun;
|
||||
};
|
||||
|
||||
export const backupsService = {
|
||||
listSchedules,
|
||||
getSchedule,
|
||||
createSchedule,
|
||||
updateSchedule,
|
||||
deleteSchedule,
|
||||
executeBackup,
|
||||
getSchedulesToExecute,
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { restic } from "../../utils/restic";
|
||||
import { volumeService } from "../volumes/volume.service";
|
||||
import { CleanupDanglingMountsJob } from "../../jobs/cleanup-dangling";
|
||||
import { VolumeHealthCheckJob } from "../../jobs/healthchecks";
|
||||
import { BackupExecutionJob } from "../../jobs/backup-execution";
|
||||
|
||||
export const startup = async () => {
|
||||
await Scheduler.start();
|
||||
@@ -30,4 +31,5 @@ export const startup = async () => {
|
||||
|
||||
Scheduler.build(CleanupDanglingMountsJob).schedule("0 * * * *");
|
||||
Scheduler.build(VolumeHealthCheckJob).schedule("* * * * *");
|
||||
Scheduler.build(BackupExecutionJob).schedule("* * * * *");
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import { $ } from "bun";
|
||||
import { RESTIC_PASS_FILE } from "../core/constants";
|
||||
import { logger } from "./logger";
|
||||
import { cryptoUtils } from "./crypto";
|
||||
import type { RetentionPolicy } from "../modules/backups/backups.dto";
|
||||
|
||||
const backupOutputSchema = type({
|
||||
message_type: "'summary'",
|
||||
@@ -110,18 +111,44 @@ const init = async (config: RepositoryConfig) => {
|
||||
return { success: true, error: null };
|
||||
};
|
||||
|
||||
const backup = async (config: RepositoryConfig, source: string) => {
|
||||
const backup = async (
|
||||
config: RepositoryConfig,
|
||||
source: string,
|
||||
options?: { exclude?: string[]; include?: string[] },
|
||||
) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const res = await $`restic --repo ${repoUrl} backup ${source} --json`.env(env).nothrow();
|
||||
const args: string[] = ["--repo", repoUrl, "backup", source];
|
||||
|
||||
if (options?.exclude && options.exclude.length > 0) {
|
||||
for (const pattern of options.exclude) {
|
||||
args.push("--exclude", pattern);
|
||||
}
|
||||
}
|
||||
|
||||
if (options?.include && options.include.length > 0) {
|
||||
for (const pattern of options.include) {
|
||||
args.push("--include", pattern);
|
||||
}
|
||||
}
|
||||
|
||||
args.push("--json");
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic backup failed: ${res.stderr}`);
|
||||
throw new Error(`Restic backup failed: ${res.stderr}`);
|
||||
}
|
||||
|
||||
const result = backupOutputSchema(res.json());
|
||||
// res is a succession of JSON objects, we need to parse the last one which contains the summary
|
||||
const stdout = res.text();
|
||||
const outputLines = stdout.trim().split("\n");
|
||||
const lastLine = outputLines[outputLines.length - 1];
|
||||
const resSummary = JSON.parse(lastLine ?? "{}");
|
||||
|
||||
const result = backupOutputSchema(resSummary);
|
||||
|
||||
if (result instanceof type.errors) {
|
||||
logger.error(`Restic backup output validation failed: ${result}`);
|
||||
@@ -166,10 +193,53 @@ const snapshots = async (config: RepositoryConfig) => {
|
||||
return result;
|
||||
};
|
||||
|
||||
const forget = async (config: RepositoryConfig, options: RetentionPolicy) => {
|
||||
const repoUrl = buildRepoUrl(config);
|
||||
const env = await buildEnv(config);
|
||||
|
||||
const args: string[] = ["--repo", repoUrl, "forget"];
|
||||
|
||||
if (options.keepLast) {
|
||||
args.push("--keep-last", String(options.keepLast));
|
||||
}
|
||||
if (options.keepHourly) {
|
||||
args.push("--keep-hourly", String(options.keepHourly));
|
||||
}
|
||||
if (options.keepDaily) {
|
||||
args.push("--keep-daily", String(options.keepDaily));
|
||||
}
|
||||
if (options.keepWeekly) {
|
||||
args.push("--keep-weekly", String(options.keepWeekly));
|
||||
}
|
||||
if (options.keepMonthly) {
|
||||
args.push("--keep-monthly", String(options.keepMonthly));
|
||||
}
|
||||
if (options.keepYearly) {
|
||||
args.push("--keep-yearly", String(options.keepYearly));
|
||||
}
|
||||
if (options.keepWithinDuration) {
|
||||
args.push("--keep-within-duration", options.keepWithinDuration);
|
||||
}
|
||||
|
||||
args.push("--prune");
|
||||
args.push("--json");
|
||||
|
||||
const res = await $`restic ${args}`.env(env).nothrow();
|
||||
|
||||
if (res.exitCode !== 0) {
|
||||
logger.error(`Restic forget failed: ${res.stderr}`);
|
||||
throw new Error(`Restic forget failed: ${res.stderr}`);
|
||||
}
|
||||
|
||||
logger.info("Restic forget completed successfully");
|
||||
return { success: true };
|
||||
};
|
||||
|
||||
export const restic = {
|
||||
ensurePassfile,
|
||||
init,
|
||||
backup,
|
||||
restore,
|
||||
snapshots,
|
||||
forget,
|
||||
};
|
||||
|
||||
5
bun.lock
5
bun.lock
@@ -68,6 +68,7 @@
|
||||
"@ironmount/schemas": "workspace:*",
|
||||
"@scalar/hono-api-reference": "^0.9.13",
|
||||
"arktype": "^2.1.23",
|
||||
"cron-parser": "^5.4.0",
|
||||
"dockerode": "^4.0.8",
|
||||
"dotenv": "^17.2.1",
|
||||
"drizzle-orm": "^0.44.6",
|
||||
@@ -699,6 +700,8 @@
|
||||
|
||||
"cpu-features": ["cpu-features@0.0.10", "", { "dependencies": { "buildcheck": "~0.0.6", "nan": "^2.19.0" } }, "sha512-9IkYqtX3YHPCzoVg1Py+o9057a3i0fp7S530UWokCSaFVTc7CwXPRiOjRjBQQ18ZCNafx78YfnG+HALxtVmOGA=="],
|
||||
|
||||
"cron-parser": ["cron-parser@5.4.0", "", { "dependencies": { "luxon": "^3.7.1" } }, "sha512-HxYB8vTvnQFx4dLsZpGRa0uHp6X3qIzS3ZJgJ9v6l/5TJMgeWQbLkR5yiJ5hOxGbc9+jCADDnydIe15ReLZnJA=="],
|
||||
|
||||
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
|
||||
|
||||
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
||||
@@ -1015,6 +1018,8 @@
|
||||
|
||||
"lucide-react": ["lucide-react@0.546.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Z94u6fKT43lKeYHiVyvyR8fT7pwCzDu7RyMPpTvh054+xahSgj4HFQ+NmflvzdXsoAjYGdCguGaFKYuvq0ThCQ=="],
|
||||
|
||||
"luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
|
||||
|
||||
"magic-string": ["magic-string@0.30.19", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-2N21sPY9Ws53PZvsEpVtNuSW+ScYbQdp4b9qUaL+9QkHUrGFKo56Lg9Emg5s9V/qrtNBmiR01sYhUOwu3H+VOw=="],
|
||||
|
||||
"make-fetch-happen": ["make-fetch-happen@9.1.0", "", { "dependencies": { "agentkeepalive": "^4.1.3", "cacache": "^15.2.0", "http-cache-semantics": "^4.1.0", "http-proxy-agent": "^4.0.1", "https-proxy-agent": "^5.0.0", "is-lambda": "^1.0.1", "lru-cache": "^6.0.0", "minipass": "^3.1.3", "minipass-collect": "^1.0.2", "minipass-fetch": "^1.3.2", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^0.6.2", "promise-retry": "^2.0.1", "socks-proxy-agent": "^6.0.0", "ssri": "^8.0.0" } }, "sha512-+zopwDy7DNknmwPQplem5lAZX/eCOzSvSNNcSKm5eVwTkOBzoktEfXsa9L23J/GIRhxRsaxzkPEhrJEpE2F4Gg=="],
|
||||
|
||||
Reference in New Issue
Block a user