From db74e748a15d33d3ea81c7afee7ed59890da8604 Mon Sep 17 00:00:00 2001 From: Cameron Diver Date: Tue, 8 Jan 2019 16:17:35 +0000 Subject: [PATCH] refactor: Fully type and validate config module set and get We define the type for each config value, and validate the data when retrieving and setting it. Change-type: minor Signed-off-by: Cameron Diver --- package.json | 2 + src/config/configJson.ts | 16 +- src/config/functions.ts | 189 +++++++------- src/config/index.ts | 410 +++++++++++++++++-------------- src/config/schema-type.ts | 254 +++++++++++++++++++ src/config/schema.ts | 191 ++++++++++++++ src/config/types.ts | 114 +++++++++ src/lib/errors.ts | 8 + test/05-device-state.spec.coffee | 5 +- 9 files changed, 890 insertions(+), 299 deletions(-) create mode 100644 src/config/schema-type.ts create mode 100644 src/config/schema.ts create mode 100644 src/config/types.ts diff --git a/package.json b/package.json index 53a16095..808982ce 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,9 @@ "event-stream": "3.3.4", "express": "^4.0.0", "fork-ts-checker-webpack-plugin": "^0.5.2", + "fp-ts": "^1.12.2", "husky": "^1.3.0", + "io-ts": "^1.5.1", "istanbul": "^0.4.5", "json-mask": "^0.3.8", "knex": "~0.15.2", diff --git a/src/config/configJson.ts b/src/config/configJson.ts index 953a2a18..8cf924ca 100644 --- a/src/config/configJson.ts +++ b/src/config/configJson.ts @@ -3,8 +3,8 @@ import * as _ from 'lodash'; import { fs } from 'mz'; import * as path from 'path'; -import { ConfigSchema, ConfigValue } from '../lib/types'; import { readLock, writeLock } from '../lib/update-lock'; +import * as Schema from './schema'; import * as constants from '../lib/constants'; import { writeAndSyncFile, writeFileAtomic } from '../lib/fs-utils'; @@ -15,11 +15,11 @@ export default class ConfigJsonConfigBackend { private writeLockConfigJson: () => Promise.Disposer<() => void>; private configPath?: string; - private cache: { [key: string]: ConfigValue } = {}; + private cache: { [key: string]: unknown } = {}; - private schema: ConfigSchema; + private schema: Schema.Schema; - public constructor(schema: ConfigSchema, configPath?: string) { + public constructor(schema: Schema.Schema, configPath?: string) { this.configPath = configPath; this.schema = schema; @@ -35,10 +35,12 @@ export default class ConfigJsonConfigBackend { }); } - public set(keyVals: { [key: string]: ConfigValue }): Promise { + public set( + keyVals: { [key in T]: unknown }, + ): Promise { let changed = false; return Promise.using(this.writeLockConfigJson(), () => { - return Promise.mapSeries(_.keys(keyVals), (key: string) => { + return Promise.mapSeries(_.keys(keyVals) as T[], (key: T) => { const value = keyVals[key]; if (this.cache[key] !== value) { @@ -62,7 +64,7 @@ export default class ConfigJsonConfigBackend { }); } - public get(key: string): Promise { + public get(key: string): Promise { return Promise.using(this.readLockConfigJson(), () => { return Promise.resolve(this.cache[key]); }); diff --git a/src/config/functions.ts b/src/config/functions.ts index ed3514e5..b3412605 100644 --- a/src/config/functions.ts +++ b/src/config/functions.ts @@ -8,107 +8,100 @@ import Config from '.'; import * as constants from '../lib/constants'; import * as osRelease from '../lib/os-release'; -type ConfigProviderFunction = () => Bluebird; - -export interface ConfigProviderFunctions { - [key: string]: ConfigProviderFunction; -} - -export function createProviderFunctions( - config: Config, -): ConfigProviderFunctions { - return { - version: () => { - return Bluebird.resolve(supervisorVersion); - }, - currentApiKey: () => { - return config - .getMany(['apiKey', 'deviceApiKey']) - .then(({ apiKey, deviceApiKey }) => { - return apiKey || deviceApiKey; - }); - }, - provisioned: () => { - return config - .getMany(['uuid', 'apiEndpoint', 'registered_at', 'deviceId']) - .then(requiredValues => { - return _.every(_.values(requiredValues), Boolean); - }); - }, - osVersion: () => { - return osRelease.getOSVersion(constants.hostOSVersionPath); - }, - osVariant: () => { - return osRelease.getOSVariant(constants.hostOSVersionPath); - }, - provisioningOptions: () => { - return config - .getMany([ - 'uuid', - 'userId', - 'applicationId', - 'apiKey', - 'deviceApiKey', - 'deviceType', - 'apiEndpoint', - 'apiTimeout', - 'registered_at', - 'deviceId', - ]) - .then(conf => { - return { - uuid: conf.uuid, - applicationId: conf.applicationId, - userId: conf.userId, - deviceType: conf.deviceType, - provisioningApiKey: conf.apiKey, - deviceApiKey: conf.deviceApiKey, - apiEndpoint: conf.apiEndpoint, - apiTimeout: conf.apiTimeout, - registered_at: conf.registered_at, - deviceId: conf.deviceId, - }; - }); - }, - mixpanelHost: () => { - return config.get('apiEndpoint').then(apiEndpoint => { - if (!apiEndpoint) { - return null; - } - const url = new URL(apiEndpoint as string); - return { host: url.host, path: '/mixpanel' }; +export const fnSchema = { + version: () => { + return Bluebird.resolve(supervisorVersion); + }, + currentApiKey: (config: Config) => { + return config + .getMany(['apiKey', 'deviceApiKey']) + .then(({ apiKey, deviceApiKey }) => { + return apiKey || deviceApiKey; }); - }, - extendedEnvOptions: () => { - return config.getMany([ + }, + provisioned: (config: Config) => { + return config + .getMany(['uuid', 'apiEndpoint', 'registered_at', 'deviceId']) + .then(requiredValues => { + return _.every(_.values(requiredValues), Boolean); + }); + }, + osVersion: () => { + return osRelease.getOSVersion(constants.hostOSVersionPath); + }, + osVariant: () => { + return osRelease.getOSVariant(constants.hostOSVersionPath); + }, + provisioningOptions: (config: Config) => { + return config + .getMany([ 'uuid', - 'listenPort', - 'name', - 'apiSecret', + 'userId', + 'applicationId', + 'apiKey', 'deviceApiKey', - 'version', 'deviceType', - 'osVersion', - ]); - }, - fetchOptions: () => { - return config.getMany([ - 'uuid', - 'currentApiKey', 'apiEndpoint', - 'deltaEndpoint', - 'delta', - 'deltaRequestTimeout', - 'deltaApplyTimeout', - 'deltaRetryCount', - 'deltaRetryInterval', - 'deltaVersion', - ]); - }, - unmanaged: () => { - return config.get('apiEndpoint').then(apiEndpoint => { - return !apiEndpoint; + 'apiTimeout', + 'registered_at', + 'deviceId', + ]) + .then(conf => { + return { + uuid: conf.uuid, + applicationId: conf.applicationId, + userId: conf.userId, + deviceType: conf.deviceType, + provisioningApiKey: conf.apiKey, + deviceApiKey: conf.deviceApiKey, + apiEndpoint: conf.apiEndpoint, + apiTimeout: conf.apiTimeout, + registered_at: conf.registered_at, + deviceId: conf.deviceId, + }; }); - }, - }; -} + }, + mixpanelHost: (config: Config) => { + return config.get('apiEndpoint').then(apiEndpoint => { + if (!apiEndpoint) { + return null; + } + const url = new URL(apiEndpoint); + return { host: url.host, path: '/mixpanel' }; + }); + }, + extendedEnvOptions: (config: Config) => { + return config.getMany([ + 'uuid', + 'listenPort', + 'name', + 'apiSecret', + 'deviceApiKey', + 'version', + 'deviceType', + 'osVersion', + ]); + }, + fetchOptions: (config: Config) => { + return config.getMany([ + 'uuid', + 'currentApiKey', + 'apiEndpoint', + 'deltaEndpoint', + 'delta', + 'deltaRequestTimeout', + 'deltaApplyTimeout', + 'deltaRetryCount', + 'deltaRetryInterval', + 'deltaVersion', + ]); + }, + unmanaged: (config: Config) => { + return config.get('apiEndpoint').then(apiEndpoint => { + return !apiEndpoint; + }); + }, +}; + +export type FnSchema = typeof fnSchema; +export type FnSchemaKey = keyof FnSchema; diff --git a/src/config/index.ts b/src/config/index.ts index 5ec9f79a..86b4a15c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -4,93 +4,39 @@ import { Transaction } from 'knex'; import * as _ from 'lodash'; import { generateUniqueKey } from 'resin-register-device'; +import { Either } from 'fp-ts/lib/Either'; +import * as t from 'io-ts'; + import ConfigJsonConfigBackend from './configJson'; -import * as constants from '../lib/constants'; -import { ConfigMap, ConfigSchema, ConfigValue } from '../lib/types'; -import { ConfigProviderFunctions, createProviderFunctions } from './functions'; +import * as FnSchema from './functions'; +import * as Schema from './schema'; +import { SchemaReturn, SchemaTypeKey, schemaTypes } from './schema-type'; import DB from '../db'; +import { + ConfigurationValidationError, + InternalInconsistencyError, +} from '../lib/errors'; interface ConfigOpts { db: DB; configPath: string; } +type ConfigMap = { [key in T]: SchemaReturn }; + export class Config extends EventEmitter { private db: DB; private configJsonBackend: ConfigJsonConfigBackend; - private providerFunctions: ConfigProviderFunctions; - - public schema: ConfigSchema = { - apiEndpoint: { source: 'config.json', default: '' }, - apiTimeout: { source: 'config.json', default: 15 * 60 * 1000 }, - listenPort: { source: 'config.json', default: 48484 }, - deltaEndpoint: { source: 'config.json', default: 'https://delta.resin.io' }, - uuid: { source: 'config.json', mutable: true }, - apiKey: { source: 'config.json', mutable: true, removeIfNull: true }, - deviceApiKey: { source: 'config.json', mutable: true, default: '' }, - deviceType: { source: 'config.json', default: 'unknown' }, - username: { source: 'config.json' }, - userId: { source: 'config.json' }, - deviceId: { source: 'config.json', mutable: true }, - registered_at: { source: 'config.json', mutable: true }, - applicationId: { source: 'config.json' }, - appUpdatePollInterval: { - source: 'config.json', - mutable: true, - default: 60000, - }, - mixpanelToken: { - source: 'config.json', - default: constants.defaultMixpanelToken, - }, - bootstrapRetryDelay: { source: 'config.json', default: 30000 }, - hostname: { source: 'config.json', mutable: true }, - persistentLogging: { source: 'config.json', default: false, mutable: true }, - - version: { source: 'func' }, - currentApiKey: { source: 'func' }, - provisioned: { source: 'func' }, - osVersion: { source: 'func' }, - osVariant: { source: 'func' }, - provisioningOptions: { source: 'func' }, - mixpanelHost: { source: 'func' }, - extendedEnvOptions: { source: 'func' }, - fetchOptions: { source: 'func' }, - unmanaged: { source: 'func' }, - - // NOTE: all 'db' values are stored and loaded as *strings*, - apiSecret: { source: 'db', mutable: true }, - name: { source: 'db', mutable: true, default: 'local' }, - initialConfigReported: { source: 'db', mutable: true, default: 'false' }, - initialConfigSaved: { source: 'db', mutable: true, default: 'false' }, - containersNormalised: { source: 'db', mutable: true, default: 'false' }, - loggingEnabled: { source: 'db', mutable: true, default: 'true' }, - connectivityCheckEnabled: { source: 'db', mutable: true, default: 'true' }, - delta: { source: 'db', mutable: true, default: 'false' }, - deltaRequestTimeout: { source: 'db', mutable: true, default: '30000' }, - deltaApplyTimeout: { source: 'db', mutable: true, default: '' }, - deltaRetryCount: { source: 'db', mutable: true, default: '30' }, - deltaRetryInterval: { source: 'db', mutable: true, default: '10000' }, - deltaVersion: { source: 'db', mutable: true, default: '2' }, - lockOverride: { source: 'db', mutable: true, default: 'false' }, - legacyAppsPresent: { source: 'db', mutable: true, default: 'false' }, - // a JSON value, which is either null, or { app: number, commit: string } - pinDevice: { source: 'db', mutable: true, default: 'null' }, - currentCommit: { source: 'db', mutable: true }, - targetStateSet: { source: 'db', mutable: true, default: 'false' }, - localMode: { source: 'db', mutable: true, default: 'false' }, - }; public constructor({ db, configPath }: ConfigOpts) { super(); this.db = db; this.configJsonBackend = new ConfigJsonConfigBackend( - this.schema, + Schema.schema, configPath, ); - this.providerFunctions = createProviderFunctions(this); } public init(): Bluebird { @@ -99,164 +45,155 @@ export class Config extends EventEmitter { }); } - public get(key: string, trx?: Transaction): Bluebird { + public get( + key: T, + trx?: Transaction, + ): Bluebird> { const db = trx || this.db.models.bind(this.db); return Bluebird.try(() => { - if (this.schema[key] == null) { + if (Schema.schema.hasOwnProperty(key)) { + const schemaKey = key as Schema.SchemaKey; + + 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 decoded = (defaultValue as t.Type).decode(undefined); + + this.checkValueDecode(decoded, key, undefined); + return decoded.value; + } + return defaultValue as SchemaReturn; + } + const decoded = this.decodeSchema(schemaKey, value); + + this.checkValueDecode(decoded, key, value); + + return decoded.value; + }); + } else if (FnSchema.fnSchema.hasOwnProperty(key)) { + const fnKey = key as FnSchema.FnSchemaKey; + // Cast the promise as something that produes 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); + + this.checkValueDecode(decoded, key, value); + + return decoded.value as SchemaReturn; + }); + } else { throw new Error(`Unknown config value ${key}`); } - switch (this.schema[key].source) { - case 'func': - return this.providerFunctions[key]().catch(e => { - console.error(`Error getting config value for ${key}`, e, e.stack); - return null; - }); - case 'config.json': - return this.configJsonBackend.get(key); - case 'db': - return db('config') - .select('value') - .where({ key }) - .then(([conf]: [{ value: string }]) => { - if (conf != null) { - return conf.value; - } - return; - }); - } - }).then(value => { - const schemaEntry = this.schema[key]; - if (value == null && schemaEntry != null && schemaEntry.default != null) { - return schemaEntry.default; - } - return value; }); } - public getMany(keys: string[], trx?: Transaction): Bluebird { - return Bluebird.map(keys, (key: string) => this.get(key, trx)).then( - values => { - return _.zipObject(keys, values); - }, - ); + 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); + }); } - public set(keyValues: ConfigMap, trx?: Transaction): Bluebird { - return Bluebird.try(() => { - // Split the values based on which storage backend they use - type SplitConfigBackend = { - configJsonVals: ConfigMap; - dbVals: ConfigMap; - fnVals: ConfigMap; - }; - const { configJsonVals, dbVals, fnVals }: SplitConfigBackend = _.reduce( - keyValues, - (acc: SplitConfigBackend, val, key) => { - if (this.schema[key] == null || !this.schema[key].mutable) { - throw new Error( - `Config field ${key} not found or is immutable in config.set`, - ); - } - if (this.schema[key].source === 'config.json') { - acc.configJsonVals[key] = val; - } else if (this.schema[key].source === 'db') { - acc.dbVals[key] = val; - } else { - throw new Error( - `Unknown config backend for key: ${key}, backend: ${ - this.schema[key].source - }`, - ); - } - return acc; - }, - { configJsonVals: {}, dbVals: {}, fnVals: {} }, - ); + public set( + keyValues: ConfigMap, + trx?: Transaction, + ): Bluebird { + const setValuesInTransaction = (tx: Transaction) => { + const configJsonVals: Dictionary = {}; + const dbVals: Dictionary = {}; - // Set these values, taking into account the knex transaction - const setValuesInTransaction = (tx: Transaction): Bluebird => { - const dbKeys = _.keys(dbVals); - return this.getMany(dbKeys, tx) - .then(oldValues => { - return Bluebird.map(dbKeys, (key: string) => { - const value = dbVals[key]; - if (oldValues[key] !== value) { - return this.db.upsertModel( - 'config', - { key, value: (value || '').toString() }, - { key }, - tx, - ); - } - }); - }) - .then(() => { - return Bluebird.map(_.toPairs(fnVals), ([key, value]) => { - const fn = this.providerFunctions[key]; - if (fn.set == null) { - throw new Error( - `Attempting to set provider function without set() method implemented - key: ${key}`, - ); - } - return fn.set(value, tx); - }); - }) - .then(() => { - if (!_.isEmpty(configJsonVals)) { - return this.configJsonBackend.set(configJsonVals); + _.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}`, + ); + break; + } + }); + + const dbKeys = _.keys(dbVals) as T[]; + return this.getMany(dbKeys, tx) + .then(oldValues => { + return Bluebird.map(dbKeys, (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); + + if (oldValues[key] !== value) { + return this.db.upsertModel( + 'config', + { key, value: strValue }, + { key }, + tx, + ); } }); - }; + }) + .then(() => { + if (!_.isEmpty(configJsonVals)) { + return this.configJsonBackend.set(configJsonVals as { + [key in Schema.SchemaKey]: unknown + }); + } + }); + }; + + return Bluebird.try(() => { + // Firstly validate all of the types as they are being set + this.validateConfigMap(keyValues); if (trx != null) { return setValuesInTransaction(trx).return(); } else { return this.db - .transaction((tx: Transaction) => { - return setValuesInTransaction(tx); - }) + .transaction((tx: Transaction) => setValuesInTransaction(tx)) .return(); } - }) - .then(() => { - return setImmediate(() => { - this.emit('change', keyValues); - }); - }) - .return(); + }).then(() => { + this.emit('change', keyValues); + }); } - public remove(key: string): Bluebird { + public remove(key: T): Bluebird { return Bluebird.try(() => { - if (this.schema[key] == null || !this.schema[key].mutable) { + if (Schema.schema[key] == null || !Schema.schema[key].mutable) { throw new Error( `Attempt to delete non-existent or immutable key ${key}`, ); } - if (this.schema[key].source === 'config.json') { + if (Schema.schema[key].source === 'config.json') { return this.configJsonBackend.remove(key); - } else if (this.schema[key].source === 'db') { + } else if (Schema.schema[key].source === 'db') { return this.db .models('config') .del() .where({ key }); - } else if (this.schema[key].source === 'func') { - const mutFn = this.providerFunctions[key]; - if (mutFn == null) { - throw new Error( - `Could not find provider function for config ${key}!`, - ); - } - if (mutFn.remove == null) { - throw new Error( - `Could not find removal provider function for config ${key}`, - ); - } - return mutFn.remove(); } else { throw new Error( - `Unknown or unsupported config backend: ${this.schema[key].source}`, + `Unknown or unsupported config backend: ${Schema.schema[key].source}`, ); } }); @@ -273,6 +210,72 @@ export class Config extends EventEmitter { return generateUniqueKey(); } + 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': + value = await db('config') + .select('value') + .where({ key }) + .then(([conf]: [{ value: string }]) => { + if (conf != null) { + return conf.value; + } + return; + }); + break; + } + + return value; + } + + private decodeSchema( + key: T, + value: unknown, + ): Either> { + return schemaTypes[key].type.decode(value); + } + + private validateConfigMap(configMap: ConfigMap) { + // Just loop over every value, run the decode function, and + // throw if any value fails verification + _.map(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 (decoded.isLeft()) { + throw new Error( + `Cannot set value for ${key}, as value failed validation: ${ + decoded.value + }`, + ); + } + }); + } + private generateRequiredFields() { return this.getMany([ 'uuid', @@ -295,6 +298,31 @@ export class Config extends EventEmitter { }); }); } + + private static valueToString(value: unknown) { + 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, value: ${value}, type: ${typeof value}`, + ); + } + } + + private checkValueDecode( + decoded: Either, + key: string, + value: unknown, + ): void { + if (decoded.isLeft()) { + throw new ConfigurationValidationError(key, value); + } + } } export default Config; diff --git a/src/config/schema-type.ts b/src/config/schema-type.ts new file mode 100644 index 00000000..13a8aa82 --- /dev/null +++ b/src/config/schema-type.ts @@ -0,0 +1,254 @@ +import * as t from 'io-ts'; + +import * as constants from '../lib/constants'; + +import { + NullOrUndefined, + PermissiveBoolean, + PermissiveNumber, + StringJSON, +} from './types'; + +export const schemaTypes = { + apiEndpoint: { + type: t.string, + default: '', + }, + apiTimeout: { + type: PermissiveNumber, + default: 15 * 60 * 1000, + }, + listenPort: { + type: PermissiveNumber, + default: 48484, + }, + deltaEndpoint: { + type: t.string, + default: 'https://delta.resin.io', + }, + uuid: { + type: t.string, + default: NullOrUndefined, + }, + apiKey: { + type: t.string, + default: NullOrUndefined, + }, + deviceApiKey: { + type: t.string, + default: '', + }, + deviceType: { + type: t.string, + default: 'unknown', + }, + username: { + type: t.string, + default: NullOrUndefined, + }, + userId: { + type: PermissiveNumber, + default: NullOrUndefined, + }, + deviceId: { + type: PermissiveNumber, + default: NullOrUndefined, + }, + registered_at: { + type: PermissiveNumber, + default: NullOrUndefined, + }, + applicationId: { + type: PermissiveNumber, + default: NullOrUndefined, + }, + appUpdatePollInterval: { + type: PermissiveNumber, + default: 60000, + }, + mixpanelToken: { + type: t.string, + default: constants.defaultMixpanelToken, + }, + bootstrapRetryDelay: { + type: PermissiveNumber, + default: 30000, + }, + hostname: { + type: t.string, + default: NullOrUndefined, + }, + persistentLogging: { + type: PermissiveBoolean, + default: false, + }, + + // Database types + apiSecret: { + type: t.string, + default: NullOrUndefined, + }, + name: { + type: t.string, + default: 'local', + }, + initialConfigReported: { + type: t.string, + default: '', + }, + initialConfigSaved: { + type: PermissiveBoolean, + default: false, + }, + containersNormalised: { + type: PermissiveBoolean, + default: false, + }, + loggingEnabled: { + type: PermissiveBoolean, + default: true, + }, + connectivityCheckEnabled: { + type: PermissiveBoolean, + default: true, + }, + delta: { + type: PermissiveBoolean, + default: false, + }, + deltaRequestTimeout: { + type: PermissiveNumber, + default: 30000, + }, + deltaApplyTimeout: { + type: PermissiveNumber, + default: NullOrUndefined, + }, + deltaRetryCount: { + type: PermissiveNumber, + default: 30, + }, + deltaRetryInterval: { + type: PermissiveNumber, + default: 10000, + }, + deltaVersion: { + type: PermissiveNumber, + default: 2, + }, + lockOverride: { + type: PermissiveBoolean, + default: false, + }, + legacyAppsPresent: { + type: PermissiveBoolean, + default: false, + }, + pinDevice: { + type: new StringJSON<{ app: number; commit: string }>( + t.interface({ app: t.number, commit: t.string }), + ), + default: NullOrUndefined, + }, + currentCommit: { + type: t.string, + default: NullOrUndefined, + }, + targetStateSet: { + type: PermissiveBoolean, + default: false, + }, + localMode: { + type: PermissiveBoolean, + default: false, + }, + + // Function schema types + // The type should be the value that the promise resolves + // to, not including the promise itself + // The type should be a union of every return type possible, + // and the default should be t.never always + version: { + type: t.string, + default: t.never, + }, + currentApiKey: { + type: t.string, + default: t.never, + }, + provisioned: { + type: t.boolean, + default: t.never, + }, + osVersion: { + type: t.union([t.string, NullOrUndefined]), + default: t.never, + }, + osVariant: { + type: t.union([t.string, NullOrUndefined]), + default: t.never, + }, + provisioningOptions: { + type: t.interface({ + // These types are taken from the types of the individual + // config values they're made from + // TODO: It would be nice if we could take the type values + // from the definitions above and still have the types work + uuid: t.union([t.string, NullOrUndefined]), + applicationId: t.union([PermissiveNumber, NullOrUndefined]), + userId: t.union([PermissiveNumber, NullOrUndefined]), + deviceType: t.string, + provisioningApiKey: t.union([t.string, NullOrUndefined]), + deviceApiKey: t.string, + apiEndpoint: t.string, + apiTimeout: PermissiveNumber, + registered_at: t.union([PermissiveNumber, NullOrUndefined]), + deviceId: t.union([PermissiveNumber, NullOrUndefined]), + }), + default: t.never, + }, + mixpanelHost: { + type: t.union([t.null, t.interface({ host: t.string, path: t.string })]), + default: t.never, + }, + extendedEnvOptions: { + type: t.interface({ + uuid: t.union([t.string, NullOrUndefined]), + listenPort: PermissiveNumber, + name: t.string, + apiSecret: t.union([t.string, NullOrUndefined]), + deviceApiKey: t.string, + version: t.string, + deviceType: t.string, + osVersion: t.union([t.string, NullOrUndefined]), + }), + default: t.never, + }, + fetchOptions: { + type: t.interface({ + uuid: t.union([t.string, NullOrUndefined]), + currentApiKey: t.string, + apiEndpoint: t.string, + deltaEndpoint: t.string, + delta: PermissiveBoolean, + deltaRequestTimeout: PermissiveNumber, + deltaApplyTimeout: t.union([PermissiveNumber, NullOrUndefined]), + deltaRetryCount: PermissiveNumber, + deltaRetryInterval: PermissiveNumber, + deltaVersion: PermissiveNumber, + }), + default: t.never, + }, + unmanaged: { + type: t.boolean, + default: t.never, + }, +}; + +export type SchemaType = typeof schemaTypes; +export type SchemaTypeKey = keyof SchemaType; + +export type RealType = T extends t.Type ? t.TypeOf : T; +export type SchemaReturn = + | t.TypeOf + | RealType; diff --git a/src/config/schema.ts b/src/config/schema.ts new file mode 100644 index 00000000..f77276a1 --- /dev/null +++ b/src/config/schema.ts @@ -0,0 +1,191 @@ +export const schema = { + apiEndpoint: { + source: 'config.json', + mutable: false, + removeIfNull: false, + }, + apiTimeout: { + source: 'config.json', + mutable: false, + removeIfNull: false, + }, + listenPort: { + source: 'config.json', + mutable: false, + removeIfNull: false, + }, + deltaEndpoint: { + source: 'config.json', + mutable: false, + removeIfNull: false, + }, + uuid: { + source: 'config.json', + mutable: true, + removeIfNull: false, + }, + apiKey: { + source: 'config.json', + mutable: true, + removeIfNull: true, + }, + deviceApiKey: { + source: 'config.json', + mutable: true, + removeIfNull: false, + }, + deviceType: { + source: 'config.json', + mutable: false, + removeIfNull: false, + }, + username: { + source: 'config.json', + mutable: false, + removeIfNull: false, + }, + userId: { + source: 'config.json', + mutable: false, + removeIfNull: false, + }, + deviceId: { + source: 'config.json', + mutable: true, + removeIfNull: false, + }, + registered_at: { + source: 'config.json', + mutable: true, + removeIfNull: false, + }, + applicationId: { + source: 'config.json', + mutable: false, + removeIfNull: false, + }, + appUpdatePollInterval: { + source: 'config.json', + mutable: true, + removeIfNull: false, + }, + mixpanelToken: { + source: 'config.json', + mutable: false, + removeIfNull: false, + }, + bootstrapRetryDelay: { + source: 'config.json', + mutable: false, + removeIfNull: false, + }, + hostname: { + source: 'config.json', + mutable: true, + removeIfNull: false, + }, + persistentLogging: { + source: 'config.json', + mutable: true, + removeIfNull: false, + }, + + apiSecret: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + name: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + initialConfigReported: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + initialConfigSaved: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + containersNormalised: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + loggingEnabled: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + connectivityCheckEnabled: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + delta: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + deltaRequestTimeout: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + deltaApplyTimeout: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + deltaRetryCount: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + deltaRetryInterval: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + deltaVersion: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + lockOverride: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + legacyAppsPresent: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + pinDevice: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + currentCommit: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + targetStateSet: { + source: 'db', + mutable: true, + removeIfNull: false, + }, + localMode: { + source: 'db', + mutable: true, + removeIfNull: false, + }, +}; + +export type Schema = typeof schema; +export type SchemaKey = keyof Schema; diff --git a/src/config/types.ts b/src/config/types.ts new file mode 100644 index 00000000..137b5102 --- /dev/null +++ b/src/config/types.ts @@ -0,0 +1,114 @@ +import * as t from 'io-ts'; +import * as _ from 'lodash'; + +import { InternalInconsistencyError } from '../lib/errors'; +import { checkTruthy } from '../lib/validation'; + +const permissiveValue = t.union([ + t.boolean, + t.string, + t.number, + t.null, + t.undefined, +]); +type PermissiveType = typeof permissiveValue; + +export const PermissiveBoolean = new t.Type>( + 'PermissiveBoolean', + _.isBoolean, + (m, c) => + permissiveValue.validate(m, c).chain(v => { + switch (typeof v) { + case 'boolean': + case 'string': + case 'number': + const val = checkTruthy(v); + if (val == null) { + return t.failure(v, c); + } + return t.success(val); + case 'undefined': + return t.success(false); + case 'object': + if (_.isNull(v)) { + return t.success(false); + } else { + return t.failure(v, c); + } + default: + return t.failure(v, c); + } + }), + () => { + throw new InternalInconsistencyError( + 'Encode not defined for PermissiveBoolean', + ); + }, +); + +export const PermissiveNumber = new t.Type( + 'PermissiveNumber', + _.isNumber, + (m, c) => + t + .union([t.string, t.number]) + .validate(m, c) + .chain(v => { + switch (typeof v) { + case 'number': + return t.success(v); + case 'string': + const i = parseInt(v, 10); + if (_.isNaN(i)) { + return t.failure(v, c); + } + return t.success(i); + default: + return t.failure(v, c); + } + }), + () => { + throw new InternalInconsistencyError( + 'Encode not defined for PermissiveNumber', + ); + }, +); + +// Define this differently, so that we can add a generic to it +export class StringJSON extends t.Type { + readonly _tag: 'StringJSON' = 'StringJSON'; + constructor(type: t.InterfaceType) { + super( + 'StringJSON', + (m): m is T => type.decode(m).isRight(), + (m, c) => + // Accept either an object, or a string which represents the + // object + t + .union([t.string, type]) + .validate(m, c) + .chain(v => { + let obj: T; + if (typeof v === 'string') { + obj = JSON.parse(v); + } else { + obj = v; + } + return type.decode(obj); + }), + () => { + throw new InternalInconsistencyError( + 'Encode not defined for StringJSON', + ); + }, + ); + // super( + // 'string', + // (m): m is string => typeof m === 'string', + // (m, c) => (this.is(m) ? t.success(m) : t.failure(m, c)), + // t.identity, + // ); + } +} + +export const NullOrUndefined = t.union([t.undefined, t.null]); diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 20a566a1..dbdb1dc8 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -52,3 +52,11 @@ export function DuplicateUuidError(err: Error) { export class ExchangeKeyError extends TypedError {} export class InternalInconsistencyError extends TypedError {} + +export class ConfigurationValidationError extends TypedError { + public constructor(key: string, value: unknown) { + super( + `There was an error validating configuration input for key: ${key}, with value: ${value}`, + ); + } +} diff --git a/test/05-device-state.spec.coffee b/test/05-device-state.spec.coffee index 745e7cab..368a46e8 100644 --- a/test/05-device-state.spec.coffee +++ b/test/05-device-state.spec.coffee @@ -243,9 +243,8 @@ describe 'deviceState', -> @deviceState.applications.images.save.restore() @deviceState.deviceConfig.getCurrent.restore() - @config.get('pinDevice').then (pinnedString) -> - pinned = JSON.parse(pinnedString) - expect(pinned).to.have.property('app').that.equals('1234') + @config.get('pinDevice').then (pinned) -> + expect(pinned).to.have.property('app').that.equals(1234) expect(pinned).to.have.property('commit').that.equals('abcdef') it 'emits a change event when a new state is reported', ->