diff --git a/src/api-binder.coffee b/src/api-binder.coffee index 89655faf..064ddd75 100644 --- a/src/api-binder.coffee +++ b/src/api-binder.coffee @@ -329,13 +329,13 @@ module.exports = class APIBinder # Creates the necessary config vars in the API to match the current device state, # without overwriting any variables that are already set. _reportInitialEnv: (apiEndpoint) => + defaultConfig = @deviceState.deviceConfig.getDefaults() Promise.join( @deviceState.getCurrentForComparison() @getTargetState().then (targetState) => @deviceState.deviceConfig.formatConfigKeys(targetState.local.config) - @deviceState.deviceConfig.getDefaults() @config.get('deviceId') - (currentState, targetConfig, defaultConfig, deviceId) => + (currentState, targetConfig, deviceId) => currentConfig = currentState.local.config Promise.mapSeries _.toPairs(currentConfig), ([ key, value ]) => # We want to disable local mode when joining a cloud diff --git a/src/device-config.coffee b/src/device-config.coffee deleted file mode 100644 index 33260a77..00000000 --- a/src/device-config.coffee +++ /dev/null @@ -1,235 +0,0 @@ -Promise = require 'bluebird' -_ = require 'lodash' - -systemd = require './lib/systemd' -{ checkTruthy, checkInt } = require './lib/validation' -{ UnitNotLoadedError } = require './lib/errors' -configUtils = require './config/utils' - -vpnServiceName = 'openvpn-resin' - -module.exports = class DeviceConfig - constructor: ({ @db, @config, @logger }) -> - @rebootRequired = false - @configKeys = { - appUpdatePollInterval: { envVarName: 'SUPERVISOR_POLL_INTERVAL', varType: 'int', defaultValue: '60000' } - localMode: { envVarName: 'SUPERVISOR_LOCAL_MODE', varType: 'bool', defaultValue: 'false' } - connectivityCheckEnabled: { envVarName: 'SUPERVISOR_CONNECTIVITY_CHECK', varType: 'bool', defaultValue: 'true' } - loggingEnabled: { envVarName: 'SUPERVISOR_LOG_CONTROL', varType: 'bool', defaultValue: 'true' } - delta: { envVarName: 'SUPERVISOR_DELTA', varType: 'bool', defaultValue: 'false' } - deltaRequestTimeout: { envVarName: 'SUPERVISOR_DELTA_REQUEST_TIMEOUT', varType: 'int', defaultValue: '30000' } - deltaApplyTimeout: { envVarName: 'SUPERVISOR_DELTA_APPLY_TIMEOUT', varType: 'int', defaultValue: '' } - deltaRetryCount: { envVarName: 'SUPERVISOR_DELTA_RETRY_COUNT', varType: 'int', defaultValue: '30' } - deltaRetryInterval: { envVarName: 'SUPERVISOR_DELTA_RETRY_INTERVAL', varType: 'int', defaultValue: '10000' } - deltaVersion: { envVarName: 'SUPERVISOR_DELTA_VERSION', varType: 'int', defaultValue: '2' } - lockOverride: { envVarName: 'SUPERVISOR_OVERRIDE_LOCK', varType: 'bool', defaultValue: 'false' } - persistentLogging: { envVarName: 'SUPERVISOR_PERSISTENT_LOGGING', varType: 'bool', defaultValue: 'false', rebootRequired: true } - } - @validKeys = [ - 'SUPERVISOR_VPN_CONTROL', - 'OVERRIDE_LOCK', - ].concat(_.map(@configKeys, 'envVarName')) - @actionExecutors = { - changeConfig: (step) => - @logger.logConfigChange(step.humanReadableTarget) - @config.set(step.target) - .then => - @logger.logConfigChange(step.humanReadableTarget, { success: true }) - if step.rebootRequired - @rebootRequired = true - .tapCatch (err) => - @logger.logConfigChange(step.humanReadableTarget, { err }) - setVPNEnabled: (step, { initial = false } = {}) => - logValue = { SUPERVISOR_VPN_CONTROL: step.target } - if !initial - @logger.logConfigChange(logValue) - @setVPNEnabled(step.target) - .then => - if !initial - @logger.logConfigChange(logValue, { success: true }) - .tapCatch (err) => - @logger.logConfigChange(logValue, { err }) - setBootConfig: (step) => - @getConfigBackend() - .then (configBackend ) => - @setBootConfig(configBackend, step.target) - } - @validActions = _.keys(@actionExecutors) - @configBackend = null - - getConfigBackend: => - if @configBackend? - Promise.resolve(@configBackend) - else - @config.get('deviceType').then (deviceType) => - @configBackend = configUtils.getConfigBackend(deviceType) - return @configBackend - - setTarget: (target, trx) => - db = trx ? @db.models.bind(@db) - @formatConfigKeys(target) - .then (formattedTarget) -> - confToUpdate = { - targetValues: JSON.stringify(formattedTarget) - } - db('deviceConfig').update(confToUpdate) - - getTarget: ({ initial = false } = {}) => - Promise.all([ - @config.get('unmanaged') - @db.models('deviceConfig').select('targetValues') - ]) - .then ([unmanaged, [ devConfig ]]) => - conf = JSON.parse(devConfig.targetValues) - if initial or !conf.SUPERVISOR_VPN_CONTROL? - conf.SUPERVISOR_VPN_CONTROL = 'true' - if unmanaged and !conf.SUPERVISOR_LOCAL_MODE? - conf.SUPERVISOR_LOCAL_MODE = 'true' - for own k, { envVarName, defaultValue } of @configKeys - conf[envVarName] ?= defaultValue - return conf - - getCurrent: => - Promise.all [ - @config.getMany([ 'deviceType' ].concat(_.keys(@configKeys))) - @getConfigBackend() - ] - .then ([ conf, configBackend ]) => - Promise.join( - @getVPNEnabled() - @getBootConfig(configBackend) - (vpnStatus, bootConfig) => - currentConf = { - SUPERVISOR_VPN_CONTROL: (vpnStatus ? 'true').toString() - } - for own key, { envVarName } of @configKeys - currentConf[envVarName] = (conf[key] ? '').toString() - return _.assign(currentConf, bootConfig) - ) - - formatConfigKeys: (conf) => - @getConfigBackend() - .then (configBackend) => - configUtils.formatConfigKeys(configBackend, @validKeys, conf) - - getDefaults: => - Promise.try => - return _.extend({ - SUPERVISOR_VPN_CONTROL: 'true' - }, _.mapValues(_.mapKeys(@configKeys, 'envVarName'), 'defaultValue')) - - bootConfigChangeRequired: (configBackend, current, target) => - targetBootConfig = configUtils.envToBootConfig(configBackend, target) - currentBootConfig = configUtils.envToBootConfig(configBackend, current) - - if !_.isEqual(currentBootConfig, targetBootConfig) - _.each targetBootConfig, (value, key) => - if not configBackend.isSupportedConfig(key) - if currentBootConfig[key] != value - err = "Attempt to change blacklisted config value #{key}" - @logger.logSystemMessage(err, { error: err }, 'Apply boot config error') - throw new Error(err) - return true - return false - - getRequiredSteps: (currentState, targetState) => - current = _.clone(currentState.local?.config ? {}) - target = _.clone(targetState.local?.config ? {}) - steps = [] - Promise.all [ - @config.getMany([ 'deviceType', 'unmanaged' ]) - @getConfigBackend() - ] - .then ([{ deviceType, unmanaged }, configBackend ]) => - configChanges = {} - humanReadableConfigChanges = {} - match = { - 'bool': (a, b) -> - checkTruthy(a) == checkTruthy(b) - 'int': (a, b) -> - checkInt(a) == checkInt(b) - } - # If the legacy lock override is used, place it as the new variable - if checkTruthy(target['OVERRIDE_LOCK']) - target['SUPERVISOR_OVERRIDE_LOCK'] = target['OVERRIDE_LOCK'] - reboot = false - for own key, { envVarName, varType, rebootRequired } of @configKeys - if !match[varType](current[envVarName], target[envVarName]) - configChanges[key] = target[envVarName] - humanReadableConfigChanges[envVarName] = target[envVarName] - reboot = reboot || (rebootRequired ? false) - if !_.isEmpty(configChanges) - steps.push({ - action: 'changeConfig' - target: configChanges - humanReadableTarget: humanReadableConfigChanges - rebootRequired: reboot - }) - return - - # Check if we need to perform special case actions for the VPN - if !checkTruthy(unmanaged) && - !_.isEmpty(target['SUPERVISOR_VPN_CONTROL']) && - @checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL') - steps.push({ - action: 'setVPNEnabled' - target: target['SUPERVISOR_VPN_CONTROL'] - }) - - # Do we need to change the boot config? - if @bootConfigChangeRequired(configBackend, current, target) - steps.push({ - action: 'setBootConfig' - target - }) - - if !_.isEmpty(steps) - return - if @rebootRequired - steps.push({ - action: 'reboot' - }) - return - .return(steps) - - executeStepAction: (step, opts) => - @actionExecutors[step.action](step, opts) - - getBootConfig: (configBackend) -> - Promise.try -> - if !configBackend? - return {} - configBackend.getBootConfig() - .then (config) -> - return configUtils.bootConfigToEnv(configBackend, config) - - setBootConfig: (configBackend, target) => - Promise.try => - if !configBackend? - return false - conf = configUtils.envToBootConfig(configBackend, target) - @logger.logSystemMessage("Applying boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config in progress') - - configBackend.setBootConfig(conf) - .then => - @logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success') - @rebootRequired = true - return true - .tapCatch (err) => - @logger.logSystemMessage("Error setting boot config: #{err}", { error: err }, 'Apply boot config error') - - getVPNEnabled: -> - systemd.serviceActiveState(vpnServiceName) - .then (activeState) -> - return activeState not in [ 'inactive', 'deactivating' ] - .catchReturn(UnitNotLoadedError, null) - - setVPNEnabled: (val) -> - enable = checkTruthy(val) ? true - if enable - systemd.startService(vpnServiceName) - else - systemd.stopService(vpnServiceName) - - checkBoolChanged: (current, target, key) -> - checkTruthy(current[key]) != checkTruthy(target[key]) diff --git a/src/device-config.ts b/src/device-config.ts new file mode 100644 index 00000000..92fdb53a --- /dev/null +++ b/src/device-config.ts @@ -0,0 +1,518 @@ +import * as _ from 'lodash'; + +import Config = require('./config'); +import Database, { Transaction } from './db'; +import Logger from './logger'; + +import { DeviceConfigBackend } from './config/backend'; +import * as configUtils from './config/utils'; +import { UnitNotLoadedError } from './lib/errors'; +import * as systemd from './lib/systemd'; +import { EnvVarObject } from './lib/types'; +import { checkInt, checkTruthy } from './lib/validation'; +import { DeviceApplicationState } from './types/state'; + +const vpnServiceName = 'openvpn-resin'; + +interface DeviceConfigConstructOpts { + db: Database; + config: Config; + logger: Logger; +} + +interface ConfigOption { + envVarName: string; + varType: string; + defaultValue: string; + rebootRequired?: boolean; +} + +interface ConfigStep { + // TODO: This is a bit of a mess, the DeviceConfig class shouldn't + // know that the reboot action exists as it is implemented by + // DeviceState. Fix this weird circular dependency + action: keyof DeviceActionExecutors | 'reboot'; + humanReadableTarget?: Dictionary; + target?: string | Dictionary; + rebootRequired?: boolean; +} + +interface DeviceActionExecutorOpts { + initial?: boolean; +} + +type DeviceActionExecutorFn = ( + step: ConfigStep, + opts?: DeviceActionExecutorOpts, +) => Promise; + +interface DeviceActionExecutors { + changeConfig: DeviceActionExecutorFn; + setVPNEnabled: DeviceActionExecutorFn; + setBootConfig: DeviceActionExecutorFn; +} + +export class DeviceConfig { + private db: Database; + private config: Config; + private logger: Logger; + private rebootRequired = false; + private actionExecutors: DeviceActionExecutors; + private configBackend: DeviceConfigBackend | null = null; + + private static configKeys: Dictionary = { + appUpdatePollInterval: { + envVarName: 'SUPERVISOR_POLL_INTERVAL', + varType: 'int', + defaultValue: '60000', + }, + localMode: { + envVarName: 'SUPERVISOR_LOCAL_MODE', + varType: 'bool', + defaultValue: 'false', + }, + connectivityCheckEnabled: { + envVarName: 'SUPERVISOR_CONNECTIVITY_CHECK', + varType: 'bool', + defaultValue: 'true', + }, + loggingEnabled: { + envVarName: 'SUPERVISOR_LOG_CONTROL', + varType: 'bool', + defaultValue: 'true', + }, + delta: { + envVarName: 'SUPERVISOR_DELTA', + varType: 'bool', + defaultValue: 'false', + }, + deltaRequestTimeout: { + envVarName: 'SUPERVISOR_DELTA_REQUEST_TIMEOUT', + varType: 'int', + defaultValue: '30000', + }, + deltaApplyTimeout: { + envVarName: 'SUPERVISOR_DELTA_APPLY_TIMEOUT', + varType: 'int', + defaultValue: '', + }, + deltaRetryCount: { + envVarName: 'SUPERVISOR_DELTA_RETRY_COUNT', + varType: 'int', + defaultValue: '30', + }, + deltaRetryInterval: { + envVarName: 'SUPERVISOR_DELTA_RETRY_INTERVAL', + varType: 'int', + defaultValue: '10000', + }, + deltaVersion: { + envVarName: 'SUPERVISOR_DELTA_VERSION', + varType: 'int', + defaultValue: '2', + }, + lockOverride: { + envVarName: 'SUPERVISOR_OVERRIDE_LOCK', + varType: 'bool', + defaultValue: 'false', + }, + persistentLogging: { + envVarName: 'SUPERVISOR_PERSISTENT_LOGGING', + varType: 'bool', + defaultValue: 'false', + rebootRequired: true, + }, + }; + + static validKeys = [ + 'SUPERVISOR_VPN_CONTROL', + 'OVERRRIDE_LOCK', + ..._.map(DeviceConfig.configKeys, 'envVarName'), + ]; + + public constructor({ db, config, logger }: DeviceConfigConstructOpts) { + this.db = db; + this.config = config; + this.logger = logger; + + this.actionExecutors = { + changeConfig: async step => { + try { + if (step.humanReadableTarget) { + this.logger.logConfigChange(step.humanReadableTarget); + } + if (!_.isObject(step.target)) { + throw new Error('Non-dictionary value passed to changeConfig'); + } + await this.config.set(step.target as Dictionary); + if (step.humanReadableTarget) { + this.logger.logConfigChange(step.humanReadableTarget, { + success: true, + }); + } + if (step.rebootRequired) { + this.rebootRequired = true; + } + } catch (err) { + if (step.humanReadableTarget) { + this.logger.logConfigChange(step.humanReadableTarget, { + err, + }); + } + throw err; + } + }, + setVPNEnabled: async (step, opts = {}) => { + const { initial = false } = opts; + if (!_.isString(step.target)) { + throw new Error('Non-string value passed to setVPNEnabled'); + } + const logValue = { SUPERVISOR_VPN_CONTROL: step.target }; + if (!initial) { + this.logger.logConfigChange(logValue); + } + try { + await this.setVPNEnabled(step.target); + if (!initial) { + this.logger.logConfigChange(logValue, { success: true }); + } + } catch (err) { + this.logger.logConfigChange(logValue, { err }); + throw err; + } + }, + setBootConfig: async step => { + const configBackend = await this.getConfigBackend(); + if (!_.isObject(step.target)) { + throw new Error( + 'Non-dictionary passed to DeviceConfig.setBootConfig', + ); + } + await this.setBootConfig(configBackend, step.target as Dictionary< + string + >); + }, + }; + } + + private async getConfigBackend() { + if (this.configBackend != null) { + return this.configBackend; + } + const dt = await this.config.get('deviceType'); + if (!_.isString(dt)) { + throw new Error('Could not detect device type'); + } + + this.configBackend = configUtils.getConfigBackend(dt) || null; + + return this.configBackend; + } + + public async setTarget( + target: Dictionary, + trx?: Transaction, + ): Promise { + const db = trx != null ? trx : this.db.models.bind(this.db); + + const formatted = await this.formatConfigKeys(target); + const confToUpdate = { + targetValues: JSON.stringify(formatted), + }; + await db('deviceConfig').update(confToUpdate); + } + + public async getTarget({ initial = false }: { initial?: boolean } = {}) { + const [unmanaged, [devConfig]] = await Promise.all([ + this.config.get('unmanaged'), + this.db.models('deviceConfig').select('targetValues'), + ]); + + let conf: Dictionary; + try { + conf = JSON.parse(devConfig.targetValues); + } catch (e) { + throw new Error(`Corrupted supervisor database! Error: ${e.message}`); + } + if (initial || conf.SUPERVISOR_VPN_CONTROL == null) { + conf.SUPERVISOR_VPN_CONTROL = 'true'; + } + if (unmanaged && conf.SUPERVISOR_LOCAL_MODE == null) { + conf.SUPERVISOR_LOCAL_MODE = 'true'; + } + + _.defaults( + conf, + _(DeviceConfig.configKeys) + .mapKeys('envVarName') + .mapValues('defaultValue') + .value(), + ); + + return conf; + } + + public async getCurrent() { + const conf = await this.config.getMany( + ['deviceType'].concat(_.keys(DeviceConfig.configKeys)), + ); + + const configBackend = await this.getConfigBackend(); + + const [vpnStatus, bootConfig] = await Promise.all([ + this.getVPNEnabled(), + this.getBootConfig(configBackend), + ]); + + const currentConf: Dictionary = { + // TODO: Fix this mess of half strings half boolean values everywhere + SUPERVISOR_VPN_CONTROL: vpnStatus != null ? vpnStatus.toString() : 'true', + }; + + for (const key in DeviceConfig.configKeys) { + const { envVarName } = DeviceConfig.configKeys[key]; + const confValue = conf[key]; + currentConf[envVarName] = confValue != null ? confValue.toString() : ''; + } + + return _.assign(currentConf, bootConfig); + } + + public async formatConfigKeys( + conf: Dictionary, + ): Promise> { + const backend = await this.getConfigBackend(); + return await configUtils.formatConfigKeys( + backend, + DeviceConfig.validKeys, + conf, + ); + } + + public getDefaults() { + return _.extend( + { + SUPERVISOR_VPN_CONTROL: 'true', + }, + _.mapValues( + _.mapKeys(DeviceConfig.configKeys, 'envVarName'), + 'defaultValues', + ), + ); + } + + private bootConfigChangeRequired( + configBackend: DeviceConfigBackend | null, + current: Dictionary, + target: Dictionary, + ): boolean { + const targetBootConfig = configUtils.envToBootConfig(configBackend, target); + const currentBootConfig = configUtils.envToBootConfig( + configBackend, + current, + ); + + if (!_.isEqual(currentBootConfig, targetBootConfig)) { + _.each(targetBootConfig, (value, key) => { + // Ignore null check because we can't get here if configBackend is null + if (!configBackend!.isSupportedConfig(key)) { + if (currentBootConfig[key] !== value) { + const err = `Attempt to change blacklisted config value ${key}`; + this.logger.logSystemMessage( + err, + { error: err }, + 'Apply boot config error', + ); + throw new Error(err); + } + } + }); + return true; + } + return false; + } + + public async getRequiredSteps( + currentState: DeviceApplicationState, + targetState: DeviceApplicationState, + ): Promise { + const current: Dictionary = _.get( + currentState, + ['local', 'config'], + {}, + ); + const target: Dictionary = _.get( + targetState, + ['local', 'config'], + {}, + ); + + const steps: ConfigStep[] = []; + + const unmanaged = await this.config.get('unmanaged'); + const backend = await this.getConfigBackend(); + + const configChanges: Dictionary = {}; + const humanReadableConfigChanges: Dictionary = {}; + let reboot = false; + + // If the legacy lock override is used, place it as the new variable + if (checkTruthy(target['OVERRIDE_LOCK'])) { + target['SUPERVISOR_OVERRIDE_LOCK'] = target['OVERRIDE_LOCK']; + } + + _.each( + DeviceConfig.configKeys, + ({ envVarName, varType, rebootRequired }, key) => { + // Test if the key is different + if ( + !DeviceConfig.configTest( + varType, + current[envVarName], + target[envVarName], + ) + ) { + // Save the change if it is + configChanges[key] = target[envVarName]; + humanReadableConfigChanges[envVarName] = target[envVarName]; + reboot = rebootRequired || reboot; + } + }, + ); + + if (!_.isEmpty(configChanges)) { + steps.push({ + action: 'changeConfig', + target: configChanges, + humanReadableTarget: humanReadableConfigChanges, + rebootRequired: reboot, + }); + } + + // Check for special case actions for the VPN + if ( + !checkTruthy(unmanaged || false) && + !_.isEmpty(target['SUPERVISOR_VPN_CONTROL']) && + DeviceConfig.checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL') + ) { + steps.push({ + action: 'setVPNEnabled', + target: target['SUPERVISOR_VPN_CONTROL'], + }); + } + + // Do we need to change the boot config? + if (this.bootConfigChangeRequired(backend, current, target)) { + steps.push({ + action: 'setBootConfig', + target, + }); + } + + if (_.isEmpty(steps) && this.rebootRequired) { + steps.push({ + action: 'reboot', + }); + } + + return steps; + } + + public executeStepAction(step: ConfigStep, opts: DeviceActionExecutorOpts) { + if (step.action !== 'reboot') { + return this.actionExecutors[step.action](step, opts); + } + } + + public isValidAction(action: string): boolean { + return _.includes(_.keys(this.actionExecutors), action); + } + + private async getBootConfig( + backend: DeviceConfigBackend | null, + ): Promise { + if (backend == null) { + return {}; + } + const conf = await backend.getBootConfig(); + return configUtils.bootConfigToEnv(backend, conf); + } + + private async setBootConfig( + backend: DeviceConfigBackend | null, + target: Dictionary, + ) { + if (backend == null) { + return false; + } + + const conf = configUtils.envToBootConfig(backend, target); + this.logger.logSystemMessage( + `Applying boot config: ${JSON.stringify(conf)}`, + {}, + 'Apply boot config in progress', + ); + + try { + await backend.setBootConfig(conf); + this.logger.logSystemMessage( + `Applied boot config: ${JSON.stringify(conf)}`, + {}, + 'Apply boot config success', + ); + this.rebootRequired = true; + return true; + } catch (err) { + this.logger.logSystemMessage( + `Error setting boot config: ${err}`, + { error: err }, + 'Apply boot config error', + ); + throw err; + } + } + + private async getVPNEnabled(): Promise { + try { + const activeState = await systemd.serviceActiveState(vpnServiceName); + return !_.includes(['inactive', 'deactivating'], activeState); + } catch (e) { + if (UnitNotLoadedError(e)) { + return false; + } + throw e; + } + } + + private async setVPNEnabled(value?: string | boolean) { + const v = checkTruthy(value || true); + const enable = v != null ? v : true; + + if (enable) { + await systemd.startService(vpnServiceName); + } else { + await systemd.stopService(vpnServiceName); + } + } + + private static configTest(method: string, a: string, b: string): boolean { + switch (method) { + case 'bool': + return checkTruthy(a) === checkTruthy(b); + case 'int': + return checkInt(a) === checkInt(b); + default: + throw new Error('Incorrect datatype passed to DeviceConfig.configTest'); + } + } + + private static checkBoolChanged( + current: Dictionary, + target: Dictionary, + key: string, + ): boolean { + return checkTruthy(current[key]) !== checkTruthy(target[key]); + } +} + +export default DeviceConfig; diff --git a/src/device-state.coffee b/src/device-state.coffee index 4de29878..9172530a 100644 --- a/src/device-state.coffee +++ b/src/device-state.coffee @@ -19,7 +19,7 @@ updateLock = require './lib/update-lock' { singleToMulticontainerApp } = require './lib/migration' { ENOENT, EISDIR, NotFoundError, UpdatesLockedError } = require './lib/errors' -DeviceConfig = require './device-config' +{ DeviceConfig } = require './device-config' ApplicationManager = require './application-manager' validateLocalState = (state) -> @@ -534,7 +534,7 @@ module.exports = class DeviceState extends EventEmitter executeStepAction: (step, { force, initial, skipLock }) => Promise.try => - if _.includes(@deviceConfig.validActions, step.action) + if @deviceConfig.isValidAction(step.action) @deviceConfig.executeStepAction(step, { initial }) else if _.includes(@applications.validActions, step.action) @applications.executeStepAction(step, { force, skipLock }) diff --git a/test/13-device-config.spec.coffee b/test/13-device-config.spec.coffee index 0a3083f4..55f0b1dd 100644 --- a/test/13-device-config.spec.coffee +++ b/test/13-device-config.spec.coffee @@ -8,7 +8,7 @@ m = require 'mochainon' prepare = require './lib/prepare' fsUtils = require '../src/lib/fs-utils' -DeviceConfig = require '../src/device-config' +{ DeviceConfig } = require '../src/device-config' { ExtlinuxConfigBackend, RPiConfigBackend } = require '../src/config/backend' extlinuxBackend = new ExtlinuxConfigBackend()