mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-19 03:06:27 +00:00
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:
parent
2b9d82e731
commit
dc59c83409
@ -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": {
|
||||
|
@ -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
198
src/lib/config-utils.ts
Normal 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();
|
||||
}
|
@ -39,6 +39,7 @@ const constants = {
|
||||
defaultVolumeLabels: {
|
||||
'io.resin.supervised': 'true',
|
||||
},
|
||||
bootBlockDevice: '/dev/mmcblk0p1',
|
||||
};
|
||||
|
||||
if (process.env.DOCKER_HOST == null) {
|
||||
|
@ -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 = {
|
||||
|
24
test/16-config-utils.spec.coffee
Normal file
24
test/16-config-utils.spec.coffee
Normal 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'
|
||||
})
|
Loading…
Reference in New Issue
Block a user