Skip to content

Commit 7dffc95

Browse files
committed
feat(server): better mount checks
1 parent d46e502 commit 7dffc95

File tree

7 files changed

+156
-61
lines changed

7 files changed

+156
-61
lines changed

Diff for: docs/docs/administration/system-integrity.md

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# System Integrity
2+
3+
## Folder checks
4+
5+
:::info
6+
The folders considered for these checks include: `upload/`, `library/`, `thumbs/`, `encoded-video/`, `profile/`
7+
:::
8+
9+
When Immich starts, it performs a series of checks in order to validate that it can read and write files to the volume mounts used by the storage system. If it cannot perform all the required operations, it will fail to start. The checks include:
10+
11+
- Creating an initial hidden file (`.immich`) in each folder
12+
- Reading a hidden file (`.immich`) in each folder
13+
- Overwriting a hidden file (`.immich`) in each folder
14+
15+
The checks are designed to catch the following situations:
16+
17+
- Incorrect permissions (cannot read/write files)
18+
- Missing volume mount (`.immich` files should exist, but are missing)
19+
20+
### Common issues
21+
22+
:::note
23+
`.immich` files serve as markers and help keep track of volume mounts being used by Immich. Except for the situations listed below, they should never be manually created or deleted.
24+
:::
25+
26+
#### Missing `.immich` files
27+
28+
```
29+
Verifying system mount folder checks (enabled=true)
30+
...
31+
ENOENT: no such file or directory, open 'upload/encoded-video/.immich'
32+
```
33+
34+
The above error messages show that the server has previously (successfully) written `.immich` files to each folder, but now does not detect them. This could be because any of the following:
35+
36+
- Permission error - unable to read the file, but it exists
37+
- File does not exist - volume mount has changed and should be corrected
38+
- File does not exist - user manually deleted it and should be manually re-created (`touch .immich`)
39+
- File does not exist - user restored from a backup, but did not restore each folder (user should restore all folders or manually create `.immich` in any missing folders)
40+
41+
### Ignoring the checks
42+
43+
The checks are designed to catch common problems that we have seen users have in the past, but if you want to disable them you can set the following environment variable:
44+
45+
```
46+
IMMICH_IGNORE_MOUNT_CHECK_ERRORS=true
47+
```

Diff for: server/src/interfaces/config.interface.ts

+3
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ export interface EnvData {
77
skipMigrations: boolean;
88
vectorExtension: VectorExtension;
99
};
10+
storage: {
11+
ignoreMountCheckErrors: boolean;
12+
};
1013
}
1114

1215
export interface IConfigRepository {

Diff for: server/src/repositories/config.repository.ts

+3
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ export class ConfigRepository implements IConfigRepository {
1010
skipMigrations: process.env.DB_SKIP_MIGRATIONS === 'true',
1111
vectorExtension: getVectorExtension(),
1212
},
13+
storage: {
14+
ignoreMountCheckErrors: (process.env.IMMICH_IGNORE_MOUNT_CHECK_ERRORS ?? 'true') === 'true',
15+
},
1316
};
1417
}
1518
}

Diff for: server/src/services/database.service.spec.ts

