From ff4a31a0e6269b823a04eeda2f433665617f583c Mon Sep 17 00:00:00 2001 From: Cameron Diver Date: Tue, 2 Jun 2020 14:29:05 +0100 Subject: [PATCH] Make the config module a singleton Change-type: patch Co-authored-by: Pagan Gazzard Signed-off-by: Cameron Diver --- src/api-binder.ts | 61 ++-- src/application-manager.d.ts | 4 +- src/application-manager.js | 34 +- src/compose/composition-steps.ts | 9 +- src/compose/images.ts | 2 - src/compose/service-manager.ts | 11 +- src/config/functions.ts | 16 +- src/config/index.ts | 503 ++++++++++++++-------------- src/device-api/v2.ts | 13 +- src/device-config.ts | 26 +- src/device-state.ts | 44 +-- src/device-state/preload.ts | 3 +- src/host-config.ts | 10 +- src/lib/migration.ts | 3 +- src/local-mode.ts | 11 +- src/logger.ts | 22 +- src/proxyvisor.js | 20 +- src/supervisor-api.ts | 18 +- src/supervisor.ts | 17 +- src/target-state.ts | 11 +- test/00-init.ts | 4 + test/03-config.spec.ts | 47 ++- test/05-device-state.spec.ts | 7 +- test/11-api-binder.spec.ts | 62 +++- test/13-device-config.spec.ts | 42 ++- test/14-application-manager.spec.ts | 5 - test/23-local-mode.ts | 3 - test/lib/mocked-device-api.ts | 25 +- test/lib/prepare.ts | 9 +- 29 files changed, 492 insertions(+), 550 deletions(-) diff --git a/src/api-binder.ts b/src/api-binder.ts index 84ddf2c9..c057f3b4 100644 --- a/src/api-binder.ts +++ b/src/api-binder.ts @@ -9,7 +9,7 @@ import { PinejsClientRequest, StatusError } from 'pinejs-client-request'; import * as url from 'url'; import * as deviceRegister from './lib/register-device'; -import Config, { ConfigType } from './config'; +import * as config from './config'; import { EventTracker } from './event-tracker'; import { loadBackupFromMigration } from './lib/migration'; @@ -41,7 +41,6 @@ const INTERNAL_STATE_KEYS = [ ]; export interface APIBinderConstructOpts { - config: Config; eventTracker: EventTracker; logger: Logger; } @@ -63,12 +62,11 @@ interface DeviceTag { value: string; } -type KeyExchangeOpts = ConfigType<'provisioningOptions'>; +type KeyExchangeOpts = config.ConfigType<'provisioningOptions'>; export class APIBinder { public router: express.Router; - private config: Config; private deviceState: DeviceState; private eventTracker: EventTracker; private logger: Logger; @@ -91,8 +89,7 @@ export class APIBinder { private targetStateFetchErrors = 0; private readyForUpdates = false; - public constructor({ config, eventTracker, logger }: APIBinderConstructOpts) { - this.config = config; + public constructor({ eventTracker, logger }: APIBinderConstructOpts) { this.eventTracker = eventTracker; this.logger = logger; @@ -108,7 +105,7 @@ export class APIBinder { appUpdatePollInterval, unmanaged, connectivityCheckEnabled, - } = await this.config.getMany([ + } = await config.getMany([ 'appUpdatePollInterval', 'unmanaged', 'connectivityCheckEnabled', @@ -160,11 +157,7 @@ export class APIBinder { } public async initClient() { - const { - unmanaged, - apiEndpoint, - currentApiKey, - } = await this.config.getMany([ + const { unmanaged, apiEndpoint, currentApiKey } = await config.getMany([ 'unmanaged', 'apiEndpoint', 'currentApiKey', @@ -188,7 +181,7 @@ export class APIBinder { } public async start() { - const conf = await this.config.getMany([ + const conf = await config.getMany([ 'apiEndpoint', 'unmanaged', 'bootstrapRetryDelay', @@ -203,14 +196,14 @@ export class APIBinder { // value to '', to ensure that when we do re-provision, we'll report // the config and hardward-specific options won't be lost if (!apiEndpoint) { - await this.config.set({ initialConfigReported: '' }); + await config.set({ initialConfigReported: '' }); } return; } log.debug('Ensuring device is provisioned'); await this.provisionDevice(); - const conf2 = await this.config.getMany([ + const conf2 = await config.getMany([ 'initialConfigReported', 'apiEndpoint', ]); @@ -278,7 +271,7 @@ export class APIBinder { } public async patchDevice(id: number, updatedFields: Dictionary) { - const conf = await this.config.getMany([ + const conf = await config.getMany([ 'unmanaged', 'provisioned', 'apiTimeout', @@ -308,7 +301,7 @@ export class APIBinder { } public async provisionDependentDevice(device: Device): Promise { - const conf = await this.config.getMany([ + const conf = await config.getMany([ 'unmanaged', 'provisioned', 'apiTimeout', @@ -341,7 +334,7 @@ export class APIBinder { } public async getTargetState(): Promise { - const { uuid, apiEndpoint, apiTimeout } = await this.config.getMany([ + const { uuid, apiEndpoint, apiTimeout } = await config.getMany([ 'uuid', 'apiEndpoint', 'apiTimeout', @@ -377,7 +370,7 @@ export class APIBinder { 'Trying to start poll without initializing API client', ); } - this.config + config .get('instantUpdates') .catch(() => { // Default to skipping the initial update if we couldn't fetch the setting @@ -414,7 +407,7 @@ export class APIBinder { ); } - const deviceId = await this.config.get('deviceId'); + const deviceId = await config.get('deviceId'); if (deviceId == null) { throw new Error('Attempt to retrieve device tags before provision'); } @@ -523,7 +516,7 @@ export class APIBinder { } private report = _.throttle(async () => { - const conf = await this.config.getMany([ + const conf = await config.getMany([ 'deviceId', 'apiTimeout', 'apiEndpoint', @@ -586,7 +579,7 @@ export class APIBinder { this.eventTracker.track('Device state report failure', { error: e }); // We use the poll interval as the upper limit of // the exponential backoff - const maxDelay = await this.config.get('appUpdatePollInterval'); + const maxDelay = await config.get('appUpdatePollInterval'); const delay = Math.min( 2 ** this.stateReportErrors * MINIMUM_BACKOFF_DELAY, maxDelay, @@ -637,7 +630,7 @@ export class APIBinder { private async pollTargetState(skipFirstGet: boolean = false): Promise { let appUpdatePollInterval; try { - appUpdatePollInterval = await this.config.get('appUpdatePollInterval'); + appUpdatePollInterval = await config.get('appUpdatePollInterval'); if (!skipFirstGet) { await this.getAndSetTargetState(false); this.targetStateFetchErrors = 0; @@ -673,7 +666,7 @@ export class APIBinder { } try { - const deviceId = await this.config.get('deviceId'); + const deviceId = await config.get('deviceId'); if (deviceId == null) { throw new InternalInconsistencyError( @@ -710,7 +703,7 @@ export class APIBinder { // Set the config value for pinDevice to null, so that we know the // task has been completed - await this.config.remove('pinDevice'); + await config.remove('pinDevice'); } catch (e) { log.error(`Could not pin device to release! ${e}`); throw e; @@ -742,7 +735,7 @@ export class APIBinder { const targetConfig = await this.deviceState.deviceConfig.formatConfigKeys( targetConfigUnformatted, ); - const deviceId = await this.config.get('deviceId'); + const deviceId = await config.get('deviceId'); if (!currentState.local.config) { throw new InternalInconsistencyError( @@ -774,7 +767,7 @@ export class APIBinder { } } - await this.config.set({ initialConfigReported: apiEndpoint }); + await config.set({ initialConfigReported: apiEndpoint }); } private async reportInitialConfig( @@ -794,7 +787,7 @@ export class APIBinder { opts?: KeyExchangeOpts, ): Promise { if (opts == null) { - opts = await this.config.get('provisioningOptions'); + opts = await config.get('provisioningOptions'); } const uuid = opts.uuid; @@ -867,7 +860,7 @@ export class APIBinder { } catch (e) { if (e instanceof ExchangeKeyError) { log.error('Exchanging key failed, re-registering...'); - await this.config.regenerateRegistrationFields(); + await config.regenerateRegistrationFields(); } throw e; } @@ -875,7 +868,7 @@ export class APIBinder { private async provision() { let device: Device | null = null; - const opts = await this.config.get('provisioningOptions'); + const opts = await config.get('provisioningOptions'); if ( opts.registered_at == null || opts.deviceId == null || @@ -926,12 +919,12 @@ export class APIBinder { deviceId: id, apiKey: null, }; - await this.config.set(configToUpdate); + await config.set(configToUpdate); this.eventTracker.track('Device bootstrap success'); } // Now check if we need to pin the device - const pinValue = await this.config.get('pinDevice'); + const pinValue = await config.get('pinDevice'); if (pinValue != null) { if (pinValue.app == null || pinValue.commit == null) { @@ -965,7 +958,7 @@ export class APIBinder { 'Trying to provision a device without initializing API client', ); } - const conf = await this.config.getMany([ + const conf = await config.getMany([ 'provisioned', 'bootstrapRetryDelay', 'apiKey', @@ -996,7 +989,7 @@ export class APIBinder { router.post('/v1/update', (req, res, next) => { apiBinder.eventTracker.track('Update notification'); if (apiBinder.readyForUpdates) { - this.config + config .get('instantUpdates') .then((instantUpdates) => { if (instantUpdates) { diff --git a/src/application-manager.d.ts b/src/application-manager.d.ts index aeecffdd..6d733721 100644 --- a/src/application-manager.d.ts +++ b/src/application-manager.d.ts @@ -13,7 +13,7 @@ import ServiceManager from './compose/service-manager'; import DeviceState from './device-state'; import { APIBinder } from './api-binder'; -import Config from './config'; +import * as config from './config'; import NetworkManager from './compose/network-manager'; import VolumeManager from './compose/volume-manager'; @@ -57,7 +57,6 @@ class ApplicationManager extends EventEmitter { public services: ServiceManager; public volumes: VolumeManager; public networks: NetworkManager; - public config: Config; public images: ImageManager; public proxyvisor: any; @@ -70,7 +69,6 @@ class ApplicationManager extends EventEmitter { public constructor({ logger: Logger, - config: Config, eventTracker: EventTracker, deviceState: DeviceState, apiBinder: APIBinder, diff --git a/src/application-manager.js b/src/application-manager.js index 439c495e..2137bdf4 100644 --- a/src/application-manager.js +++ b/src/application-manager.js @@ -8,6 +8,7 @@ import * as path from 'path'; import * as constants from './lib/constants'; import { log } from './lib/supervisor-console'; +import * as config from './config'; import { validateTargetContracts } from './lib/contracts'; import { DockerUtils as Docker } from './lib/docker-utils'; @@ -77,7 +78,7 @@ const createApplicationManagerRouter = function (applications) { }; export class ApplicationManager extends EventEmitter { - constructor({ logger, config, eventTracker, deviceState, apiBinder }) { + constructor({ logger, eventTracker, deviceState, apiBinder }) { super(); this.serviceAction = serviceAction; @@ -168,7 +169,6 @@ export class ApplicationManager extends EventEmitter { this.localModeSwitchCompletion = this.localModeSwitchCompletion.bind(this); this.reportOptionalContainers = this.reportOptionalContainers.bind(this); this.logger = logger; - this.config = config; this.eventTracker = eventTracker; this.deviceState = deviceState; this.apiBinder = apiBinder; @@ -176,12 +176,10 @@ export class ApplicationManager extends EventEmitter { this.images = new Images({ docker: this.docker, logger: this.logger, - config: this.config, }); this.services = new ServiceManager({ docker: this.docker, logger: this.logger, - config: this.config, }); this.networks = new NetworkManager({ docker: this.docker, @@ -192,25 +190,20 @@ export class ApplicationManager extends EventEmitter { logger: this.logger, }); this.proxyvisor = new Proxyvisor({ - config: this.config, logger: this.logger, docker: this.docker, images: this.images, applications: this, }); - this.localModeManager = new LocalModeManager( - this.config, - this.docker, - this.logger, - ); + this.localModeManager = new LocalModeManager(this.docker, this.logger); this.timeSpentFetching = 0; this.fetchesInProgress = 0; this._targetVolatilePerImageId = {}; this._containerStarted = {}; - this.targetStateWrapper = new TargetStateAccessor(this, this.config); + this.targetStateWrapper = new TargetStateAccessor(this); - this.config.on('change', (changedConfig) => { + config.on('change', (changedConfig) => { if (changedConfig.appUpdatePollInterval) { this.images.appUpdatePollInterval = changedConfig.appUpdatePollInterval; } @@ -223,7 +216,6 @@ export class ApplicationManager extends EventEmitter { volumes: this.volumes, applications: this, images: this.images, - config: this.config, callbacks: { containerStarted: (id) => { this._containerStarted[id] = true; @@ -257,7 +249,7 @@ export class ApplicationManager extends EventEmitter { } init() { - return this.config + return config .get('appUpdatePollInterval') .then((interval) => { this.images.appUpdatePollInterval = interval; @@ -297,7 +289,7 @@ export class ApplicationManager extends EventEmitter { return Promise.join( this.services.getStatus(), this.images.getStatus(), - this.config.get('currentCommit'), + config.get('currentCommit'), function (services, images, currentCommit) { const apps = {}; const dependent = {}; @@ -430,7 +422,7 @@ export class ApplicationManager extends EventEmitter { this.services.getAll(), this.networks.getAll(), this.volumes.getAll(), - this.config.get('currentCommit'), + config.get('currentCommit'), this._buildApps, ); } @@ -440,7 +432,7 @@ export class ApplicationManager extends EventEmitter { this.services.getAllByAppId(appId), this.networks.getAllByAppId(appId), this.volumes.getAllByAppId(appId), - this.config.get('currentCommit'), + config.get('currentCommit'), this._buildApps, ).get(appId); } @@ -1083,7 +1075,7 @@ export class ApplicationManager extends EventEmitter { normaliseAndExtendAppFromDB(app) { return Promise.join( - this.config.get('extendedEnvOptions'), + config.get('extendedEnvOptions'), this.docker .getNetworkGateway(constants.supervisorNetworkInterface) .catch(() => '127.0.0.1'), @@ -1584,7 +1576,7 @@ export class ApplicationManager extends EventEmitter { if (skipLock) { return Promise.try(fn); } - return this.config + return config .get('lockOverride') .then((lockOverride) => lockOverride || force) .then((lockOverridden) => @@ -1618,13 +1610,13 @@ export class ApplicationManager extends EventEmitter { containerIdsByAppId[intId] = this.services.getContainerIdMap(intId); }); - return this.config.get('localMode').then((localMode) => { + return config.get('localMode').then((localMode) => { return Promise.props({ cleanupNeeded: this.images.isCleanupNeeded(), availableImages: this.images.getAvailable(), downloading: this.images.getDownloadingImageIds(), supervisorNetworkReady: this.networks.supervisorNetworkReady(), - delta: this.config.get('delta'), + delta: config.get('delta'), containerIds: Promise.props(containerIdsByAppId), localMode, }); diff --git a/src/compose/composition-steps.ts b/src/compose/composition-steps.ts index 76708409..1514dd07 100644 --- a/src/compose/composition-steps.ts +++ b/src/compose/composition-steps.ts @@ -1,6 +1,6 @@ import * as _ from 'lodash'; -import Config from '../config'; +import * as config from '../config'; import { ApplicationManager } from '../application-manager'; import Images, { Image } from './images'; @@ -140,7 +140,6 @@ export function getExecutors(app: { volumes: VolumeManager; applications: ApplicationManager; images: Images; - config: Config; callbacks: CompositionCallbacks; }) { const executors: Executors = { @@ -223,7 +222,7 @@ export function getExecutors(app: { app.callbacks.containerStarted(container.id); }, updateCommit: async (step) => { - await app.config.set({ currentCommit: step.target }); + await config.set({ currentCommit: step.target }); }, handover: (step) => { return app.lockFn( @@ -241,7 +240,7 @@ export function getExecutors(app: { const startTime = process.hrtime(); app.callbacks.fetchStart(); const [fetchOpts, availableImages] = await Promise.all([ - app.config.get('fetchOptions'), + config.get('fetchOptions'), app.images.getAvailable(), ]); @@ -276,7 +275,7 @@ export function getExecutors(app: { await app.images.save(step.image); }, cleanup: async () => { - const localMode = await app.config.get('localMode'); + const localMode = await config.get('localMode'); if (!localMode) { await app.images.cleanup(); } diff --git a/src/compose/images.ts b/src/compose/images.ts index 2007a574..a77fc10f 100644 --- a/src/compose/images.ts +++ b/src/compose/images.ts @@ -4,7 +4,6 @@ import { EventEmitter } from 'events'; import * as _ from 'lodash'; import StrictEventEmitter from 'strict-event-emitter-types'; -import Config from '../config'; import * as db from '../db'; import * as constants from '../lib/constants'; import { @@ -29,7 +28,6 @@ type ImageEventEmitter = StrictEventEmitter; interface ImageConstructOpts { docker: DockerUtils; logger: Logger; - config: Config; } interface FetchProgressEvent { diff --git a/src/compose/service-manager.ts b/src/compose/service-manager.ts index 1c2bc102..b42de5df 100644 --- a/src/compose/service-manager.ts +++ b/src/compose/service-manager.ts @@ -7,7 +7,7 @@ import * as _ from 'lodash'; import { fs } from 'mz'; import StrictEventEmitter from 'strict-event-emitter-types'; -import Config from '../config'; +import * as config from '../config'; import Docker from '../lib/docker-utils'; import Logger from '../logger'; @@ -28,7 +28,6 @@ import log from '../lib/supervisor-console'; interface ServiceConstructOpts { docker: Docker; logger: Logger; - config: Config; } interface ServiceManagerEvents { @@ -47,7 +46,6 @@ interface KillOpts { export class ServiceManager extends (EventEmitter as new () => ServiceManagerEventEmitter) { private docker: Docker; private logger: Logger; - private config: Config; // Whether a container has died, indexed by ID private containerHasDied: Dictionary = {}; @@ -60,7 +58,6 @@ export class ServiceManager extends (EventEmitter as new () => ServiceManagerEve super(); this.docker = opts.docker; this.logger = opts.logger; - this.config = opts.config; } public async getAll( @@ -239,7 +236,7 @@ export class ServiceManager extends (EventEmitter as new () => ServiceManagerEve } public async create(service: Service) { - const mockContainerId = this.config.newUniqueKey(); + const mockContainerId = config.newUniqueKey(); try { const existing = await this.get(service); if (existing.containerId == null) { @@ -257,7 +254,7 @@ export class ServiceManager extends (EventEmitter as new () => ServiceManagerEve throw e; } - const deviceName = await this.config.get('name'); + const deviceName = await config.get('name'); if (!isValidDeviceName(deviceName)) { throw new Error( 'The device name contains a newline, which is unsupported by balena. ' + @@ -340,7 +337,7 @@ export class ServiceManager extends (EventEmitter as new () => ServiceManagerEve message.trim().match(/exec format error$/) ) { // Provide a friendlier error message for "exec format error" - const deviceType = await this.config.get('deviceType'); + const deviceType = await config.get('deviceType'); err = new Error( `Application architecture incompatible with ${deviceType}: exec format error`, ); diff --git a/src/config/functions.ts b/src/config/functions.ts index 2d0c52ce..68b397d5 100644 --- a/src/config/functions.ts +++ b/src/config/functions.ts @@ -5,7 +5,7 @@ import { URL } from 'url'; import supervisorVersion = require('../lib/supervisor-version'); -import Config from '.'; +import * as config from '.'; import * as constants from '../lib/constants'; import * as osRelease from '../lib/os-release'; import log from '../lib/supervisor-console'; @@ -14,14 +14,14 @@ export const fnSchema = { version: () => { return Bluebird.resolve(supervisorVersion); }, - currentApiKey: (config: Config) => { + currentApiKey: () => { return config .getMany(['apiKey', 'deviceApiKey']) .then(({ apiKey, deviceApiKey }) => { return apiKey || deviceApiKey; }); }, - provisioned: (config: Config) => { + provisioned: () => { return config .getMany(['uuid', 'apiEndpoint', 'registered_at', 'deviceId']) .then((requiredValues) => { @@ -50,7 +50,7 @@ export const fnSchema = { return 'unknown'; } }, - provisioningOptions: (config: Config) => { + provisioningOptions: () => { return config .getMany([ 'uuid', @@ -79,7 +79,7 @@ export const fnSchema = { }; }); }, - mixpanelHost: (config: Config) => { + mixpanelHost: () => { return config.get('apiEndpoint').then((apiEndpoint) => { if (!apiEndpoint) { return null; @@ -88,7 +88,7 @@ export const fnSchema = { return { host: url.host, path: '/mixpanel' }; }); }, - extendedEnvOptions: (config: Config) => { + extendedEnvOptions: () => { return config.getMany([ 'uuid', 'listenPort', @@ -102,7 +102,7 @@ export const fnSchema = { 'osVersion', ]); }, - fetchOptions: (config: Config) => { + fetchOptions: () => { return config.getMany([ 'uuid', 'currentApiKey', @@ -116,7 +116,7 @@ export const fnSchema = { 'deltaVersion', ]); }, - unmanaged: (config: Config) => { + unmanaged: () => { return config.get('apiEndpoint').then((apiEndpoint) => { return !apiEndpoint; }); diff --git a/src/config/index.ts b/src/config/index.ts index 646051d0..25eacda0 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -1,4 +1,3 @@ -import * as Bluebird from 'bluebird'; import { EventEmitter } from 'events'; import { Transaction } from 'knex'; import * as _ from 'lodash'; @@ -21,10 +20,6 @@ import { InternalInconsistencyError, } from '../lib/errors'; -interface ConfigOpts { - configPath?: string; -} - export type ConfigMap = { [key in T]: SchemaReturn; }; @@ -36,181 +31,235 @@ export type ConfigChangeMap = { export type ConfigKey = SchemaTypeKey; export type ConfigType = SchemaReturn; -interface ConfigEvents { +interface ConfigEventTypes { change: ConfigChangeMap; } -type ConfigEventEmitter = StrictEventEmitter; +export const configJsonBackend: ConfigJsonConfigBackend = new ConfigJsonConfigBackend( + Schema.schema, +); -export class Config extends (EventEmitter as new () => ConfigEventEmitter) { - private configJsonBackend: ConfigJsonConfigBackend; +type ConfigEventEmitter = StrictEventEmitter; +class ConfigEvents extends (EventEmitter as new () => ConfigEventEmitter) {} +const events = new ConfigEvents(); - public constructor({ configPath }: ConfigOpts = {}) { - super(); - this.configJsonBackend = new ConfigJsonConfigBackend( - Schema.schema, - configPath, - ); +// Expose methods which make this module act as an EventEmitter +export const on: typeof events['on'] = events.on.bind(events); +export const once: typeof events['once'] = events.once.bind(events); +export const removeListener: typeof events['removeListener'] = events.removeListener.bind( + events, +); +export const removeAllListeners: typeof events['removeAllListeners'] = events.removeAllListeners.bind( + events, +); + +export async function get( + key: T, + trx?: Transaction, +): Promise> { + const $db = trx || db.models.bind(db); + + if (Schema.schema.hasOwnProperty(key)) { + const schemaKey = key as Schema.SchemaKey; + + return getSchema(schemaKey, $db).then((value) => { + if (value == null) { + const defaultValue = schemaTypes[key].default; + if (defaultValue instanceof t.Type) { + // The only reason that this would be the case in a non-function + // schema key is for the meta nullOrUndefined value. We check this + // by first decoding the value undefined with the default type, and + // then return undefined + const maybeDecoded = (defaultValue as t.Type).decode(undefined); + + return ( + checkValueDecode(maybeDecoded, key, undefined) && maybeDecoded.right + ); + } + return defaultValue as SchemaReturn; + } + const decoded = decodeSchema(schemaKey, value); + + // The following function will throw if the value + // is not correct, so we chain it this way to keep + // the type system happy + return checkValueDecode(decoded, key, value) && decoded.right; + }); + } else if (FnSchema.fnSchema.hasOwnProperty(key)) { + const fnKey = key as FnSchema.FnSchemaKey; + // Cast the promise as something that produces an unknown, and this means that + // we can validate the output of the function as well, ensuring that the type matches + const promiseValue = FnSchema.fnSchema[fnKey](); + return promiseValue.then((value: unknown) => { + const decoded = schemaTypes[key].type.decode(value); + + return checkValueDecode(decoded, key, value) && decoded.right; + }); + } else { + throw new Error(`Unknown config value ${key}`); } +} - public async init() { - await this.generateRequiredFields(); - } +export async function getMany( + keys: T[], + trx?: Transaction, +): Promise<{ [key in T]: SchemaReturn }> { + const values = await Promise.all(keys.map((k) => get(k, trx))); + return (_.zipObject(keys, values) as unknown) as Promise< + { [key in T]: SchemaReturn } + >; +} - public get( - key: T, - trx?: Transaction, - ): Bluebird> { - const $db = trx || db.models.bind(db); +export async function set( + keyValues: ConfigMap, + trx?: Transaction, +): Promise { + const setValuesInTransaction = async (tx: Transaction) => { + const configJsonVals: Dictionary = {}; + const dbVals: Dictionary = {}; - return Bluebird.try(() => { - if (Schema.schema.hasOwnProperty(key)) { - const schemaKey = key as Schema.SchemaKey; + _.each(keyValues, (v, k: T) => { + const schemaKey = k as Schema.SchemaKey; + const source = Schema.schema[schemaKey].source; - return this.getSchema(schemaKey, $db).then((value) => { - if (value == null) { - const defaultValue = schemaTypes[key].default; - if (defaultValue instanceof t.Type) { - // The only reason that this would be the case in a non-function - // schema key is for the meta nullOrUndefined value. We check this - // by first decoding the value undefined with the default type, and - // then return undefined - const maybeDecoded = (defaultValue as t.Type).decode( - undefined, - ); - - return ( - this.checkValueDecode(maybeDecoded, key, undefined) && - maybeDecoded.right - ); - } - return defaultValue as SchemaReturn; - } - const decoded = this.decodeSchema(schemaKey, value); - - // The following function will throw if the value - // is not correct, so we chain it this way to keep - // the type system happy - return this.checkValueDecode(decoded, key, value) && decoded.right; - }); - } else if (FnSchema.fnSchema.hasOwnProperty(key)) { - const fnKey = key as FnSchema.FnSchemaKey; - // Cast the promise as something that produces an unknown, and this means that - // we can validate the output of the function as well, ensuring that the type matches - const promiseValue = FnSchema.fnSchema[fnKey](this) as Bluebird< - unknown - >; - return promiseValue.then((value: unknown) => { - const decoded = schemaTypes[key].type.decode(value); - - return this.checkValueDecode(decoded, key, value) && decoded.right; - }); - } else { - throw new Error(`Unknown config value ${key}`); + switch (source) { + case 'config.json': + configJsonVals[schemaKey] = v; + break; + case 'db': + dbVals[schemaKey] = v; + break; + default: + throw new Error( + `Unknown configuration source: ${source} for config key: ${k}`, + ); } }); - } - public getMany( - keys: T[], - trx?: Transaction, - ): Bluebird<{ [key in T]: SchemaReturn }> { - return Bluebird.map(keys, (key: T) => this.get(key, trx)).then((values) => { - return _.zipObject(keys, values); - }) as Bluebird<{ [key in T]: SchemaReturn }>; - } - - public async set( - keyValues: ConfigMap, - trx?: Transaction, - ): Promise { - const setValuesInTransaction = async (tx: Transaction) => { - const configJsonVals: Dictionary = {}; - const dbVals: Dictionary = {}; - - _.each(keyValues, (v, k: T) => { - const schemaKey = k as Schema.SchemaKey; - const source = Schema.schema[schemaKey].source; - - switch (source) { - case 'config.json': - configJsonVals[schemaKey] = v; - break; - case 'db': - dbVals[schemaKey] = v; - break; - default: - throw new Error( - `Unknown configuration source: ${source} for config key: ${k}`, - ); - } - }); - - const dbKeys = _.keys(dbVals) as T[]; - const oldValues = await this.getMany(dbKeys, tx); - await Bluebird.map(dbKeys, async (key: T) => { + const dbKeys = _.keys(dbVals) as T[]; + const oldValues = await getMany(dbKeys, tx); + await Promise.all( + dbKeys.map(async (key: T) => { const value = dbVals[key]; // if we have anything other than a string, it must be converted to // a string before being stored in the db - const strValue = Config.valueToString(value, key); + const strValue = valueToString(value, key); if (oldValues[key] !== value) { await db.upsertModel('config', { key, value: strValue }, { key }, tx); } - }); + }), + ); - if (!_.isEmpty(configJsonVals)) { - await this.configJsonBackend.set( - configJsonVals as { - [name in Schema.SchemaKey]: unknown; - }, - ); - } - }; - - // Firstly validate and coerce all of the types as - // they are being set - keyValues = this.validateConfigMap(keyValues); - - if (trx != null) { - await setValuesInTransaction(trx); - } else { - await db.transaction((tx: Transaction) => setValuesInTransaction(tx)); - } - this.emit('change', keyValues as ConfigMap); - } - - public async remove(key: T): Promise { - if (Schema.schema[key] == null || !Schema.schema[key].mutable) { - throw new Error(`Attempt to delete non-existent or immutable key ${key}`); - } - if (Schema.schema[key].source === 'config.json') { - return this.configJsonBackend.remove(key); - } else if (Schema.schema[key].source === 'db') { - await db.models('config').del().where({ key }); - } else { - throw new Error( - `Unknown or unsupported config backend: ${Schema.schema[key].source}`, + if (!_.isEmpty(configJsonVals)) { + await configJsonBackend.set( + configJsonVals as { + [name in Schema.SchemaKey]: unknown; + }, ); } + }; + + // Firstly validate and coerce all of the types as + // they are being set + keyValues = validateConfigMap(keyValues); + + if (trx != null) { + await setValuesInTransaction(trx); + } else { + await db.transaction((tx: Transaction) => setValuesInTransaction(tx)); + } + events.emit('change', keyValues as ConfigMap); +} + +export async function remove( + key: T, +): Promise { + if (Schema.schema[key] == null || !Schema.schema[key].mutable) { + throw new Error(`Attempt to delete non-existent or immutable key ${key}`); + } + if (Schema.schema[key].source === 'config.json') { + return configJsonBackend.remove(key); + } else if (Schema.schema[key].source === 'db') { + await db.models('config').del().where({ key }); + } else { + throw new Error( + `Unknown or unsupported config backend: ${Schema.schema[key].source}`, + ); + } +} + +export async function regenerateRegistrationFields(): Promise { + await set({ + uuid: newUniqueKey(), + deviceApiKey: newUniqueKey(), + }); +} + +export function newUniqueKey(): string { + return generateUniqueKey(); +} + +export function valueIsValid( + key: T, + value: unknown, +): boolean { + // If the default entry in the schema is a type and not a value, + // use this in the validation of the value + const schemaTypesEntry = schemaTypes[key as SchemaTypeKey]; + let type: t.Type; + if (schemaTypesEntry.default instanceof t.Type) { + type = t.union([schemaTypesEntry.type, schemaTypesEntry.default]); + } else { + type = schemaTypesEntry.type; } - public async regenerateRegistrationFields(): Promise { - await this.set({ - uuid: this.newUniqueKey(), - deviceApiKey: this.newUniqueKey(), - }); + return isRight(type.decode(value)); +} + +async function getSchema( + key: T, + $db: Transaction, +): Promise { + let value: unknown; + switch (Schema.schema[key].source) { + case 'config.json': + value = await configJsonBackend.get(key); + break; + case 'db': + const [conf] = await $db('config').select('value').where({ key }); + if (conf != null) { + return conf.value; + } + break; } - public newUniqueKey(): string { - return generateUniqueKey(); - } + return value; +} + +function decodeSchema( + key: T, + value: unknown, +): Either> { + return schemaTypes[key].type.decode(value); +} + +function validateConfigMap( + configMap: ConfigMap, +): ConfigMap { + // Just loop over every value, run the decode function, and + // throw if any value fails verification + return _.mapValues(configMap, (value, key) => { + if ( + !Schema.schema.hasOwnProperty(key) || + !Schema.schema[key as Schema.SchemaKey].mutable + ) { + throw new Error( + `Attempt to set value for non-mutable schema key: ${key}`, + ); + } - public valueIsValid( - key: T, - value: unknown, - ): boolean { // If the default entry in the schema is a type and not a value, // use this in the validation of the value const schemaTypesEntry = schemaTypes[key as SchemaTypeKey]; @@ -221,122 +270,66 @@ export class Config extends (EventEmitter as new () => ConfigEventEmitter) { type = schemaTypesEntry.type; } - return isRight(type.decode(value)); - } - - private async getSchema( - key: T, - $db: Transaction, - ): Promise { - let value: unknown; - switch (Schema.schema[key].source) { - case 'config.json': - value = await this.configJsonBackend.get(key); - break; - case 'db': - const [conf] = await $db('config').select('value').where({ key }); - if (conf != null) { - return conf.value; - } - break; + const decoded = type.decode(value); + if (isLeft(decoded)) { + throw new TypeError( + `Cannot set value for ${key}, as value failed validation: ${inspect( + value, + { depth: Infinity }, + )}`, + ); } + return decoded.right; + }) as ConfigMap; +} - return value; - } - - private decodeSchema( - key: T, - value: unknown, - ): Either> { - return schemaTypes[key].type.decode(value); - } - - private validateConfigMap( - configMap: ConfigMap, - ): ConfigMap { - // Just loop over every value, run the decode function, and - // throw if any value fails verification - return _.mapValues(configMap, (value, key) => { - if ( - !Schema.schema.hasOwnProperty(key) || - !Schema.schema[key as Schema.SchemaKey].mutable - ) { - throw new Error( - `Attempt to set value for non-mutable schema key: ${key}`, - ); - } - - // If the default entry in the schema is a type and not a value, - // use this in the validation of the value - const schemaTypesEntry = schemaTypes[key as SchemaTypeKey]; - let type: t.Type; - if (schemaTypesEntry.default instanceof t.Type) { - type = t.union([schemaTypesEntry.type, schemaTypesEntry.default]); - } else { - type = schemaTypesEntry.type; - } - - const decoded = type.decode(value); - if (isLeft(decoded)) { - throw new TypeError( - `Cannot set value for ${key}, as value failed validation: ${inspect( - value, - { depth: Infinity }, - )}`, - ); - } - return decoded.right; - }) as ConfigMap; - } - - private async generateRequiredFields() { - return this.getMany([ - 'uuid', - 'deviceApiKey', - 'apiSecret', - 'unmanaged', - ]).then(({ uuid, deviceApiKey, apiSecret, unmanaged }) => { +export async function generateRequiredFields() { + return getMany(['uuid', 'deviceApiKey', 'apiSecret', 'unmanaged']).then( + ({ uuid, deviceApiKey, apiSecret, unmanaged }) => { // These fields need to be set regardless if (uuid == null || apiSecret == null) { - uuid = uuid || this.newUniqueKey(); - apiSecret = apiSecret || this.newUniqueKey(); + uuid = uuid || newUniqueKey(); + apiSecret = apiSecret || newUniqueKey(); } - return this.set({ uuid, apiSecret }).then(() => { + return set({ uuid, apiSecret }).then(() => { if (unmanaged) { return; } if (!deviceApiKey) { - return this.set({ deviceApiKey: this.newUniqueKey() }); + return set({ deviceApiKey: newUniqueKey() }); } }); - }); - } + }, + ); +} - private static valueToString(value: unknown, name: string) { - switch (typeof value) { - case 'object': - return JSON.stringify(value); - case 'number': - case 'string': - case 'boolean': - return value.toString(); - default: - throw new InternalInconsistencyError( - `Could not convert configuration value to string for storage, name: ${name}, value: ${value}, type: ${typeof value}`, - ); - } - } - - private checkValueDecode( - decoded: Either, - key: string, - value: unknown, - ): decoded is Right { - if (isLeft(decoded)) { - throw new ConfigurationValidationError(key, value); - } - return true; +function valueToString(value: unknown, name: string) { + switch (typeof value) { + case 'object': + return JSON.stringify(value); + case 'number': + case 'string': + case 'boolean': + return value.toString(); + default: + throw new InternalInconsistencyError( + `Could not convert configuration value to string for storage, name: ${name}, value: ${value}, type: ${typeof value}`, + ); } } -export default Config; +function checkValueDecode( + decoded: Either, + key: string, + value: unknown, +): decoded is Right { + if (isLeft(decoded)) { + throw new ConfigurationValidationError(key, value); + } + return true; +} + +export const initialized = (async () => { + await db.initialized; + await generateRequiredFields(); +})(); diff --git a/src/device-api/v2.ts b/src/device-api/v2.ts index f4c1a30e..df8d101b 100644 --- a/src/device-api/v2.ts +++ b/src/device-api/v2.ts @@ -5,6 +5,7 @@ import * as _ from 'lodash'; import { ApplicationManager } from '../application-manager'; import { Service } from '../compose/service'; import Volume from '../compose/volume'; +import * as config from '../config'; import * as db from '../db'; import { spawnJournalctl } from '../lib/journald'; import { @@ -272,7 +273,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) { router.post('/v2/local/target-state', async (req, res) => { // let's first ensure that we're in local mode, otherwise // this function should not do anything - const localMode = await deviceState.config.get('localMode'); + const localMode = await config.get('localMode'); if (!localMode) { return res.status(400).json({ status: 'failed', @@ -300,7 +301,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) { router.get('/v2/local/device-info', async (_req, res) => { try { - const { deviceType, deviceArch } = await applications.config.getMany([ + const { deviceType, deviceArch } = await config.getMany([ 'deviceType', 'deviceArch', ]); @@ -386,7 +387,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) { }); router.get('/v2/state/status', async (_req, res) => { - const currentRelease = await applications.config.get('currentCommit'); + const currentRelease = await config.get('currentCommit'); const pending = applications.deviceState.applyInProgress; const containerStates = (await applications.services.getAll()).map((svc) => @@ -437,7 +438,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) { }); router.get('/v2/device/name', async (_req, res) => { - const deviceName = await applications.config.get('name'); + const deviceName = await config.get('name'); res.json({ status: 'success', deviceName, @@ -460,10 +461,10 @@ export function createV2Api(router: Router, applications: ApplicationManager) { }); router.get('/v2/device/vpn', async (_req, res) => { - const config = await deviceState.deviceConfig.getCurrent(); + const conf = await deviceState.deviceConfig.getCurrent(); // Build VPNInfo const info = { - enabled: config.SUPERVISOR_VPN_CONTROL === 'true', + enabled: conf.SUPERVISOR_VPN_CONTROL === 'true', connected: await isVPNActive(), }; // Return payload diff --git a/src/device-config.ts b/src/device-config.ts index 0ceaf3ac..4934d1a9 100644 --- a/src/device-config.ts +++ b/src/device-config.ts @@ -1,7 +1,7 @@ import * as _ from 'lodash'; import { inspect } from 'util'; -import Config from './config'; +import * as config from './config'; import { SchemaTypeKey } from './config/schema-type'; import * as db from './db'; import Logger from './logger'; @@ -17,7 +17,6 @@ import { DeviceStatus } from './types/state'; const vpnServiceName = 'openvpn'; interface DeviceConfigConstructOpts { - config: Config; logger: Logger; } @@ -56,7 +55,6 @@ interface DeviceActionExecutors { } export class DeviceConfig { - private config: Config; private logger: Logger; private rebootRequired = false; private actionExecutors: DeviceActionExecutors; @@ -148,8 +146,7 @@ export class DeviceConfig { }, }; - public constructor({ config, logger }: DeviceConfigConstructOpts) { - this.config = config; + public constructor({ logger }: DeviceConfigConstructOpts) { this.logger = logger; this.actionExecutors = { @@ -163,7 +160,7 @@ export class DeviceConfig { } // TODO: Change the typing of step so that the types automatically // work out and we don't need this cast to any - await this.config.set(step.target as { [key in SchemaTypeKey]: any }); + await config.set(step.target as { [key in SchemaTypeKey]: any }); if (step.humanReadableTarget) { this.logger.logConfigChange(step.humanReadableTarget, { success: true, @@ -219,7 +216,7 @@ export class DeviceConfig { if (this.configBackend != null) { return this.configBackend; } - const dt = await this.config.get('deviceType'); + const dt = await config.get('deviceType'); this.configBackend = (await configUtils.initialiseConfigBackend(dt, { logger: this.logger, @@ -249,7 +246,7 @@ export class DeviceConfig { public async getTarget({ initial = false }: { initial?: boolean } = {}) { const [unmanaged, [devConfig]] = await Promise.all([ - this.config.get('unmanaged'), + config.get('unmanaged'), db.models('deviceConfig').select('targetValues'), ]); @@ -278,7 +275,7 @@ export class DeviceConfig { } public async getCurrent() { - const conf = await this.config.getMany( + const conf = await config.getMany( ['deviceType'].concat(_.keys(DeviceConfig.configKeys)) as SchemaTypeKey[], ); @@ -384,7 +381,7 @@ export class DeviceConfig { let steps: ConfigStep[] = []; - const { deviceType, unmanaged } = await this.config.getMany([ + const { deviceType, unmanaged } = await config.getMany([ 'deviceType', 'unmanaged', ]); @@ -408,9 +405,7 @@ export class DeviceConfig { ) { // Check that the difference is not due to the variable having an invalid // value set from the cloud - if ( - this.config.valueIsValid(key as SchemaTypeKey, target[envVarName]) - ) { + if (config.valueIsValid(key as SchemaTypeKey, target[envVarName])) { // Save the change if it is both valid and different changingValue = target[envVarName]; } else { @@ -544,10 +539,7 @@ export class DeviceConfig { ); // Ensure devices already have required overlays - DeviceConfig.ensureRequiredOverlay( - await this.config.get('deviceType'), - conf, - ); + DeviceConfig.ensureRequiredOverlay(await config.get('deviceType'), conf); try { await backend.setBootConfig(conf); diff --git a/src/device-state.ts b/src/device-state.ts index 42c441f7..bb06ab86 100644 --- a/src/device-state.ts +++ b/src/device-state.ts @@ -8,7 +8,7 @@ import StrictEventEmitter from 'strict-event-emitter-types'; import prettyMs = require('pretty-ms'); -import Config, { ConfigType } from './config'; +import * as config from './config'; import * as db from './db'; import EventTracker from './event-tracker'; import Logger from './logger'; @@ -98,7 +98,7 @@ function createDeviceStateRouter(deviceState: DeviceState) { res: express.Response, action: DeviceStateStepTarget, ) => { - const override = await deviceState.config.get('lockOverride'); + const override = await config.get('lockOverride'); const force = validation.checkTruthy(req.body.force) || override; try { const response = await deviceState.executeStepAction( @@ -130,7 +130,7 @@ function createDeviceStateRouter(deviceState: DeviceState) { router.patch('/v1/device/host-config', (req, res) => hostConfig - .patch(req.body, deviceState.config) + .patch(req.body) .then(() => res.status(200).send('OK')) .catch((err) => res.status(503).send(err?.message ?? err ?? 'Unknown error'), @@ -177,7 +177,6 @@ function createDeviceStateRouter(deviceState: DeviceState) { } interface DeviceStateConstructOpts { - config: Config; eventTracker: EventTracker; logger: Logger; apiBinder: APIBinder; @@ -219,7 +218,6 @@ type DeviceStateStep = | ConfigStep; export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmitter) { - public config: Config; public eventTracker: EventTracker; public logger: Logger; @@ -244,22 +242,14 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit public connected: boolean; public router: express.Router; - constructor({ - config, - eventTracker, - logger, - apiBinder, - }: DeviceStateConstructOpts) { + constructor({ eventTracker, logger, apiBinder }: DeviceStateConstructOpts) { super(); - this.config = config; this.eventTracker = eventTracker; this.logger = logger; this.deviceConfig = new DeviceConfig({ - config: this.config, logger: this.logger, }); this.applications = new ApplicationManager({ - config: this.config, logger: this.logger, eventTracker: this.eventTracker, deviceState: this, @@ -285,7 +275,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit } public async healthcheck() { - const unmanaged = await this.config.get('unmanaged'); + const unmanaged = await config.get('unmanaged'); // Don't have to perform checks for unmanaged if (unmanaged) { @@ -319,7 +309,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit } public async init() { - this.config.on('change', (changedConfig) => { + config.on('change', (changedConfig) => { if (changedConfig.loggingEnabled != null) { this.logger.enable(changedConfig.loggingEnabled); } @@ -331,7 +321,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit } }); - const conf = await this.config.getMany([ + const conf = await config.getMany([ 'initialConfigSaved', 'listenPort', 'apiSecret', @@ -376,7 +366,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit try { await loadTargetFromFile(null, this); } finally { - await this.config.set({ targetStateSet: true }); + await config.set({ targetStateSet: true }); } } else { log.debug('Skipping preloading'); @@ -385,7 +375,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit // and we need to mark that the target state has been set so that // the supervisor doesn't try to preload again if in the future target // apps are empty again (which may happen with multi-app). - await this.config.set({ targetStateSet: true }); + await config.set({ targetStateSet: true }); } } await this.triggerApplyTarget({ initial: true }); @@ -395,8 +385,8 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit apiEndpoint, connectivityCheckEnabled, }: { - apiEndpoint: ConfigType<'apiEndpoint'>; - connectivityCheckEnabled: ConfigType<'connectivityCheckEnabled'>; + apiEndpoint: config.ConfigType<'apiEndpoint'>; + connectivityCheckEnabled: config.ConfigType<'connectivityCheckEnabled'>; }) { network.startConnectivityCheck( apiEndpoint, @@ -405,7 +395,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit return (this.connected = connected); }, ); - this.config.on('change', function (changedConfig) { + config.on('change', function (changedConfig) { if (changedConfig.connectivityCheckEnabled != null) { return network.enableConnectivityCheck( changedConfig.connectivityCheckEnabled, @@ -425,7 +415,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit const devConf = await this.deviceConfig.getCurrent(); await this.deviceConfig.setTarget(devConf); - await this.config.set({ initialConfigSaved: true }); + await config.set({ initialConfigSaved: true }); } // We keep compatibility with the StrictEventEmitter types @@ -472,11 +462,11 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit globalEventBus.getInstance().emit('targetStateChanged', target); - const apiEndpoint = await this.config.get('apiEndpoint'); + const apiEndpoint = await config.get('apiEndpoint'); await this.usingWriteLockTarget(async () => { await db.transaction(async (trx) => { - await this.config.set({ name: target.local.name }, trx); + await config.set({ name: target.local.name }, trx); await this.deviceConfig.setTarget(target.local.config, trx); if (localSource || apiEndpoint == null) { @@ -511,7 +501,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit return { local: { - name: await this.config.get('name'), + name: await config.get('name'), config: await this.deviceConfig.getTarget({ initial }), apps: await this.applications.getTargetApps(), }, @@ -540,7 +530,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit DeviceStatus & { local: { name: string } } > { const [name, devConfig, apps, dependent] = await Promise.all([ - this.config.get('name'), + config.get('name'), this.deviceConfig.getCurrent(), this.applications.getCurrentForComparison(), this.applications.getDependentState(), diff --git a/src/device-state/preload.ts b/src/device-state/preload.ts index 831ed08e..742df09d 100644 --- a/src/device-state/preload.ts +++ b/src/device-state/preload.ts @@ -3,6 +3,7 @@ import { fs } from 'mz'; import { Image } from '../compose/images'; import DeviceState from '../device-state'; +import * as config from '../config'; import constants = require('../lib/constants'); import { AppsJsonParseError, EISDIR, ENOENT } from '../lib/errors'; @@ -94,7 +95,7 @@ export async function loadTargetFromFile( // multiple applications is possible if (commitToPin != null && appToPin != null) { log.debug('Device will be pinned'); - await deviceState.config.set({ + await config.set({ pinDevice: { commit: commitToPin, app: parseInt(appToPin, 10), diff --git a/src/host-config.ts b/src/host-config.ts index 92fdee46..f6a95925 100644 --- a/src/host-config.ts +++ b/src/host-config.ts @@ -5,7 +5,7 @@ import * as mkdirCb from 'mkdirp'; import { fs } from 'mz'; import * as path from 'path'; -import Config from './config'; +import * as config from './config'; import * as constants from './lib/constants'; import * as dbus from './lib/dbus'; import { ENOENT } from './lib/errors'; @@ -150,8 +150,8 @@ async function readHostname() { return _.trim(hostnameData); } -async function setHostname(val: string, configModel: Config) { - await configModel.set({ hostname: val }); +async function setHostname(val: string) { + await config.set({ hostname: val }); await dbus.restartService('resin-hostname'); } @@ -168,14 +168,14 @@ export function get(): Bluebird { }); } -export function patch(conf: HostConfig, configModel: Config): Bluebird { +export function patch(conf: HostConfig): Bluebird { const promises: Array> = []; if (conf != null && conf.network != null) { if (conf.network.proxy != null) { promises.push(setProxy(conf.network.proxy)); } if (conf.network.hostname != null) { - promises.push(setHostname(conf.network.hostname, configModel)); + promises.push(setHostname(conf.network.hostname)); } } return Bluebird.all(promises).return(); diff --git a/src/lib/migration.ts b/src/lib/migration.ts index e3c391b7..5c45608b 100644 --- a/src/lib/migration.ts +++ b/src/lib/migration.ts @@ -10,7 +10,7 @@ const mkdirpAsync = Bluebird.promisify(mkdirp); const rimrafAsync = Bluebird.promisify(rimraf); import { ApplicationManager } from '../application-manager'; -import Config from '../config'; +import * as config from '../config'; import * as db from '../db'; import DeviceState from '../device-state'; import * as constants from '../lib/constants'; @@ -106,7 +106,6 @@ export function convertLegacyAppsJson(appsArray: any[]): AppsJsonFormat { } export async function normaliseLegacyDatabase( - config: Config, application: ApplicationManager, balenaApi: PinejsClientRequest, ) { diff --git a/src/local-mode.ts b/src/local-mode.ts index 7fab7f68..f77d532f 100644 --- a/src/local-mode.ts +++ b/src/local-mode.ts @@ -2,7 +2,7 @@ import * as Bluebird from 'bluebird'; import * as Docker from 'dockerode'; import * as _ from 'lodash'; -import Config from './config'; +import * as config from './config'; import * as db from './db'; import * as constants from './lib/constants'; import { SupervisorContainerNotFoundError } from './lib/errors'; @@ -71,7 +71,6 @@ const SUPERVISOR_CONTAINER_NAME_FALLBACK = 'resin_supervisor'; */ export class LocalModeManager { public constructor( - public config: Config, public docker: Docker, public logger: Logger, private containerId: string | undefined = constants.containerId, @@ -82,7 +81,7 @@ export class LocalModeManager { public async init() { // Setup a listener to catch state changes relating to local mode - this.config.on('change', (changed) => { + config.on('change', (changed) => { if (changed.localMode != null) { const local = changed.localMode || false; @@ -96,15 +95,15 @@ export class LocalModeManager { // On startup, check if we're in unmanaged mode, // as local mode needs to be set let unmanagedLocalMode = false; - if (await this.config.get('unmanaged')) { + if (await config.get('unmanaged')) { log.info('Starting up in unmanaged mode, activating local mode'); - await this.config.set({ localMode: true }); + await config.set({ localMode: true }); unmanagedLocalMode = true; } const localMode = // short circuit the next get if we know we're in local mode - unmanagedLocalMode || (await this.config.get('localMode')); + unmanagedLocalMode || (await config.get('localMode')); if (!localMode) { // Remove any leftovers if necessary diff --git a/src/logger.ts b/src/logger.ts index 74ec948f..0c45d03c 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -1,7 +1,7 @@ import * as Bluebird from 'bluebird'; import * as _ from 'lodash'; -import Config, { ConfigType } from './config'; +import * as config from './config'; import * as db from './db'; import { EventTracker } from './event-tracker'; import Docker from './lib/docker-utils'; @@ -20,14 +20,13 @@ import * as globalEventBus from './event-bus'; import log from './lib/supervisor-console'; interface LoggerSetupOptions { - apiEndpoint: ConfigType<'apiEndpoint'>; - uuid: ConfigType<'uuid'>; - deviceApiKey: ConfigType<'deviceApiKey'>; - unmanaged: ConfigType<'unmanaged'>; - localMode: ConfigType<'localMode'>; + apiEndpoint: config.ConfigType<'apiEndpoint'>; + uuid: config.ConfigType<'uuid'>; + deviceApiKey: config.ConfigType<'deviceApiKey'>; + unmanaged: config.ConfigType<'unmanaged'>; + localMode: config.ConfigType<'localMode'>; enableLogs: boolean; - config: Config; } type LogEventObject = Dictionary | null; @@ -58,7 +57,6 @@ export class Logger { unmanaged, enableLogs, localMode, - config, }: LoggerSetupOptions) { this.balenaBackend = new BalenaLogBackend(apiEndpoint, uuid, deviceApiKey); this.localBackend = new LocalLogBackend(); @@ -221,21 +219,21 @@ export class Logger { } public logConfigChange( - config: { [configName: string]: string }, + conf: { [configName: string]: string }, { success = false, err }: { success?: boolean; err?: Error } = {}, ) { - const obj: LogEventObject = { config }; + const obj: LogEventObject = { conf }; let message: string; let eventName: string; if (success) { - message = `Applied configuration change ${JSON.stringify(config)}`; + message = `Applied configuration change ${JSON.stringify(conf)}`; eventName = 'Apply config change success'; } else if (err != null) { message = `Error applying configuration change: ${err}`; eventName = 'Apply config change error'; obj.error = err; } else { - message = `Applying configuration change ${JSON.stringify(config)}`; + message = `Applying configuration change ${JSON.stringify(conf)}`; eventName = 'Apply config change in progress'; } diff --git a/src/proxyvisor.js b/src/proxyvisor.js index 62b256fd..a255a6d9 100644 --- a/src/proxyvisor.js +++ b/src/proxyvisor.js @@ -16,6 +16,7 @@ import * as url from 'url'; import { log } from './lib/supervisor-console'; import * as db from './db'; +import * as config from './config'; const mkdirpAsync = Promise.promisify(mkdirp); @@ -201,7 +202,7 @@ const createProxyvisorRouter = function (proxyvisor) { commit, releaseId, environment, - config, + config: conf, } = req.body; const validateDeviceFields = function () { if (isDefined(is_online) && !_.isBoolean(is_online)) { @@ -219,7 +220,7 @@ const createProxyvisorRouter = function (proxyvisor) { if (!validObjectOrUndefined(environment)) { return 'environment must be an object'; } - if (!validObjectOrUndefined(config)) { + if (!validObjectOrUndefined(conf)) { return 'config must be an object'; } return null; @@ -233,12 +234,12 @@ const createProxyvisorRouter = function (proxyvisor) { if (isDefined(environment)) { environment = JSON.stringify(environment); } - if (isDefined(config)) { - config = JSON.stringify(config); + if (isDefined(conf)) { + conf = JSON.stringify(conf); } const fieldsToUpdateOnDB = _.pickBy( - { status, is_online, commit, releaseId, config, environment }, + { status, is_online, commit, releaseId, config: conf, environment }, isDefined, ); /** @type {Dictionary} */ @@ -343,7 +344,7 @@ const createProxyvisorRouter = function (proxyvisor) { }; export class Proxyvisor { - constructor({ config, logger, docker, images, applications }) { + constructor({ logger, docker, images, applications }) { this.bindToAPI = this.bindToAPI.bind(this); this.executeStepAction = this.executeStepAction.bind(this); this.getCurrentStates = this.getCurrentStates.bind(this); @@ -359,7 +360,6 @@ export class Proxyvisor { this.sendUpdate = this.sendUpdate.bind(this); this.sendDeleteHook = this.sendDeleteHook.bind(this); this.sendUpdates = this.sendUpdates.bind(this); - this.config = config; this.logger = logger; this.docker = docker; this.images = images; @@ -369,7 +369,7 @@ export class Proxyvisor { this.router = createProxyvisorRouter(this); this.actionExecutors = { updateDependentTargets: (step) => { - return this.config + return config .getMany(['currentApiKey', 'apiTimeout']) .then(({ currentApiKey, apiTimeout }) => { // - take each of the step.devices and update dependentDevice with it (targetCommit, targetEnvironment, targetConfig) @@ -446,7 +446,7 @@ export class Proxyvisor { sendDependentHooks: (step) => { return Promise.join( - this.config.get('apiTimeout'), + config.get('apiTimeout'), this.getHookEndpoint(step.appId), (apiTimeout, endpoint) => { return Promise.mapSeries(step.devices, (device) => { @@ -965,7 +965,7 @@ export class Proxyvisor { sendUpdates({ uuid }) { return Promise.join( db.models('dependentDevice').where({ uuid }).select(), - this.config.get('apiTimeout'), + config.get('apiTimeout'), ([dev], apiTimeout) => { if (dev == null) { log.warn(`Trying to send update to non-existent device ${uuid}`); diff --git a/src/supervisor-api.ts b/src/supervisor-api.ts index 99021628..0715f55c 100644 --- a/src/supervisor-api.ts +++ b/src/supervisor-api.ts @@ -4,7 +4,7 @@ import { Server } from 'http'; import * as _ from 'lodash'; import * as morgan from 'morgan'; -import Config from './config'; +import * as config from './config'; import { EventTracker } from './event-tracker'; import blink = require('./lib/blink'); import * as iptables from './lib/iptables'; @@ -29,7 +29,7 @@ function getKeyFromReq(req: express.Request): string | undefined { return match?.[1]; } -function authenticate(config: Config): express.RequestHandler { +function authenticate(): express.RequestHandler { return async (req, res, next) => { try { const conf = await config.getMany([ @@ -76,7 +76,6 @@ const expressLogger = morgan( ); interface SupervisorAPIConstructOpts { - config: Config; eventTracker: EventTracker; routers: express.Router[]; healthchecks: Array<() => Promise>; @@ -87,7 +86,6 @@ interface SupervisorAPIStopOpts { } export class SupervisorAPI { - private config: Config; private eventTracker: EventTracker; private routers: express.Router[]; private healthchecks: Array<() => Promise>; @@ -104,12 +102,10 @@ export class SupervisorAPI { : this.applyListeningRules.bind(this); public constructor({ - config, eventTracker, routers, healthchecks, }: SupervisorAPIConstructOpts) { - this.config = config; this.eventTracker = eventTracker; this.routers = routers; this.healthchecks = healthchecks; @@ -133,7 +129,7 @@ export class SupervisorAPI { this.api.get('/ping', (_req, res) => res.send('OK')); - this.api.use(authenticate(this.config)); + this.api.use(authenticate()); this.api.post('/v1/blink', (_req, res) => { this.eventTracker.track('Device blink'); @@ -145,8 +141,8 @@ export class SupervisorAPI { // Expires the supervisor's API key and generates a new one. // It also communicates the new key to the balena API. this.api.post('/v1/regenerate-api-key', async (_req, res) => { - const secret = await this.config.newUniqueKey(); - await this.config.set({ apiSecret: secret }); + const secret = config.newUniqueKey(); + await config.set({ apiSecret: secret }); res.status(200).send(secret); }); @@ -190,11 +186,11 @@ export class SupervisorAPI { port: number, apiTimeout: number, ): Promise { - const localMode = await this.config.get('localMode'); + const localMode = await config.get('localMode'); await this.applyRules(localMode || false, port, allowedInterfaces); // Monitor the switching of local mode, and change which interfaces will // be listened to based on that - this.config.on('change', (changedConfig) => { + config.on('change', (changedConfig) => { if (changedConfig.localMode != null) { this.applyRules( changedConfig.localMode || false, diff --git a/src/supervisor.ts b/src/supervisor.ts index bceffe3e..757837d5 100644 --- a/src/supervisor.ts +++ b/src/supervisor.ts @@ -1,6 +1,6 @@ import APIBinder from './api-binder'; -import Config, { ConfigKey } from './config'; import * as db from './db'; +import * as config from './config'; import DeviceState from './device-state'; import EventTracker from './event-tracker'; import { intialiseContractRequirements } from './lib/contracts'; @@ -13,7 +13,7 @@ import constants = require('./lib/constants'); import log from './lib/supervisor-console'; import version = require('./lib/supervisor-version'); -const startupConfigFields: ConfigKey[] = [ +const startupConfigFields: config.ConfigKey[] = [ 'uuid', 'listenPort', 'apiEndpoint', @@ -29,7 +29,6 @@ const startupConfigFields: ConfigKey[] = [ ]; export class Supervisor { - private config: Config; private eventTracker: EventTracker; private logger: Logger; private deviceState: DeviceState; @@ -37,16 +36,13 @@ export class Supervisor { private api: SupervisorAPI; public constructor() { - this.config = new Config(); this.eventTracker = new EventTracker(); this.logger = new Logger({ eventTracker: this.eventTracker }); this.apiBinder = new APIBinder({ - config: this.config, eventTracker: this.eventTracker, logger: this.logger, }); this.deviceState = new DeviceState({ - config: this.config, eventTracker: this.eventTracker, logger: this.logger, apiBinder: this.apiBinder, @@ -59,7 +55,6 @@ export class Supervisor { this.deviceState.applications.proxyvisor.bindToAPI(this.apiBinder); this.api = new SupervisorAPI({ - config: this.config, eventTracker: this.eventTracker, routers: [this.apiBinder.router, this.deviceState.router], healthchecks: [ @@ -73,9 +68,9 @@ export class Supervisor { log.info(`Supervisor v${version} starting up...`); await db.initialized; - await this.config.init(); + await config.initialized; - const conf = await this.config.getMany(startupConfigFields); + const conf = await config.getMany(startupConfigFields); // We can't print to the dashboard until the logger // has started up, so we leave a trail of breadcrumbs @@ -87,13 +82,12 @@ export class Supervisor { log.debug('Starting logging infrastructure'); this.logger.init({ enableLogs: conf.loggingEnabled, - config: this.config, ...conf, }); intialiseContractRequirements({ supervisorVersion: version, - deviceType: await this.config.get('deviceType'), + deviceType: await config.get('deviceType'), l4tVersion: await osRelease.getL4tVersion(), }); @@ -104,7 +98,6 @@ export class Supervisor { if (conf.legacyAppsPresent && this.apiBinder.balenaApi != null) { log.info('Legacy app detected, running migration'); await normaliseLegacyDatabase( - this.deviceState.config, this.deviceState.applications, this.apiBinder.balenaApi, ); diff --git a/src/target-state.ts b/src/target-state.ts index 184b421f..6488ee11 100644 --- a/src/target-state.ts +++ b/src/target-state.ts @@ -1,7 +1,7 @@ import * as _ from 'lodash'; import { ApplicationManager } from './application-manager'; -import Config from './config'; +import * as config from './config'; import * as db from './db'; // Once we have correct types for both applications and the @@ -23,14 +23,11 @@ export type DatabaseApps = DatabaseApp[]; export class TargetStateAccessor { private targetState?: DatabaseApps; - public constructor( - protected applications: ApplicationManager, - protected config: Config, - ) { + public constructor(protected applications: ApplicationManager) { // If we switch backend, the target state also needs to // be invalidated (this includes switching to and from // local mode) - this.config.on('change', (conf) => { + config.on('change', (conf) => { if (conf.apiEndpoint != null || conf.localMode != null) { this.targetState = undefined; } @@ -49,7 +46,7 @@ export class TargetStateAccessor { public async getTargetApps(): Promise { if (this.targetState == null) { - const { apiEndpoint, localMode } = await this.config.getMany([ + const { apiEndpoint, localMode } = await config.getMany([ 'apiEndpoint', 'localMode', ]); diff --git a/test/00-init.ts b/test/00-init.ts index 92f8a4d7..535776cb 100644 --- a/test/00-init.ts +++ b/test/00-init.ts @@ -28,6 +28,10 @@ try { } catch { /* noop */ } +fs.writeFileSync( + './test/data/config.json', + fs.readFileSync('./test/data/testconfig.json'), +); stub(dbus, 'getBus').returns({ getInterface: ( diff --git a/test/03-config.spec.ts b/test/03-config.spec.ts index f2a12f74..6dcce1b5 100644 --- a/test/03-config.spec.ts +++ b/test/03-config.spec.ts @@ -3,37 +3,27 @@ import { fs } from 'mz'; import chai = require('./lib/chai-config'); import prepare = require('./lib/prepare'); +import * as conf from '../src/config'; + +import constants = require('../src/lib/constants'); +import { SchemaTypeKey } from '../src/config/schema-type'; // tslint:disable-next-line chai.use(require('chai-events')); const { expect } = chai; -import Config from '../src/config'; -import constants = require('../src/lib/constants'); - describe('Config', () => { - let conf: Config; - before(async () => { await prepare(); - conf = new Config(); - - await conf.init(); + await conf.initialized; }); it('uses the correct config.json path', async () => { - expect(await (conf as any).configJsonBackend.path()).to.equal( + expect(await conf.configJsonBackend.path()).to.equal( 'test/data/config.json', ); }); - it('uses the correct config.json path from the root mount when passed as argument to the constructor', async () => { - const conf2 = new Config({ configPath: '/foo.json' }); - expect(await (conf2 as any).configJsonBackend.path()).to.equal( - 'test/data/foo.json', - ); - }); - it('reads and exposes values from the config.json', async () => { const id = await conf.get('applicationId'); return expect(id).to.equal(78373); @@ -107,13 +97,20 @@ describe('Config', () => { expect(conf.get('unknownInvalidValue' as any)).to.be.rejected; }); - it('emits a change event when values are set', (done) => { - conf.on('change', (val) => { - expect(val).to.deep.equal({ name: 'someValue' }); - return done(); - }); + it('emits a change event when values', (done) => { + const listener = (val: conf.ConfigChangeMap) => { + try { + if ('name' in val) { + expect(val.name).to.equal('someValue'); + done(); + conf.removeListener('change', listener); + } + } catch (e) { + done(e); + } + }; + conf.on('change', listener); conf.set({ name: 'someValue' }); - (expect(conf).to as any).emit('change'); }); it("returns an undefined OS variant if it doesn't exist", async () => { @@ -126,12 +123,6 @@ describe('Config', () => { }); describe('Function config providers', () => { - before(async () => { - await prepare(); - conf = new Config(); - await conf.init(); - }); - it('should throw if a non-mutable function provider is set', () => { expect(conf.set({ version: 'some-version' })).to.be.rejected; }); diff --git a/test/05-device-state.spec.ts b/test/05-device-state.spec.ts index 463e8ecb..6931b9e3 100644 --- a/test/05-device-state.spec.ts +++ b/test/05-device-state.spec.ts @@ -6,7 +6,7 @@ import { SinonSpy, SinonStub, spy, stub } from 'sinon'; import chai = require('./lib/chai-config'); import prepare = require('./lib/prepare'); import Log from '../src/lib/supervisor-console'; -import Config from '../src/config'; +import * as config from '../src/config'; import { RPiConfigBackend } from '../src/config/backend'; import DeviceState from '../src/device-state'; import { loadTargetFromFile } from '../src/device-state/preload'; @@ -207,7 +207,6 @@ const testTargetInvalid = { }; describe('deviceState', () => { - const config = new Config(); const logger = { clearOutOfDateDBLogs() { /* noop */ @@ -231,7 +230,6 @@ describe('deviceState', () => { }); deviceState = new DeviceState({ - config, eventTracker: eventTracker as any, logger: logger as any, apiBinder: null as any, @@ -248,7 +246,6 @@ describe('deviceState', () => { }); (deviceState as any).deviceConfig.configBackend = new RPiConfigBackend(); - await config.init(); }); after(() => { @@ -391,7 +388,7 @@ describe('deviceState', () => { beforeEach(() => { // This configStub will be modified in each test case so we can // create the exact conditions we want to for testing healthchecks - configStub = stub(Config.prototype, 'get'); + configStub = stub(config, 'get'); infoLobSpy = spy(Log, 'info'); }); diff --git a/test/11-api-binder.spec.ts b/test/11-api-binder.spec.ts index 4ba1445f..283dbc58 100644 --- a/test/11-api-binder.spec.ts +++ b/test/11-api-binder.spec.ts @@ -4,19 +4,28 @@ import { Server } from 'net'; import { SinonSpy, SinonStub, spy, stub } from 'sinon'; import ApiBinder from '../src/api-binder'; -import Config from '../src/config'; +import prepare = require('./lib/prepare'); +import * as config from '../src/config'; import DeviceState from '../src/device-state'; import Log from '../src/lib/supervisor-console'; import chai = require('./lib/chai-config'); import balenaAPI = require('./lib/mocked-balena-api'); -import prepare = require('./lib/prepare'); +import { schema } from '../src/config/schema'; +import ConfigJsonConfigBackend from '../src/config/configJson'; const { expect } = chai; +const defaultConfigBackend = config.configJsonBackend; const initModels = async (obj: Dictionary, filename: string) => { await prepare(); + config.removeAllListeners(); - obj.config = new Config({ configPath: filename }); + // @ts-ignore + config.configJsonBackend = new ConfigJsonConfigBackend(schema, filename); + config.generateRequiredFields(); + // @ts-expect-error using private properties + config.configJsonBackend.cache = await config.configJsonBackend.read(); + await config.generateRequiredFields(); obj.eventTracker = { track: stub().callsFake((ev, props) => console.log(ev, props)), @@ -29,13 +38,11 @@ const initModels = async (obj: Dictionary, filename: string) => { } as any; obj.apiBinder = new ApiBinder({ - config: obj.config, logger: obj.logger, eventTracker: obj.eventTracker, }); obj.deviceState = new DeviceState({ - config: obj.config, eventTracker: obj.eventTracker, logger: obj.logger, apiBinder: obj.apiBinder, @@ -43,7 +50,6 @@ const initModels = async (obj: Dictionary, filename: string) => { obj.apiBinder.setDeviceState(obj.deviceState); - await obj.config.init(); await obj.apiBinder.initClient(); // Initializes the clients but doesn't trigger provisioning }; @@ -80,6 +86,12 @@ describe('ApiBinder', () => { return initModels(components, '/config-apibinder.json'); }); + after(async () => { + // @ts-ignore + config.configJsonBackend = defaultConfigBackend; + await config.generateRequiredFields(); + }); + it('provisions a device', () => { // @ts-ignore const promise = components.apiBinder.provisionDevice(); @@ -97,11 +109,9 @@ describe('ApiBinder', () => { it('exchanges keys if resource conflict when provisioning', async () => { // Get current config to extend - const currentConfig = await components.apiBinder.config.get( - 'provisioningOptions', - ); + const currentConfig = await config.get('provisioningOptions'); // Stub config values so we have correct conditions - const configStub = stub(Config.prototype, 'get').resolves({ + const configStub = stub(config, 'get').resolves({ ...currentConfig, registered_at: null, provisioningApiKey: '123', // Previous test case deleted the provisioningApiKey so add one @@ -135,7 +145,7 @@ describe('ApiBinder', () => { }); it('deletes the provisioning key', async () => { - expect(await components.config.get('apiKey')).to.be.undefined; + expect(await config.get('apiKey')).to.be.undefined; }); it('sends the correct parameters when provisioning', async () => { @@ -159,6 +169,11 @@ describe('ApiBinder', () => { before(() => { return initModels(components, '/config-apibinder.json'); }); + after(async () => { + // @ts-ignore + config.configJsonBackend = defaultConfigBackend; + await config.generateRequiredFields(); + }); it('gets a device by its uuid from the balena API', async () => { // Manually add a device to the mocked API @@ -185,6 +200,11 @@ describe('ApiBinder', () => { before(() => { return initModels(components, '/config-apibinder.json'); }); + after(async () => { + // @ts-ignore + config.configJsonBackend = defaultConfigBackend; + await config.generateRequiredFields(); + }); it('returns the device if it can fetch it with the deviceApiKey', async () => { spy(balenaAPI.balenaBackend!, 'deviceKeyHandler'); @@ -254,13 +274,18 @@ describe('ApiBinder', () => { before(() => { return initModels(components, '/config-apibinder-offline.json'); }); + after(async () => { + // @ts-ignore + config.configJsonBackend = defaultConfigBackend; + await config.generateRequiredFields(); + }); it('does not generate a key if the device is in unmanaged mode', async () => { - const mode = await components.config.get('unmanaged'); + const mode = await config.get('unmanaged'); // Ensure offline mode is set expect(mode).to.equal(true); // Check that there is no deviceApiKey - const conf = await components.config.getMany(['deviceApiKey', 'uuid']); + const conf = await config.getMany(['deviceApiKey', 'uuid']); expect(conf['deviceApiKey']).to.be.empty; expect(conf['uuid']).to.not.be.undefined; }); @@ -272,9 +297,9 @@ describe('ApiBinder', () => { }); it('does not generate a key with the minimal config', async () => { - const mode = await components2.config.get('unmanaged'); + const mode = await config.get('unmanaged'); expect(mode).to.equal(true); - const conf = await components2.config.getMany(['deviceApiKey', 'uuid']); + const conf = await config.getMany(['deviceApiKey', 'uuid']); expect(conf['deviceApiKey']).to.be.empty; return expect(conf['uuid']).to.not.be.undefined; }); @@ -289,11 +314,16 @@ describe('ApiBinder', () => { before(async () => { await initModels(components, '/config-apibinder.json'); }); + after(async () => { + // @ts-ignore + config.configJsonBackend = defaultConfigBackend; + await config.generateRequiredFields(); + }); beforeEach(() => { // This configStub will be modified in each test case so we can // create the exact conditions we want to for testing healthchecks - configStub = stub(Config.prototype, 'getMany'); + configStub = stub(config, 'getMany'); infoLobSpy = spy(Log, 'info'); }); diff --git a/test/13-device-config.spec.ts b/test/13-device-config.spec.ts index 5b55d7d1..29b95e1d 100644 --- a/test/13-device-config.spec.ts +++ b/test/13-device-config.spec.ts @@ -3,6 +3,7 @@ import { stripIndent } from 'common-tags'; import { child_process, fs } from 'mz'; import { SinonSpy, SinonStub, spy, stub } from 'sinon'; +import * as config from '../src/config'; import { ExtlinuxConfigBackend, RPiConfigBackend } from '../src/config/backend'; import { DeviceConfig } from '../src/device-config'; import * as fsUtils from '../src/lib/fs-utils'; @@ -32,7 +33,6 @@ describe('DeviceConfig', function () { }; return (this.deviceConfig = new DeviceConfig({ logger: this.fakeLogger, - config: this.fakeConfig, })); }); @@ -397,19 +397,16 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\ describe('ConfigFS', function () { before(function () { - const fakeConfig = { - get(key: string) { - return Promise.try(function () { - if (key === 'deviceType') { - return 'up-board'; - } - throw new Error('Unknown fake config key'); - }); - }, - }; + stub(config, 'get').callsFake((key) => { + return Promise.try(function () { + if (key === 'deviceType') { + return 'up-board'; + } + throw new Error('Unknown fake config key'); + }); + }); this.upboardConfig = new DeviceConfig({ logger: this.fakeLogger, - config: fakeConfig as any, }); stub(child_process, 'exec').resolves(); @@ -440,6 +437,17 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\ }); }); + after(function () { + (child_process.exec as SinonStub).restore(); + (fs.exists as SinonStub).restore(); + (fs.mkdir as SinonStub).restore(); + (fs.readdir as SinonStub).restore(); + (fs.readFile as SinonStub).restore(); + (fsUtils.writeFileAtomic as SinonStub).restore(); + (config.get as SinonStub).restore(); + this.fakeLogger.logSystemMessage.resetHistory(); + }); + it('should correctly load the configfs.json file', function () { expect(child_process.exec).to.be.calledWith('modprobe acpi_configfs'); expect(child_process.exec).to.be.calledWith( @@ -491,16 +499,6 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\ ).to.equal('Apply boot config success'); }); }); - - return after(function () { - (child_process.exec as SinonStub).restore(); - (fs.exists as SinonStub).restore(); - (fs.mkdir as SinonStub).restore(); - (fs.readdir as SinonStub).restore(); - (fs.readFile as SinonStub).restore(); - (fsUtils.writeFileAtomic as SinonStub).restore(); - return this.fakeLogger.logSystemMessage.resetHistory(); - }); }); // This will require stubbing device.reboot, gosuper.post, config.get/set diff --git a/test/14-application-manager.spec.ts b/test/14-application-manager.spec.ts index 982aaff8..a0c87c41 100644 --- a/test/14-application-manager.spec.ts +++ b/test/14-application-manager.spec.ts @@ -2,8 +2,6 @@ import * as Bluebird from 'bluebird'; import * as _ from 'lodash'; import { stub } from 'sinon'; -import Config from '../src/config'; - import Network from '../src/compose/network'; import Service from '../src/compose/service'; @@ -127,7 +125,6 @@ const dependentDBFormat = { describe('ApplicationManager', function () { before(async function () { await prepare(); - this.config = new Config(); const eventTracker = new EventTracker(); this.logger = { clearOutOfDateDBLogs: () => { @@ -135,7 +132,6 @@ describe('ApplicationManager', function () { }, } as any; this.deviceState = new DeviceState({ - config: this.config, eventTracker, logger: this.logger, apiBinder: null as any, @@ -226,7 +222,6 @@ describe('ApplicationManager', function () { return targetCloned; }); }; - return this.config.init(); }); beforeEach( diff --git a/test/23-local-mode.ts b/test/23-local-mode.ts index d854aac3..08b2a740 100644 --- a/test/23-local-mode.ts +++ b/test/23-local-mode.ts @@ -3,7 +3,6 @@ import { expect } from 'chai'; import * as Docker from 'dockerode'; import * as sinon from 'sinon'; -import Config from '../src/config'; import * as db from '../src/db'; import LocalModeManager, { EngineSnapshot, @@ -34,11 +33,9 @@ describe('LocalModeManager', () => { await db.initialized; dockerStub = sinon.createStubInstance(Docker); - const configStub = (sinon.createStubInstance(Config) as unknown) as Config; const loggerStub = (sinon.createStubInstance(Logger) as unknown) as Logger; localMode = new LocalModeManager( - configStub, (dockerStub as unknown) as Docker, loggerStub, supervisorContainerId, diff --git a/test/lib/mocked-device-api.ts b/test/lib/mocked-device-api.ts index 7ae45a53..5db36113 100644 --- a/test/lib/mocked-device-api.ts +++ b/test/lib/mocked-device-api.ts @@ -7,7 +7,7 @@ import { Images } from '../../src/compose/images'; import { NetworkManager } from '../../src/compose/network-manager'; import { ServiceManager } from '../../src/compose/service-manager'; import { VolumeManager } from '../../src/compose/volume-manager'; -import Config from '../../src/config'; +import * as config from '../../src/config'; import * as db from '../../src/db'; import { createV1Api } from '../../src/device-api/v1'; import { createV2Api } from '../../src/device-api/v2'; @@ -68,17 +68,11 @@ const STUBBED_VALUES = { async function create(): Promise { // Get SupervisorAPI construct options - const { - config, - eventTracker, - deviceState, - apiBinder, - } = await createAPIOpts(); + const { eventTracker, deviceState, apiBinder } = await createAPIOpts(); // Stub functions setupStubs(); // Create ApplicationManager const appManager = new ApplicationManager({ - config, eventTracker, logger: null, deviceState, @@ -86,7 +80,6 @@ async function create(): Promise { }); // Create SupervisorAPI const api = new SupervisorAPI({ - config, eventTracker, routers: [buildRoutes(appManager)], healthchecks: [deviceState.healthcheck, apiBinder.healthcheck], @@ -108,33 +101,30 @@ async function cleanUp(): Promise { async function createAPIOpts(): Promise { await db.initialized; - // Create config - const mockedConfig = new Config(); // Initialize and set values for mocked Config - await initConfig(mockedConfig); + await initConfig(); // Create EventTracker const tracker = new EventTracker(); // Create deviceState const deviceState = new DeviceState({ - config: mockedConfig, eventTracker: tracker, logger: null as any, apiBinder: null as any, }); const apiBinder = new APIBinder({ - config: mockedConfig, eventTracker: tracker, logger: null as any, }); return { - config: mockedConfig, eventTracker: tracker, deviceState, apiBinder, }; } -async function initConfig(config: Config): Promise { +async function initConfig(): Promise { + // Initialize this config + await config.initialized; // Set testing secret await config.set({ apiSecret: STUBBED_VALUES.config.apiSecret, @@ -143,8 +133,6 @@ async function initConfig(config: Config): Promise { await config.set({ currentCommit: STUBBED_VALUES.config.currentCommit, }); - // Initialize this config - return config.init(); } function buildRoutes(appManager: ApplicationManager): Router { @@ -177,7 +165,6 @@ function restoreStubs() { } interface SupervisorAPIOpts { - config: Config; eventTracker: EventTracker; deviceState: DeviceState; apiBinder: APIBinder; diff --git a/test/lib/prepare.ts b/test/lib/prepare.ts index 56c4499b..93953de8 100644 --- a/test/lib/prepare.ts +++ b/test/lib/prepare.ts @@ -1,8 +1,11 @@ import * as fs from 'fs'; import * as db from '../../src/db'; +import * as config from '../../src/config'; export = async function () { await db.initialized; + await config.initialized; + await db.transaction(async (trx) => { const result = await trx.raw(` SELECT name, sql @@ -51,11 +54,15 @@ export = async function () { './test/data/config-apibinder-offline.json', fs.readFileSync('./test/data/testconfig-apibinder-offline.json'), ); - return fs.writeFileSync( + fs.writeFileSync( './test/data/config-apibinder-offline2.json', fs.readFileSync('./test/data/testconfig-apibinder-offline2.json'), ); } catch (e) { /* ignore /*/ } + + // @ts-expect-error using private properties + config.configJsonBackend.cache = await config.configJsonBackend.read(); + await config.generateRequiredFields(); };