mirror of
				https://github.com/gristlabs/grist-core.git
				synced 2025-06-13 20:53:59 +00:00 
			
		
		
		
	Extracts config.json into its own module (#1061)
This adds a config file that's loaded very early on during startup. It enables us to save/load settings from within Grist's admin panel, that affect the startup of the FlexServer. The config file loading: - Is type-safe, - Validates the config file on startup - Provides a path to upgrade to future versions. It should be extensible from other versions of Grist (such as desktop), by overriding `getGlobalConfig` in stubs. ---- Some minor refactors needed to occur to make this possible. This includes: - Extracting config loading into its own module (out of FlexServer). - Cleaning up the `loadConfig` function in FlexServer into `loadLoginSystem` (which is what its main purpose was before).
This commit is contained in:
		
							parent
							
								
									6171a012db
								
							
						
					
					
						commit
						6908807236
					
				| @ -54,7 +54,7 @@ import {InstallAdmin} from 'app/server/lib/InstallAdmin'; | ||||
| import log from 'app/server/lib/log'; | ||||
| import {getLoginSystem} from 'app/server/lib/logins'; | ||||
| import {IPermitStore} from 'app/server/lib/Permit'; | ||||
| import {getAppPathTo, getAppRoot, getUnpackedAppRoot} from 'app/server/lib/places'; | ||||
| import {getAppPathTo, getAppRoot, getInstanceRoot, getUnpackedAppRoot} from 'app/server/lib/places'; | ||||
| import {addPluginEndpoints, limitToPlugins} from 'app/server/lib/PluginEndpoint'; | ||||
| import {PluginManager} from 'app/server/lib/PluginManager'; | ||||
| import * as ProcessMonitor from 'app/server/lib/ProcessMonitor'; | ||||
| @ -87,6 +87,7 @@ import {AddressInfo} from 'net'; | ||||
| import fetch from 'node-fetch'; | ||||
| import * as path from 'path'; | ||||
| import * as serveStatic from "serve-static"; | ||||
| import {IGristCoreConfig} from "./configCore"; | ||||
| 
 | ||||
| // Health checks are a little noisy in the logs, so we don't show them all.
 | ||||
| // We show the first N health checks:
 | ||||
| @ -105,6 +106,9 @@ export interface FlexServerOptions { | ||||
|   baseDomain?: string; | ||||
|   // Base URL for plugins, if permitted. Defaults to APP_UNTRUSTED_URL.
 | ||||
|   pluginUrl?: string; | ||||
| 
 | ||||
|   // Global grist config options
 | ||||
|   settings?: IGristCoreConfig; | ||||
| } | ||||
| 
 | ||||
| const noop: express.RequestHandler = (req, res, next) => next(); | ||||
| @ -122,7 +126,7 @@ export class FlexServer implements GristServer { | ||||
|   public housekeeper: Housekeeper; | ||||
|   public server: http.Server; | ||||
|   public httpsServer?: https.Server; | ||||
|   public settings?: Readonly<Record<string, unknown>>; | ||||
|   public settings?: IGristCoreConfig; | ||||
|   public worker: DocWorkerInfo; | ||||
|   public electronServerMethods: ElectronServerMethods; | ||||
|   public readonly docsRoot: string; | ||||
| @ -186,6 +190,7 @@ export class FlexServer implements GristServer { | ||||
| 
 | ||||
|   constructor(public port: number, public name: string = 'flexServer', | ||||
|               public readonly options: FlexServerOptions = {}) { | ||||
|     this.settings = options.settings; | ||||
|     this.app = express(); | ||||
|     this.app.set('port', port); | ||||
| 
 | ||||
| @ -662,7 +667,7 @@ export class FlexServer implements GristServer { | ||||
| 
 | ||||
|   public get instanceRoot() { | ||||
|     if (!this._instanceRoot) { | ||||
|       this._instanceRoot = path.resolve(process.env.GRIST_INST_DIR || this.appRoot); | ||||
|       this._instanceRoot = getInstanceRoot(); | ||||
|       this.info.push(['instanceRoot', this._instanceRoot]); | ||||
|     } | ||||
|     return this._instanceRoot; | ||||
| @ -774,7 +779,7 @@ export class FlexServer implements GristServer { | ||||
|   // Set up the main express middleware used.  For a single user setup, without logins,
 | ||||
|   // all this middleware is currently a no-op.
 | ||||
|   public addAccessMiddleware() { | ||||
|     if (this._check('middleware', 'map', 'config', isSingleUserMode() ? null : 'hosts')) { return; } | ||||
|     if (this._check('middleware', 'map', 'loginMiddleware', isSingleUserMode() ? null : 'hosts')) { return; } | ||||
| 
 | ||||
|     if (!isSingleUserMode()) { | ||||
|       const skipSession = appSettings.section('login').flag('skipSession').readBool({ | ||||
| @ -938,7 +943,7 @@ export class FlexServer implements GristServer { | ||||
|   } | ||||
| 
 | ||||
|   public addSessions() { | ||||
|     if (this._check('sessions', 'config')) { return; } | ||||
|     if (this._check('sessions', 'loginMiddleware')) { return; } | ||||
|     this.addTagChecker(); | ||||
|     this.addOrg(); | ||||
| 
 | ||||
| @ -1135,25 +1140,8 @@ export class FlexServer implements GristServer { | ||||
|     }); | ||||
|   } | ||||
| 
 | ||||
|   /** | ||||
|    * Load user config file from standard location (if present). | ||||
|    * | ||||
|    * Note that the user config file doesn't do anything today, but may be useful in | ||||
|    * the future for configuring things that don't fit well into environment variables. | ||||
|    * | ||||
|    * TODO: Revisit this, and update `GristServer.settings` type to match the expected shape | ||||
|    * of config.json. (ts-interface-checker could be useful here for runtime validation.) | ||||
|    */ | ||||
|   public async loadConfig() { | ||||
|     if (this._check('config')) { return; } | ||||
|     const settingsPath = path.join(this.instanceRoot, 'config.json'); | ||||
|     if (await fse.pathExists(settingsPath)) { | ||||
|       log.info(`Loading config from ${settingsPath}`); | ||||
|       this.settings = JSON.parse(await fse.readFile(settingsPath, 'utf8')); | ||||
|     } else { | ||||
|       log.info(`Loading empty config because ${settingsPath} missing`); | ||||
|       this.settings = {}; | ||||
|     } | ||||
|   public async addLoginMiddleware() { | ||||
|     if (this._check('loginMiddleware')) { return; } | ||||
| 
 | ||||
|     // TODO: We could include a third mock provider of login/logout URLs for better tests. Or we
 | ||||
|     // could create a mock SAML identity provider for testing this using the SAML flow.
 | ||||
| @ -1169,9 +1157,9 @@ export class FlexServer implements GristServer { | ||||
|   } | ||||
| 
 | ||||
|   public addComm() { | ||||
|     if (this._check('comm', 'start', 'homedb', 'config')) { return; } | ||||
|     if (this._check('comm', 'start', 'homedb', 'loginMiddleware')) { return; } | ||||
|     this._comm = new Comm(this.server, { | ||||
|       settings: this.settings, | ||||
|       settings: {}, | ||||
|       sessions: this._sessions, | ||||
|       hosts: this._hosts, | ||||
|       loginMiddleware: this._loginMiddleware, | ||||
| @ -1311,7 +1299,7 @@ export class FlexServer implements GristServer { | ||||
|       null : 'homedb', 'api-mw', 'map', 'telemetry'); | ||||
|     // add handlers for cleanup, if we are in charge of the doc manager.
 | ||||
|     if (!this._docManager) { this.addCleanup(); } | ||||
|     await this.loadConfig(); | ||||
|     await this.addLoginMiddleware(); | ||||
|     this.addComm(); | ||||
| 
 | ||||
|     await this.create.configure?.(); | ||||
|  | ||||
| @ -25,6 +25,7 @@ import { Sessions } from 'app/server/lib/Sessions'; | ||||
| import { ITelemetry } from 'app/server/lib/Telemetry'; | ||||
| import * as express from 'express'; | ||||
| import { IncomingMessage } from 'http'; | ||||
| import { IGristCoreConfig, loadGristCoreConfig } from "./configCore"; | ||||
| 
 | ||||
| /** | ||||
|  * Basic information about a Grist server.  Accessible in many | ||||
| @ -32,7 +33,7 @@ import { IncomingMessage } from 'http'; | ||||
|  */ | ||||
| export interface GristServer { | ||||
|   readonly create: ICreate; | ||||
|   settings?: Readonly<Record<string, unknown>>; | ||||
|   settings?: IGristCoreConfig; | ||||
|   getHost(): string; | ||||
|   getHomeUrl(req: express.Request, relPath?: string): string; | ||||
|   getHomeInternalUrl(relPath?: string): string; | ||||
| @ -126,7 +127,7 @@ export interface DocTemplate { | ||||
| export function createDummyGristServer(): GristServer { | ||||
|   return { | ||||
|     create, | ||||
|     settings: {}, | ||||
|     settings: loadGristCoreConfig(), | ||||
|     getHost() { return 'localhost:4242'; }, | ||||
|     getHomeUrl() { return 'http://localhost:4242'; }, | ||||
|     getHomeInternalUrl() { return 'http://localhost:4242'; }, | ||||
|  | ||||
							
								
								
									
										143
									
								
								app/server/lib/config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										143
									
								
								app/server/lib/config.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,143 @@ | ||||
| import * as fse from "fs-extra"; | ||||
| 
 | ||||
| // Export dependencies for stubbing in tests.
 | ||||
| export const Deps = { | ||||
|   readFile: fse.readFile, | ||||
|   writeFile: fse.writeFile, | ||||
|   pathExists: fse.pathExists, | ||||
| }; | ||||
| 
 | ||||
| /** | ||||
|  * Readonly config value - no write access. | ||||
|  */ | ||||
| export interface IReadableConfigValue<T> { | ||||
|   get(): T; | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Writeable config value. Write behaviour is asynchronous and defined by the implementation. | ||||
|  */ | ||||
| export interface IWritableConfigValue<T> extends IReadableConfigValue<T> { | ||||
|   set(value: T): Promise<void>; | ||||
| } | ||||
| 
 | ||||
| type FileContentsValidator<T> = (value: any) => T | null; | ||||
| 
 | ||||
| export class MissingConfigFileError extends Error { | ||||
|   public name: string = "MissingConfigFileError"; | ||||
| 
 | ||||
|   constructor(message: string) { | ||||
|     super(message); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export class ConfigValidationError extends Error { | ||||
|   public name: string = "ConfigValidationError"; | ||||
| 
 | ||||
|   constructor(message: string) { | ||||
|     super(message); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| export interface ConfigAccessors<ValueType> { | ||||
|   get: () => ValueType, | ||||
|   set?: (value: ValueType) => Promise<void> | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Provides type safe access to an underlying JSON file. | ||||
|  * | ||||
|  * Multiple FileConfigs for the same file shouldn't be used, as they risk going out of sync. | ||||
|  */ | ||||
| export class FileConfig<FileContents> { | ||||
|   /** | ||||
|    * Creates a new type-safe FileConfig, by loading and checking the contents of the file with `validator`. | ||||
|    * @param configPath - Path to load. | ||||
|    * @param validator - Validates the contents are in the correct format, and converts to the correct type. | ||||
|    *  Should throw an error or return null if not valid. | ||||
|    */ | ||||
|   public static async create<CreateConfigFileContents>( | ||||
|     configPath: string, | ||||
|     validator: FileContentsValidator<CreateConfigFileContents> | ||||
|   ): Promise<FileConfig<CreateConfigFileContents>> { | ||||
|     // Start with empty object, as it can be upgraded to a full config.
 | ||||
|     let rawFileContents: any = {}; | ||||
| 
 | ||||
|     if (await Deps.pathExists(configPath)) { | ||||
|       rawFileContents = JSON.parse(await Deps.readFile(configPath, 'utf8')); | ||||
|     } | ||||
| 
 | ||||
|     let fileContents = null; | ||||
| 
 | ||||
|     try { | ||||
|       fileContents = validator(rawFileContents); | ||||
|     } catch (error) { | ||||
|       const configError = | ||||
|         new ConfigValidationError(`Config at ${configPath} failed validation: ${error.message}`); | ||||
|       configError.cause = error; | ||||
|       throw configError; | ||||
|     } | ||||
| 
 | ||||
|     if (!fileContents) { | ||||
|       throw new ConfigValidationError(`Config at ${configPath} failed validation - check the format?`); | ||||
|     } | ||||
| 
 | ||||
|     return new FileConfig<CreateConfigFileContents>(configPath, fileContents); | ||||
|   } | ||||
| 
 | ||||
|   constructor(private _filePath: string, private _rawConfig: FileContents) { | ||||
|   } | ||||
| 
 | ||||
|   public get<Key extends keyof FileContents>(key: Key): FileContents[Key] { | ||||
|     return this._rawConfig[key]; | ||||
|   } | ||||
| 
 | ||||
|   public async set<Key extends keyof FileContents>(key: Key, value: FileContents[Key]) { | ||||
|     this._rawConfig[key] = value; | ||||
|     await this.persistToDisk(); | ||||
|   } | ||||
| 
 | ||||
|   public async persistToDisk(): Promise<void> { | ||||
|     await Deps.writeFile(this._filePath, JSON.stringify(this._rawConfig, null, 2)); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Creates a function for creating accessors for a given key. | ||||
|  * Propagates undefined values, so if no file config is available, accessors are undefined. | ||||
|  * @param fileConfig - Config to load/save values to. | ||||
|  */ | ||||
| export function fileConfigAccessorFactory<FileContents>( | ||||
|   fileConfig?: FileConfig<FileContents> | ||||
| ): <Key extends keyof FileContents>(key: Key) => ConfigAccessors<FileContents[Key]> | undefined | ||||
| { | ||||
|   if (!fileConfig) { return (key) => undefined; } | ||||
|   return (key) => ({ | ||||
|     get: () => fileConfig.get(key), | ||||
|     set: (value) => fileConfig.set(key, value) | ||||
|   }); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Creates a config value optionally backed by persistent storage. | ||||
|  * Can be used as an in-memory value without persistent storage. | ||||
|  * @param defaultValue - Value to use if no persistent value is available. | ||||
|  * @param persistence - Accessors for saving/loading persistent value. | ||||
|  */ | ||||
| export function createConfigValue<ValueType>( | ||||
|   defaultValue: ValueType, | ||||
|   persistence?: ConfigAccessors<ValueType> | ConfigAccessors<ValueType | undefined>, | ||||
| ): IWritableConfigValue<ValueType> { | ||||
|   let inMemoryValue = (persistence && persistence.get()); | ||||
|   return { | ||||
|     get(): ValueType { | ||||
|       return inMemoryValue ?? defaultValue; | ||||
|     }, | ||||
|     async set(value: ValueType) { | ||||
|       if (persistence && persistence.set) { | ||||
|         await persistence.set(value); | ||||
|       } | ||||
|       inMemoryValue = value; | ||||
|     } | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										28
									
								
								app/server/lib/configCore.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										28
									
								
								app/server/lib/configCore.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,28 @@ | ||||
| import { | ||||
|   createConfigValue, | ||||
|   FileConfig, | ||||
|   fileConfigAccessorFactory, | ||||
|   IWritableConfigValue | ||||
| } from "./config"; | ||||
| import { convertToCoreFileContents, IGristCoreConfigFileLatest } from "./configCoreFileFormats"; | ||||
| 
 | ||||
| export type Edition = "core" | "enterprise"; | ||||
| 
 | ||||
| /** | ||||
|  * Config options for Grist Core. | ||||
|  */ | ||||
| export interface IGristCoreConfig { | ||||
|   edition: IWritableConfigValue<Edition>; | ||||
| } | ||||
| 
 | ||||
| export async function loadGristCoreConfigFile(configPath?: string): Promise<IGristCoreConfig> { | ||||
|   const fileConfig = configPath ? await FileConfig.create(configPath, convertToCoreFileContents) : undefined; | ||||
|   return loadGristCoreConfig(fileConfig); | ||||
| } | ||||
| 
 | ||||
| export function loadGristCoreConfig(fileConfig?: FileConfig<IGristCoreConfigFileLatest>): IGristCoreConfig { | ||||
|   const fileConfigValue = fileConfigAccessorFactory(fileConfig); | ||||
|   return { | ||||
|     edition: createConfigValue<Edition>("core", fileConfigValue("edition")) | ||||
|   }; | ||||
| } | ||||
							
								
								
									
										23
									
								
								app/server/lib/configCoreFileFormats-ti.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										23
									
								
								app/server/lib/configCoreFileFormats-ti.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,23 @@ | ||||
| /** | ||||
|  * This module was automatically generated by `ts-interface-builder` | ||||
|  */ | ||||
| import * as t from "ts-interface-checker"; | ||||
| // tslint:disable:object-literal-key-quotes
 | ||||
| 
 | ||||
| export const IGristCoreConfigFileLatest = t.name("IGristCoreConfigFileV1"); | ||||
| 
 | ||||
| export const IGristCoreConfigFileV1 = t.iface([], { | ||||
|   "version": t.lit("1"), | ||||
|   "edition": t.opt(t.union(t.lit("core"), t.lit("enterprise"))), | ||||
| }); | ||||
| 
 | ||||
| export const IGristCoreConfigFileV0 = t.iface([], { | ||||
|   "version": "undefined", | ||||
| }); | ||||
| 
 | ||||
| const exportedTypeSuite: t.ITypeSuite = { | ||||
|   IGristCoreConfigFileLatest, | ||||
|   IGristCoreConfigFileV1, | ||||
|   IGristCoreConfigFileV0, | ||||
| }; | ||||
| export default exportedTypeSuite; | ||||
							
								
								
									
										53
									
								
								app/server/lib/configCoreFileFormats.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										53
									
								
								app/server/lib/configCoreFileFormats.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,53 @@ | ||||
| import configCoreTI from './configCoreFileFormats-ti'; | ||||
| import { CheckerT, createCheckers } from "ts-interface-checker"; | ||||
| 
 | ||||
| /** | ||||
|  * Latest core config file format | ||||
|  */ | ||||
| export type IGristCoreConfigFileLatest = IGristCoreConfigFileV1; | ||||
| 
 | ||||
| /** | ||||
|  * Format of config files on disk - V1 | ||||
|  */ | ||||
| export interface IGristCoreConfigFileV1 { | ||||
|   version: "1" | ||||
|   edition?: "core" | "enterprise" | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Format of config files on disk - V0 | ||||
|  */ | ||||
| export interface IGristCoreConfigFileV0 { | ||||
|   version: undefined; | ||||
| } | ||||
| 
 | ||||
| export const checkers = createCheckers(configCoreTI) as | ||||
|   { | ||||
|     IGristCoreConfigFileV0: CheckerT<IGristCoreConfigFileV0>, | ||||
|     IGristCoreConfigFileV1: CheckerT<IGristCoreConfigFileV1>, | ||||
|     IGristCoreConfigFileLatest: CheckerT<IGristCoreConfigFileLatest>, | ||||
|   }; | ||||
| 
 | ||||
| function upgradeV0toV1(config: IGristCoreConfigFileV0): IGristCoreConfigFileV1 { | ||||
|   return { | ||||
|     ...config, | ||||
|     version: "1", | ||||
|   }; | ||||
| } | ||||
| 
 | ||||
| export function convertToCoreFileContents(input: any): IGristCoreConfigFileLatest | null { | ||||
|   if (!(input instanceof Object)) { | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   let configObject = { ...input }; | ||||
| 
 | ||||
|   if (checkers.IGristCoreConfigFileV0.test(configObject)) { | ||||
|     configObject = upgradeV0toV1(configObject); | ||||
|   } | ||||
| 
 | ||||
|   // This will throw an exception if the config object is still not in the correct format.
 | ||||
|   checkers.IGristCoreConfigFileLatest.check(configObject); | ||||
| 
 | ||||
|   return configObject; | ||||
| } | ||||
| @ -63,3 +63,10 @@ export function getAppRootFor(appRoot: string, subdirectory: string): string { | ||||
| export function getAppPathTo(appRoot: string, subdirectory: string): string { | ||||
|   return path.resolve(getAppRootFor(appRoot, subdirectory), subdirectory); | ||||
| } | ||||
| 
 | ||||
| /** | ||||
|  * Returns the instance root. Defaults to appRoot, unless overridden by GRIST_INST_DIR. | ||||
|  */ | ||||
| export function getInstanceRoot() { | ||||
|   return path.resolve(process.env.GRIST_INST_DIR || getAppRoot()); | ||||
| } | ||||
|  | ||||
| @ -8,6 +8,7 @@ | ||||
| import {FlexServer, FlexServerOptions} from 'app/server/lib/FlexServer'; | ||||
| import {GristLoginSystem} from 'app/server/lib/GristServer'; | ||||
| import log from 'app/server/lib/log'; | ||||
| import {getGlobalConfig} from "app/server/lib/globalConfig"; | ||||
| 
 | ||||
| // Allowed server types. We'll start one or a combination based on the value of GRIST_SERVERS
 | ||||
| // environment variable.
 | ||||
| @ -70,6 +71,8 @@ export async function main(port: number, serverTypes: ServerType[], | ||||
|   const includeStatic = serverTypes.includes("static"); | ||||
|   const includeApp = serverTypes.includes("app"); | ||||
| 
 | ||||
|   options.settings ??= await getGlobalConfig(); | ||||
| 
 | ||||
|   const server = new FlexServer(port, `server(${serverTypes.join(",")})`, options); | ||||
| 
 | ||||
|   // We need to know early on whether we will be serving plugins or not.
 | ||||
| @ -94,7 +97,7 @@ export async function main(port: number, serverTypes: ServerType[], | ||||
| 
 | ||||
|   if (options.logToConsole !== false) { server.addLogging(); } | ||||
|   if (options.externalStorage === false) { server.disableExternalStorage(); } | ||||
|   await server.loadConfig(); | ||||
|   await server.addLoginMiddleware(); | ||||
| 
 | ||||
|   if (includeDocs) { | ||||
|     // It is important that /dw and /v prefixes are accepted (if present) by health check
 | ||||
| @ -195,12 +198,14 @@ export async function main(port: number, serverTypes: ServerType[], | ||||
| 
 | ||||
| export async function startMain() { | ||||
|   try { | ||||
| 
 | ||||
|     const serverTypes = parseServerTypes(process.env.GRIST_SERVERS); | ||||
| 
 | ||||
|     // No defaults for a port, since this server can serve very different purposes.
 | ||||
|     if (!process.env.GRIST_PORT) { | ||||
|       throw new Error("GRIST_PORT must be specified"); | ||||
|     } | ||||
| 
 | ||||
|     const port = parseInt(process.env.GRIST_PORT, 10); | ||||
| 
 | ||||
|     const server = await main(port, serverTypes); | ||||
|  | ||||
							
								
								
									
										19
									
								
								stubs/app/server/lib/globalConfig.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										19
									
								
								stubs/app/server/lib/globalConfig.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,19 @@ | ||||
| import path from "path"; | ||||
| import { getInstanceRoot } from "app/server/lib/places"; | ||||
| import { IGristCoreConfig, loadGristCoreConfigFile } from "app/server/lib/configCore"; | ||||
| import log from "app/server/lib/log"; | ||||
| 
 | ||||
| const globalConfigPath: string = path.join(getInstanceRoot(), 'config.json'); | ||||
| let cachedGlobalConfig: IGristCoreConfig | undefined = undefined; | ||||
| 
 | ||||
| /** | ||||
|  * Retrieves the cached grist config, or loads it from the default global path. | ||||
|  */ | ||||
| export async function getGlobalConfig(): Promise<IGristCoreConfig> { | ||||
|   if (!cachedGlobalConfig) { | ||||
|     log.info(`Loading config file from ${globalConfigPath}`); | ||||
|     cachedGlobalConfig = await loadGristCoreConfigFile(globalConfigPath); | ||||
|   } | ||||
| 
 | ||||
|   return cachedGlobalConfig; | ||||
| } | ||||
| @ -605,7 +605,7 @@ export async function createServer(port: number, initDb = createInitialDb): Prom | ||||
|   await flexServer.start(); | ||||
|   await flexServer.initHomeDBManager(); | ||||
|   flexServer.addDocWorkerMap(); | ||||
|   await flexServer.loadConfig(); | ||||
|   await flexServer.addLoginMiddleware(); | ||||
|   flexServer.addHosts(); | ||||
|   flexServer.addAccessMiddleware(); | ||||
|   flexServer.addApiMiddleware(); | ||||
|  | ||||
| @ -17,13 +17,13 @@ let server: FlexServer; | ||||
| let dbManager: HomeDBManager; | ||||
| 
 | ||||
| async function activateServer(home: FlexServer, docManager: DocManager) { | ||||
|   await home.loadConfig(); | ||||
|   await home.addLoginMiddleware(); | ||||
|   await home.initHomeDBManager(); | ||||
|   home.addHosts(); | ||||
|   home.addDocWorkerMap(); | ||||
|   home.addAccessMiddleware(); | ||||
|   dbManager = home.getHomeDBManager(); | ||||
|   await home.loadConfig(); | ||||
|   await home.addLoginMiddleware(); | ||||
|   home.addSessions(); | ||||
|   home.addHealthCheck(); | ||||
|   docManager.testSetHomeDbManager(dbManager); | ||||
|  | ||||
							
								
								
									
										107
									
								
								test/server/lib/config.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										107
									
								
								test/server/lib/config.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,107 @@ | ||||
| import { assert } from 'chai'; | ||||
| import * as sinon from 'sinon'; | ||||
| import { ConfigAccessors, createConfigValue, Deps, FileConfig } from "app/server/lib/config"; | ||||
| 
 | ||||
| interface TestFileContents { | ||||
|   myNum?: number | ||||
|   myStr?: string | ||||
| } | ||||
| 
 | ||||
| const testFileContentsExample: TestFileContents = { | ||||
|   myNum: 1, | ||||
|   myStr: "myStr", | ||||
| }; | ||||
| 
 | ||||
| const testFileContentsJSON = JSON.stringify(testFileContentsExample); | ||||
| 
 | ||||
| describe('FileConfig', () => { | ||||
|   const useFakeConfigFile = (contents: string) => { | ||||
|     const fakeFile = { contents }; | ||||
|     sinon.replace(Deps, 'pathExists', sinon.fake.resolves(true)); | ||||
|     sinon.replace(Deps, 'readFile', sinon.fake((path, encoding: string) => Promise.resolve(fakeFile.contents)) as any); | ||||
|     sinon.replace(Deps, 'writeFile', sinon.fake((path, newContents) => { | ||||
|       fakeFile.contents = newContents; | ||||
|       return Promise.resolve(); | ||||
|     })); | ||||
| 
 | ||||
|     return fakeFile; | ||||
|   }; | ||||
| 
 | ||||
|   afterEach(() => { | ||||
|     sinon.restore(); | ||||
|   }); | ||||
| 
 | ||||
|   it('throws an error from create if the validator does not return a value', async () => { | ||||
|     useFakeConfigFile(testFileContentsJSON); | ||||
|     const validator = () => null; | ||||
|     await assert.isRejected(FileConfig.create<TestFileContents>("anypath.json", validator)); | ||||
|   }); | ||||
| 
 | ||||
|   it('persists changes when values are assigned', async () => { | ||||
|     const fakeFile = useFakeConfigFile(testFileContentsJSON); | ||||
|     // Don't validate - this is guaranteed to be valid above.
 | ||||
|     const validator = (input: any) => input as TestFileContents; | ||||
|     const fileConfig = await FileConfig.create("anypath.json", validator); | ||||
|     await fileConfig.set("myNum", 999); | ||||
| 
 | ||||
|     assert.equal(fileConfig.get("myNum"), 999); | ||||
|     assert.equal(JSON.parse(fakeFile.contents).myNum, 999); | ||||
|   }); | ||||
| 
 | ||||
|   // Avoid removing extra properties from the file, in case another edition of grist is doing something.
 | ||||
|   it('does not remove extra values from the file', async () => { | ||||
|     const configWithExtraProperties = { | ||||
|       ...testFileContentsExample, | ||||
|       someProperty: "isPresent", | ||||
|     }; | ||||
| 
 | ||||
|     const fakeFile = useFakeConfigFile(JSON.stringify(configWithExtraProperties)); | ||||
|     // It's entirely possible the validator can damage the extra properties, but that's not in scope for this test.
 | ||||
|     const validator = (input: any) => input as TestFileContents; | ||||
|     const fileConfig = await FileConfig.create("anypath.json", validator); | ||||
|     // Triggering a write to the file
 | ||||
|     await fileConfig.set("myNum", 999); | ||||
|     await fileConfig.set("myStr", "Something"); | ||||
| 
 | ||||
|     const newContents = JSON.parse(fakeFile.contents); | ||||
|     assert.equal(newContents.myNum, 999); | ||||
|     assert.equal(newContents.myStr, "Something"); | ||||
|     assert.equal(newContents.someProperty, "isPresent"); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
| describe('createConfigValue', () => { | ||||
|   const makeInMemoryAccessors = <T>(initialValue: T): ConfigAccessors<T> => { | ||||
|     let value: T = initialValue; | ||||
|     return { | ||||
|       get: () => value, | ||||
|       set: async (newValue: T) => { value = newValue; }, | ||||
|     }; | ||||
|   }; | ||||
| 
 | ||||
|   it('works without persistence', async () => { | ||||
|     const configValue = createConfigValue(1); | ||||
|     assert.equal(configValue.get(), 1); | ||||
|     await configValue.set(2); | ||||
|     assert.equal(configValue.get(), 2); | ||||
|   }); | ||||
| 
 | ||||
|   it('writes to persistence when saved', async () => { | ||||
|     const accessors = makeInMemoryAccessors(1); | ||||
|     const configValue = createConfigValue(1, accessors); | ||||
|     assert.equal(accessors.get(), 1); | ||||
|     await configValue.set(2); | ||||
|     assert.equal(accessors.get(), 2); | ||||
|   }); | ||||
| 
 | ||||
|   it('initialises with the persistent value if available', async () => { | ||||
|     const accessors = makeInMemoryAccessors(22); | ||||
|     const configValue = createConfigValue(1, accessors); | ||||
|     assert.equal(configValue.get(), 22); | ||||
| 
 | ||||
|     const accessorsWithUndefinedValue = makeInMemoryAccessors<number | undefined>(undefined); | ||||
|     const configValueWithDefault = createConfigValue(333, accessorsWithUndefinedValue); | ||||
|     assert.equal(configValueWithDefault.get(), 333); | ||||
|   }); | ||||
| }); | ||||
| 
 | ||||
							
								
								
									
										48
									
								
								test/server/lib/configCore.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								test/server/lib/configCore.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,48 @@ | ||||
| import * as sinon from 'sinon'; | ||||
| import { assert } from 'chai'; | ||||
| import { IGristCoreConfig, loadGristCoreConfig, loadGristCoreConfigFile } from "app/server/lib/configCore"; | ||||
| import { createConfigValue, Deps, IWritableConfigValue } from "app/server/lib/config"; | ||||
| 
 | ||||
| describe('loadGristCoreConfig', () => { | ||||
|   afterEach(() => { | ||||
|     sinon.restore(); | ||||
|   }); | ||||
| 
 | ||||
|   it('can be used with an in-memory store if no file config is provided', async () => { | ||||
|     const config = loadGristCoreConfig(); | ||||
|     await config.edition.set("enterprise"); | ||||
|     assert.equal(config.edition.get(), "enterprise"); | ||||
|   }); | ||||
| 
 | ||||
|   it('will function correctly when no config file is present', async () => { | ||||
|     sinon.replace(Deps, 'pathExists', sinon.fake.resolves(false)); | ||||
|     sinon.replace(Deps, 'readFile', sinon.fake.resolves("")); | ||||
|     const writeFileFake = sinon.fake.resolves(undefined); | ||||
|     sinon.replace(Deps, 'writeFile', writeFileFake); | ||||
| 
 | ||||
|     const config = await loadGristCoreConfigFile("doesntmatter.json"); | ||||
|     assert.exists(config.edition.get()); | ||||
| 
 | ||||
|     await config.edition.set("enterprise"); | ||||
|     // Make sure that the change was written back to the file.
 | ||||
|     assert.isTrue(writeFileFake.calledOnce); | ||||
|   }); | ||||
| 
 | ||||
|   it('can be extended', async () => { | ||||
|     // Extend the core config
 | ||||
|     type NewConfig = IGristCoreConfig & { | ||||
|       newThing: IWritableConfigValue<number> | ||||
|     }; | ||||
| 
 | ||||
|     const coreConfig = loadGristCoreConfig(); | ||||
| 
 | ||||
|     const newConfig: NewConfig = { | ||||
|       ...coreConfig, | ||||
|       newThing: createConfigValue(3) | ||||
|     }; | ||||
| 
 | ||||
|     // Ensure that it's backwards compatible.
 | ||||
|     const gristConfig: IGristCoreConfig = newConfig; | ||||
|     return gristConfig; | ||||
|   }); | ||||
| }); | ||||
							
								
								
									
										29
									
								
								test/server/lib/configCoreFileFormats.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										29
									
								
								test/server/lib/configCoreFileFormats.ts
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,29 @@ | ||||
| import { assert } from 'chai'; | ||||
| import { convertToCoreFileContents, IGristCoreConfigFileLatest } from "app/server/lib/configCoreFileFormats"; | ||||
| 
 | ||||
| describe('convertToCoreFileContents', () => { | ||||
|   it('fails with a malformed config', async () => { | ||||
|     const badConfig = { | ||||
|       version: "This is a random version number that will never exist", | ||||
|     }; | ||||
| 
 | ||||
|     assert.throws(() => convertToCoreFileContents(badConfig)); | ||||
|   }); | ||||
| 
 | ||||
|   // This is necessary to handle users who don't have a config file yet.
 | ||||
|   it('will upgrade an empty object to a valid config', () => { | ||||
|     const validConfig = convertToCoreFileContents({}); | ||||
|     assert.exists(validConfig?.version); | ||||
|   }); | ||||
| 
 | ||||
|   it('will validate the latest config file format', () => { | ||||
|     const validRawObject: IGristCoreConfigFileLatest = { | ||||
|       version: "1", | ||||
|       edition: "enterprise", | ||||
|     }; | ||||
| 
 | ||||
|     const validConfig = convertToCoreFileContents(validRawObject); | ||||
|     assert.exists(validConfig?.version); | ||||
|     assert.exists(validConfig?.edition); | ||||
|   }); | ||||
| }); | ||||
		Loading…
	
		Reference in New Issue
	
	Block a user