Move boot config related code to config-utils module

This commit abstracts all of the boot config code out of the
device-config module, ready to extend with different config backends.

Change-type: patch
Signed-off-by: Cameron Diver <cameron@resin.io>
This commit is contained in:
Cameron Diver 2018-06-05 14:35:54 +01:00
parent 2b9d82e731
commit dc59c83409
No known key found for this signature in database
GPG Key ID: 69264F9C923F55C1
6 changed files with 246 additions and 95 deletions

View File

@ -17,7 +17,9 @@
},
"dependencies": {
"@types/lodash": "^4.14.108",
"@types/mz": "0.0.32",
"mkfifo": "^0.1.5",
"mz": "^2.7.0",
"sqlite3": "^3.1.0"
},
"engines": {

View File

@ -1,20 +1,12 @@
Promise = require 'bluebird'
_ = require 'lodash'
childProcess = Promise.promisifyAll(require('child_process'))
fs = Promise.promisifyAll(require('fs'))
constants = require './lib/constants'
systemd = require './lib/systemd'
fsUtils = require './lib/fs-utils'
{ checkTruthy, checkInt } = require './lib/validation'
{ UnitNotLoadedError } = require './lib/errors'
configUtils = require './lib/config-utils'
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'
@ -32,13 +24,9 @@ forbiddenConfigKeys = [
'boot_delay_ms'
'avoid_safe_mode'
]
arrayConfigKeys = [ 'dtparam', 'dtoverlay', 'device_tree_param', 'device_tree_overlay' ]
vpnServiceName = 'openvpn-resin'
isRPiDeviceType = (deviceType) ->
_.startsWith(deviceType, 'raspberry') or deviceType == 'fincm3'
module.exports = class DeviceConfig
constructor: ({ @db, @config, @logger }) ->
@rebootRequired = false
@ -89,16 +77,15 @@ module.exports = class DeviceConfig
}
db('deviceConfig').update(confToUpdate)
filterConfigKeys: (conf) =>
_.pickBy conf, (v, k) =>
_.includes(@validKeys, k) or _.startsWith(k, bootConfigEnvVarPrefix)
getTarget: ({ initial = false } = {}) =>
@db.models('deviceConfig').select('targetValues')
.then ([ devConfig ]) ->
return JSON.parse(devConfig.targetValues)
.then (conf) =>
conf = @filterConfigKeys(conf)
.then ([ devConfig ]) =>
return Promise.all [
JSON.parse(devConfig.targetValues)
@config.get('deviceType')
]
.then ([ conf, deviceType ]) =>
conf = configUtils.filterConfigKeys(deviceType, @validKeys, conf)
if initial or !conf.RESIN_SUPERVISOR_VPN_CONTROL?
conf.RESIN_SUPERVISOR_VPN_CONTROL = 'true'
for own k, { envVarName, defaultValue } of @configKeys
@ -127,8 +114,8 @@ module.exports = class DeviceConfig
}, _.mapValues(_.mapKeys(@configKeys, 'envVarName'), 'defaultValue'))
bootConfigChangeRequired: (deviceType, current, target) =>
targetBootConfig = @envToBootConfig(target)
currentBootConfig = @envToBootConfig(current)
targetBootConfig = configUtils.envToBootConfig(deviceType, target)
currentBootConfig = configUtils.envToBootConfig(deviceType, current)
if !_.isEqual(currentBootConfig, targetBootConfig)
for key in forbiddenConfigKeys
if currentBootConfig[key] != targetBootConfig[key]
@ -190,75 +177,27 @@ module.exports = class DeviceConfig
executeStepAction: (step, opts) =>
@actionExecutors[step.action](step, opts)
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)
if !_.startsWith(val, '"')
return [ val ]
else
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')
readBootConfig: (deviceType) ->
fs.readFileAsync(configUtils.getBootConfigPath(deviceType), 'utf8')
getBootConfig: (deviceType) =>
Promise.try =>
if !isRPiDeviceType(deviceType)
if !configUtils.isConfigDeviceType(deviceType)
return {}
@readBootConfig()
.then (configTxt) =>
conf = {}
configStatements = configTxt.split(/\r?\n/)
for configStr in configStatements
keyValue = /^([^#=]+)=(.+)/.exec(configStr)
if keyValue?
if !_.includes(arrayConfigKeys, keyValue[1])
conf[keyValue[1]] = keyValue[2]
else
conf[keyValue[1]] ?= []
conf[keyValue[1]].push(keyValue[2])
else
keyValue = /^(initramfs) (.+)/.exec(configStr)
if keyValue?
conf[keyValue[1]] = keyValue[2]
return @bootConfigToEnv(conf)
@readBootConfig(deviceType)
.then (configTxt) ->
config = configUtils.parseBootConfig(deviceType, configTxt)
return configUtils.bootConfigToEnv(deviceType, config)
setBootConfig: (deviceType, target) =>
Promise.try =>
conf = @envToBootConfig(target)
if !isRPiDeviceType(deviceType)
if !configUtils.isConfigDeviceType(deviceType)
return false
conf = configUtils.envToBootConfig(deviceType, target)
@logger.logSystemMessage("Applying boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config in progress')
configStatements = []
for own key, val of conf
if key is 'initramfs'
configStatements.push("#{key} #{val}")
else if _.isArray(val)
configStatements = configStatements.concat(_.map(val, (entry) -> "#{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')
configUtils.setBootConfig(deviceType, conf)
.then =>
@logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success')
@rebootRequired = true

198
src/lib/config-utils.ts Normal file
View File

@ -0,0 +1,198 @@
import * as Promise from 'bluebird';
import * as childProcessSync from 'child_process';
import * as _ from 'lodash';
import * as constants from './constants';
import * as fsUtils from './fs-utils';
import { EnvVarObject } from './types';
const childProcess: any = Promise.promisifyAll(childProcessSync);
export interface ConfigOptions {
[key: string]: string | string[];
}
export const rpiArrayConfigKeys = [
'dtparam',
'dtoverlay',
'device_tree_param',
'device_tree_overlay',
];
export const hostConfigConfigVarPrefix = 'RESIN_HOST_';
export const rPiBootConfigPrefix = hostConfigConfigVarPrefix + 'CONFIG_';
export const rPiConfigRegex = new RegExp('(' + _.escapeRegExp(rPiBootConfigPrefix) + ')(.+)');
export const bootMountPoint = constants.rootMountPoint + constants.bootMountPoint;
export function isRPiDeviceType(deviceType: string): boolean {
return _.startsWith(deviceType, 'raspberry') || deviceType === 'fincm3';
}
export function isConfigDeviceType(deviceType: string): boolean {
return isRPiDeviceType(deviceType);
}
export function bootConfigVarPrefix(deviceType: string): string {
if (isRPiDeviceType(deviceType)) {
return rPiBootConfigPrefix;
} else {
throw new Error(`No boot config var prefix for device type: ${deviceType}`);
}
}
export function bootConfigVarRegex(deviceType: string): RegExp {
if (isRPiDeviceType(deviceType)) {
return rPiConfigRegex;
} else {
throw new Error(`No boot config var regex for device type: ${deviceType}`);
}
}
export function envToBootConfig(deviceType: string, env: EnvVarObject): ConfigOptions {
if (!isConfigDeviceType(deviceType)) {
return { };
}
const prefix = bootConfigVarPrefix(deviceType);
const regex = bootConfigVarRegex(deviceType);
let parsedEnv = _.pickBy(env, (_val: string, key: string) => {
return _.startsWith(key, prefix);
});
parsedEnv = _.mapKeys(parsedEnv, (_val, key) => {
return key.replace(regex, '$2');
});
parsedEnv = _.mapValues(parsedEnv, (val, key) => {
if (isRPiDeviceType(deviceType)) {
if (_.includes(rpiArrayConfigKeys, key)) {
if (!_.startsWith(val, '"')) {
return [ val ];
} else {
return JSON.parse(`[${val}]`);
}
}
}
return val;
});
return parsedEnv as ConfigOptions;
}
export function bootConfigToEnv(deviceType: string, config: ConfigOptions): EnvVarObject {
const prefix = bootConfigVarPrefix(deviceType);
const confWithEnvKeys = _.mapKeys(config, (_val, key) => {
return prefix + key;
});
return _.mapValues(confWithEnvKeys, (val) => {
if (_.isArray(val)) {
return JSON.stringify(val).replace(/^\[(.*)\]$/, '$1');
}
return val;
});
}
export function filterConfigKeys(
deviceType: string,
allowedKeys: string[],
conf: { [key: string]: any },
): { [key: string]: any } {
let isConfigType: boolean = false;
let prefix: string;
if (isConfigDeviceType(deviceType)) {
prefix = bootConfigVarPrefix(deviceType);
isConfigType = true;
}
return _.pickBy(conf, (_v, k) => {
return _.includes(allowedKeys, k) || (isConfigType && _.startsWith(k, prefix));
});
}
export function getBootConfigPath(deviceType: string): string {
if (isRPiDeviceType(deviceType)) {
return getRpiBootConfig();
} else {
throw new Error(`No boot config exists for device type: ${deviceType}`);
}
}
function getRpiBootConfig(): string {
return bootMountPoint + '/config.txt';
}
export function parseBootConfig(deviceType: string, conf: string): ConfigOptions {
if (isRPiDeviceType(deviceType)) {
return parseRpiBootConfig(conf);
} else {
throw new Error(`Cannot parse boot config for device type: ${deviceType}`);
}
}
export function parseRpiBootConfig(confStr: string): ConfigOptions {
const conf: ConfigOptions = { };
const configStatements = confStr.split(/\r?\n/);
for (const configStr of configStatements) {
// Don't show warnings for comments and empty lines
const trimmed = _.trimStart(configStr);
if (_.startsWith(trimmed, '#') || trimmed === '') {
continue;
}
let keyValue = /^([^#=]+)=(.+)/.exec(configStr);
if (keyValue != null) {
if (!_.includes(rpiArrayConfigKeys, keyValue[1])) {
conf[keyValue[1]] = keyValue[2];
} else {
const key = keyValue[1];
if (conf[key] == null) {
conf[key] = [];
}
(conf[key] as string[]).push(keyValue[2]);
}
} else {
keyValue = /^(initramfs) (.+)/.exec(configStr);
if (keyValue != null) {
conf[keyValue[1]] = keyValue[2];
} else {
console.log(`Warning - Could not parse config.txt entry: ${configStr}. Ignoring.`);
}
}
}
return conf;
}
export function setBootConfig(deviceType: string, target: ConfigOptions): Promise<void> {
if (isRPiDeviceType(deviceType)) {
return setRpiBootConfig(target);
} else {
throw new Error(`Could not set boot config for non-boot config device: ${deviceType}`);
}
}
function setRpiBootConfig(target: ConfigOptions): Promise<void> {
let confStatements: string[] = [];
_.each(target, (value, key) => {
if (key === 'initramfs') {
confStatements.push(`${key} ${value}`);
} else if(_.isArray(value)) {
confStatements = confStatements.concat(_.map(value, (entry) => `${key}=${entry}`));
} else {
confStatements.push(`${key}=${value}`);
}
});
return remountAndWriteAtomic(getRpiBootConfig(), confStatements.join('\n') + '\n');
}
function remountAndWriteAtomic(file: string, data: string): Promise<void> {
// TODO: Find out why the below Promise.resolve() is required
// Here's the dangerous part:
return Promise.resolve(childProcess.execAsync(`mount -t vfat -o remount,rw ${constants.bootBlockDevice} ${bootMountPoint}`))
.then(() => {
return fsUtils.writeFileAtomic(file, data);
})
.return();
}

View File

@ -39,6 +39,7 @@ const constants = {
defaultVolumeLabels: {
'io.resin.supervised': 'true',
},
bootBlockDevice: '/dev/mmcblk0p1',
};
if (process.env.DOCKER_HOST == null) {

View File

@ -54,19 +54,6 @@ describe 'DeviceConfig', ->
RESIN_HOST_CONFIG_gpu_mem: '16'
})
it 'correctly transforms environments to boot config objects', ->
bootConfig = @deviceConfig.envToBootConfig({
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"'
RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
RESIN_HOST_CONFIG_foobar: 'baz'
})
expect(bootConfig).to.deep.equal({
initramfs: 'initramf.gz 0x00800000'
dtparam: [ 'i2c=on', 'audio=on' ]
dtoverlay: [ 'ads7846', 'lirc-rpi,gpio_out_pin=17,gpio_in_pin=13' ]
foobar: 'baz'
})
# Test that the format for special values like initramfs and array variables is preserved
it 'does not allow setting forbidden keys', ->
current = {

View File

@ -0,0 +1,24 @@
m = require 'mochainon'
{ expect } = m.chai
configUtils = require '../src/lib/config-utils'
describe 'Config Utilities', ->
describe 'Boot config utilities', ->
describe 'Env <-> Config', ->
it 'correctly transforms environments to boot config objects', ->
bootConfig = configUtils.envToBootConfig('raspberry-pi', {
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"'
RESIN_HOST_CONFIG_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
RESIN_HOST_CONFIG_foobar: 'baz'
})
expect(bootConfig).to.deep.equal({
initramfs: 'initramf.gz 0x00800000'
dtparam: [ 'i2c=on', 'audio=on' ]
dtoverlay: [ 'ads7846', 'lirc-rpi,gpio_out_pin=17,gpio_in_pin=13' ]
foobar: 'baz'
})