mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-18 18:56:24 +00:00
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 <cameron@balena.io>
This commit is contained in:
parent
ac4866170e
commit
db74e748a1
@ -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",
|
||||
|
@ -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<void> {
|
||||
public set<T extends Schema.SchemaKey>(
|
||||
keyVals: { [key in T]: unknown },
|
||||
): Promise<void> {
|
||||
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<ConfigValue> {
|
||||
public get(key: string): Promise<unknown> {
|
||||
return Promise.using(this.readLockConfigJson(), () => {
|
||||
return Promise.resolve(this.cache[key]);
|
||||
});
|
||||
|
@ -8,107 +8,100 @@ import Config from '.';
|
||||
import * as constants from '../lib/constants';
|
||||
import * as osRelease from '../lib/os-release';
|
||||
|
||||
type ConfigProviderFunction = () => Bluebird<any>;
|
||||
|
||||
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;
|
||||
|
@ -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<T extends SchemaTypeKey> = { [key in T]: SchemaReturn<key> };
|
||||
|
||||
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<void> {
|
||||
@ -99,164 +45,155 @@ export class Config extends EventEmitter {
|
||||
});
|
||||
}
|
||||
|
||||
public get(key: string, trx?: Transaction): Bluebird<ConfigValue> {
|
||||
public get<T extends SchemaTypeKey>(
|
||||
key: T,
|
||||
trx?: Transaction,
|
||||
): Bluebird<SchemaReturn<T>> {
|
||||
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<any>).decode(undefined);
|
||||
|
||||
this.checkValueDecode(decoded, key, undefined);
|
||||
return decoded.value;
|
||||
}
|
||||
return defaultValue as SchemaReturn<T>;
|
||||
}
|
||||
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<T>;
|
||||
});
|
||||
} 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<ConfigMap> {
|
||||
return Bluebird.map(keys, (key: string) => this.get(key, trx)).then(
|
||||
values => {
|
||||
return _.zipObject(keys, values);
|
||||
},
|
||||
);
|
||||
public getMany<T extends SchemaTypeKey>(
|
||||
keys: T[],
|
||||
trx?: Transaction,
|
||||
): Bluebird<{ [key in T]: SchemaReturn<key> }> {
|
||||
return Bluebird.map(keys, (key: T) => this.get(key, trx)).then(values => {
|
||||
return _.zipObject(keys, values);
|
||||
});
|
||||
}
|
||||
|
||||
public set(keyValues: ConfigMap, trx?: Transaction): Bluebird<void> {
|
||||
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<T extends SchemaTypeKey>(
|
||||
keyValues: ConfigMap<T>,
|
||||
trx?: Transaction,
|
||||
): Bluebird<void> {
|
||||
const setValuesInTransaction = (tx: Transaction) => {
|
||||
const configJsonVals: Dictionary<unknown> = {};
|
||||
const dbVals: Dictionary<unknown> = {};
|
||||
|
||||
// Set these values, taking into account the knex transaction
|
||||
const setValuesInTransaction = (tx: Transaction): Bluebird<void> => {
|
||||
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<void> {
|
||||
public remove<T extends Schema.SchemaKey>(key: T): Bluebird<void> {
|
||||
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<T extends Schema.SchemaKey>(
|
||||
key: T,
|
||||
db: Transaction,
|
||||
): Promise<unknown> {
|
||||
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<T extends Schema.SchemaKey>(
|
||||
key: T,
|
||||
value: unknown,
|
||||
): Either<t.Errors, SchemaReturn<T>> {
|
||||
return schemaTypes[key].type.decode(value);
|
||||
}
|
||||
|
||||
private validateConfigMap<T extends SchemaTypeKey>(configMap: ConfigMap<T>) {
|
||||
// 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<unknown>;
|
||||
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<t.Errors, unknown>,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): void {
|
||||
if (decoded.isLeft()) {
|
||||
throw new ConfigurationValidationError(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Config;
|
||||
|
254
src/config/schema-type.ts
Normal file
254
src/config/schema-type.ts
Normal file
@ -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> = T extends t.Type<any> ? t.TypeOf<T> : T;
|
||||
export type SchemaReturn<T extends SchemaTypeKey> =
|
||||
| t.TypeOf<SchemaType[T]['type']>
|
||||
| RealType<SchemaType[T]['default']>;
|
191
src/config/schema.ts
Normal file
191
src/config/schema.ts
Normal file
@ -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;
|
114
src/config/types.ts
Normal file
114
src/config/types.ts
Normal file
@ -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<boolean, t.TypeOf<PermissiveType>>(
|
||||
'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<number, string | number>(
|
||||
'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<T> extends t.Type<T, string> {
|
||||
readonly _tag: 'StringJSON' = 'StringJSON';
|
||||
constructor(type: t.InterfaceType<any>) {
|
||||
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]);
|
@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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', ->
|
||||
|
Loading…
Reference in New Issue
Block a user