2018-06-18 10:18:42 +00:00
|
|
|
import * as Bluebird from 'bluebird';
|
|
|
|
import { EventEmitter } from 'events';
|
|
|
|
import { Transaction } from 'knex';
|
|
|
|
import * as _ from 'lodash';
|
|
|
|
import { generateUniqueKey } from 'resin-register-device';
|
2019-01-21 11:11:50 +00:00
|
|
|
import StrictEventEmitter from 'strict-event-emitter-types';
|
2018-06-18 10:18:42 +00:00
|
|
|
|
2019-01-08 16:17:35 +00:00
|
|
|
import { Either } from 'fp-ts/lib/Either';
|
|
|
|
import * as t from 'io-ts';
|
|
|
|
|
2018-11-28 14:18:33 +00:00
|
|
|
import ConfigJsonConfigBackend from './configJson';
|
2018-06-18 10:18:42 +00:00
|
|
|
|
2019-01-08 16:17:35 +00:00
|
|
|
import * as FnSchema from './functions';
|
|
|
|
import * as Schema from './schema';
|
|
|
|
import { SchemaReturn, SchemaTypeKey, schemaTypes } from './schema-type';
|
2018-06-18 10:18:42 +00:00
|
|
|
|
2018-12-20 11:47:17 +00:00
|
|
|
import DB from '../db';
|
2019-01-08 16:17:35 +00:00
|
|
|
import {
|
|
|
|
ConfigurationValidationError,
|
|
|
|
InternalInconsistencyError,
|
|
|
|
} from '../lib/errors';
|
2018-06-18 10:18:42 +00:00
|
|
|
|
|
|
|
interface ConfigOpts {
|
|
|
|
db: DB;
|
|
|
|
configPath: string;
|
|
|
|
}
|
|
|
|
|
2019-01-08 16:17:35 +00:00
|
|
|
type ConfigMap<T extends SchemaTypeKey> = { [key in T]: SchemaReturn<key> };
|
2019-01-21 11:11:50 +00:00
|
|
|
type ConfigChangeMap<T extends SchemaTypeKey> = {
|
|
|
|
[key in T]: SchemaReturn<key> | undefined
|
|
|
|
};
|
2019-01-08 16:17:35 +00:00
|
|
|
|
2019-01-21 11:11:50 +00:00
|
|
|
interface ConfigEvents {
|
|
|
|
change: ConfigChangeMap<SchemaTypeKey>;
|
|
|
|
}
|
|
|
|
|
|
|
|
type ConfigEventEmitter = StrictEventEmitter<EventEmitter, ConfigEvents>;
|
|
|
|
|
|
|
|
export class Config extends (EventEmitter as {
|
|
|
|
new (): ConfigEventEmitter;
|
|
|
|
}) {
|
2018-06-18 10:18:42 +00:00
|
|
|
private db: DB;
|
|
|
|
private configJsonBackend: ConfigJsonConfigBackend;
|
|
|
|
|
|
|
|
public constructor({ db, configPath }: ConfigOpts) {
|
|
|
|
super();
|
|
|
|
this.db = db;
|
2018-11-02 14:17:58 +00:00
|
|
|
this.configJsonBackend = new ConfigJsonConfigBackend(
|
2019-01-08 16:17:35 +00:00
|
|
|
Schema.schema,
|
2018-11-02 14:17:58 +00:00
|
|
|
configPath,
|
|
|
|
);
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
public init(): Bluebird<void> {
|
2018-11-02 14:17:58 +00:00
|
|
|
return this.configJsonBackend.init().then(() => {
|
|
|
|
return this.generateRequiredFields();
|
|
|
|
});
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
|
|
|
|
2019-01-08 16:17:35 +00:00
|
|
|
public get<T extends SchemaTypeKey>(
|
|
|
|
key: T,
|
|
|
|
trx?: Transaction,
|
|
|
|
): Bluebird<SchemaReturn<T>> {
|
2018-06-18 10:18:42 +00:00
|
|
|
const db = trx || this.db.models.bind(this.db);
|
|
|
|
|
|
|
|
return Bluebird.try(() => {
|
2019-01-08 16:17:35 +00:00
|
|
|
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;
|
2019-01-11 10:36:54 +00:00
|
|
|
// Cast the promise as something that produces an unknown, and this means that
|
2019-01-08 16:17:35 +00:00
|
|
|
// 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 {
|
2018-06-18 10:18:42 +00:00
|
|
|
throw new Error(`Unknown config value ${key}`);
|
|
|
|
}
|
2018-11-02 14:17:58 +00:00
|
|
|
});
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
|
|
|
|
2019-01-08 16:17:35 +00:00
|
|
|
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);
|
|
|
|
});
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
|
|
|
|
2019-01-08 16:17:35 +00:00
|
|
|
public set<T extends SchemaTypeKey>(
|
|
|
|
keyValues: ConfigMap<T>,
|
|
|
|
trx?: Transaction,
|
|
|
|
): Bluebird<void> {
|
|
|
|
const setValuesInTransaction = (tx: Transaction) => {
|
|
|
|
const configJsonVals: Dictionary<unknown> = {};
|
|
|
|
const dbVals: Dictionary<unknown> = {};
|
|
|
|
|
|
|
|
_.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:
|
2018-11-02 14:17:58 +00:00
|
|
|
throw new Error(
|
2019-01-08 16:17:35 +00:00
|
|
|
`Unknown configuration source: ${source} for config key: ${k}`,
|
2018-11-02 14:17:58 +00:00
|
|
|
);
|
2019-01-08 16:17:35 +00:00
|
|
|
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,
|
|
|
|
);
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
|
|
|
});
|
2019-01-08 16:17:35 +00:00
|
|
|
})
|
|
|
|
.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);
|
2018-06-18 10:18:42 +00:00
|
|
|
|
|
|
|
if (trx != null) {
|
|
|
|
return setValuesInTransaction(trx).return();
|
|
|
|
} else {
|
2018-11-02 14:17:58 +00:00
|
|
|
return this.db
|
2019-01-08 16:17:35 +00:00
|
|
|
.transaction((tx: Transaction) => setValuesInTransaction(tx))
|
2018-11-02 14:17:58 +00:00
|
|
|
.return();
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
2019-01-08 16:17:35 +00:00
|
|
|
}).then(() => {
|
2019-01-21 11:11:50 +00:00
|
|
|
this.emit('change', keyValues as ConfigMap<SchemaTypeKey>);
|
2019-01-08 16:17:35 +00:00
|
|
|
});
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
|
|
|
|
2019-01-08 16:17:35 +00:00
|
|
|
public remove<T extends Schema.SchemaKey>(key: T): Bluebird<void> {
|
2018-06-18 10:18:42 +00:00
|
|
|
return Bluebird.try(() => {
|
2019-01-08 16:17:35 +00:00
|
|
|
if (Schema.schema[key] == null || !Schema.schema[key].mutable) {
|
2018-11-02 14:17:58 +00:00
|
|
|
throw new Error(
|
|
|
|
`Attempt to delete non-existent or immutable key ${key}`,
|
|
|
|
);
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
2019-01-08 16:17:35 +00:00
|
|
|
if (Schema.schema[key].source === 'config.json') {
|
2018-06-18 10:18:42 +00:00
|
|
|
return this.configJsonBackend.remove(key);
|
2019-01-08 16:17:35 +00:00
|
|
|
} else if (Schema.schema[key].source === 'db') {
|
2018-11-02 14:17:58 +00:00
|
|
|
return this.db
|
|
|
|
.models('config')
|
|
|
|
.del()
|
|
|
|
.where({ key });
|
2018-06-18 10:18:42 +00:00
|
|
|
} else {
|
2018-11-02 14:17:58 +00:00
|
|
|
throw new Error(
|
2019-01-08 16:17:35 +00:00
|
|
|
`Unknown or unsupported config backend: ${Schema.schema[key].source}`,
|
2018-11-02 14:17:58 +00:00
|
|
|
);
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
|
|
|
public regenerateRegistrationFields(): Bluebird<void> {
|
|
|
|
return this.set({
|
|
|
|
uuid: this.newUniqueKey(),
|
|
|
|
deviceApiKey: this.newUniqueKey(),
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2018-12-24 12:31:35 +00:00
|
|
|
public newUniqueKey(): string {
|
2018-06-18 10:18:42 +00:00
|
|
|
return generateUniqueKey();
|
|
|
|
}
|
|
|
|
|
2019-01-08 16:17:35 +00:00
|
|
|
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':
|
2019-01-11 10:36:54 +00:00
|
|
|
const [conf] = await db('config')
|
2019-01-08 16:17:35 +00:00
|
|
|
.select('value')
|
2019-01-11 10:36:54 +00:00
|
|
|
.where({ key });
|
|
|
|
if (conf != null) {
|
|
|
|
return conf.value;
|
|
|
|
}
|
2019-01-08 16:17:35 +00:00
|
|
|
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
|
|
|
|
}`,
|
|
|
|
);
|
|
|
|
}
|
|
|
|
});
|
|
|
|
}
|
|
|
|
|
2018-06-18 10:18:42 +00:00
|
|
|
private generateRequiredFields() {
|
|
|
|
return this.getMany([
|
|
|
|
'uuid',
|
|
|
|
'deviceApiKey',
|
|
|
|
'apiSecret',
|
2018-12-13 14:14:15 +00:00
|
|
|
'unmanaged',
|
|
|
|
]).then(({ uuid, deviceApiKey, apiSecret, unmanaged }) => {
|
2018-11-02 14:17:58 +00:00
|
|
|
// These fields need to be set regardless
|
|
|
|
if (uuid == null || apiSecret == null) {
|
|
|
|
uuid = uuid || this.newUniqueKey();
|
|
|
|
apiSecret = apiSecret || this.newUniqueKey();
|
|
|
|
}
|
|
|
|
return this.set({ uuid, apiSecret }).then(() => {
|
2018-12-13 14:14:15 +00:00
|
|
|
if (unmanaged) {
|
2018-11-02 14:17:58 +00:00
|
|
|
return;
|
|
|
|
}
|
2018-11-28 14:19:25 +00:00
|
|
|
if (!deviceApiKey) {
|
2018-11-02 14:17:58 +00:00
|
|
|
return this.set({ deviceApiKey: this.newUniqueKey() });
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
|
|
|
});
|
2018-11-02 14:17:58 +00:00
|
|
|
});
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
2019-01-08 16:17:35 +00:00
|
|
|
|
|
|
|
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);
|
|
|
|
}
|
|
|
|
}
|
2018-06-18 10:18:42 +00:00
|
|
|
}
|
|
|
|
|
2018-12-24 10:45:03 +00:00
|
|
|
export default Config;
|