+20-14
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import {
77
} from 'src/interfaces/database.interface';
88
import { ILoggerRepository } from 'src/interfaces/logger.interface';
99
import { DatabaseService } from 'src/services/database.service';
10-
import { newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
10+
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
1111
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
1212
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
1313
import { Mocked } from 'vitest';
@@ -60,7 +60,9 @@ describe(DatabaseService.name, () => {
6060
{ extension: DatabaseExtension.VECTORS, extensionName: EXTENSION_NAMES[DatabaseExtension.VECTORS] },
6161
])('should work with $extensionName', ({ extension, extensionName }) => {
6262
beforeEach(() => {
63-
configMock.getEnv.mockReturnValue({ database: { skipMigrations: false, vectorExtension: extension } });
63+
configMock.getEnv.mockReturnValue(
64+
mockEnvData({ database: { skipMigrations: false, vectorExtension: extension } }),
65+
);
6466
});
6567

6668
it(`should start up successfully with ${extension}`, async () => {
@@ -244,25 +246,29 @@ describe(DatabaseService.name, () => {
244246
});
245247

246248
it('should skip migrations if DB_SKIP_MIGRATIONS=true', async () => {
247-
configMock.getEnv.mockReturnValue({
248-
database: {
249-
skipMigrations: true,
250-
vectorExtension: DatabaseExtension.VECTORS,
251-
},
252-
});
249+
configMock.getEnv.mockReturnValue(
250+
mockEnvData({
251+
database: {
252+
skipMigrations: true,
253+
vectorExtension: DatabaseExtension.VECTORS,
254+
},
255+
}),
256+
);
253257

254258
await expect(sut.onBootstrap()).resolves.toBeUndefined();
255259

256260
expect(databaseMock.runMigrations).not.toHaveBeenCalled();
257261
});
258262

259263
it(`should throw error if pgvector extension could not be created`, async () => {
260-
configMock.getEnv.mockReturnValue({
261-
database: {
262-
skipMigrations: true,
263-
vectorExtension: DatabaseExtension.VECTOR,
264-
},
265-
});
264+
configMock.getEnv.mockReturnValue(
265+
mockEnvData({
266+
database: {
267+
skipMigrations: true,
268+
vectorExtension: DatabaseExtension.VECTOR,
269+
},
270+
}),
271+
);
266272
databaseMock.getExtensionVersion.mockResolvedValue({
267273
installedVersion: null,
268274
availableVersion: minVersionInRange,

Diff for: server/src/services/storage.service.spec.ts

+21-3
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import { SystemMetadataKey } from 'src/enum';
2+
import { IConfigRepository } from 'src/interfaces/config.interface';
23
import { IDatabaseRepository } from 'src/interfaces/database.interface';
34
import { ILoggerRepository } from 'src/interfaces/logger.interface';
45
import { IStorageRepository } from 'src/interfaces/storage.interface';
56
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
67
import { StorageService } from 'src/services/storage.service';
8+
import { mockEnvData, newConfigRepositoryMock } from 'test/repositories/config.repository.mock';
79
import { newDatabaseRepositoryMock } from 'test/repositories/database.repository.mock';
810
import { newLoggerRepositoryMock } from 'test/repositories/logger.repository.mock';
911
import { newStorageRepositoryMock } from 'test/repositories/storage.repository.mock';
@@ -12,18 +14,20 @@ import { Mocked } from 'vitest';
1214

1315
describe(StorageService.name, () => {
1416
let sut: StorageService;
17+
let configMock: Mocked<IConfigRepository>;
1518
let databaseMock: Mocked<IDatabaseRepository>;
1619
let storageMock: Mocked<IStorageRepository>;
1720
let loggerMock: Mocked<ILoggerRepository>;
1821
let systemMock: Mocked<ISystemMetadataRepository>;
1922

2023
beforeEach(() => {
24+
configMock = newConfigRepositoryMock();
2125
databaseMock = newDatabaseRepositoryMock();
2226
storageMock = newStorageRepositoryMock();
2327
loggerMock = newLoggerRepositoryMock();
2428
systemMock = newSystemMetadataRepositoryMock();
2529

26-
sut = new StorageService(databaseMock, storageMock, loggerMock, systemMock);
30+
sut = new StorageService(configMock, databaseMock, storageMock, loggerMock, systemMock);
2731
});
2832

2933
it('should work', () => {
@@ -52,7 +56,7 @@ describe(StorageService.name, () => {
5256
systemMock.get.mockResolvedValue({ mountFiles: true });
5357
storageMock.readFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
5458

55-
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
59+
await expect(sut.onBootstrap()).rejects.toThrow('Failed to read');
5660

5761
expect(storageMock.createOrOverwriteFile).not.toHaveBeenCalled();
5862
expect(systemMock.set).not.toHaveBeenCalled();
@@ -62,7 +66,21 @@ describe(StorageService.name, () => {
6266
systemMock.get.mockResolvedValue({ mountFiles: true });
6367
storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
6468

65-
await expect(sut.onBootstrap()).rejects.toThrow('Failed to validate folder mount');
69+
await expect(sut.onBootstrap()).rejects.toThrow('Failed to write');
70+
71+
expect(systemMock.set).not.toHaveBeenCalled();
72+
});
73+
74+
it('should startup if checks are disabled', async () => {
75+
systemMock.get.mockResolvedValue({ mountFiles: true });
76+
configMock.getEnv.mockReturnValue(
77+
mockEnvData({
78+
storage: { ignoreMountCheckErrors: true },
79+
}),
80+
);
81+
storageMock.overwriteFile.mockRejectedValue(new Error("ENOENT: no such file or directory, open '/app/.immich'"));
82+
83+
await expect(sut.onBootstrap()).resolves.toBeUndefined();
6684

6785
expect(systemMock.set).not.toHaveBeenCalled();
6886
});

Diff for: server/src/services/storage.service.ts

+48-37
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,20 @@ import { join } from 'node:path';
33
import { StorageCore } from 'src/cores/storage.core';
44
import { OnEvent } from 'src/decorators';
55
import { StorageFolder, SystemMetadataKey } from 'src/enum';
6+
import { IConfigRepository } from 'src/interfaces/config.interface';
67
import { DatabaseLock, IDatabaseRepository } from 'src/interfaces/database.interface';
78
import { IDeleteFilesJob, JobStatus } from 'src/interfaces/job.interface';
89
import { ILoggerRepository } from 'src/interfaces/logger.interface';
910
import { IStorageRepository } from 'src/interfaces/storage.interface';
1011
import { ISystemMetadataRepository } from 'src/interfaces/system-metadata.interface';
1112
import { ImmichStartupError } from 'src/utils/events';
1213

14+
const docsMessage = `Please see https://immich.app/docs/administration/system-integrity#folder-checks for more information.`;
15+
1316
@Injectable()
1417
export class StorageService {
1518
constructor(
19+
@Inject(IConfigRepository) private configRepository: IConfigRepository,
1620
@Inject(IDatabaseRepository) private databaseRepository: IDatabaseRepository,
1721
@Inject(IStorageRepository) private storageRepository: IStorageRepository,
1822
@Inject(ILoggerRepository) private logger: ILoggerRepository,
@@ -23,30 +27,41 @@ export class StorageService {
2327

2428
@OnEvent({ name: 'app.bootstrap' })
2529
async onBootstrap() {
30+
const envData = this.configRepository.getEnv();
31+
2632
await this.databaseRepository.withLock(DatabaseLock.SystemFileMounts, async () => {
2733
const flags = (await this.systemMetadata.get(SystemMetadataKey.SYSTEM_FLAGS)) || { mountFiles: false };
2834
const enabled = flags.mountFiles ?? false;
2935

3036
this.logger.log(`Verifying system mount folder checks (enabled=${enabled})`);
3137

32-
// check each folder exists and is writable
33-
for (const folder of Object.values(StorageFolder)) {
34-
if (!enabled) {
35-
this.logger.log(`Writing initial mount file for the ${folder} folder`);
36-
await this.createMountFile(folder);
38+
try {
39+
// check each folder exists and is writable
40+
for (const folder of Object.values(StorageFolder)) {
41+
if (!enabled) {
42+
this.logger.log(`Writing initial mount file for the ${folder} folder`);
43+
await this.createMountFile(folder);
44+
}
45+
46+
await this.verifyReadAccess(folder);
47+
await this.verifyWriteAccess(folder);
3748
}
3849

39-
await this.verifyReadAccess(folder);
40-
await this.verifyWriteAccess(folder);
41-
}
50+
if (!flags.mountFiles) {
51+
flags.mountFiles = true;
52+
await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags);
53+
this.logger.log('Successfully enabled system mount folders checks');
54+
}
4255

43-
if (!flags.mountFiles) {
44-
flags.mountFiles = true;
45-
await this.systemMetadata.set(SystemMetadataKey.SYSTEM_FLAGS, flags);
46-
this.logger.log('Successfully enabled system mount folders checks');
56+
this.logger.log('Successfully verified system mount folder checks');
57+
} catch (error) {
58+
if (envData.storage.ignoreMountCheckErrors) {
59+
this.logger.error(error);
60+
this.logger.warn('Ignoring mount folder errors');
61+
} else {
62+
throw error;
63+
}
4764
}
48-
49-
this.logger.log('Successfully verified system mount folder checks');
5065
});
5166
}
5267

@@ -70,49 +85,45 @@ export class StorageService {
7085
}
7186

7287
private async verifyReadAccess(folder: StorageFolder) {
73-
const { filePath } = this.getMountFilePaths(folder);
88+
const { internalPath, externalPath } = this.getMountFilePaths(folder);
7489
try {
75-
await this.storageRepository.readFile(filePath);
90+
await this.storageRepository.readFile(internalPath);
7691
} catch (error) {
77-
this.logger.error(`Failed to read ${filePath}: ${error}`);
78-
this.logger.error(
79-
`The "${folder}" folder appears to be offline/missing, please make sure the volume is mounted with the correct permissions`,
80-
);
81-
throw new ImmichStartupError(`Failed to validate folder mount (read from "<MEDIA_LOCATION>/${folder}")`);
92+
this.logger.error(`Failed to read ${internalPath}: ${error}`);
93+
throw new ImmichStartupError(`Failed to read "${externalPath} - ${docsMessage}"`);
8294
}
8395
}
8496

8597
private async createMountFile(folder: StorageFolder) {
86-
const { folderPath, filePath } = this.getMountFilePaths(folder);
98+
const { folderPath, internalPath, externalPath } = this.getMountFilePaths(folder);
8799
try {
88100
this.storageRepository.mkdirSync(folderPath);
89-
await this.storageRepository.createFile(filePath, Buffer.from(`${Date.now()}`));
101+
await this.storageRepository.createFile(internalPath, Buffer.from(`${Date.now()}`));
90102
} catch (error) {
91-
this.logger.error(`Failed to create ${filePath}: ${error}`);
92-
this.logger.error(
93-
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
94-
);
95-
throw new ImmichStartupError(`Failed to validate folder mount (write to "<UPLOAD_LOCATION>/${folder}")`);
103+
if ((error as NodeJS.ErrnoException).code === 'EEXIST') {
104+
this.logger.warn('Found existing mount file, skipping creation');
105+
return;
106+
}
107+
this.logger.error(`Failed to create ${internalPath}: ${error}`);
108+
throw new ImmichStartupError(`Failed to create "${externalPath} - ${docsMessage}"`);
96109
}
97110
}
98111

99112
private async verifyWriteAccess(folder: StorageFolder) {
100-
const { filePath } = this.getMountFilePaths(folder);
113+
const { internalPath, externalPath } = this.getMountFilePaths(folder);
101114
try {
102-
await this.storageRepository.overwriteFile(filePath, Buffer.from(`${Date.now()}`));
115+
await this.storageRepository.overwriteFile(internalPath, Buffer.from(`${Date.now()}`));
103116
} catch (error) {
104-
this.logger.error(`Failed to write ${filePath}: ${error}`);
105-
this.logger.error(
106-
`The "${folder}" folder cannot be written to, please make sure the volume is mounted with the correct permissions`,
107-
);
108-
throw new ImmichStartupError(`Failed to validate folder mount (write to "<UPLOAD_LOCATION>/${folder}")`);
117+
this.logger.error(`Failed to write ${internalPath}: ${error}`);
118+
throw new ImmichStartupError(`Failed to write "${externalPath} - ${docsMessage}"`);
109119
}
110120
}
111121

112122
private getMountFilePaths(folder: StorageFolder) {
113123
const folderPath = StorageCore.getBaseFolder(folder);
114-
const filePath = join(folderPath, '.immich');
124+
const internalPath = join(folderPath, '.immich');
125+
const externalPath = `<UPLOAD_LOCATION>/${folder}/.immich`;
115126

116-
return { folderPath, filePath };
127+
return { folderPath, internalPath, externalPath };
117128
}
118129
}

Diff for: server/test/repositories/config.repository.mock.ts

+14-7
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,21 @@
1-
import { IConfigRepository } from 'src/interfaces/config.interface';
1+
import { EnvData, IConfigRepository } from 'src/interfaces/config.interface';
22
import { DatabaseExtension } from 'src/interfaces/database.interface';
33
import { Mocked, vitest } from 'vitest';
44

5+
const envData: EnvData = {
6+
database: {
7+
skipMigrations: false,
8+
vectorExtension: DatabaseExtension.VECTORS,
9+
},
10+
storage: {
11+
ignoreMountCheckErrors: false,
12+
},
13+
};
14+
515
export const newConfigRepositoryMock = (): Mocked<IConfigRepository> => {
616
return {
7-
getEnv: vitest.fn().mockReturnValue({
8-
database: {
9-
skipMigration: false,
10-
vectorExtension: DatabaseExtension.VECTORS,
11-
},
12-
}),
17+
getEnv: vitest.fn().mockReturnValue(envData),
1318
};
1419
};
20+
21+
export const mockEnvData = (config: Partial<EnvData>) => ({ ...envData, ...config });

0 commit comments

Comments
 (0)