mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-21 03:55:23 +00:00
DeviceConfig: implement a module to manage device configuration, including config.txt
This model allows modifying config.txt on raspberry pi devices, as well as logging to display, bandwidth control variables and other supervisor configuration settings. Configuration values are read from the underlying OS and the supervisor configuration where appropriate (i.e. the Config object), instead of storing the current state in the database. This means that the supervisor will always use the real values to determine if changes have to be made. This fixes several issues with config.txt, as the current values are now read from the file, and can be reported on the supervisor's first run (which will be implemented in APIBinder). It also now treats dtoverlay and dtparam values as a JSON array without the enclosing brackets, for instance: ``` RESIN_HOST_CONFIG_dtparam="audio=on","spi=on" ``` Will produce the following lines in config.txt: ``` dtparam=audio=on dtparam=spi=on ``` Changelog-Entry: Implement inference of device configuration. Allow array values for dtoverlay and dtparam. Change-Type: major Signed-off-by: Pablo Carranza Velez <pablo@resin.io>
This commit is contained in:
parent
2953b745ce
commit
d3e98eab11
@ -1,15 +1,288 @@
|
||||
knex = require './db'
|
||||
Promise = require 'bluebird'
|
||||
_ = require 'lodash'
|
||||
childProcess = Promise.promisifyAll(require('child_process'))
|
||||
fs = Promise.promisifyAll(require('fs'))
|
||||
|
||||
exports.set = (conf) ->
|
||||
confToUpdate = {}
|
||||
confToUpdate.values = JSON.stringify(conf.values) if conf.values?
|
||||
confToUpdate.targetValues = JSON.stringify(conf.targetValues) if conf.targetValues?
|
||||
knex('deviceConfig').update(confToUpdate)
|
||||
constants = require './lib/constants'
|
||||
gosuper = require './lib/gosuper'
|
||||
fsUtils = require './lib/fs-utils'
|
||||
{ checkTruthy, checkInt } = require './lib/validation'
|
||||
|
||||
exports.get = ->
|
||||
knex('deviceConfig').select()
|
||||
.then ([ deviceConfig ]) ->
|
||||
return {
|
||||
values: JSON.parse(deviceConfig.values)
|
||||
targetValues: JSON.parse(deviceConfig.targetValues)
|
||||
hostConfigConfigVarPrefix = 'RESIN_HOST_'
|
||||
bootConfigEnvVarPrefix = hostConfigConfigVarPrefix + 'CONFIG_'
|
||||
bootBlockDevice = '/dev/mmcblk0p1'
|
||||
bootMountPoint = constants.rootMountPoint + constants.bootMountPoint
|
||||
bootConfigPath = bootMountPoint + '/config.txt'
|
||||
configRegex = ->
|
||||
new RegExp('(' + _.escapeRegExp(bootConfigEnvVarPrefix) + ')(.+)')
|
||||
forbiddenConfigKeys = [
|
||||
'disable_commandline_tags'
|
||||
'cmdline'
|
||||
'kernel'
|
||||
'kernel_address'
|
||||
'kernel_old'
|
||||
'ramfsfile'
|
||||
'ramfsaddr'
|
||||
'initramfs'
|
||||
'device_tree_address'
|
||||
'init_uart_baud'
|
||||
'init_uart_clock'
|
||||
'init_emmc_clock'
|
||||
'boot_delay'
|
||||
'boot_delay_ms'
|
||||
'avoid_safe_mode'
|
||||
]
|
||||
arrayConfigKeys = [ 'dtparam', 'dtoverlay', 'device_tree_param', 'device_tree_overlay' ]
|
||||
|
||||
module.exports = class DeviceConfig
|
||||
constructor: ({ @db, @config, @logger }) ->
|
||||
@rebootRequired = false
|
||||
@validActions = [ 'changeConfig', 'setLogToDisplay', 'setBootConfig' ]
|
||||
@configKeys = {
|
||||
appUpdatePollInterval: { envVarName: 'RESIN_SUPERVISOR_POLL_INTERVAL', varType: 'int', defaultValue: '60000' }
|
||||
localMode: { envVarName: 'RESIN_SUPERVISOR_LOCAL_MODE', varType: 'bool', defaultValue: 'false' }
|
||||
connectivityCheckEnabled: { envVarName: 'RESIN_SUPERVISOR_CONNECTIVITY_CHECK', varType: 'bool', defaultValue: 'true' }
|
||||
loggingEnabled: { envVarName: 'RESIN_SUPERVISOR_LOG_CONTROL', varType: 'bool', defaultValue: 'true' }
|
||||
delta: { envVarName: 'RESIN_SUPERVISOR_DELTA', varType: 'bool', defaultValue: 'false' }
|
||||
deltaRequestTimeout: { envVarName: 'RESIN_SUPERVISOR_DELTA_REQUEST_TIMEOUT', varType: 'int' }
|
||||
deltaApplyTimeout: { envVarName: 'RESIN_SUPERVISOR_DELTA_APPLY_TIMEOUT', varType: 'int' }
|
||||
deltaRetryCount: { envVarName: 'RESIN_SUPERVISOR_DELTA_RETRY_COUNT', varType: 'int' }
|
||||
deltaRetryInterval: { envVarName: 'RESIN_SUPERVISOR_DELTA_RETRY_INTERVAL', varType: 'int' }
|
||||
lockOverride: { envVarName: 'RESIN_SUPERVISOR_OVERRIDE_LOCK', varType: 'bool', defaultValue: 'false' }
|
||||
}
|
||||
@validKeys = [ 'RESIN_HOST_LOG_TO_DISPLAY', 'RESIN_SUPERVISOR_VPN_CONTROL' ].concat(_.map(@configKeys, 'envVarName'))
|
||||
|
||||
setTarget: (target, trx) =>
|
||||
db = trx ? @db.models
|
||||
confToUpdate = {
|
||||
targetValues: JSON.stringify(target)
|
||||
}
|
||||
db('deviceConfig').update(confToUpdate)
|
||||
|
||||
filterConfigKeys: (conf) =>
|
||||
_.pickBy conf, (v, k) =>
|
||||
_.includes(@validKeys, k) or _.startsWith(k, bootConfigEnvVarPrefix)
|
||||
|
||||
getTarget: =>
|
||||
@db.models('deviceConfig').select('targetValues')
|
||||
.then ([ devConfig ]) ->
|
||||
return JSON.parse(devConfig.targetValues)
|
||||
.then (conf) =>
|
||||
conf = @filterConfigKeys(conf)
|
||||
conf.RESIN_HOST_LOG_TO_DISPLAY ?= ''
|
||||
conf.RESIN_SUPERVISOR_VPN_CONTROL ?= 'true'
|
||||
_.forEach @configKeys, ({ envVarName, defaultValue }) ->
|
||||
conf[envVarName] ?= defaultValue ? ''
|
||||
return conf
|
||||
|
||||
getCurrent: =>
|
||||
@config.getMany([ 'deviceType' ].concat(_.keys(@configKeys)))
|
||||
.then (conf) =>
|
||||
Promise.join(
|
||||
@getLogToDisplay()
|
||||
@getVPNEnabled()
|
||||
@getBootConfig(conf.deviceType)
|
||||
(logToDisplayStatus, vpnStatus, bootConfig) =>
|
||||
currentConf = {
|
||||
RESIN_HOST_LOG_TO_DISPLAY: (logToDisplayStatus ? '').toString()
|
||||
RESIN_SUPERVISOR_VPN_CONTROL: (vpnStatus ? 'true').toString()
|
||||
}
|
||||
_.forEach @configKeys, ({ envVarName }, key) ->
|
||||
currentConf[envVarName] = (conf[key] ? '').toString()
|
||||
return _.assign(currentConf, bootConfig)
|
||||
)
|
||||
|
||||
bootConfigChangeRequired: (deviceType, current, target) =>
|
||||
targetBootConfig = @envToBootConfig(target)
|
||||
currentBootConfig = @envToBootConfig(current)
|
||||
if !_.isEqual(currentBootConfig, targetBootConfig)
|
||||
_.forEach forbiddenConfigKeys, (key) =>
|
||||
if currentBootConfig[key] != targetBootConfig[key]
|
||||
err = "Attempt to change blacklisted config value #{key}"
|
||||
@logger.logSystemMessage(err, { error: err }, 'Apply boot config error')
|
||||
throw new Error(err)
|
||||
return true
|
||||
else return false
|
||||
|
||||
getRequiredSteps: (currentState, targetState, stepsInProgress) =>
|
||||
current = currentState.local?.config ? {}
|
||||
target = targetState.local?.config ? {}
|
||||
steps = []
|
||||
@config.get('deviceType')
|
||||
.then (deviceType) =>
|
||||
configChanges = {}
|
||||
humanReadableConfigChanges = {}
|
||||
match = {
|
||||
'bool': (a, b) ->
|
||||
checkTruthy(a) == checkTruthy(b)
|
||||
'int': (a, b) ->
|
||||
checkInt(a) == checkInt(b)
|
||||
}
|
||||
_.forEach @configKeys, ({ envVarName, varType }, key) ->
|
||||
if !match[varType](current[envVarName], target[envVarName])
|
||||
configChanges[key] = target[envVarName]
|
||||
humanReadableConfigChanges[envVarName] = target[envVarName]
|
||||
if !_.isEmpty(configChanges)
|
||||
steps.push({
|
||||
action: 'changeConfig'
|
||||
target: configChanges
|
||||
humanReadableTarget: humanReadableConfigChanges
|
||||
})
|
||||
return
|
||||
if !_.isUndefined(current['RESIN_HOST_LOG_TO_DISPLAY'])
|
||||
if !_.isEmpty(target['RESIN_HOST_LOG_TO_DISPLAY']) && checkTruthy(current['RESIN_HOST_LOG_TO_DISPLAY']) != checkTruthy(target['RESIN_HOST_LOG_TO_DISPLAY'])
|
||||
steps.push({
|
||||
action: 'setLogToDisplay'
|
||||
target: target['RESIN_HOST_LOG_TO_DISPLAY']
|
||||
})
|
||||
if @bootConfigChangeRequired(deviceType, current, target)
|
||||
steps.push({
|
||||
action: 'setBootConfig'
|
||||
target
|
||||
})
|
||||
return if !_.isEmpty(steps)
|
||||
if @rebootRequired
|
||||
steps.push({
|
||||
action: 'reboot'
|
||||
})
|
||||
return
|
||||
.then ->
|
||||
needsWait = !_.isEmpty(steps)
|
||||
filteredSteps = _.filter steps, (step) ->
|
||||
!_.find(stepsInProgress, (stepInProgress) -> _.isEqual(stepInProgress, step))?
|
||||
|
||||
if _.isEmpty(filteredSteps) and needsWait
|
||||
return [{ action: 'noop' }]
|
||||
else return filteredSteps
|
||||
|
||||
executeStepAction: (step) =>
|
||||
switch step.action
|
||||
when 'changeConfig'
|
||||
@logger.logConfigChange(step.humanReadableTarget)
|
||||
@config.set(step.target)
|
||||
.then =>
|
||||
@logger.logConfigChange(step.humanReadableTarget, { success: true })
|
||||
.catch (err) =>
|
||||
@logger.logConfigChange(step.humanReadableTarget, { err })
|
||||
throw err
|
||||
when 'setLogToDisplay'
|
||||
@setLogToDisplay(step.target)
|
||||
when 'setBootConfig'
|
||||
@config.get('deviceType')
|
||||
.then (deviceType) =>
|
||||
@setBootConfig(deviceType, step.target)
|
||||
|
||||
envToBootConfig: (env) ->
|
||||
# We ensure env doesn't have garbage
|
||||
parsedEnv = _.pickBy env, (val, key) ->
|
||||
return _.startsWith(key, bootConfigEnvVarPrefix)
|
||||
parsedEnv = _.mapKeys parsedEnv, (val, key) ->
|
||||
key.replace(configRegex(), '$2')
|
||||
parsedEnv = _.mapValues parsedEnv, (val, key) ->
|
||||
if _.includes(arrayConfigKeys, key)
|
||||
return JSON.parse("[#{val}]")
|
||||
else
|
||||
return val
|
||||
return parsedEnv
|
||||
|
||||
bootConfigToEnv: (config) ->
|
||||
confWithEnvKeys = _.mapKeys config, (val, key) ->
|
||||
return bootConfigEnvVarPrefix + key
|
||||
return _.mapValues confWithEnvKeys, (val, key) ->
|
||||
if _.isArray(val)
|
||||
return JSON.stringify(val).replace(/^\[(.*)\]$/, '$1')
|
||||
else
|
||||
return val
|
||||
|
||||
readBootConfig: ->
|
||||
fs.readFileAsync(bootConfigPath, 'utf8')
|
||||
|
||||
getBootConfig: (deviceType) =>
|
||||
Promise.try =>
|
||||
return {} if !_.startsWith(deviceType, 'raspberry')
|
||||
@readBootConfig()
|
||||
.then (configTxt) =>
|
||||
conf = {}
|
||||
configStatements = configTxt.split(/\r?\n/)
|
||||
_.forEach configStatements, (configStr) ->
|
||||
keyValue = /^([^#=]+)=(.+)/.exec(configStr)
|
||||
if keyValue?
|
||||
if !_.includes(arrayConfigKeys, keyValue[1])
|
||||
conf[keyValue[1]] = keyValue[2]
|
||||
return
|
||||
else
|
||||
conf[keyValue[1]] ?= []
|
||||
conf[keyValue[1]].push(keyValue[2])
|
||||
return
|
||||
keyValue = /^(initramfs) (.+)/.exec(configStr)
|
||||
if keyValue?
|
||||
conf[keyValue[1]] = keyValue[2]
|
||||
return
|
||||
return @bootConfigToEnv(conf)
|
||||
|
||||
getLogToDisplay: ->
|
||||
gosuper.get('/v1/log-to-display', { json: true })
|
||||
.spread (res, body) ->
|
||||
return undefined if res.statusCode == 404
|
||||
throw new Error("Error getting log to display status: #{res.statusCode} #{body.Error}") if res.statusCode != 200
|
||||
return Boolean(body.Data)
|
||||
|
||||
setLogToDisplay: (val) =>
|
||||
Promise.try =>
|
||||
enable = checkTruthy(val)
|
||||
if !enable?
|
||||
throw new Error("Invalid value in call to setLogToDisplay: #{val}")
|
||||
gosuper.post('/v1/log-to-display', { json: true, body: Enable: enable })
|
||||
.spread (response, body) =>
|
||||
if response.statusCode != 200
|
||||
throw new Error("#{response.statusCode} #{body.Error}")
|
||||
else
|
||||
if body.Data == true
|
||||
@logger.logSystemMessage("#{if enable then 'Enabled' else 'Disabled'} logs to display")
|
||||
@rebootRequired = true
|
||||
return body.Data
|
||||
.catch (err) =>
|
||||
@logger.logSystemMessage("Error setting log to display: #{err}", { error: err }, 'Set log to display error')
|
||||
throw err
|
||||
|
||||
setBootConfig: (deviceType, target) =>
|
||||
Promise.try =>
|
||||
conf = @envToBootConfig(target)
|
||||
return false if !_.startsWith(deviceType, 'raspberry')
|
||||
@logger.logSystemMessage("Applying boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config in progress')
|
||||
configStatements = []
|
||||
_.forEach conf, (val, key) ->
|
||||
if key is 'initramfs'
|
||||
configStatements.push("#{key} #{val}")
|
||||
else if _.isArray(val)
|
||||
configStatements = configStatements.concat _.map val, (entry) ->
|
||||
return "#{key}=#{entry}"
|
||||
else
|
||||
configStatements.push("#{key}=#{val}")
|
||||
|
||||
# Here's the dangerous part:
|
||||
childProcess.execAsync("mount -t vfat -o remount,rw #{bootBlockDevice} #{bootMountPoint}")
|
||||
.then ->
|
||||
fsUtils.writeFileAtomic(bootConfigPath, configStatements.join('\n') + '\n')
|
||||
.then =>
|
||||
@logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success')
|
||||
@rebootRequired = true
|
||||
return true
|
||||
.catch (err) =>
|
||||
@logger.logSystemMessage("Error setting boot config: #{err}", { error: err }, 'Apply boot config error')
|
||||
throw err
|
||||
|
||||
getVPNEnabled: ->
|
||||
gosuper.get('/v1/vpncontrol', { json: true })
|
||||
.spread (res, body) ->
|
||||
throw new Error("Error getting vpn status: #{res.statusCode} #{body.Error}") if res.statusCode != 200
|
||||
return Boolean(body.Data)
|
||||
|
||||
setVPNEnabled: (val) ->
|
||||
enable = checkTruthy(val) ? true
|
||||
gosuper.post('/v1/vpncontrol', { json: true, body: Enable: enable })
|
||||
.spread (response, body) ->
|
||||
if response.statusCode == 202
|
||||
console.log('VPN enabled: ' + enable)
|
||||
else
|
||||
console.log('Error: ' + body + ' response:' + response.statusCode)
|
||||
|
Loading…
Reference in New Issue
Block a user