Merge pull request #865 from balena-io/fully-typed-config

Add types to the config module, and remove unnecessary casts and validations
This commit is contained in:
CameronDiver 2019-01-12 12:46:53 +00:00 committed by GitHub
commit 7524b3a109
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1000 additions and 485 deletions

2
package-lock.json generated
View File

@ -1,6 +1,6 @@
{ {
"name": "balena-supervisor", "name": "balena-supervisor",
"version": "9.2.8", "version": "9.2.10",
"lockfileVersion": 1, "lockfileVersion": 1,
"requires": true, "requires": true,
"dependencies": { "dependencies": {

View File

@ -64,7 +64,9 @@
"event-stream": "3.3.4", "event-stream": "3.3.4",
"express": "^4.0.0", "express": "^4.0.0",
"fork-ts-checker-webpack-plugin": "^0.5.2", "fork-ts-checker-webpack-plugin": "^0.5.2",
"fp-ts": "^1.12.2",
"husky": "^1.3.0", "husky": "^1.3.0",
"io-ts": "^1.5.1",
"istanbul": "^0.4.5", "istanbul": "^0.4.5",
"json-mask": "^0.3.8", "json-mask": "^0.3.8",
"knex": "~0.15.2", "knex": "~0.15.2",

View File

@ -20,11 +20,11 @@ import {
} from './lib/errors'; } from './lib/errors';
import { pathExistsOnHost } from './lib/fs-utils'; import { pathExistsOnHost } from './lib/fs-utils';
import { request, requestOpts } from './lib/request'; import { request, requestOpts } from './lib/request';
import { ConfigValue } from './lib/types';
import { writeLock } from './lib/update-lock'; import { writeLock } from './lib/update-lock';
import { checkInt, checkTruthy } from './lib/validation';
import { DeviceApplicationState } from './types/state'; import { DeviceApplicationState } from './types/state';
import { SchemaReturn as ConfigSchemaType } from './config/schema-type';
const REPORT_SUCCESS_DELAY = 1000; const REPORT_SUCCESS_DELAY = 1000;
const MAX_REPORT_RETRY_DELAY = 60000; const MAX_REPORT_RETRY_DELAY = 60000;
@ -46,16 +46,8 @@ interface APIBinderConstructOpts {
eventTracker: EventTracker; eventTracker: EventTracker;
} }
interface KeyExchangeOpts {
uuid: ConfigValue;
deviceApiKey: ConfigValue;
apiTimeout: ConfigValue;
apiEndpoint: ConfigValue;
provisioningApiKey: ConfigValue;
}
interface Device { interface Device {
id: string; id: number;
[key: string]: unknown; [key: string]: unknown;
} }
@ -65,6 +57,8 @@ interface DevicePinInfo {
commit: string; commit: string;
} }
type KeyExchangeOpts = ConfigSchemaType<'provisioningOptions'>;
export class APIBinder { export class APIBinder {
public router: express.Router; public router: express.Router;
@ -126,8 +120,7 @@ export class APIBinder {
const timeSinceLastFetch = process.hrtime(this.lastTargetStateFetch); const timeSinceLastFetch = process.hrtime(this.lastTargetStateFetch);
const timeSinceLastFetchMs = const timeSinceLastFetchMs =
timeSinceLastFetch[0] * 1000 + timeSinceLastFetch[1] / 1e6; timeSinceLastFetch[0] * 1000 + timeSinceLastFetch[1] / 1e6;
const stateFetchHealthy = const stateFetchHealthy = timeSinceLastFetchMs < 2 * appUpdatePollInterval;
timeSinceLastFetchMs < 2 * (appUpdatePollInterval as number);
const stateReportHealthy = const stateReportHealthy =
!connectivityCheckEnabled || !connectivityCheckEnabled ||
!this.deviceState.connected || !this.deviceState.connected ||
@ -146,7 +139,7 @@ export class APIBinder {
return; return;
} }
const baseUrl = url.resolve(apiEndpoint as string, '/v5/'); const baseUrl = url.resolve(apiEndpoint, '/v5/');
const passthrough = _.cloneDeep(requestOpts); const passthrough = _.cloneDeep(requestOpts);
passthrough.headers = passthrough.headers =
passthrough.headers != null ? passthrough.headers : {}; passthrough.headers != null ? passthrough.headers : {};
@ -210,16 +203,13 @@ export class APIBinder {
// Either we haven't reported our initial config or we've been re-provisioned // Either we haven't reported our initial config or we've been re-provisioned
if (apiEndpoint !== initialConfigReported) { if (apiEndpoint !== initialConfigReported) {
console.log('Reporting initial configuration'); console.log('Reporting initial configuration');
await this.reportInitialConfig( await this.reportInitialConfig(apiEndpoint, bootstrapRetryDelay);
apiEndpoint as string,
bootstrapRetryDelay as number,
);
} }
console.log('Starting current state report'); console.log('Starting current state report');
await this.startCurrentStateReport(); await this.startCurrentStateReport();
await this.loadBackupFromMigration(bootstrapRetryDelay as number); await this.loadBackupFromMigration(bootstrapRetryDelay);
this.readyForUpdates = true; this.readyForUpdates = true;
console.log('Starting target state poll'); console.log('Starting target state poll');
@ -288,7 +278,7 @@ export class APIBinder {
id, id,
body: updatedFields, body: updatedFields,
}) })
.timeout(conf.apiTimeout as number); .timeout(conf.apiTimeout);
} }
public async provisionDependentDevice(device: Device): Promise<Device> { public async provisionDependentDevice(device: Device): Promise<Device> {
@ -325,7 +315,7 @@ export class APIBinder {
return (await this.balenaApi return (await this.balenaApi
.post({ resource: 'device', body: device }) .post({ resource: 'device', body: device })
// TODO: Remove the `as number` when we fix the config typings // TODO: Remove the `as number` when we fix the config typings
.timeout(conf.apiTimeout as number)) as Device; .timeout(conf.apiTimeout)) as Device;
} }
public async getTargetState(): Promise<DeviceApplicationState> { public async getTargetState(): Promise<DeviceApplicationState> {
@ -354,7 +344,7 @@ export class APIBinder {
return await this.cachedBalenaApi return await this.cachedBalenaApi
._request(requestParams) ._request(requestParams)
.timeout(apiTimeout as number); .timeout(apiTimeout);
} }
// TODO: Once 100% typescript, change this to a native promise // TODO: Once 100% typescript, change this to a native promise
@ -451,7 +441,7 @@ export class APIBinder {
'localMode', 'localMode',
]); ]);
if (checkTruthy(conf.localMode || false)) { if (conf.localMode) {
return; return;
} }
@ -460,12 +450,17 @@ export class APIBinder {
return 0; return 0;
} }
const apiEndpoint = conf.apiEndpoint;
const uuid = conf.uuid;
if (uuid == null || apiEndpoint == null) {
throw new InternalInconsistencyError(
'No uuid or apiEndpoint provided to ApiBinder.report',
);
}
await Bluebird.resolve( await Bluebird.resolve(
this.sendReportPatch(stateDiff, conf as { this.sendReportPatch(stateDiff, { apiEndpoint, uuid }),
uuid: string; ).timeout(conf.apiTimeout);
apiEndpoint: string;
}),
).timeout(conf.apiTimeout as number);
this.stateReportErrors = 0; this.stateReportErrors = 0;
_.assign(this.lastReportedState.local, stateDiff.local); _.assign(this.lastReportedState.local, stateDiff.local);
@ -523,14 +518,7 @@ export class APIBinder {
private async pollTargetState(): Promise<void> { private async pollTargetState(): Promise<void> {
// TODO: Remove the checkInt here with the config changes // TODO: Remove the checkInt here with the config changes
let pollInterval = checkInt((await this.config.get( let pollInterval = await this.config.get('appUpdatePollInterval');
'appUpdatePollInterval',
)) as string);
if (!_.isNumber(pollInterval)) {
throw new InternalInconsistencyError(
'appUpdatePollInterval not a number in ApiBinder.pollTargetState',
);
}
try { try {
await this.getAndSetTargetState(false); await this.getAndSetTargetState(false);
@ -556,6 +544,13 @@ export class APIBinder {
try { try {
const deviceId = await this.config.get('deviceId'); const deviceId = await this.config.get('deviceId');
if (deviceId == null) {
throw new InternalInconsistencyError(
'Device ID not defined in ApiBinder.pinDevice',
);
}
const release = await this.balenaApi.get({ const release = await this.balenaApi.get({
resource: 'release', resource: 'release',
options: { options: {
@ -577,7 +572,7 @@ export class APIBinder {
await this.balenaApi.patch({ await this.balenaApi.patch({
resource: 'device', resource: 'device',
id: deviceId as number, id: deviceId,
body: { body: {
should_be_running__release: releaseId, should_be_running__release: releaseId,
}, },
@ -665,15 +660,11 @@ export class APIBinder {
opts?: KeyExchangeOpts, opts?: KeyExchangeOpts,
): Promise<Device> { ): Promise<Device> {
if (opts == null) { if (opts == null) {
// FIXME: This casting shouldn't be necessary and stems from the opts = await this.config.get('provisioningOptions');
// meta-option provioningOptions not returning a ConfigValue
opts = ((await this.config.get(
'provisioningOptions',
)) as any) as KeyExchangeOpts;
} }
const uuid = opts.uuid as string; const uuid = opts.uuid;
const apiTimeout = opts.apiTimeout as number; const apiTimeout = opts.apiTimeout;
if (!(uuid && apiTimeout)) { if (!(uuid && apiTimeout)) {
throw new InternalInconsistencyError( throw new InternalInconsistencyError(
'UUID and apiTimeout should be defined in exchangeKeyAndGetDevice', 'UUID and apiTimeout should be defined in exchangeKeyAndGetDevice',
@ -685,7 +676,7 @@ export class APIBinder {
if (opts.deviceApiKey != null) { if (opts.deviceApiKey != null) {
const device = await this.fetchDevice( const device = await this.fetchDevice(
uuid, uuid,
opts.deviceApiKey as string, opts.deviceApiKey,
apiTimeout, apiTimeout,
); );
if (device != null) { if (device != null) {
@ -695,9 +686,14 @@ export class APIBinder {
// If it's not valid or doesn't exist then we try to use the // If it's not valid or doesn't exist then we try to use the
// user/provisioning api key for the exchange // user/provisioning api key for the exchange
if (!opts.provisioningApiKey) {
throw new InternalInconsistencyError(
'Required a provisioning key in exchangeKeyAndGetDevice',
);
}
const device = await this.fetchDevice( const device = await this.fetchDevice(
uuid, uuid,
opts.provisioningApiKey as string, opts.provisioningApiKey,
apiTimeout, apiTimeout,
); );
@ -746,10 +742,7 @@ export class APIBinder {
private async provision() { private async provision() {
let device: Device | null = null; let device: Device | null = null;
// FIXME: Config typing // FIXME: Config typing
const opts = ((await this.config.get( const opts = await this.config.get('provisioningOptions');
'provisioningOptions',
)) as any) as Dictionary<any>;
if ( if (
opts.registered_at != null && opts.registered_at != null &&
opts.deviceId != null && opts.deviceId != null &&
@ -762,10 +755,7 @@ export class APIBinder {
console.log( console.log(
'Device is registered but no device id available, attempting key exchange', 'Device is registered but no device id available, attempting key exchange',
); );
device = device = (await this.exchangeKeyAndGetDeviceOrRegenerate(opts)) || null;
(await this.exchangeKeyAndGetDeviceOrRegenerate(
opts as KeyExchangeOpts,
)) || null;
} else if (opts.registered_at == null) { } else if (opts.registered_at == null) {
console.log('New device detected. Provisioning...'); console.log('New device detected. Provisioning...');
try { try {
@ -774,9 +764,7 @@ export class APIBinder {
} catch (err) { } catch (err) {
if (DuplicateUuidError(err)) { if (DuplicateUuidError(err)) {
console.log('UUID already registered, trying a key exchange'); console.log('UUID already registered, trying a key exchange');
await this.exchangeKeyAndGetDeviceOrRegenerate( await this.exchangeKeyAndGetDeviceOrRegenerate(opts);
opts as KeyExchangeOpts,
);
} else { } else {
throw err; throw err;
} }
@ -785,7 +773,7 @@ export class APIBinder {
console.log( console.log(
'Device is registered but we still have an apiKey, attempting key exchange', 'Device is registered but we still have an apiKey, attempting key exchange',
); );
device = await this.exchangeKeyAndGetDevice(opts as KeyExchangeOpts); device = await this.exchangeKeyAndGetDevice(opts);
} }
if (!device) { if (!device) {
@ -811,13 +799,7 @@ export class APIBinder {
this.eventTracker.track('Device bootstrap success'); this.eventTracker.track('Device bootstrap success');
// Now check if we need to pin the device // Now check if we need to pin the device
const toPin = await this.config.get('pinDevice'); const pinValue = await this.config.get('pinDevice');
let pinValue: DevicePinInfo | null = null;
try {
pinValue = JSON.parse(toPin as string);
} catch (e) {
console.log('Warning: Malformed pinDevice value in supervisor database');
}
if (pinValue != null) { if (pinValue != null) {
if (pinValue.app == null || pinValue.commit == null) { if (pinValue.app == null || pinValue.commit == null) {

View File

@ -140,7 +140,7 @@ module.exports = class ApplicationManager extends EventEmitter
@images.save(step.image) @images.save(step.image)
cleanup: (step) => cleanup: (step) =>
@config.get('localMode').then (localMode) => @config.get('localMode').then (localMode) =>
if !checkTruthy(localMode) if !localMode
@images.cleanup() @images.cleanup()
createNetworkOrVolume: (step) => createNetworkOrVolume: (step) =>
if step.model is 'network' if step.model is 'network'
@ -762,7 +762,7 @@ module.exports = class ApplicationManager extends EventEmitter
getTargetApps: => getTargetApps: =>
@config.getMany(['apiEndpoint', 'localMode']). then ({ apiEndpoint, localMode }) => @config.getMany(['apiEndpoint', 'localMode']). then ({ apiEndpoint, localMode }) =>
source = apiEndpoint source = apiEndpoint
if checkTruthy(localMode) if localMode
source = 'local' source = 'local'
Promise.map(@db.models('app').where({ source }), @normaliseAndExtendAppFromDB) Promise.map(@db.models('app').where({ source }), @normaliseAndExtendAppFromDB)
.map (app) => .map (app) =>
@ -847,8 +847,6 @@ module.exports = class ApplicationManager extends EventEmitter
return { imagesToSave, imagesToRemove } return { imagesToSave, imagesToRemove }
_inferNextSteps: (cleanupNeeded, availableImages, downloading, supervisorNetworkReady, current, target, ignoreImages, { localMode, delta }) => _inferNextSteps: (cleanupNeeded, availableImages, downloading, supervisorNetworkReady, current, target, ignoreImages, { localMode, delta }) =>
localMode = checkTruthy(localMode)
delta = checkTruthy(delta)
Promise.try => Promise.try =>
if localMode if localMode
ignoreImages = true ignoreImages = true
@ -895,7 +893,7 @@ module.exports = class ApplicationManager extends EventEmitter
return Promise.try(fn) return Promise.try(fn)
@config.get('lockOverride') @config.get('lockOverride')
.then (lockOverride) -> .then (lockOverride) ->
return checkTruthy(lockOverride) or force return lockOverride or force
.then (force) -> .then (force) ->
updateLock.lock(appId, { force }, fn) updateLock.lock(appId, { force }, fn)
@ -919,7 +917,7 @@ module.exports = class ApplicationManager extends EventEmitter
getRequiredSteps: (currentState, targetState, extraState, ignoreImages = false) => getRequiredSteps: (currentState, targetState, extraState, ignoreImages = false) =>
{ cleanupNeeded, availableImages, downloading, supervisorNetworkReady, delta, localMode } = extraState { cleanupNeeded, availableImages, downloading, supervisorNetworkReady, delta, localMode } = extraState
conf = _.mapValues({ delta, localMode }, (v) -> checkTruthy(v)) conf = { delta, localMode }
if conf.localMode if conf.localMode
cleanupNeeded = false cleanupNeeded = false
@_inferNextSteps(cleanupNeeded, availableImages, downloading, supervisorNetworkReady, currentState, targetState, ignoreImages, conf) @_inferNextSteps(cleanupNeeded, availableImages, downloading, supervisorNetworkReady, currentState, targetState, ignoreImages, conf)

View File

@ -59,7 +59,7 @@ module.exports = class Images extends EventEmitter
.catch => .catch =>
@reportChange(image.imageId, _.merge(_.clone(image), { status: 'Downloading', downloadProgress: 0 })) @reportChange(image.imageId, _.merge(_.clone(image), { status: 'Downloading', downloadProgress: 0 }))
Promise.try => Promise.try =>
if validation.checkTruthy(opts.delta) and opts.deltaSource? if opts.delta and opts.deltaSource?
@logger.logSystemEvent(logTypes.downloadImageDelta, { image }) @logger.logSystemEvent(logTypes.downloadImageDelta, { image })
@inspectByName(opts.deltaSource) @inspectByName(opts.deltaSource)
.then (srcImage) => .then (srcImage) =>

View File

@ -3,8 +3,8 @@ import * as _ from 'lodash';
import { fs } from 'mz'; import { fs } from 'mz';
import * as path from 'path'; import * as path from 'path';
import { ConfigSchema, ConfigValue } from '../lib/types';
import { readLock, writeLock } from '../lib/update-lock'; import { readLock, writeLock } from '../lib/update-lock';
import * as Schema from './schema';
import * as constants from '../lib/constants'; import * as constants from '../lib/constants';
import { writeAndSyncFile, writeFileAtomic } from '../lib/fs-utils'; import { writeAndSyncFile, writeFileAtomic } from '../lib/fs-utils';
@ -15,11 +15,11 @@ export default class ConfigJsonConfigBackend {
private writeLockConfigJson: () => Promise.Disposer<() => void>; private writeLockConfigJson: () => Promise.Disposer<() => void>;
private configPath?: string; 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.configPath = configPath;
this.schema = schema; 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; let changed = false;
return Promise.using(this.writeLockConfigJson(), () => { 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]; const value = keyVals[key];
if (this.cache[key] !== value) { 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.using(this.readLockConfigJson(), () => {
return Promise.resolve(this.cache[key]); return Promise.resolve(this.cache[key]);
}); });

View File

@ -1,5 +1,4 @@
import * as Bluebird from 'bluebird'; import * as Bluebird from 'bluebird';
import { Transaction } from 'knex';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { URL } from 'url'; import { URL } from 'url';
@ -8,65 +7,32 @@ import supervisorVersion = require('../lib/supervisor-version');
import Config from '.'; import Config from '.';
import * as constants from '../lib/constants'; import * as constants from '../lib/constants';
import * as osRelease from '../lib/os-release'; import * as osRelease from '../lib/os-release';
import { ConfigValue } from '../lib/types';
// A provider for schema entries with source 'func' export const fnSchema = {
type ConfigProviderFunctionGetter = () => Bluebird<any>; version: () => {
type ConfigProviderFunctionSetter = (
value: ConfigValue,
tx?: Transaction,
) => Bluebird<void>;
type ConfigProviderFunctionRemover = () => Bluebird<void>;
interface ConfigProviderFunction {
get: ConfigProviderFunctionGetter;
set?: ConfigProviderFunctionSetter;
remove?: ConfigProviderFunctionRemover;
}
export interface ConfigProviderFunctions {
[key: string]: ConfigProviderFunction;
}
export function createProviderFunctions(
config: Config,
): ConfigProviderFunctions {
return {
version: {
get: () => {
return Bluebird.resolve(supervisorVersion); return Bluebird.resolve(supervisorVersion);
}, },
}, currentApiKey: (config: Config) => {
currentApiKey: {
get: () => {
return config return config
.getMany(['apiKey', 'deviceApiKey']) .getMany(['apiKey', 'deviceApiKey'])
.then(({ apiKey, deviceApiKey }) => { .then(({ apiKey, deviceApiKey }) => {
return apiKey || deviceApiKey; return apiKey || deviceApiKey;
}); });
}, },
}, provisioned: (config: Config) => {
provisioned: {
get: () => {
return config return config
.getMany(['uuid', 'apiEndpoint', 'registered_at', 'deviceId']) .getMany(['uuid', 'apiEndpoint', 'registered_at', 'deviceId'])
.then(requiredValues => { .then(requiredValues => {
return _.every(_.values(requiredValues), Boolean); return _.every(_.values(requiredValues));
}); });
}, },
}, osVersion: () => {
osVersion: {
get: () => {
return osRelease.getOSVersion(constants.hostOSVersionPath); return osRelease.getOSVersion(constants.hostOSVersionPath);
}, },
}, osVariant: () => {
osVariant: {
get: () => {
return osRelease.getOSVariant(constants.hostOSVersionPath); return osRelease.getOSVariant(constants.hostOSVersionPath);
}, },
}, provisioningOptions: (config: Config) => {
provisioningOptions: {
get: () => {
return config return config
.getMany([ .getMany([
'uuid', 'uuid',
@ -95,20 +61,16 @@ export function createProviderFunctions(
}; };
}); });
}, },
}, mixpanelHost: (config: Config) => {
mixpanelHost: {
get: () => {
return config.get('apiEndpoint').then(apiEndpoint => { return config.get('apiEndpoint').then(apiEndpoint => {
if (!apiEndpoint) { if (!apiEndpoint) {
return null; return null;
} }
const url = new URL(apiEndpoint as string); const url = new URL(apiEndpoint);
return { host: url.host, path: '/mixpanel' }; return { host: url.host, path: '/mixpanel' };
}); });
}, },
}, extendedEnvOptions: (config: Config) => {
extendedEnvOptions: {
get: () => {
return config.getMany([ return config.getMany([
'uuid', 'uuid',
'listenPort', 'listenPort',
@ -120,9 +82,7 @@ export function createProviderFunctions(
'osVersion', 'osVersion',
]); ]);
}, },
}, fetchOptions: (config: Config) => {
fetchOptions: {
get: () => {
return config.getMany([ return config.getMany([
'uuid', 'uuid',
'currentApiKey', 'currentApiKey',
@ -136,13 +96,12 @@ export function createProviderFunctions(
'deltaVersion', 'deltaVersion',
]); ]);
}, },
}, unmanaged: (config: Config) => {
unmanaged: {
get: () => {
return config.get('apiEndpoint').then(apiEndpoint => { return config.get('apiEndpoint').then(apiEndpoint => {
return !apiEndpoint; return !apiEndpoint;
}); });
}, },
}, };
};
} export type FnSchema = typeof fnSchema;
export type FnSchemaKey = keyof FnSchema;

View File

@ -4,93 +4,39 @@ import { Transaction } from 'knex';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { generateUniqueKey } from 'resin-register-device'; import { generateUniqueKey } from 'resin-register-device';
import { Either } from 'fp-ts/lib/Either';
import * as t from 'io-ts';
import ConfigJsonConfigBackend from './configJson'; import ConfigJsonConfigBackend from './configJson';
import * as constants from '../lib/constants'; import * as FnSchema from './functions';
import { ConfigMap, ConfigSchema, ConfigValue } from '../lib/types'; import * as Schema from './schema';
import { ConfigProviderFunctions, createProviderFunctions } from './functions'; import { SchemaReturn, SchemaTypeKey, schemaTypes } from './schema-type';
import DB from '../db'; import DB from '../db';
import {
ConfigurationValidationError,
InternalInconsistencyError,
} from '../lib/errors';
interface ConfigOpts { interface ConfigOpts {
db: DB; db: DB;
configPath: string; configPath: string;
} }
type ConfigMap<T extends SchemaTypeKey> = { [key in T]: SchemaReturn<key> };
export class Config extends EventEmitter { export class Config extends EventEmitter {
private db: DB; private db: DB;
private configJsonBackend: ConfigJsonConfigBackend; 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) { public constructor({ db, configPath }: ConfigOpts) {
super(); super();
this.db = db; this.db = db;
this.configJsonBackend = new ConfigJsonConfigBackend( this.configJsonBackend = new ConfigJsonConfigBackend(
this.schema, Schema.schema,
configPath, configPath,
); );
this.providerFunctions = createProviderFunctions(this);
} }
public init(): Bluebird<void> { public init(): Bluebird<void> {
@ -99,166 +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); const db = trx || this.db.models.bind(this.db);
return Bluebird.try(() => { 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 produces an unknown, and this means that
// we can validate the output of the function as well, ensuring that the type matches
const promiseValue = FnSchema.fnSchema[fnKey](this) as Bluebird<
unknown
>;
return promiseValue.then((value: unknown) => {
const decoded = schemaTypes[key].type.decode(value);
this.checkValueDecode(decoded, key, value);
return decoded.value as SchemaReturn<T>;
});
} else {
throw new Error(`Unknown config value ${key}`); throw new Error(`Unknown config value ${key}`);
} }
switch (this.schema[key].source) {
case 'func':
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':
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> { public getMany<T extends SchemaTypeKey>(
return Bluebird.map(keys, (key: string) => this.get(key, trx)).then( keys: T[],
values => { trx?: Transaction,
): Bluebird<{ [key in T]: SchemaReturn<key> }> {
return Bluebird.map(keys, (key: T) => this.get(key, trx)).then(values => {
return _.zipObject(keys, values); return _.zipObject(keys, values);
}, });
);
} }
public set(keyValues: ConfigMap, trx?: Transaction): Bluebird<void> { public set<T extends SchemaTypeKey>(
return Bluebird.try(() => { keyValues: ConfigMap<T>,
// Split the values based on which storage backend they use trx?: Transaction,
type SplitConfigBackend = { ): Bluebird<void> {
configJsonVals: ConfigMap; const setValuesInTransaction = (tx: Transaction) => {
dbVals: ConfigMap; const configJsonVals: Dictionary<unknown> = {};
fnVals: ConfigMap; const dbVals: Dictionary<unknown> = {};
};
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 _.each(keyValues, (v, k: T) => {
const setValuesInTransaction = (tx: Transaction): Bluebird<void> => { const schemaKey = k as Schema.SchemaKey;
const dbKeys = _.keys(dbVals); 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) return this.getMany(dbKeys, tx)
.then(oldValues => { .then(oldValues => {
return Bluebird.map(dbKeys, (key: string) => { return Bluebird.map(dbKeys, (key: T) => {
const value = dbVals[key]; 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) { if (oldValues[key] !== value) {
return this.db.upsertModel( return this.db.upsertModel(
'config', 'config',
{ key, value: (value || '').toString() }, { key, value: strValue },
{ key }, { key },
tx, 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(() => { .then(() => {
if (!_.isEmpty(configJsonVals)) { if (!_.isEmpty(configJsonVals)) {
return this.configJsonBackend.set(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) { if (trx != null) {
return setValuesInTransaction(trx).return(); return setValuesInTransaction(trx).return();
} else { } else {
return this.db return this.db
.transaction((tx: Transaction) => { .transaction((tx: Transaction) => setValuesInTransaction(tx))
return setValuesInTransaction(tx);
})
.return(); .return();
} }
}) }).then(() => {
.then(() => {
return setImmediate(() => {
this.emit('change', keyValues); this.emit('change', keyValues);
}); });
})
.return();
} }
public remove(key: string): Bluebird<void> { public remove<T extends Schema.SchemaKey>(key: T): Bluebird<void> {
return Bluebird.try(() => { return Bluebird.try(() => {
if (this.schema[key] == null || !this.schema[key].mutable) { if (Schema.schema[key] == null || !Schema.schema[key].mutable) {
throw new Error( throw new Error(
`Attempt to delete non-existent or immutable key ${key}`, `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); return this.configJsonBackend.remove(key);
} else if (this.schema[key].source === 'db') { } else if (Schema.schema[key].source === 'db') {
return this.db return this.db
.models('config') .models('config')
.del() .del()
.where({ key }); .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 { } else {
throw new Error( throw new Error(
`Unknown or unsupported config backend: ${this.schema[key].source}`, `Unknown or unsupported config backend: ${Schema.schema[key].source}`,
); );
} }
}); });
@ -275,6 +210,69 @@ export class Config extends EventEmitter {
return generateUniqueKey(); 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':
const [conf] = await db('config')
.select('value')
.where({ key });
if (conf != null) {
return conf.value;
}
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() { private generateRequiredFields() {
return this.getMany([ return this.getMany([
'uuid', 'uuid',
@ -297,6 +295,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; export default Config;

254
src/config/schema-type.ts Normal file
View 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.balena-cloud.com',
},
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
View 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
View 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]);

View File

@ -10,7 +10,6 @@ import {
serviceNotFoundMessage, serviceNotFoundMessage,
v2ServiceEndpointInputErrorMessage, v2ServiceEndpointInputErrorMessage,
} from '../lib/messages'; } from '../lib/messages';
import { checkTruthy } from '../lib/validation';
import { doPurge, doRestart, serviceAction } from './common'; import { doPurge, doRestart, serviceAction } from './common';
import supervisorVersion = require('../lib/supervisor-version'); import supervisorVersion = require('../lib/supervisor-version');
@ -232,7 +231,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
router.get('/v2/local/target-state', async (_req, res) => { router.get('/v2/local/target-state', async (_req, res) => {
try { try {
const localMode = checkTruthy(await deviceState.config.get('localMode')); const localMode = await deviceState.config.get('localMode');
if (!localMode) { if (!localMode) {
return res.status(400).json({ return res.status(400).json({
status: 'failed', status: 'failed',
@ -258,7 +257,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
// TODO: We really should refactor the config module to provide bools // TODO: We really should refactor the config module to provide bools
// as bools etc // as bools etc
try { try {
const localMode = checkTruthy(await deviceState.config.get('localMode')); const localMode = await deviceState.config.get('localMode');
if (!localMode) { if (!localMode) {
return res.status(400).json({ return res.status(400).json({
status: 'failed', status: 'failed',

View File

@ -1,6 +1,7 @@
import * as _ from 'lodash'; import * as _ from 'lodash';
import Config from './config'; import Config from './config';
import { SchemaTypeKey } from './config/schema-type';
import Database, { Transaction } from './db'; import Database, { Transaction } from './db';
import Logger from './logger'; import Logger from './logger';
@ -60,7 +61,7 @@ export class DeviceConfig {
private actionExecutors: DeviceActionExecutors; private actionExecutors: DeviceActionExecutors;
private configBackend: DeviceConfigBackend | null = null; private configBackend: DeviceConfigBackend | null = null;
private static configKeys: Dictionary<ConfigOption> = { private static readonly configKeys: Dictionary<ConfigOption> = {
appUpdatePollInterval: { appUpdatePollInterval: {
envVarName: 'SUPERVISOR_POLL_INTERVAL', envVarName: 'SUPERVISOR_POLL_INTERVAL',
varType: 'int', varType: 'int',
@ -144,7 +145,9 @@ export class DeviceConfig {
if (!_.isObject(step.target)) { if (!_.isObject(step.target)) {
throw new Error('Non-dictionary value passed to changeConfig'); throw new Error('Non-dictionary value passed to changeConfig');
} }
await this.config.set(step.target as Dictionary<string>); // TODO: Change the typing of step so that the types automatically
// work out and we don't need this cast to any
await this.config.set(step.target as { [key in SchemaTypeKey]: any });
if (step.humanReadableTarget) { if (step.humanReadableTarget) {
this.logger.logConfigChange(step.humanReadableTarget, { this.logger.logConfigChange(step.humanReadableTarget, {
success: true, success: true,
@ -200,9 +203,6 @@ export class DeviceConfig {
return this.configBackend; return this.configBackend;
} }
const dt = await this.config.get('deviceType'); const dt = await this.config.get('deviceType');
if (!_.isString(dt)) {
throw new Error('Could not detect device type');
}
this.configBackend = configUtils.getConfigBackend(dt) || null; this.configBackend = configUtils.getConfigBackend(dt) || null;
@ -253,9 +253,9 @@ export class DeviceConfig {
} }
public async getCurrent() { public async getCurrent() {
const conf = await this.config.getMany( const conf = await this.config.getMany(['deviceType'].concat(
['deviceType'].concat(_.keys(DeviceConfig.configKeys)), _.keys(DeviceConfig.configKeys),
); ) as SchemaTypeKey[]);
const configBackend = await this.getConfigBackend(); const configBackend = await this.getConfigBackend();
@ -271,7 +271,7 @@ export class DeviceConfig {
for (const key in DeviceConfig.configKeys) { for (const key in DeviceConfig.configKeys) {
const { envVarName } = DeviceConfig.configKeys[key]; const { envVarName } = DeviceConfig.configKeys[key];
const confValue = conf[key]; const confValue = conf[key as SchemaTypeKey];
currentConf[envVarName] = confValue != null ? confValue.toString() : ''; currentConf[envVarName] = confValue != null ? confValue.toString() : '';
} }
@ -391,7 +391,7 @@ export class DeviceConfig {
// Check for special case actions for the VPN // Check for special case actions for the VPN
if ( if (
!checkTruthy(unmanaged || false) && !unmanaged &&
!_.isEmpty(target['SUPERVISOR_VPN_CONTROL']) && !_.isEmpty(target['SUPERVISOR_VPN_CONTROL']) &&
DeviceConfig.checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL') DeviceConfig.checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL')
) { ) {

View File

@ -54,7 +54,7 @@ createDeviceStateRouter = (deviceState) ->
rebootOrShutdown = (req, res, action) -> rebootOrShutdown = (req, res, action) ->
deviceState.config.get('lockOverride') deviceState.config.get('lockOverride')
.then (lockOverride) -> .then (lockOverride) ->
force = validation.checkTruthy(req.body.force) or validation.checkTruthy(lockOverride) force = validation.checkTruthy(req.body.force) or lockOverride
deviceState.executeStepAction({ action }, { force }) deviceState.executeStepAction({ action }, { force })
.then (response) -> .then (response) ->
res.status(202).json(response) res.status(202).json(response)
@ -246,7 +246,7 @@ module.exports = class DeviceState extends EventEmitter
init: -> init: ->
@config.on 'change', (changedConfig) => @config.on 'change', (changedConfig) =>
if changedConfig.loggingEnabled? if changedConfig.loggingEnabled?
@logger.enable(validation.checkTruthy(changedConfig.loggingEnabled)) @logger.enable(changedConfig.loggingEnabled)
if changedConfig.apiSecret? if changedConfig.apiSecret?
@reportCurrentState(api_secret: changedConfig.apiSecret) @reportCurrentState(api_secret: changedConfig.apiSecret)
@ -258,7 +258,7 @@ module.exports = class DeviceState extends EventEmitter
.then (conf) => .then (conf) =>
@applications.init() @applications.init()
.then => .then =>
if !validation.checkTruthy(conf.initialConfigSaved) if !conf.initialConfigSaved
@saveInitialConfig() @saveInitialConfig()
.then => .then =>
@initNetworkChecks(conf) @initNetworkChecks(conf)
@ -280,7 +280,7 @@ module.exports = class DeviceState extends EventEmitter
.then => .then =>
@applications.getTargetApps() @applications.getTargetApps()
.then (targetApps) => .then (targetApps) =>
if !conf.provisioned or (_.isEmpty(targetApps) and !validation.checkTruthy(conf.targetStateSet)) if !conf.provisioned or (_.isEmpty(targetApps) and !conf.targetStateSet)
@loadTargetFromFile() @loadTargetFromFile()
.finally => .finally =>
@config.set({ targetStateSet: 'true' }) @config.set({ targetStateSet: 'true' })
@ -296,7 +296,7 @@ module.exports = class DeviceState extends EventEmitter
@triggerApplyTarget({ initial: true }) @triggerApplyTarget({ initial: true })
initNetworkChecks: ({ apiEndpoint, connectivityCheckEnabled, unmanaged }) => initNetworkChecks: ({ apiEndpoint, connectivityCheckEnabled, unmanaged }) =>
return if validation.checkTruthy(unmanaged) return if unmanaged
network.startConnectivityCheck apiEndpoint, connectivityCheckEnabled, (connected) => network.startConnectivityCheck apiEndpoint, connectivityCheckEnabled, (connected) =>
@connected = connected @connected = connected
@config.on 'change', (changedConfig) -> @config.on 'change', (changedConfig) ->
@ -499,9 +499,9 @@ module.exports = class DeviceState extends EventEmitter
console.log('Device will be pinned') console.log('Device will be pinned')
if commitToPin? and appToPin? if commitToPin? and appToPin?
@config.set @config.set
pinDevice: JSON.stringify { pinDevice: {
commit: commitToPin, commit: commitToPin,
app: appToPin, app: parseInt(appToPin, 10),
} }
# Ensure that this is actually a file, and not an empty path # Ensure that this is actually a file, and not an empty path
# It can be an empty path because if the file does not exist # It can be an empty path because if the file does not exist

View File

@ -52,3 +52,11 @@ export function DuplicateUuidError(err: Error) {
export class ExchangeKeyError extends TypedError {} export class ExchangeKeyError extends TypedError {}
export class InternalInconsistencyError 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}`,
);
}
}

View File

@ -5,20 +5,3 @@ export interface EnvVarObject {
export interface LabelObject { export interface LabelObject {
[name: string]: string; [name: string]: string;
} }
// For backwards compatibility we need to use export = Config in config.ts
// so to export these types they have been moved here
export type ConfigValue = string | number | boolean | null;
export interface ConfigMap {
[key: string]: ConfigValue;
}
export interface ConfigSchema {
[key: string]: {
source: string;
default?: any;
mutable?: boolean;
removeIfNull?: boolean;
};
}

View File

@ -3,8 +3,8 @@ import * as Docker from 'dockerode';
import * as _ from 'lodash'; import * as _ from 'lodash';
import Config from './config'; import Config from './config';
import { SchemaReturn, SchemaTypeKey } from './config/schema-type';
import Database from './db'; import Database from './db';
import { checkTruthy } from './lib/validation';
import { Logger } from './logger'; import { Logger } from './logger';
/** /**
@ -25,9 +25,11 @@ export class LocalModeManager {
public async init() { public async init() {
// Setup a listener to catch state changes relating to local mode // Setup a listener to catch state changes relating to local mode
this.config.on('change', changed => { this.config.on(
'change',
(changed: { [key in SchemaTypeKey]: SchemaReturn<key> }) => {
if (changed.localMode != null) { if (changed.localMode != null) {
const localMode = checkTruthy(changed.localMode) || false; const localMode = changed.localMode || false;
// First switch the logger to it's correct state // First switch the logger to it's correct state
this.logger.switchBackend(localMode); this.logger.switchBackend(localMode);
@ -38,12 +40,13 @@ export class LocalModeManager {
this.removeLocalModeArtifacts(); this.removeLocalModeArtifacts();
} }
} }
}); },
);
// On startup, check if we're in unmanaged mode, // On startup, check if we're in unmanaged mode,
// as local mode needs to be set // as local mode needs to be set
let unmanagedLocalMode = false; let unmanagedLocalMode = false;
if (checkTruthy((await this.config.get('unmanaged')) || false)) { if (await this.config.get('unmanaged')) {
console.log('Starting up in unmanaged mode, activating local mode'); console.log('Starting up in unmanaged mode, activating local mode');
await this.config.set({ localMode: true }); await this.config.set({ localMode: true });
unmanagedLocalMode = true; unmanagedLocalMode = true;
@ -51,8 +54,7 @@ export class LocalModeManager {
const localMode = const localMode =
// short circuit the next get if we know we're in local mode // short circuit the next get if we know we're in local mode
unmanagedLocalMode || unmanagedLocalMode || (await this.config.get('localMode'));
checkTruthy((await this.config.get('localMode')) || false);
if (!localMode) { if (!localMode) {
// Remove any leftovers if necessary // Remove any leftovers if necessary

View File

@ -4,6 +4,7 @@ import * as _ from 'lodash';
import * as morgan from 'morgan'; import * as morgan from 'morgan';
import Config from './config'; import Config from './config';
import { SchemaReturn, SchemaTypeKey } from './config/schema-type';
import { EventTracker } from './event-tracker'; import { EventTracker } from './event-tracker';
import blink = require('./lib/blink'); import blink = require('./lib/blink');
import * as iptables from './lib/iptables'; import * as iptables from './lib/iptables';
@ -141,24 +142,23 @@ export class SupervisorAPI {
port: number, port: number,
apiTimeout: number, apiTimeout: number,
): Promise<void> { ): Promise<void> {
const localMode = (await this.config.get('localMode')) || false; const localMode = await this.config.get('localMode');
await this.applyListeningRules( await this.applyListeningRules(localMode || false, port, allowedInterfaces);
checkTruthy(localMode) || false,
port,
allowedInterfaces,
);
// Monitor the switching of local mode, and change which interfaces will // Monitor the switching of local mode, and change which interfaces will
// be listened to based on that // be listened to based on that
this.config.on('change', (changedConfig: Dictionary<string>) => { this.config.on(
'change',
(changedConfig: { [key in SchemaTypeKey]: SchemaReturn<key> }) => {
if (changedConfig.localMode != null) { if (changedConfig.localMode != null) {
this.applyListeningRules( this.applyListeningRules(
checkTruthy(changedConfig.localMode || false) || false, changedConfig.localMode || false,
port, port,
allowedInterfaces, allowedInterfaces,
); );
} }
}); },
);
this.server = this.api.listen(port); this.server = this.api.listen(port);
this.server.timeout = apiTimeout; this.server.timeout = apiTimeout;

View File

@ -7,7 +7,6 @@ EventEmitter = require 'events'
DeviceState = require './device-state' DeviceState = require './device-state'
{ SupervisorAPI } = require './supervisor-api' { SupervisorAPI } = require './supervisor-api'
{ Logger } = require './logger' { Logger } = require './logger'
{ checkTruthy } = require './lib/validation'
constants = require './lib/constants' constants = require './lib/constants'
@ -57,12 +56,12 @@ module.exports = class Supervisor extends EventEmitter
apiEndpoint: conf.apiEndpoint, apiEndpoint: conf.apiEndpoint,
uuid: conf.uuid, uuid: conf.uuid,
deviceApiKey: conf.deviceApiKey, deviceApiKey: conf.deviceApiKey,
unmanaged: checkTruthy(conf.unmanaged), unmanaged: conf.unmanaged,
enableLogs: checkTruthy(conf.loggingEnabled), enableLogs: conf.loggingEnabled,
localMode: checkTruthy(conf.localMode) localMode: conf.localMode
}) })
.then => .then =>
if checkTruthy(conf.legacyAppsPresent) if conf.legacyAppsPresent
console.log('Legacy app detected, running migration') console.log('Legacy app detected, running migration')
@deviceState.normaliseLegacy(@apiBinder.balenaApi) @deviceState.normaliseLegacy(@apiBinder.balenaApi)
.then => .then =>

View File

@ -243,9 +243,8 @@ describe 'deviceState', ->
@deviceState.applications.images.save.restore() @deviceState.applications.images.save.restore()
@deviceState.deviceConfig.getCurrent.restore() @deviceState.deviceConfig.getCurrent.restore()
@config.get('pinDevice').then (pinnedString) -> @config.get('pinDevice').then (pinned) ->
pinned = JSON.parse(pinnedString) expect(pinned).to.have.property('app').that.equals(1234)
expect(pinned).to.have.property('app').that.equals('1234')
expect(pinned).to.have.property('commit').that.equals('abcdef') expect(pinned).to.have.property('commit').that.equals('abcdef')
it 'emits a change event when a new state is reported', -> it 'emits a change event when a new state is reported', ->