From 7d933ec97a97da4a01a08b8d1e59499b188a68d5 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Thu, 31 Oct 2024 11:29:42 +0000 Subject: [PATCH] feat: built-in automatic database backups (#13773) --- .../docs/administration/backup-and-restore.md | 11 + i18n/en.json | 11 +- mobile/openapi/README.md | 2 + mobile/openapi/lib/api.dart | 2 + mobile/openapi/lib/api_client.dart | 4 + .../model/all_job_status_response_dto.dart | 10 +- .../lib/model/database_backup_config.dart | 116 ++++++++++ mobile/openapi/lib/model/job_name.dart | 3 + .../lib/model/system_config_backups_dto.dart | 99 ++++++++ .../openapi/lib/model/system_config_dto.dart | 10 +- open-api/immich-openapi-specs.json | 42 +++- open-api/typescript-sdk/src/fetch-client.ts | 13 +- server/src/config.ts | 14 ++ server/src/dtos/job.dto.ts | 3 + server/src/dtos/system-config.dto.ts | 29 +++ server/src/enum.ts | 1 + server/src/interfaces/database.interface.ts | 1 + server/src/interfaces/job.interface.ts | 12 +- server/src/interfaces/process.interface.ts | 25 ++ server/src/interfaces/storage.interface.ts | 3 +- server/src/repositories/index.ts | 3 + server/src/repositories/job.repository.ts | 3 + server/src/repositories/process.repository.ts | 16 ++ server/src/repositories/storage.repository.ts | 7 +- server/src/services/backup.service.spec.ts | 217 ++++++++++++++++++ server/src/services/backup.service.ts | 157 +++++++++++++ server/src/services/base.service.ts | 2 + server/src/services/index.ts | 2 + server/src/services/job.service.spec.ts | 3 +- server/src/services/job.service.ts | 1 + server/src/services/microservices.service.ts | 3 + server/src/services/storage.service.spec.ts | 11 +- .../services/system-config.service.spec.ts | 7 + server/test/fixtures/system-config.stub.ts | 9 + .../repositories/process.repository.mock.ts | 8 + .../repositories/storage.repository.mock.ts | 1 + server/test/utils.ts | 43 +++- .../backup-settings/backup-settings.svelte | 91 ++++++++ .../library-settings/library-settings.svelte | 6 +- web/src/lib/utils.ts | 1 + .../routes/admin/system-settings/+page.svelte | 9 + 41 files changed, 994 insertions(+), 17 deletions(-) create mode 100644 mobile/openapi/lib/model/database_backup_config.dart create mode 100644 mobile/openapi/lib/model/system_config_backups_dto.dart create mode 100644 server/src/interfaces/process.interface.ts create mode 100644 server/src/repositories/process.repository.ts create mode 100644 server/src/services/backup.service.spec.ts create mode 100644 server/src/services/backup.service.ts create mode 100644 server/test/repositories/process.repository.mock.ts create mode 100644 web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte diff --git a/docs/docs/administration/backup-and-restore.md b/docs/docs/administration/backup-and-restore.md index 9b5793054ba..70f393e6e10 100644 --- a/docs/docs/administration/backup-and-restore.md +++ b/docs/docs/administration/backup-and-restore.md @@ -21,6 +21,17 @@ The recommended way to backup and restore the Immich database is to use the `pg_ It is not recommended to directly backup the `DB_DATA_LOCATION` folder. Doing so while the database is running can lead to a corrupted backup that cannot be restored. ::: +### Automatic Database Backups + +Immich will automatically create database backups by default. The backups are stored in `UPLOAD_LOCATION/backups`. +You can adjust the schedule and amount of kept backups in the [admin settings](http://my.immich.app/admin/system-settings?isOpen=backup). +By default, Immich will keep the last 14 backups and create a new backup every day at 2:00 AM. + +#### Restoring + +We hope to make restoring simpler in future versions, for now you can find the backups in the `UPLOAD_LOCATION/backups` folder on your host. +Then please follow the steps in the following section for restoring the database. + ### Manual Backup and Restore diff --git a/i18n/en.json b/i18n/en.json index 1aa77718e4d..d607e088b39 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -34,6 +34,11 @@ "authentication_settings_disable_all": "Are you sure you want to disable all login methods? Login will be completely disabled.", "authentication_settings_reenable": "To re-enable, use a Server Command.", "background_task_job": "Background Tasks", + "backup_database": "Backup Database", + "backup_database_enable_description": "Enable database backups", + "backup_keep_last_amount": "Amount of previous backups to keep", + "backup_settings": "Backup Settings", + "backup_settings_description": "Manage database backup settings", "check_all": "Check All", "cleared_jobs": "Cleared jobs for: {job}", "config_set_by_file": "Config is currently set by a config file", @@ -43,6 +48,9 @@ "confirm_reprocess_all_faces": "Are you sure you want to reprocess all faces? This will also clear named people.", "confirm_user_password_reset": "Are you sure you want to reset {user}'s password?", "create_job": "Create job", + "cron_expression": "Cron expression", + "cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. Crontab Guru", + "cron_expression_presets": "Cron expression presets", "disable_login": "Disable login", "duplicate_detection_job_description": "Run machine learning on assets to detect similar images. Relies on Smart Search", "exclusion_pattern_description": "Exclusion patterns lets you ignore files and folders when scanning your library. This is useful if you have folders that contain files you don't want to import, such as RAW files.", @@ -80,9 +88,6 @@ "jobs_delayed": "{jobCount, plural, other {# delayed}}", "jobs_failed": "{jobCount, plural, other {# failed}}", "library_created": "Created library: {library}", - "library_cron_expression": "Cron expression", - "library_cron_expression_description": "Set the scanning interval using the cron format. For more information please refer to e.g. Crontab Guru", - "library_cron_expression_presets": "Cron expression presets", "library_deleted": "Library deleted", "library_import_path_description": "Specify a folder to import. This folder, including subfolders, will be scanned for images and videos.", "library_scanning": "Periodic Scanning", diff --git a/mobile/openapi/README.md b/mobile/openapi/README.md index 411ac818b4c..4cdb08ce998 100644 --- a/mobile/openapi/README.md +++ b/mobile/openapi/README.md @@ -306,6 +306,7 @@ Class | Method | HTTP request | Description - [CreateAlbumDto](doc//CreateAlbumDto.md) - [CreateLibraryDto](doc//CreateLibraryDto.md) - [CreateProfileImageResponseDto](doc//CreateProfileImageResponseDto.md) + - [DatabaseBackupConfig](doc//DatabaseBackupConfig.md) - [DownloadArchiveInfo](doc//DownloadArchiveInfo.md) - [DownloadInfoDto](doc//DownloadInfoDto.md) - [DownloadResponse](doc//DownloadResponse.md) @@ -413,6 +414,7 @@ Class | Method | HTTP request | Description - [StackCreateDto](doc//StackCreateDto.md) - [StackResponseDto](doc//StackResponseDto.md) - [StackUpdateDto](doc//StackUpdateDto.md) + - [SystemConfigBackupsDto](doc//SystemConfigBackupsDto.md) - [SystemConfigDto](doc//SystemConfigDto.md) - [SystemConfigFFmpegDto](doc//SystemConfigFFmpegDto.md) - [SystemConfigFacesDto](doc//SystemConfigFacesDto.md) diff --git a/mobile/openapi/lib/api.dart b/mobile/openapi/lib/api.dart index 6fb7478d04b..b4c51c8e997 100644 --- a/mobile/openapi/lib/api.dart +++ b/mobile/openapi/lib/api.dart @@ -120,6 +120,7 @@ part 'model/colorspace.dart'; part 'model/create_album_dto.dart'; part 'model/create_library_dto.dart'; part 'model/create_profile_image_response_dto.dart'; +part 'model/database_backup_config.dart'; part 'model/download_archive_info.dart'; part 'model/download_info_dto.dart'; part 'model/download_response.dart'; @@ -227,6 +228,7 @@ part 'model/source_type.dart'; part 'model/stack_create_dto.dart'; part 'model/stack_response_dto.dart'; part 'model/stack_update_dto.dart'; +part 'model/system_config_backups_dto.dart'; part 'model/system_config_dto.dart'; part 'model/system_config_f_fmpeg_dto.dart'; part 'model/system_config_faces_dto.dart'; diff --git a/mobile/openapi/lib/api_client.dart b/mobile/openapi/lib/api_client.dart index c1025b0bd48..b6ddf86e70d 100644 --- a/mobile/openapi/lib/api_client.dart +++ b/mobile/openapi/lib/api_client.dart @@ -294,6 +294,8 @@ class ApiClient { return CreateLibraryDto.fromJson(value); case 'CreateProfileImageResponseDto': return CreateProfileImageResponseDto.fromJson(value); + case 'DatabaseBackupConfig': + return DatabaseBackupConfig.fromJson(value); case 'DownloadArchiveInfo': return DownloadArchiveInfo.fromJson(value); case 'DownloadInfoDto': @@ -508,6 +510,8 @@ class ApiClient { return StackResponseDto.fromJson(value); case 'StackUpdateDto': return StackUpdateDto.fromJson(value); + case 'SystemConfigBackupsDto': + return SystemConfigBackupsDto.fromJson(value); case 'SystemConfigDto': return SystemConfigDto.fromJson(value); case 'SystemConfigFFmpegDto': diff --git a/mobile/openapi/lib/model/all_job_status_response_dto.dart b/mobile/openapi/lib/model/all_job_status_response_dto.dart index 6ec248a638e..787d02dd0e2 100644 --- a/mobile/openapi/lib/model/all_job_status_response_dto.dart +++ b/mobile/openapi/lib/model/all_job_status_response_dto.dart @@ -14,6 +14,7 @@ class AllJobStatusResponseDto { /// Returns a new [AllJobStatusResponseDto] instance. AllJobStatusResponseDto({ required this.backgroundTask, + required this.backupDatabase, required this.duplicateDetection, required this.faceDetection, required this.facialRecognition, @@ -31,6 +32,8 @@ class AllJobStatusResponseDto { JobStatusDto backgroundTask; + JobStatusDto backupDatabase; + JobStatusDto duplicateDetection; JobStatusDto faceDetection; @@ -60,6 +63,7 @@ class AllJobStatusResponseDto { @override bool operator ==(Object other) => identical(this, other) || other is AllJobStatusResponseDto && other.backgroundTask == backgroundTask && + other.backupDatabase == backupDatabase && other.duplicateDetection == duplicateDetection && other.faceDetection == faceDetection && other.facialRecognition == facialRecognition && @@ -78,6 +82,7 @@ class AllJobStatusResponseDto { int get hashCode => // ignore: unnecessary_parenthesis (backgroundTask.hashCode) + + (backupDatabase.hashCode) + (duplicateDetection.hashCode) + (faceDetection.hashCode) + (facialRecognition.hashCode) + @@ -93,11 +98,12 @@ class AllJobStatusResponseDto { (videoConversion.hashCode); @override - String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; + String toString() => 'AllJobStatusResponseDto[backgroundTask=$backgroundTask, backupDatabase=$backupDatabase, duplicateDetection=$duplicateDetection, faceDetection=$faceDetection, facialRecognition=$facialRecognition, library_=$library_, metadataExtraction=$metadataExtraction, migration=$migration, notifications=$notifications, search=$search, sidecar=$sidecar, smartSearch=$smartSearch, storageTemplateMigration=$storageTemplateMigration, thumbnailGeneration=$thumbnailGeneration, videoConversion=$videoConversion]'; Map toJson() { final json = {}; json[r'backgroundTask'] = this.backgroundTask; + json[r'backupDatabase'] = this.backupDatabase; json[r'duplicateDetection'] = this.duplicateDetection; json[r'faceDetection'] = this.faceDetection; json[r'facialRecognition'] = this.facialRecognition; @@ -124,6 +130,7 @@ class AllJobStatusResponseDto { return AllJobStatusResponseDto( backgroundTask: JobStatusDto.fromJson(json[r'backgroundTask'])!, + backupDatabase: JobStatusDto.fromJson(json[r'backupDatabase'])!, duplicateDetection: JobStatusDto.fromJson(json[r'duplicateDetection'])!, faceDetection: JobStatusDto.fromJson(json[r'faceDetection'])!, facialRecognition: JobStatusDto.fromJson(json[r'facialRecognition'])!, @@ -185,6 +192,7 @@ class AllJobStatusResponseDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { 'backgroundTask', + 'backupDatabase', 'duplicateDetection', 'faceDetection', 'facialRecognition', diff --git a/mobile/openapi/lib/model/database_backup_config.dart b/mobile/openapi/lib/model/database_backup_config.dart new file mode 100644 index 00000000000..d82128bd444 --- /dev/null +++ b/mobile/openapi/lib/model/database_backup_config.dart @@ -0,0 +1,116 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class DatabaseBackupConfig { + /// Returns a new [DatabaseBackupConfig] instance. + DatabaseBackupConfig({ + required this.cronExpression, + required this.enabled, + required this.keepLastAmount, + }); + + String cronExpression; + + bool enabled; + + /// Minimum value: 1 + num keepLastAmount; + + @override + bool operator ==(Object other) => identical(this, other) || other is DatabaseBackupConfig && + other.cronExpression == cronExpression && + other.enabled == enabled && + other.keepLastAmount == keepLastAmount; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (cronExpression.hashCode) + + (enabled.hashCode) + + (keepLastAmount.hashCode); + + @override + String toString() => 'DatabaseBackupConfig[cronExpression=$cronExpression, enabled=$enabled, keepLastAmount=$keepLastAmount]'; + + Map toJson() { + final json = {}; + json[r'cronExpression'] = this.cronExpression; + json[r'enabled'] = this.enabled; + json[r'keepLastAmount'] = this.keepLastAmount; + return json; + } + + /// Returns a new [DatabaseBackupConfig] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static DatabaseBackupConfig? fromJson(dynamic value) { + upgradeDto(value, "DatabaseBackupConfig"); + if (value is Map) { + final json = value.cast(); + + return DatabaseBackupConfig( + cronExpression: mapValueOfType(json, r'cronExpression')!, + enabled: mapValueOfType(json, r'enabled')!, + keepLastAmount: num.parse('${json[r'keepLastAmount']}'), + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = DatabaseBackupConfig.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = DatabaseBackupConfig.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of DatabaseBackupConfig-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = DatabaseBackupConfig.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'cronExpression', + 'enabled', + 'keepLastAmount', + }; +} + diff --git a/mobile/openapi/lib/model/job_name.dart b/mobile/openapi/lib/model/job_name.dart index 072da76d4c2..6b9a002cbec 100644 --- a/mobile/openapi/lib/model/job_name.dart +++ b/mobile/openapi/lib/model/job_name.dart @@ -37,6 +37,7 @@ class JobName { static const sidecar = JobName._(r'sidecar'); static const library_ = JobName._(r'library'); static const notifications = JobName._(r'notifications'); + static const backupDatabase = JobName._(r'backupDatabase'); /// List of all possible values in this [enum][JobName]. static const values = [ @@ -54,6 +55,7 @@ class JobName { sidecar, library_, notifications, + backupDatabase, ]; static JobName? fromJson(dynamic value) => JobNameTypeTransformer().decode(value); @@ -106,6 +108,7 @@ class JobNameTypeTransformer { case r'sidecar': return JobName.sidecar; case r'library': return JobName.library_; case r'notifications': return JobName.notifications; + case r'backupDatabase': return JobName.backupDatabase; default: if (!allowNull) { throw ArgumentError('Unknown enum value to decode: $data'); diff --git a/mobile/openapi/lib/model/system_config_backups_dto.dart b/mobile/openapi/lib/model/system_config_backups_dto.dart new file mode 100644 index 00000000000..82cd6e59ebd --- /dev/null +++ b/mobile/openapi/lib/model/system_config_backups_dto.dart @@ -0,0 +1,99 @@ +// +// AUTO-GENERATED FILE, DO NOT MODIFY! +// +// @dart=2.18 + +// ignore_for_file: unused_element, unused_import +// ignore_for_file: always_put_required_named_parameters_first +// ignore_for_file: constant_identifier_names +// ignore_for_file: lines_longer_than_80_chars + +part of openapi.api; + +class SystemConfigBackupsDto { + /// Returns a new [SystemConfigBackupsDto] instance. + SystemConfigBackupsDto({ + required this.database, + }); + + DatabaseBackupConfig database; + + @override + bool operator ==(Object other) => identical(this, other) || other is SystemConfigBackupsDto && + other.database == database; + + @override + int get hashCode => + // ignore: unnecessary_parenthesis + (database.hashCode); + + @override + String toString() => 'SystemConfigBackupsDto[database=$database]'; + + Map toJson() { + final json = {}; + json[r'database'] = this.database; + return json; + } + + /// Returns a new [SystemConfigBackupsDto] instance and imports its values from + /// [value] if it's a [Map], null otherwise. + // ignore: prefer_constructors_over_static_methods + static SystemConfigBackupsDto? fromJson(dynamic value) { + upgradeDto(value, "SystemConfigBackupsDto"); + if (value is Map) { + final json = value.cast(); + + return SystemConfigBackupsDto( + database: DatabaseBackupConfig.fromJson(json[r'database'])!, + ); + } + return null; + } + + static List listFromJson(dynamic json, {bool growable = false,}) { + final result = []; + if (json is List && json.isNotEmpty) { + for (final row in json) { + final value = SystemConfigBackupsDto.fromJson(row); + if (value != null) { + result.add(value); + } + } + } + return result.toList(growable: growable); + } + + static Map mapFromJson(dynamic json) { + final map = {}; + if (json is Map && json.isNotEmpty) { + json = json.cast(); // ignore: parameter_assignments + for (final entry in json.entries) { + final value = SystemConfigBackupsDto.fromJson(entry.value); + if (value != null) { + map[entry.key] = value; + } + } + } + return map; + } + + // maps a json object with a list of SystemConfigBackupsDto-objects as value to a dart map + static Map> mapListFromJson(dynamic json, {bool growable = false,}) { + final map = >{}; + if (json is Map && json.isNotEmpty) { + // ignore: parameter_assignments + json = json.cast(); + for (final entry in json.entries) { + map[entry.key] = SystemConfigBackupsDto.listFromJson(entry.value, growable: growable,); + } + } + return map; + } + + /// The list of required keys that must be present in a JSON. + static const requiredKeys = { + 'database', + }; +} + diff --git a/mobile/openapi/lib/model/system_config_dto.dart b/mobile/openapi/lib/model/system_config_dto.dart index 5306370d2d1..42159539066 100644 --- a/mobile/openapi/lib/model/system_config_dto.dart +++ b/mobile/openapi/lib/model/system_config_dto.dart @@ -13,6 +13,7 @@ part of openapi.api; class SystemConfigDto { /// Returns a new [SystemConfigDto] instance. SystemConfigDto({ + required this.backup, required this.ffmpeg, required this.image, required this.job, @@ -33,6 +34,8 @@ class SystemConfigDto { required this.user, }); + SystemConfigBackupsDto backup; + SystemConfigFFmpegDto ffmpeg; SystemConfigImageDto image; @@ -71,6 +74,7 @@ class SystemConfigDto { @override bool operator ==(Object other) => identical(this, other) || other is SystemConfigDto && + other.backup == backup && other.ffmpeg == ffmpeg && other.image == image && other.job == job && @@ -93,6 +97,7 @@ class SystemConfigDto { @override int get hashCode => // ignore: unnecessary_parenthesis + (backup.hashCode) + (ffmpeg.hashCode) + (image.hashCode) + (job.hashCode) + @@ -113,10 +118,11 @@ class SystemConfigDto { (user.hashCode); @override - String toString() => 'SystemConfigDto[ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metadata=$metadata, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, trash=$trash, user=$user]'; + String toString() => 'SystemConfigDto[backup=$backup, ffmpeg=$ffmpeg, image=$image, job=$job, library_=$library_, logging=$logging, machineLearning=$machineLearning, map=$map, metadata=$metadata, newVersionCheck=$newVersionCheck, notifications=$notifications, oauth=$oauth, passwordLogin=$passwordLogin, reverseGeocoding=$reverseGeocoding, server=$server, storageTemplate=$storageTemplate, theme=$theme, trash=$trash, user=$user]'; Map toJson() { final json = {}; + json[r'backup'] = this.backup; json[r'ffmpeg'] = this.ffmpeg; json[r'image'] = this.image; json[r'job'] = this.job; @@ -147,6 +153,7 @@ class SystemConfigDto { final json = value.cast(); return SystemConfigDto( + backup: SystemConfigBackupsDto.fromJson(json[r'backup'])!, ffmpeg: SystemConfigFFmpegDto.fromJson(json[r'ffmpeg'])!, image: SystemConfigImageDto.fromJson(json[r'image'])!, job: SystemConfigJobDto.fromJson(json[r'job'])!, @@ -212,6 +219,7 @@ class SystemConfigDto { /// The list of required keys that must be present in a JSON. static const requiredKeys = { + 'backup', 'ffmpeg', 'image', 'job', diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index f91ddcc42ca..ef488fe5c00 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7745,6 +7745,9 @@ "backgroundTask": { "$ref": "#/components/schemas/JobStatusDto" }, + "backupDatabase": { + "$ref": "#/components/schemas/JobStatusDto" + }, "duplicateDetection": { "$ref": "#/components/schemas/JobStatusDto" }, @@ -7787,6 +7790,7 @@ }, "required": [ "backgroundTask", + "backupDatabase", "duplicateDetection", "faceDetection", "facialRecognition", @@ -8754,6 +8758,26 @@ ], "type": "object" }, + "DatabaseBackupConfig": { + "properties": { + "cronExpression": { + "type": "string" + }, + "enabled": { + "type": "boolean" + }, + "keepLastAmount": { + "minimum": 1, + "type": "number" + } + }, + "required": [ + "cronExpression", + "enabled", + "keepLastAmount" + ], + "type": "object" + }, "DownloadArchiveInfo": { "properties": { "assetIds": { @@ -9289,7 +9313,8 @@ "search", "sidecar", "library", - "notifications" + "notifications", + "backupDatabase" ], "type": "string" }, @@ -11456,8 +11481,22 @@ }, "type": "object" }, + "SystemConfigBackupsDto": { + "properties": { + "database": { + "$ref": "#/components/schemas/DatabaseBackupConfig" + } + }, + "required": [ + "database" + ], + "type": "object" + }, "SystemConfigDto": { "properties": { + "backup": { + "$ref": "#/components/schemas/SystemConfigBackupsDto" + }, "ffmpeg": { "$ref": "#/components/schemas/SystemConfigFFmpegDto" }, @@ -11514,6 +11553,7 @@ } }, "required": [ + "backup", "ffmpeg", "image", "job", diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index ece9c8d2a02..23181554739 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -535,6 +535,7 @@ export type JobStatusDto = { }; export type AllJobStatusResponseDto = { backgroundTask: JobStatusDto; + backupDatabase: JobStatusDto; duplicateDetection: JobStatusDto; faceDetection: JobStatusDto; facialRecognition: JobStatusDto; @@ -1084,6 +1085,14 @@ export type AssetFullSyncDto = { updatedUntil: string; userId?: string; }; +export type DatabaseBackupConfig = { + cronExpression: string; + enabled: boolean; + keepLastAmount: number; +}; +export type SystemConfigBackupsDto = { + database: DatabaseBackupConfig; +}; export type SystemConfigFFmpegDto = { accel: TranscodeHWAccel; accelDecode: boolean; @@ -1232,6 +1241,7 @@ export type SystemConfigUserDto = { deleteDelay: number; }; export type SystemConfigDto = { + backup: SystemConfigBackupsDto; ffmpeg: SystemConfigFFmpegDto; image: SystemConfigImageDto; job: SystemConfigJobDto; @@ -3445,7 +3455,8 @@ export enum JobName { Search = "search", Sidecar = "sidecar", Library = "library", - Notifications = "notifications" + Notifications = "notifications", + BackupDatabase = "backupDatabase" } export enum JobCommand { Start = "start", diff --git a/server/src/config.ts b/server/src/config.ts index 12e6e6576b1..7a7a7b71acf 100644 --- a/server/src/config.ts +++ b/server/src/config.ts @@ -15,6 +15,13 @@ import { ConcurrentQueueName, QueueName } from 'src/interfaces/job.interface'; import { ImageOptions } from 'src/interfaces/media.interface'; export interface SystemConfig { + backup: { + database: { + enabled: boolean; + cronExpression: string; + keepLastAmount: number; + }; + }; ffmpeg: { crf: number; threads: number; @@ -150,6 +157,13 @@ export interface SystemConfig { } export const defaults = Object.freeze({ + backup: { + database: { + enabled: true, + cronExpression: CronExpression.EVERY_DAY_AT_2AM, + keepLastAmount: 14, + }, + }, ffmpeg: { crf: 23, threads: 0, diff --git a/server/src/dtos/job.dto.ts b/server/src/dtos/job.dto.ts index 49e4cfb67b3..31612bd8a4f 100644 --- a/server/src/dtos/job.dto.ts +++ b/server/src/dtos/job.dto.ts @@ -97,4 +97,7 @@ export class AllJobStatusResponseDto implements Record @ApiProperty({ type: JobStatusDto }) [QueueName.NOTIFICATION]!: JobStatusDto; + + @ApiProperty({ type: JobStatusDto }) + [QueueName.BACKUP_DATABASE]!: JobStatusDto; } diff --git a/server/src/dtos/system-config.dto.ts b/server/src/dtos/system-config.dto.ts index 039dbd20ff3..7e7a8e08797 100644 --- a/server/src/dtos/system-config.dto.ts +++ b/server/src/dtos/system-config.dto.ts @@ -46,6 +46,30 @@ const isLibraryScanEnabled = (config: SystemConfigLibraryScanDto) => config.enab const isOAuthEnabled = (config: SystemConfigOAuthDto) => config.enabled; const isOAuthOverrideEnabled = (config: SystemConfigOAuthDto) => config.mobileOverrideEnabled; const isEmailNotificationEnabled = (config: SystemConfigSmtpDto) => config.enabled; +const isDatabaseBackupEnabled = (config: DatabaseBackupConfig) => config.enabled; + +export class DatabaseBackupConfig { + @ValidateBoolean() + enabled!: boolean; + + @ValidateIf(isDatabaseBackupEnabled) + @IsNotEmpty() + @Validate(CronValidator, { message: 'Invalid cron expression' }) + @IsString() + cronExpression!: string; + + @IsInt() + @IsPositive() + @IsNotEmpty() + keepLastAmount!: number; +} + +export class SystemConfigBackupsDto { + @Type(() => DatabaseBackupConfig) + @ValidateNested() + @IsObject() + database!: DatabaseBackupConfig; +} export class SystemConfigFFmpegDto { @IsInt() @@ -531,6 +555,11 @@ class SystemConfigUserDto { } export class SystemConfigDto implements SystemConfig { + @Type(() => SystemConfigBackupsDto) + @ValidateNested() + @IsObject() + backup!: SystemConfigBackupsDto; + @Type(() => SystemConfigFFmpegDto) @ValidateNested() @IsObject() diff --git a/server/src/enum.ts b/server/src/enum.ts index 1212f41ab07..c3b3d8341ba 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -181,6 +181,7 @@ export enum StorageFolder { UPLOAD = 'upload', PROFILE = 'profile', THUMBNAILS = 'thumbs', + BACKUPS = 'backups', } export enum SystemMetadataKey { diff --git a/server/src/interfaces/database.interface.ts b/server/src/interfaces/database.interface.ts index 7745f54a32b..6a10a92f315 100644 --- a/server/src/interfaces/database.interface.ts +++ b/server/src/interfaces/database.interface.ts @@ -37,6 +37,7 @@ export enum DatabaseLock { CLIPDimSize = 512, Library = 1337, GetSystemConfig = 69, + BackupDatabase = 42, } export const EXTENSION_NAMES: Record = { diff --git a/server/src/interfaces/job.interface.ts b/server/src/interfaces/job.interface.ts index 82176ffa93c..31945f97ece 100644 --- a/server/src/interfaces/job.interface.ts +++ b/server/src/interfaces/job.interface.ts @@ -15,11 +15,15 @@ export enum QueueName { SIDECAR = 'sidecar', LIBRARY = 'library', NOTIFICATION = 'notifications', + BACKUP_DATABASE = 'backupDatabase', } export type ConcurrentQueueName = Exclude< QueueName, - QueueName.STORAGE_TEMPLATE_MIGRATION | QueueName.FACIAL_RECOGNITION | QueueName.DUPLICATE_DETECTION + | QueueName.STORAGE_TEMPLATE_MIGRATION + | QueueName.FACIAL_RECOGNITION + | QueueName.DUPLICATE_DETECTION + | QueueName.BACKUP_DATABASE >; export enum JobCommand { @@ -31,6 +35,9 @@ export enum JobCommand { } export enum JobName { + //backups + BACKUP_DATABASE = 'database-backup', + // conversion QUEUE_VIDEO_CONVERSION = 'queue-video-conversion', VIDEO_CONVERSION = 'video-conversion', @@ -209,6 +216,9 @@ export enum QueueCleanType { } export type JobItem = + // Backups + | { name: JobName.BACKUP_DATABASE; data?: IBaseJob } + // Transcoding | { name: JobName.QUEUE_VIDEO_CONVERSION; data: IBaseJob } | { name: JobName.VIDEO_CONVERSION; data: IEntityJob } diff --git a/server/src/interfaces/process.interface.ts b/server/src/interfaces/process.interface.ts new file mode 100644 index 00000000000..14a8c1ff334 --- /dev/null +++ b/server/src/interfaces/process.interface.ts @@ -0,0 +1,25 @@ +import { ChildProcessWithoutNullStreams, SpawnOptionsWithoutStdio } from 'node:child_process'; +import { Readable } from 'node:stream'; + +export interface ImmichReadStream { + stream: Readable; + type?: string; + length?: number; +} + +export interface ImmichZipStream extends ImmichReadStream { + addFile: (inputPath: string, filename: string) => void; + finalize: () => Promise; +} + +export interface DiskUsage { + available: number; + free: number; + total: number; +} + +export const IProcessRepository = 'IProcessRepository'; + +export interface IProcessRepository { + spawn(command: string, args?: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams; +} diff --git a/server/src/interfaces/storage.interface.ts b/server/src/interfaces/storage.interface.ts index 321f7b8367f..b304d94fefd 100644 --- a/server/src/interfaces/storage.interface.ts +++ b/server/src/interfaces/storage.interface.ts @@ -1,7 +1,7 @@ import { WatchOptions } from 'chokidar'; import { Stats } from 'node:fs'; import { FileReadOptions } from 'node:fs/promises'; -import { Readable } from 'node:stream'; +import { Readable, Writable } from 'node:stream'; import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; export interface ImmichReadStream { @@ -36,6 +36,7 @@ export interface IStorageRepository { createReadStream(filepath: string, mimeType?: string | null): Promise; readFile(filepath: string, options?: FileReadOptions): Promise; createFile(filepath: string, buffer: Buffer): Promise; + createWriteStream(filepath: string): Writable; createOrOverwriteFile(filepath: string, buffer: Buffer): Promise; overwriteFile(filepath: string, buffer: Buffer): Promise; realpath(filepath: string): Promise; diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 94a02122047..e487df503c8 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -22,6 +22,7 @@ import { INotificationRepository } from 'src/interfaces/notification.interface'; import { IOAuthRepository } from 'src/interfaces/oauth.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; +import { IProcessRepository } from 'src/interfaces/process.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; @@ -59,6 +60,7 @@ import { NotificationRepository } from 'src/repositories/notification.repository import { OAuthRepository } from 'src/repositories/oauth.repository'; import { PartnerRepository } from 'src/repositories/partner.repository'; import { PersonRepository } from 'src/repositories/person.repository'; +import { ProcessRepository } from 'src/repositories/process.repository'; import { SearchRepository } from 'src/repositories/search.repository'; import { ServerInfoRepository } from 'src/repositories/server-info.repository'; import { SessionRepository } from 'src/repositories/session.repository'; @@ -98,6 +100,7 @@ export const repositories = [ { provide: IOAuthRepository, useClass: OAuthRepository }, { provide: IPartnerRepository, useClass: PartnerRepository }, { provide: IPersonRepository, useClass: PersonRepository }, + { provide: IProcessRepository, useClass: ProcessRepository }, { provide: ISearchRepository, useClass: SearchRepository }, { provide: IServerInfoRepository, useClass: ServerInfoRepository }, { provide: ISessionRepository, useClass: SessionRepository }, diff --git a/server/src/repositories/job.repository.ts b/server/src/repositories/job.repository.ts index 2b783e7d2f6..131dd770aa4 100644 --- a/server/src/repositories/job.repository.ts +++ b/server/src/repositories/job.repository.ts @@ -30,6 +30,9 @@ export const JOBS_TO_QUEUE: Record = { [JobName.PERSON_CLEANUP]: QueueName.BACKGROUND_TASK, [JobName.USER_SYNC_USAGE]: QueueName.BACKGROUND_TASK, + // backups + [JobName.BACKUP_DATABASE]: QueueName.BACKUP_DATABASE, + // conversion [JobName.QUEUE_VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION, [JobName.VIDEO_CONVERSION]: QueueName.VIDEO_CONVERSION, diff --git a/server/src/repositories/process.repository.ts b/server/src/repositories/process.repository.ts new file mode 100644 index 00000000000..99ec51037cf --- /dev/null +++ b/server/src/repositories/process.repository.ts @@ -0,0 +1,16 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ChildProcessWithoutNullStreams, spawn, SpawnOptionsWithoutStdio } from 'node:child_process'; +import { ILoggerRepository } from 'src/interfaces/logger.interface'; +import { IProcessRepository } from 'src/interfaces/process.interface'; +import { StorageRepository } from 'src/repositories/storage.repository'; + +@Injectable() +export class ProcessRepository implements IProcessRepository { + constructor(@Inject(ILoggerRepository) private logger: ILoggerRepository) { + this.logger.setContext(StorageRepository.name); + } + + spawn(command: string, args: readonly string[], options?: SpawnOptionsWithoutStdio): ChildProcessWithoutNullStreams { + return spawn(command, args, options); + } +} diff --git a/server/src/repositories/storage.repository.ts b/server/src/repositories/storage.repository.ts index 1ef0e9d6bf1..e4c0c684514 100644 --- a/server/src/repositories/storage.repository.ts +++ b/server/src/repositories/storage.repository.ts @@ -2,9 +2,10 @@ import { Inject, Injectable } from '@nestjs/common'; import archiver from 'archiver'; import chokidar, { WatchOptions } from 'chokidar'; import { escapePath, glob, globStream } from 'fast-glob'; -import { constants, createReadStream, existsSync, mkdirSync } from 'node:fs'; +import { constants, createReadStream, createWriteStream, existsSync, mkdirSync } from 'node:fs'; import fs from 'node:fs/promises'; import path from 'node:path'; +import { Writable } from 'node:stream'; import { CrawlOptionsDto, WalkOptionsDto } from 'src/dtos/library.dto'; import { ILoggerRepository } from 'src/interfaces/logger.interface'; import { @@ -42,6 +43,10 @@ export class StorageRepository implements IStorageRepository { return fs.writeFile(filepath, buffer, { flag: 'wx' }); } + createWriteStream(filepath: string): Writable { + return createWriteStream(filepath, { flags: 'w' }); + } + createOrOverwriteFile(filepath: string, buffer: Buffer) { return fs.writeFile(filepath, buffer, { flag: 'w' }); } diff --git a/server/src/services/backup.service.spec.ts b/server/src/services/backup.service.spec.ts new file mode 100644 index 00000000000..8f006d0d6ba --- /dev/null +++ b/server/src/services/backup.service.spec.ts @@ -0,0 +1,217 @@ +import { PassThrough } from 'node:stream'; +import { defaults, SystemConfig } from 'src/config'; +import { StorageCore } from 'src/cores/storage.core'; +import { ImmichWorker, StorageFolder } from 'src/enum'; +import { IDatabaseRepository } from 'src/interfaces/database.interface'; +import { IJobRepository, JobStatus } from 'src/interfaces/job.interface'; +import { IProcessRepository } from 'src/interfaces/process.interface'; +import { IStorageRepository } from 'src/interfaces/storage.interface'; +import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface'; +import { BackupService } from 'src/services/backup.service'; +import { systemConfigStub } from 'test/fixtures/system-config.stub'; +import { mockSpawn, newTestService } from 'test/utils'; +import { describe, Mocked } from 'vitest'; + +describe(BackupService.name, () => { + let sut: BackupService; + + let databaseMock: Mocked; + let jobMock: Mocked; + let processMock: Mocked; + let storageMock: Mocked; + let systemMock: Mocked; + + beforeEach(() => { + ({ sut, databaseMock, jobMock, processMock, storageMock, systemMock } = newTestService(BackupService)); + }); + + it('should work', () => { + expect(sut).toBeDefined(); + }); + + describe('onBootstrapEvent', () => { + it('should init cron job and handle config changes', async () => { + databaseMock.tryLock.mockResolvedValue(true); + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + + await sut.onBootstrap(ImmichWorker.API); + + expect(jobMock.addCronJob).toHaveBeenCalled(); + expect(systemMock.get).toHaveBeenCalled(); + }); + + it('should not initialize backup database cron job when lock is taken', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + databaseMock.tryLock.mockResolvedValue(false); + + await sut.onBootstrap(ImmichWorker.API); + + expect(jobMock.addCronJob).not.toHaveBeenCalled(); + }); + + it('should not initialise backup database job when running on microservices', async () => { + await sut.onBootstrap(ImmichWorker.MICROSERVICES); + + expect(jobMock.addCronJob).not.toHaveBeenCalled(); + }); + }); + + describe('onConfigUpdateEvent', () => { + beforeEach(async () => { + systemMock.get.mockResolvedValue(defaults); + databaseMock.tryLock.mockResolvedValue(true); + await sut.onBootstrap(ImmichWorker.API); + }); + + it('should update cron job if backup is enabled', () => { + sut.onConfigUpdate({ + oldConfig: defaults, + newConfig: { + backup: { + database: { + enabled: true, + cronExpression: '0 1 * * *', + }, + }, + } as SystemConfig, + }); + + expect(jobMock.updateCronJob).toHaveBeenCalledWith('backupDatabase', '0 1 * * *', true); + expect(jobMock.updateCronJob).toHaveBeenCalled(); + }); + + it('should do nothing if oldConfig is not provided', () => { + sut.onConfigUpdate({ newConfig: systemConfigStub.backupEnabled as SystemConfig }); + expect(jobMock.updateCronJob).not.toHaveBeenCalled(); + }); + + it('should do nothing if instance does not have the backup database lock', async () => { + databaseMock.tryLock.mockResolvedValue(false); + await sut.onBootstrap(ImmichWorker.API); + sut.onConfigUpdate({ newConfig: systemConfigStub.backupEnabled as SystemConfig, oldConfig: defaults }); + expect(jobMock.updateCronJob).not.toHaveBeenCalled(); + }); + }); + + describe('onConfigValidateEvent', () => { + it('should allow a valid cron expression', () => { + expect(() => + sut.onConfigValidate({ + newConfig: { backup: { database: { cronExpression: '0 0 * * *' } } } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).not.toThrow(expect.stringContaining('Invalid cron expression')); + }); + + it('should fail for an invalid cron expression', () => { + expect(() => + sut.onConfigValidate({ + newConfig: { backup: { database: { cronExpression: 'foo' } } } as SystemConfig, + oldConfig: {} as SystemConfig, + }), + ).toThrow(/Invalid cron expression.*/); + }); + }); + + describe('cleanupDatabaseBackups', () => { + it('should do nothing if not reached keepLastAmount', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz']); + await sut.cleanupDatabaseBackups(); + expect(storageMock.unlink).not.toHaveBeenCalled(); + }); + + it('should remove failed backup files', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + storageMock.readdir.mockResolvedValue([ + 'immich-db-backup-123.sql.gz.tmp', + 'immich-db-backup-234.sql.gz', + 'immich-db-backup-345.sql.gz.tmp', + ]); + await sut.cleanupDatabaseBackups(); + expect(storageMock.unlink).toHaveBeenCalledTimes(2); + expect(storageMock.unlink).toHaveBeenCalledWith( + `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-123.sql.gz.tmp`, + ); + expect(storageMock.unlink).toHaveBeenCalledWith( + `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-345.sql.gz.tmp`, + ); + }); + + it('should remove old backup files over keepLastAmount', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + storageMock.readdir.mockResolvedValue(['immich-db-backup-1.sql.gz', 'immich-db-backup-2.sql.gz']); + await sut.cleanupDatabaseBackups(); + expect(storageMock.unlink).toHaveBeenCalledTimes(1); + expect(storageMock.unlink).toHaveBeenCalledWith( + `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz`, + ); + }); + + it('should remove old backup files over keepLastAmount and failed backups', async () => { + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + storageMock.readdir.mockResolvedValue([ + 'immich-db-backup-1.sql.gz.tmp', + 'immich-db-backup-2.sql.gz', + 'immich-db-backup-3.sql.gz', + ]); + await sut.cleanupDatabaseBackups(); + expect(storageMock.unlink).toHaveBeenCalledTimes(2); + expect(storageMock.unlink).toHaveBeenCalledWith( + `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-1.sql.gz.tmp`, + ); + expect(storageMock.unlink).toHaveBeenCalledWith( + `${StorageCore.getBaseFolder(StorageFolder.BACKUPS)}/immich-db-backup-2.sql.gz`, + ); + }); + }); + + describe('handleBackupDatabase', () => { + beforeEach(() => { + storageMock.readdir.mockResolvedValue([]); + processMock.spawn.mockReturnValue(mockSpawn(0, 'data', '')); + storageMock.rename.mockResolvedValue(); + systemMock.get.mockResolvedValue(systemConfigStub.backupEnabled); + storageMock.createWriteStream.mockReturnValue(new PassThrough()); + }); + it('should run a database backup successfully', async () => { + const result = await sut.handleBackupDatabase(); + expect(result).toBe(JobStatus.SUCCESS); + expect(storageMock.createWriteStream).toHaveBeenCalled(); + }); + it('should rename file on success', async () => { + const result = await sut.handleBackupDatabase(); + expect(result).toBe(JobStatus.SUCCESS); + expect(storageMock.rename).toHaveBeenCalled(); + }); + it('should fail if pg_dumpall fails', async () => { + processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + const result = await sut.handleBackupDatabase(); + expect(result).toBe(JobStatus.FAILED); + }); + it('should not rename file if pgdump fails and gzip succeeds', async () => { + processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + const result = await sut.handleBackupDatabase(); + expect(result).toBe(JobStatus.FAILED); + expect(storageMock.rename).not.toHaveBeenCalled(); + }); + it('should fail if gzip fails', async () => { + processMock.spawn.mockReturnValueOnce(mockSpawn(0, 'data', '')); + processMock.spawn.mockReturnValueOnce(mockSpawn(1, '', 'error')); + const result = await sut.handleBackupDatabase(); + expect(result).toBe(JobStatus.FAILED); + }); + it('should fail if write stream fails', async () => { + storageMock.createWriteStream.mockImplementation(() => { + throw new Error('error'); + }); + const result = await sut.handleBackupDatabase(); + expect(result).toBe(JobStatus.FAILED); + }); + it('should fail if rename fails', async () => { + storageMock.rename.mockRejectedValue(new Error('error')); + const result = await sut.handleBackupDatabase(); + expect(result).toBe(JobStatus.FAILED); + }); + }); +}); diff --git a/server/src/services/backup.service.ts b/server/src/services/backup.service.ts new file mode 100644 index 00000000000..ba2ab816cd7 --- /dev/null +++ b/server/src/services/backup.service.ts @@ -0,0 +1,157 @@ +import { Injectable } from '@nestjs/common'; +import { default as path } from 'node:path'; +import { StorageCore } from 'src/cores/storage.core'; +import { OnEvent } from 'src/decorators'; +import { ImmichWorker, StorageFolder } from 'src/enum'; +import { DatabaseLock } from 'src/interfaces/database.interface'; +import { ArgOf } from 'src/interfaces/event.interface'; +import { JobName, JobStatus } from 'src/interfaces/job.interface'; +import { BaseService } from 'src/services/base.service'; +import { handlePromiseError } from 'src/utils/misc'; +import { validateCronExpression } from 'src/validation'; + +@Injectable() +export class BackupService extends BaseService { + private backupLock = false; + + @OnEvent({ name: 'app.bootstrap' }) + async onBootstrap(workerType: ImmichWorker) { + if (workerType !== ImmichWorker.API) { + return; + } + const { + backup: { database }, + } = await this.getConfig({ withCache: true }); + + this.backupLock = await this.databaseRepository.tryLock(DatabaseLock.BackupDatabase); + + if (this.backupLock) { + this.jobRepository.addCronJob( + 'backupDatabase', + database.cronExpression, + () => handlePromiseError(this.jobRepository.queue({ name: JobName.BACKUP_DATABASE }), this.logger), + database.enabled, + ); + } + } + + @OnEvent({ name: 'config.update', server: true }) + onConfigUpdate({ newConfig: { backup }, oldConfig }: ArgOf<'config.update'>) { + if (!oldConfig || !this.backupLock) { + return; + } + + this.jobRepository.updateCronJob('backupDatabase', backup.database.cronExpression, backup.database.enabled); + } + + @OnEvent({ name: 'config.validate' }) + onConfigValidate({ newConfig }: ArgOf<'config.validate'>) { + const { database } = newConfig.backup; + if (!validateCronExpression(database.cronExpression)) { + throw new Error(`Invalid cron expression ${database.cronExpression}`); + } + } + + async cleanupDatabaseBackups() { + this.logger.debug(`Database Backup Cleanup Started`); + const { + backup: { database: config }, + } = await this.getConfig({ withCache: false }); + + const backupsFolder = StorageCore.getBaseFolder(StorageFolder.BACKUPS); + const files = await this.storageRepository.readdir(backupsFolder); + const failedBackups = files.filter((file) => file.match(/immich-db-backup-\d+\.sql\.gz\.tmp$/)); + const backups = files + .filter((file) => file.match(/immich-db-backup-\d+\.sql\.gz$/)) + .sort() + .reverse(); + + const toDelete = backups.slice(config.keepLastAmount); + toDelete.push(...failedBackups); + + for (const file of toDelete) { + await this.storageRepository.unlink(path.join(backupsFolder, file)); + } + this.logger.debug(`Database Backup Cleanup Finished, deleted ${toDelete.length} backups`); + } + + async handleBackupDatabase(): Promise { + this.logger.debug(`Database Backup Started`); + + const { + database: { config }, + } = this.configRepository.getEnv(); + + const isUrlConnection = config.connectionType === 'url'; + const databaseParams = isUrlConnection ? [config.url] : ['-U', config.username, '-h', config.host]; + const backupFilePath = path.join( + StorageCore.getBaseFolder(StorageFolder.BACKUPS), + `immich-db-backup-${Date.now()}.sql.gz.tmp`, + ); + + try { + await new Promise((resolve, reject) => { + const pgdump = this.processRepository.spawn(`pg_dumpall`, [...databaseParams, '--clean', '--if-exists'], { + env: { PATH: process.env.PATH, PGPASSWORD: isUrlConnection ? undefined : config.password }, + }); + + const gzip = this.processRepository.spawn(`gzip`, []); + pgdump.stdout.pipe(gzip.stdin); + + const fileStream = this.storageRepository.createWriteStream(backupFilePath); + + gzip.stdout.pipe(fileStream); + + pgdump.on('error', (err) => { + this.logger.error('Backup failed with error', err); + reject(err); + }); + + gzip.on('error', (err) => { + this.logger.error('Gzip failed with error', err); + reject(err); + }); + + let pgdumpLogs = ''; + let gzipLogs = ''; + + pgdump.stderr.on('data', (data) => (pgdumpLogs += data)); + gzip.stderr.on('data', (data) => (gzipLogs += data)); + + pgdump.on('exit', (code) => { + if (code !== 0) { + this.logger.error(`Backup failed with code ${code}`); + reject(`Backup failed with code ${code}`); + this.logger.error(pgdumpLogs); + return; + } + if (pgdumpLogs) { + this.logger.debug(`pgdump_all logs\n${pgdumpLogs}`); + } + }); + + gzip.on('exit', (code) => { + if (code !== 0) { + this.logger.error(`Gzip failed with code ${code}`); + reject(`Gzip failed with code ${code}`); + this.logger.error(gzipLogs); + return; + } + if (pgdump.exitCode !== 0) { + this.logger.error(`Gzip exited with code 0 but pgdump exited with ${pgdump.exitCode}`); + return; + } + resolve(); + }); + }); + await this.storageRepository.rename(backupFilePath, backupFilePath.replace('.tmp', '')); + } catch (error) { + this.logger.error('Database Backup Failure', error); + return JobStatus.FAILED; + } + + this.logger.debug(`Database Backup Success`); + await this.cleanupDatabaseBackups(); + return JobStatus.SUCCESS; + } +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 5ee4014e910..a312050f083 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -28,6 +28,7 @@ import { INotificationRepository } from 'src/interfaces/notification.interface'; import { IOAuthRepository } from 'src/interfaces/oauth.interface'; import { IPartnerRepository } from 'src/interfaces/partner.interface'; import { IPersonRepository } from 'src/interfaces/person.interface'; +import { IProcessRepository } from 'src/interfaces/process.interface'; import { ISearchRepository } from 'src/interfaces/search.interface'; import { IServerInfoRepository } from 'src/interfaces/server-info.interface'; import { ISessionRepository } from 'src/interfaces/session.interface'; @@ -72,6 +73,7 @@ export class BaseService { @Inject(IOAuthRepository) protected oauthRepository: IOAuthRepository, @Inject(IPartnerRepository) protected partnerRepository: IPartnerRepository, @Inject(IPersonRepository) protected personRepository: IPersonRepository, + @Inject(IProcessRepository) protected processRepository: IProcessRepository, @Inject(ISearchRepository) protected searchRepository: ISearchRepository, @Inject(IServerInfoRepository) protected serverInfoRepository: IServerInfoRepository, @Inject(ISessionRepository) protected sessionRepository: ISessionRepository, diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 2cfbdb40c21..89c6afd7f4c 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -6,6 +6,7 @@ import { AssetMediaService } from 'src/services/asset-media.service'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; import { AuthService } from 'src/services/auth.service'; +import { BackupService } from 'src/services/backup.service'; import { CliService } from 'src/services/cli.service'; import { DatabaseService } from 'src/services/database.service'; import { DownloadService } from 'src/services/download.service'; @@ -48,6 +49,7 @@ export const services = [ AssetService, AuditService, AuthService, + BackupService, CliService, DatabaseService, DownloadService, diff --git a/server/src/services/job.service.spec.ts b/server/src/services/job.service.spec.ts index 0353deb39b8..8e42693dc02 100644 --- a/server/src/services/job.service.spec.ts +++ b/server/src/services/job.service.spec.ts @@ -44,7 +44,7 @@ describe(JobService.name, () => { sut.onBootstrap(ImmichWorker.MICROSERVICES); sut.onConfigUpdate({ oldConfig: defaults, newConfig: defaults }); - expect(jobMock.setConcurrency).toHaveBeenCalledTimes(14); + expect(jobMock.setConcurrency).toHaveBeenCalledTimes(15); expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(5, QueueName.FACIAL_RECOGNITION, 1); expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(7, QueueName.DUPLICATE_DETECTION, 1); expect(jobMock.setConcurrency).toHaveBeenNthCalledWith(8, QueueName.BACKGROUND_TASK, 5); @@ -114,6 +114,7 @@ describe(JobService.name, () => { [QueueName.SIDECAR]: expectedJobStatus, [QueueName.LIBRARY]: expectedJobStatus, [QueueName.NOTIFICATION]: expectedJobStatus, + [QueueName.BACKUP_DATABASE]: expectedJobStatus, }); }); }); diff --git a/server/src/services/job.service.ts b/server/src/services/job.service.ts index 46771ff0461..15046a0ef53 100644 --- a/server/src/services/job.service.ts +++ b/server/src/services/job.service.ts @@ -220,6 +220,7 @@ export class JobService extends BaseService { QueueName.FACIAL_RECOGNITION, QueueName.STORAGE_TEMPLATE_MIGRATION, QueueName.DUPLICATE_DETECTION, + QueueName.BACKUP_DATABASE, ].includes(name); } diff --git a/server/src/services/microservices.service.ts b/server/src/services/microservices.service.ts index 275103d05c4..c6000778093 100644 --- a/server/src/services/microservices.service.ts +++ b/server/src/services/microservices.service.ts @@ -5,6 +5,7 @@ import { ArgOf } from 'src/interfaces/event.interface'; import { IDeleteFilesJob, JobName } from 'src/interfaces/job.interface'; import { AssetService } from 'src/services/asset.service'; import { AuditService } from 'src/services/audit.service'; +import { BackupService } from 'src/services/backup.service'; import { DuplicateService } from 'src/services/duplicate.service'; import { JobService } from 'src/services/job.service'; import { LibraryService } from 'src/services/library.service'; @@ -26,6 +27,7 @@ export class MicroservicesService { constructor( private auditService: AuditService, private assetService: AssetService, + private backupService: BackupService, private jobService: JobService, private libraryService: LibraryService, private mediaService: MediaService, @@ -52,6 +54,7 @@ export class MicroservicesService { await this.jobService.init({ [JobName.ASSET_DELETION]: (data) => this.assetService.handleAssetDeletion(data), [JobName.ASSET_DELETION_CHECK]: () => this.assetService.handleAssetDeletionCheck(), + [JobName.BACKUP_DATABASE]: () => this.backupService.handleBackupDatabase(), [JobName.DELETE_FILES]: (data: IDeleteFilesJob) => this.storageService.handleDeleteFiles(data), [JobName.CLEAN_OLD_AUDIT_LOGS]: () => this.auditService.handleCleanup(), [JobName.CLEAN_OLD_SESSION_TOKENS]: () => this.sessionService.handleCleanup(), diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index 85e535a6580..dd9bb9969d0 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -32,6 +32,7 @@ describe(StorageService.name, () => { expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountChecks: { + backups: true, 'encoded-video': true, library: true, profile: true, @@ -44,16 +45,19 @@ describe(StorageService.name, () => { expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload'); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/backups'); expect(storageMock.createFile).toHaveBeenCalledWith('upload/encoded-video/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/profile/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/thumbs/.immich', expect.any(Buffer)); expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); }); it('should enable mount folder checking for a new folder type', async () => { systemMock.get.mockResolvedValue({ mountChecks: { + backups: false, 'encoded-video': true, library: false, profile: true, @@ -66,6 +70,7 @@ describe(StorageService.name, () => { expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountChecks: { + backups: true, 'encoded-video': true, library: true, profile: true, @@ -73,10 +78,12 @@ describe(StorageService.name, () => { upload: true, }, }); - expect(storageMock.mkdirSync).toHaveBeenCalledTimes(1); + expect(storageMock.mkdirSync).toHaveBeenCalledTimes(2); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); - expect(storageMock.createFile).toHaveBeenCalledTimes(1); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/backups'); + expect(storageMock.createFile).toHaveBeenCalledTimes(2); expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/backups/.immich', expect.any(Buffer)); }); it('should throw an error if .immich is missing', async () => { diff --git a/server/src/services/system-config.service.spec.ts b/server/src/services/system-config.service.spec.ts index ffbb695e125..2ad7c78ca22 100644 --- a/server/src/services/system-config.service.spec.ts +++ b/server/src/services/system-config.service.spec.ts @@ -44,6 +44,13 @@ const updatedConfig = Object.freeze({ [QueueName.VIDEO_CONVERSION]: { concurrency: 1 }, [QueueName.NOTIFICATION]: { concurrency: 5 }, }, + backup: { + database: { + enabled: true, + cronExpression: '0 02 * * *', + keepLastAmount: 14, + }, + }, ffmpeg: { crf: 30, threads: 0, diff --git a/server/test/fixtures/system-config.stub.ts b/server/test/fixtures/system-config.stub.ts index be21fc40607..9c6822d52f3 100644 --- a/server/test/fixtures/system-config.stub.ts +++ b/server/test/fixtures/system-config.stub.ts @@ -74,6 +74,15 @@ export const systemConfigStub = { }, }, }, + backupEnabled: { + backup: { + database: { + enabled: true, + cronExpression: '0 0 * * *', + keepLastAmount: 1, + }, + }, + }, machineLearningDisabled: { machineLearning: { enabled: false, diff --git a/server/test/repositories/process.repository.mock.ts b/server/test/repositories/process.repository.mock.ts new file mode 100644 index 00000000000..9a3c5a30b61 --- /dev/null +++ b/server/test/repositories/process.repository.mock.ts @@ -0,0 +1,8 @@ +import { IProcessRepository } from 'src/interfaces/process.interface'; +import { Mocked, vitest } from 'vitest'; + +export const newProcessRepositoryMock = (): Mocked => { + return { + spawn: vitest.fn(), + }; +}; diff --git a/server/test/repositories/storage.repository.mock.ts b/server/test/repositories/storage.repository.mock.ts index 5226e0bb1e9..0af16a8d175 100644 --- a/server/test/repositories/storage.repository.mock.ts +++ b/server/test/repositories/storage.repository.mock.ts @@ -49,6 +49,7 @@ export const newStorageRepositoryMock = (reset = true): Mocked( const oauthMock = newOAuthRepositoryMock(); const partnerMock = newPartnerRepositoryMock(); const personMock = newPersonRepositoryMock(); + const processMock = newProcessRepositoryMock(); const searchMock = newSearchRepositoryMock(); const serverInfoMock = newServerInfoRepositoryMock(); const sessionMock = newSessionRepositoryMock(); @@ -117,6 +122,7 @@ export const newTestService = ( oauthMock, partnerMock, personMock, + processMock, searchMock, serverInfoMock, sessionMock, @@ -158,6 +164,7 @@ export const newTestService = ( oauthMock, partnerMock, personMock, + processMock, searchMock, serverInfoMock, sessionMock, @@ -203,3 +210,37 @@ export const newRandomImage = () => { return value; }; + +export const mockSpawn = vitest.fn((exitCode: number, stdout: string, stderr: string, error?: unknown) => { + return { + stdout: new Readable({ + read() { + this.push(stdout); // write mock data to stdout + this.push(null); // end stream + }, + }), + stderr: new Readable({ + read() { + this.push(stderr); // write mock data to stderr + this.push(null); // end stream + }, + }), + stdin: new Writable({ + write(chunk, encoding, callback) { + callback(); + }, + }), + exitCode, + on: vitest.fn((event, callback: any) => { + if (event === 'close') { + callback(0); + } + if (event === 'error' && error) { + callback(error); + } + if (event === 'exit') { + callback(exitCode); + } + }), + } as unknown as ChildProcessWithoutNullStreams; +}); diff --git a/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte b/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte new file mode 100644 index 00000000000..05543f11246 --- /dev/null +++ b/web/src/lib/components/admin-page/settings/backup-settings/backup-settings.svelte @@ -0,0 +1,91 @@ + + +
+
+
+
+ + + + + + +

+ + + {message} +
+
+
+

+
+
+ + + + onReset({ ...options, configKeys: ['backup'] })} + onSave={() => onSave({ backup: config.backup })} + showResetToDefault={!isEqual(savedConfig.backup, defaultConfig.backup)} + {disabled} + /> +
+
+
+
diff --git a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte index 72f899a7d61..6cf5505d5bb 100644 --- a/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte +++ b/web/src/lib/components/admin-page/settings/library-settings/library-settings.svelte @@ -65,7 +65,7 @@ class="font-medium text-immich-primary dark:text-immich-dark-primary text-sm" for="expression-select" > - {$t('admin.library_cron_expression_presets')} + {$t('admin.cron_expression_presets')}