balena-supervisor/src/config/index.ts

303 lines
9.2 KiB
TypeScript
Raw Normal View History

import * as Bluebird from 'bluebird';
import { EventEmitter } from 'events';
import { Transaction } from 'knex';
import * as _ from 'lodash';
import { generateUniqueKey } from 'resin-register-device';
import ConfigJsonConfigBackend from './configJson';
import { ConfigProviderFunctions, createProviderFunctions } from './functions';
import * as constants from '../lib/constants';
import { ConfigMap, ConfigSchema, ConfigValue } from '../lib/types';
import DB = require('../db');
interface ConfigOpts {
db: DB;
configPath: string;
}
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' },
2018-11-02 14:17:58 +00:00
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;
2018-11-02 14:17:58 +00:00
this.configJsonBackend = new ConfigJsonConfigBackend(
this.schema,
configPath,
);
this.providerFunctions = createProviderFunctions(this);
}
public init(): Bluebird<void> {
2018-11-02 14:17:58 +00:00
return this.configJsonBackend.init().then(() => {
return this.generateRequiredFields();
});
}
public get(key: string, trx?: Transaction): Bluebird<ConfigValue> {
const db = trx || this.db.models.bind(this.db);
return Bluebird.try(() => {
if (this.schema[key] == null) {
throw new Error(`Unknown config value ${key}`);
}
2018-11-02 14:17:58 +00:00
switch (this.schema[key].source) {
case 'func':
2018-11-02 14:17:58 +00:00
return this.providerFunctions[key].get().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':
2018-11-02 14:17:58 +00:00
return db('config')
.select('value')
.where({ key })
.then(([conf]: [{ value: string }]) => {
if (conf != null) {
return conf.value;
}
return;
});
}
2018-11-02 14:17:58 +00:00
}).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> {
2018-11-02 14:17:58 +00:00
return Bluebird.map(keys, (key: string) => this.get(key, trx)).then(
values => {
return _.zipObject(keys, values);
2018-11-02 14:17:58 +00:00
},
);
}
public set(keyValues: ConfigMap, trx?: Transaction): Bluebird<void> {
return Bluebird.try(() => {
// Split the values based on which storage backend they use
2018-11-02 14:17:58 +00:00
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 if (this.schema[key].source === 'func') {
acc.fnVals[key] = val;
} else {
throw new Error(
`Unknown config backend for key: ${key}, backend: ${
this.schema[key].source
}`,
);
}
return acc;
},
{ configJsonVals: {}, dbVals: {}, fnVals: {} },
);
// Set these values, taking into account the knex transaction
const setValuesInTransaction = (tx: Transaction): Bluebird<void> => {
const dbKeys = _.keys(dbVals);
return this.getMany(dbKeys, tx)
2018-11-02 14:17:58 +00:00
.then(oldValues => {
return Bluebird.map(dbKeys, (key: string) => {
const value = dbVals[key];
if (oldValues[key] !== value) {
2018-11-02 14:17:58 +00:00
return this.db.upsertModel(
'config',
{ key, value: (value || '').toString() },
2018-11-02 14:17:58 +00:00
{ key },
tx,
);
}
});
})
.then(() => {
return Bluebird.map(_.toPairs(fnVals), ([key, value]) => {
const fn = this.providerFunctions[key];
if (fn.set == null) {
2018-11-02 14:17:58 +00:00
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);
}
});
};
if (trx != null) {
return setValuesInTransaction(trx).return();
} else {
2018-11-02 14:17:58 +00:00
return this.db
.transaction(tx => {
return setValuesInTransaction(tx);
})
.return();
}
})
.then(() => {
return setImmediate(() => {
this.emit('change', keyValues);
});
})
.return();
}
public remove(key: string): Bluebird<void> {
return Bluebird.try(() => {
if (this.schema[key] == null || !this.schema[key].mutable) {
2018-11-02 14:17:58 +00:00
throw new Error(
`Attempt to delete non-existent or immutable key ${key}`,
);
}
if (this.schema[key].source === 'config.json') {
return this.configJsonBackend.remove(key);
} else if (this.schema[key].source === 'db') {
2018-11-02 14:17:58 +00:00
return this.db
.models('config')
.del()
.where({ key });
} else if (this.schema[key].source === 'func') {
const mutFn = this.providerFunctions[key];
if (mutFn == null) {
2018-11-02 14:17:58 +00:00
throw new Error(
`Could not find provider function for config ${key}!`,
);
}
if (mutFn.remove == null) {
2018-11-02 14:17:58 +00:00
throw new Error(
`Could not find removal provider function for config ${key}`,
);
}
return mutFn.remove();
} else {
2018-11-02 14:17:58 +00:00
throw new Error(
`Unknown or unsupported config backend: ${this.schema[key].source}`,
);
}
});
}
public regenerateRegistrationFields(): Bluebird<void> {
return this.set({
uuid: this.newUniqueKey(),
deviceApiKey: this.newUniqueKey(),
});
}
private newUniqueKey(): string {
return generateUniqueKey();
}
private generateRequiredFields() {
return this.getMany([
'uuid',
'deviceApiKey',
'apiSecret',
'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(() => {
if (unmanaged) {
2018-11-02 14:17:58 +00:00
return;
}
if (!deviceApiKey) {
2018-11-02 14:17:58 +00:00
return this.set({ deviceApiKey: this.newUniqueKey() });
}
});
2018-11-02 14:17:58 +00:00
});
}
}
export = Config;