From ed5b1b1085da228ffe419bb2890039a5ce3c4688 Mon Sep 17 00:00:00 2001 From: Zack Pollard Date: Mon, 28 Oct 2024 14:41:57 +0000 Subject: [PATCH] feat: mount checks on a folder level --- server/src/entities/system-metadata.entity.ts | 6 +-- server/src/services/storage.service.spec.ts | 49 ++++++++++++++++--- server/src/services/storage.service.ts | 24 ++++++--- 3 files changed, 64 insertions(+), 15 deletions(-) diff --git a/server/src/entities/system-metadata.entity.ts b/server/src/entities/system-metadata.entity.ts index 0a238e1da5f6b..0a03a554039f3 100644 --- a/server/src/entities/system-metadata.entity.ts +++ b/server/src/entities/system-metadata.entity.ts @@ -1,5 +1,5 @@ import { SystemConfig } from 'src/config'; -import { SystemMetadataKey } from 'src/enum'; +import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { Column, DeepPartial, Entity, PrimaryColumn } from 'typeorm'; @Entity('system_metadata') @@ -12,7 +12,7 @@ export class SystemMetadataEntity }; export interface SystemMetadata extends Record> { [SystemMetadataKey.ADMIN_ONBOARDING]: { isOnboarded: boolean }; @@ -20,6 +20,6 @@ export interface SystemMetadata extends Record; - [SystemMetadataKey.SYSTEM_FLAGS]: SystemFlags; + [SystemMetadataKey.SYSTEM_FLAGS]: DeepPartial; [SystemMetadataKey.VERSION_CHECK_STATE]: VersionCheckMetadata; } diff --git a/server/src/services/storage.service.spec.ts b/server/src/services/storage.service.spec.ts index a4903a3987b10..85e535a658001 100644 --- a/server/src/services/storage.service.spec.ts +++ b/server/src/services/storage.service.spec.ts @@ -30,11 +30,20 @@ describe(StorageService.name, () => { await expect(sut.onBootstrap()).resolves.toBeUndefined(); - expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { mountFiles: true }); + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + mountChecks: { + 'encoded-video': true, + library: true, + profile: true, + thumbs: true, + upload: true, + }, + }); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/encoded-video'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/profile'); expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/thumbs'); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/upload'); 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)); @@ -42,8 +51,36 @@ describe(StorageService.name, () => { expect(storageMock.createFile).toHaveBeenCalledWith('upload/upload/.immich', expect.any(Buffer)); }); + it('should enable mount folder checking for a new folder type', async () => { + systemMock.get.mockResolvedValue({ + mountChecks: { + 'encoded-video': true, + library: false, + profile: true, + thumbs: true, + upload: true, + }, + }); + + await expect(sut.onBootstrap()).resolves.toBeUndefined(); + + expect(systemMock.set).toHaveBeenCalledWith(SystemMetadataKey.SYSTEM_FLAGS, { + mountChecks: { + 'encoded-video': true, + library: true, + profile: true, + thumbs: true, + upload: true, + }, + }); + expect(storageMock.mkdirSync).toHaveBeenCalledTimes(1); + expect(storageMock.mkdirSync).toHaveBeenCalledWith('upload/library'); + expect(storageMock.createFile).toHaveBeenCalledTimes(1); + expect(storageMock.createFile).toHaveBeenCalledWith('upload/library/.immich', expect.any(Buffer)); + }); + it('should throw an error if .immich is missing', async () => { - systemMock.get.mockResolvedValue({ mountFiles: true }); + systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); await expect(sut.onBootstrap()).rejects.toThrow('Failed to read'); @@ -53,7 +90,7 @@ describe(StorageService.name, () => { }); it('should throw an error if .immich is present but read-only', async () => { - systemMock.get.mockResolvedValue({ mountFiles: true }); + systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'")); await expect(sut.onBootstrap()).rejects.toThrow('Failed to write'); @@ -64,7 +101,7 @@ describe(StorageService.name, () => { it('should skip mount file creation if file already exists', async () => { const error = new Error('Error creating file') as any; error.code = 'EEXIST'; - systemMock.get.mockResolvedValue({ mountFiles: false }); + systemMock.get.mockResolvedValue({ mountChecks: {} }); storageMock.createFile.mockRejectedValue(error); await expect(sut.onBootstrap()).resolves.toBeUndefined(); @@ -73,7 +110,7 @@ describe(StorageService.name, () => { }); it('should throw an error if mount file could not be created', async () => { - systemMock.get.mockResolvedValue({ mountFiles: false }); + systemMock.get.mockResolvedValue({ mountChecks: {} }); storageMock.createFile.mockRejectedValue(new Error('Error creating file')); await expect(sut.onBootstrap()).rejects.toBeInstanceOf(ImmichStartupError); @@ -81,7 +118,7 @@ describe(StorageService.name, () => { }); it('should startup if checks are disabled', async () => { - systemMock.get.mockResolvedValue({ mountFiles: true }); + systemMock.get.mockResolvedValue({ mountChecks: { upload: true } }); configMock.getEnv.mockReturnValue( mockEnvData({ storage: { ignoreMountCheckErrors: true }, diff --git a/server/src/services/storage.service.ts b/server/src/services/storage.service.ts index e8620b4371dd0..3b6a16fb41911 100644 --- a/server/src/services/storage.service.ts +++ b/server/src/services/storage.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { join } from 'node:path'; import { StorageCore } from 'src/cores/storage.core'; import { OnEvent } from 'src/decorators'; +import { SystemFlags } from 'src/entities/system-metadata.entity'; import { StorageFolder, SystemMetadataKey } from 'src/enum'; import { DatabaseLock } from 'src/interfaces/database.interface'; import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface'; @@ -19,25 +20,36 @@ export class StorageService extends BaseService { const envData = this.configRepository.getEnv(); await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => { - const flags = (await this.systemMetadataRepository.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false }; - const enabled = flags.mountFiles ?? false; + const flags = + (await this.systemMetadataRepository.get(SystemMetadataKey.SYSTEM_FLAGS)) || + ({ mountChecks: {} } as SystemFlags); - this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`); + if (!flags.mountChecks) { + flags.mountChecks = {}; + } + + let updated = false; + + this.logger.log(`Verifying system mount folder checks, current state: ${JSON.stringify(flags)}`); try { // check each folder exists and is writable for (const folder of Object.values(StorageFolder)) { - if (!enabled) { + if (!flags.mountChecks[folder]) { this.logger.log(`Writing initial mount file for the ${folder} folder`); await this.createMountFile(folder); } await this.verifyReadAccess(folder); await this.verifyWriteAccess(folder); + + if (!flags.mountChecks[folder]) { + flags.mountChecks[folder] = true; + updated = true; + } } - if (!flags.mountFiles) { - flags.mountFiles = true; + if (updated) { await this.systemMetadataRepository.set(SystemMetadataKey.SYSTEM_FLAGS, flags); this.logger.log('Successfully enabled system mount folders checks'); }