From 72f9295490e8dd4d35993b0dab7661fb26493864 Mon Sep 17 00:00:00 2001 From: bo0tzz Date: Tue, 12 Mar 2024 16:29:49 +0100 Subject: [PATCH] feat(server): YAML config file support (#7894) * test(server): Load config from yaml * docs: YAML config support * feat(server): YAML config file support * fix format --------- Co-authored-by: Alex Tran --- docs/docs/install/config-file.md | 6 +++- server/package-lock.json | 14 ++++++++ server/package.json | 2 ++ .../system-config/system-config.core.ts | 11 ++++--- .../system-config.service.spec.ts | 32 ++++++++++++++++++- 5 files changed, 58 insertions(+), 7 deletions(-) diff --git a/docs/docs/install/config-file.md b/docs/docs/install/config-file.md index 78f2663b55..9a1d1acb1b 100644 --- a/docs/docs/install/config-file.md +++ b/docs/docs/install/config-file.md @@ -4,7 +4,7 @@ A config file can be provided as an alternative to the UI configuration. ### Step 1 - Create a new config file -In JSON format, create a new config file (e.g. `immich.config`) and put it in a location that can be accessed by Immich. +In JSON format, create a new config file (e.g. `immich.json`) and put it in a location that can be accessed by Immich. The default configuration looks like this: ```json @@ -163,3 +163,7 @@ So you can just grab it from there, paste it into a file and you're pretty much In your `.env` file, set the variable `IMMICH_CONFIG_FILE` to the path of your config. For more information, refer to the [Environment Variables](/docs/install/environment-variables.md) section. + +:::tip +YAML-formatted config files are also supported. +::: diff --git a/server/package-lock.json b/server/package-lock.json index da5251d9b5..49226d28f8 100644 --- a/server/package-lock.json +++ b/server/package-lock.json @@ -44,6 +44,7 @@ "i18n-iso-countries": "^7.6.0", "ioredis": "^5.3.2", "joi": "^17.10.0", + "js-yaml": "^4.1.0", "lodash": "^4.17.21", "luxon": "^3.4.2", "nest-commander": "^3.11.1", @@ -75,6 +76,7 @@ "@types/imagemin": "^8.0.1", "@types/jest": "29.5.12", "@types/jest-when": "^3.5.2", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", @@ -4571,6 +4573,12 @@ "@types/jest": "*" } }, + "node_modules/@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true + }, "node_modules/@types/json-schema": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz", @@ -17578,6 +17586,12 @@ "@types/jest": "*" } }, + "@types/js-yaml": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz", + "integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==", + "dev": true + }, "@types/json-schema": { "version": "7.0.13", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.13.tgz", diff --git a/server/package.json b/server/package.json index f6baa5969c..1d045b82ad 100644 --- a/server/package.json +++ b/server/package.json @@ -68,6 +68,7 @@ "i18n-iso-countries": "^7.6.0", "ioredis": "^5.3.2", "joi": "^17.10.0", + "js-yaml": "^4.1.0", "lodash": "^4.17.21", "luxon": "^3.4.2", "nest-commander": "^3.11.1", @@ -99,6 +100,7 @@ "@types/imagemin": "^8.0.1", "@types/jest": "29.5.12", "@types/jest-when": "^3.5.2", + "@types/js-yaml": "^4.0.9", "@types/lodash": "^4.14.197", "@types/mock-fs": "^4.13.1", "@types/multer": "^1.4.7", diff --git a/server/src/domain/system-config/system-config.core.ts b/server/src/domain/system-config/system-config.core.ts index 644d5c3cbb..4a45de93e9 100644 --- a/server/src/domain/system-config/system-config.core.ts +++ b/server/src/domain/system-config/system-config.core.ts @@ -17,6 +17,7 @@ import { BadRequestException, ForbiddenException, Injectable } from '@nestjs/com import { CronExpression } from '@nestjs/schedule'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; +import { load as loadYaml } from 'js-yaml'; import * as _ from 'lodash'; import { Subject } from 'rxjs'; import { QueueName } from '../job/job.constants'; @@ -341,19 +342,19 @@ export class SystemConfigCore { if (force || !this.configCache) { try { const file = await this.repository.readFile(filepath); - const json = JSON.parse(file.toString()); + const config = loadYaml(file.toString()) as any; const overrides: SystemConfigEntity[] = []; for (const key of Object.values(SystemConfigKey)) { - const value = _.get(json, key); - this.unsetDeep(json, key); + const value = _.get(config, key); + this.unsetDeep(config, key); if (value !== undefined) { overrides.push({ key, value }); } } - if (!_.isEmpty(json)) { - this.logger.warn(`Unknown keys found: ${JSON.stringify(json, null, 2)}`); + if (!_.isEmpty(config)) { + this.logger.warn(`Unknown keys found: ${JSON.stringify(config, null, 2)}`); } this.configCache = overrides; diff --git a/server/src/domain/system-config/system-config.service.spec.ts b/server/src/domain/system-config/system-config.service.spec.ts index 91c095cb75..8fa203ae24 100644 --- a/server/src/domain/system-config/system-config.service.spec.ts +++ b/server/src/domain/system-config/system-config.service.spec.ts @@ -209,7 +209,7 @@ describe(SystemConfigService.name, () => { await expect(sut.getConfig()).resolves.toEqual(updatedConfig); }); - it('should load the config from a file', async () => { + it('should load the config from a json file', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; const partialConfig = { ffmpeg: { crf: 30 }, @@ -224,6 +224,25 @@ describe(SystemConfigService.name, () => { expect(configMock.readFile).toHaveBeenCalledWith('immich-config.json'); }); + it('should load the config from a yaml file', async () => { + process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml'; + const partialConfig = ` + ffmpeg: + crf: 30 + oauth: + autoLaunch: true + trash: + days: 10 + user: + deleteDelay: 15 + `; + configMock.readFile.mockResolvedValue(partialConfig); + + await expect(sut.getConfig()).resolves.toEqual(updatedConfig); + + expect(configMock.readFile).toHaveBeenCalledWith('immich-config.yaml'); + }); + it('should accept an empty configuration file', async () => { process.env.IMMICH_CONFIG_FILE = 'immich-config.json'; configMock.readFile.mockResolvedValue(JSON.stringify({})); @@ -242,6 +261,17 @@ describe(SystemConfigService.name, () => { expect(config.machineLearning.url).toEqual('immich_machine_learning'); }); + it('should warn for unknown options in yaml', async () => { + process.env.IMMICH_CONFIG_FILE = 'immich-config.yaml'; + const partialConfig = ` + unknownOption: true + `; + configMock.readFile.mockResolvedValue(partialConfig); + + await sut.getConfig(); + expect(warnLog).toHaveBeenCalled(); + }); + const tests = [ { should: 'validate numbers', config: { ffmpeg: { crf: 'not-a-number' } } }, { should: 'validate booleans', config: { oauth: { enabled: 'invalid' } } },