Move config backend code out to classes which implement a common base

Change-type: patch
Signed-off-by: Cameron Diver <cameron@resin.io>
This commit is contained in:
Cameron Diver
2018-06-06 12:33:37 +01:00
parent 89627a3392
commit 1e48d02781
7 changed files with 498 additions and 421 deletions

View File

@ -1,30 +1,11 @@
Promise = require 'bluebird' Promise = require 'bluebird'
_ = require 'lodash' _ = require 'lodash'
fs = Promise.promisifyAll(require('fs'))
systemd = require './lib/systemd' systemd = require './lib/systemd'
{ checkTruthy, checkInt } = require './lib/validation' { checkTruthy, checkInt } = require './lib/validation'
{ UnitNotLoadedError } = require './lib/errors' { UnitNotLoadedError } = require './lib/errors'
configUtils = require './lib/config-utils' configUtils = require './lib/config-utils'
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'
]
vpnServiceName = 'openvpn-resin' vpnServiceName = 'openvpn-resin'
module.exports = class DeviceConfig module.exports = class DeviceConfig
@ -64,11 +45,20 @@ module.exports = class DeviceConfig
.tapCatch (err) => .tapCatch (err) =>
@logger.logConfigChange(logValue, { err }) @logger.logConfigChange(logValue, { err })
setBootConfig: (step) => setBootConfig: (step) =>
@config.get('deviceType') @getConfigBackend()
.then (deviceType) => .then (configBackend ) =>
@setBootConfig(deviceType, step.target) @setBootConfig(configBackend, step.target)
} }
@validActions = _.keys(@actionExecutors) @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) => setTarget: (target, trx) =>
db = trx ? @db.models db = trx ? @db.models
@ -82,10 +72,10 @@ module.exports = class DeviceConfig
.then ([ devConfig ]) => .then ([ devConfig ]) =>
return Promise.all [ return Promise.all [
JSON.parse(devConfig.targetValues) JSON.parse(devConfig.targetValues)
@config.get('deviceType') @getConfigBackend()
] ]
.then ([ conf, deviceType ]) => .then ([ conf, configBackend ]) =>
conf = configUtils.filterConfigKeys(deviceType, @validKeys, conf) conf = configUtils.filterConfigKeys(configBackend, @validKeys, conf)
if initial or !conf.RESIN_SUPERVISOR_VPN_CONTROL? if initial or !conf.RESIN_SUPERVISOR_VPN_CONTROL?
conf.RESIN_SUPERVISOR_VPN_CONTROL = 'true' conf.RESIN_SUPERVISOR_VPN_CONTROL = 'true'
for own k, { envVarName, defaultValue } of @configKeys for own k, { envVarName, defaultValue } of @configKeys
@ -93,11 +83,14 @@ module.exports = class DeviceConfig
return conf return conf
getCurrent: => getCurrent: =>
@config.getMany([ 'deviceType' ].concat(_.keys(@configKeys))) Promise.all [
.then (conf) => @config.getMany([ 'deviceType' ].concat(_.keys(@configKeys)))
@getConfigBackend()
]
.then ([ conf, configBackend ]) =>
Promise.join( Promise.join(
@getVPNEnabled() @getVPNEnabled()
@getBootConfig(conf.deviceType) @getBootConfig(configBackend)
(vpnStatus, bootConfig) => (vpnStatus, bootConfig) =>
currentConf = { currentConf = {
RESIN_SUPERVISOR_VPN_CONTROL: (vpnStatus ? 'true').toString() RESIN_SUPERVISOR_VPN_CONTROL: (vpnStatus ? 'true').toString()
@ -113,15 +106,17 @@ module.exports = class DeviceConfig
RESIN_SUPERVISOR_VPN_CONTROL: 'true' RESIN_SUPERVISOR_VPN_CONTROL: 'true'
}, _.mapValues(_.mapKeys(@configKeys, 'envVarName'), 'defaultValue')) }, _.mapValues(_.mapKeys(@configKeys, 'envVarName'), 'defaultValue'))
bootConfigChangeRequired: (deviceType, current, target) => bootConfigChangeRequired: (configBackend, current, target) =>
targetBootConfig = configUtils.envToBootConfig(deviceType, target) targetBootConfig = configUtils.envToBootConfig(configBackend, target)
currentBootConfig = configUtils.envToBootConfig(deviceType, current) currentBootConfig = configUtils.envToBootConfig(configBackend, current)
if !_.isEqual(currentBootConfig, targetBootConfig) if !_.isEqual(currentBootConfig, targetBootConfig)
for key in forbiddenConfigKeys _.each targetBootConfig, (value, key) =>
if currentBootConfig[key] != targetBootConfig[key] if not configBackend.isSupportedConfig(key)
err = "Attempt to change blacklisted config value #{key}" if currentBootConfig[key] != value
@logger.logSystemMessage(err, { error: err }, 'Apply boot config error') err = "Attempt to change blacklisted config value #{key}"
throw new Error(err) @logger.logSystemMessage(err, { error: err }, 'Apply boot config error')
throw new Error(err)
return true return true
return false return false
@ -129,8 +124,11 @@ module.exports = class DeviceConfig
current = _.clone(currentState.local?.config ? {}) current = _.clone(currentState.local?.config ? {})
target = _.clone(targetState.local?.config ? {}) target = _.clone(targetState.local?.config ? {})
steps = [] steps = []
@config.getMany([ 'deviceType', 'offlineMode' ]) Promise.all [
.then ({ deviceType, offlineMode }) => @config.getMany([ 'deviceType', 'offlineMode' ])
@getConfigBackend()
]
.then ([{ deviceType, offlineMode }, configBackend ]) =>
configChanges = {} configChanges = {}
humanReadableConfigChanges = {} humanReadableConfigChanges = {}
match = { match = {
@ -160,7 +158,7 @@ module.exports = class DeviceConfig
action: 'setVPNEnabled' action: 'setVPNEnabled'
target: target['RESIN_SUPERVISOR_VPN_CONTROL'] target: target['RESIN_SUPERVISOR_VPN_CONTROL']
}) })
if @bootConfigChangeRequired(deviceType, current, target) if @bootConfigChangeRequired(configBackend, current, target)
steps.push({ steps.push({
action: 'setBootConfig' action: 'setBootConfig'
target target
@ -177,27 +175,22 @@ module.exports = class DeviceConfig
executeStepAction: (step, opts) => executeStepAction: (step, opts) =>
@actionExecutors[step.action](step, opts) @actionExecutors[step.action](step, opts)
readBootConfig: (deviceType) -> getBootConfig: (configBackend) ->
fs.readFileAsync(configUtils.getBootConfigPath(deviceType), 'utf8') Promise.try ->
if !configBackend?
getBootConfig: (deviceType) =>
Promise.try =>
if !configUtils.isConfigDeviceType(deviceType)
return {} return {}
@readBootConfig(deviceType) configBackend.getBootConfig()
.then (configTxt) -> .then (config) ->
return configUtils.bootConfigToEnv(configBackend, config)
config = configUtils.parseBootConfig(deviceType, configTxt) setBootConfig: (configBackend, target) =>
return configUtils.bootConfigToEnv(deviceType, config)
setBootConfig: (deviceType, target) =>
Promise.try => Promise.try =>
if !configUtils.isConfigDeviceType(deviceType) if !configBackend?
return false return false
conf = configUtils.envToBootConfig(deviceType, target) conf = configUtils.envToBootConfig(configBackend, target)
@logger.logSystemMessage("Applying boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config in progress') @logger.logSystemMessage("Applying boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config in progress')
configUtils.setBootConfig(deviceType, conf) configBackend.setBootConfig(conf)
.then => .then =>
@logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success') @logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success')
@rebootRequired = true @rebootRequired = true

371
src/lib/config-backend.ts Normal file
View File

@ -0,0 +1,371 @@
import * as Promise from 'bluebird';
import * as childProcessSync from 'child_process';
import * as _ from 'lodash';
import { fs } from 'mz';
import * as constants from './constants';
import * as fsUtils from './fs-utils';
const childProcess: any = Promise.promisifyAll(childProcessSync);
export interface ConfigOptions {
[key: string]: string | string[];
}
interface ExtLinuxFile {
labels: {
[labelName: string]: {
[directive: string]: string;
};
};
globals: { [directive: string]: string };
}
const bootMountPoint = constants.rootMountPoint + constants.bootMountPoint;
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();
}
export abstract class DeviceConfigBackend {
// Does this config backend support the given device type?
public abstract matches(deviceType: string): boolean;
// A function which reads and parses the configuration options from
// specific boot config
public abstract getBootConfig(): Promise<ConfigOptions>;
// A function to take a set of options and flush to the configuration
// file/backend
public abstract setBootConfig(opts: ConfigOptions): Promise<void>;
// Is the configuration option provided supported by this configuration
// backend
public abstract isSupportedConfig(configName: string): boolean;
// Is this variable a boot config variable for this backend?
public abstract isBootConfigVar(envVar: string): boolean;
// Convert a configuration environment variable to a config backend
// variable
public abstract processConfigVarName(envVar: string): string;
// Process the value if the environment variable, ready to be written to
// the backend
public abstract processConfigVarValue(key: string, value: string): string | string[];
// Return the env var name for this config option
public abstract createConfigVarName(configName: string): string;
}
export class RPiConfigBackend extends DeviceConfigBackend {
private static bootConfigVarPrefix = `${constants.hostConfigVarPrefix}CONFIG_`;
private static bootConfigPath = `${bootMountPoint}/config.txt`;
public static bootConfigVarRegex = new RegExp('(' + _.escapeRegExp(RPiConfigBackend.bootConfigVarPrefix) + ')(.+)');
private static arrayConfigKeys = [
'dtparam',
'dtoverlay',
'device_tree_param',
'device_tree_overlay',
];
private static 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',
];
public matches(deviceType: string): boolean {
return _.startsWith(deviceType, 'raspberry') || deviceType === 'fincm3';
}
public getBootConfig(): Promise<ConfigOptions> {
return Promise.resolve(fs.readFile(RPiConfigBackend.bootConfigPath, 'utf-8'))
.then((confStr) => {
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) {
const [ , key, value ] = keyValue;
if (!_.includes(RPiConfigBackend.arrayConfigKeys, key)) {
conf[key] = value;
} else {
if (conf[key] == null) {
conf[key] = [];
}
(conf[key] as string[]).push(value);
}
continue;
}
// Try the next regex instead
keyValue = /^(initramfs) (.+)/.exec(configStr);
if (keyValue != null) {
const [ , key, value ] = keyValue;
conf[key] = value;
} else {
console.log(`Warning - Could not parse config.txt entry: ${configStr}. Ignoring.`);
}
}
return conf;
});
}
public setBootConfig(opts: ConfigOptions): Promise<void> {
let confStatements: string[] = [];
_.each(opts, (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}`);
}
});
const confStr = confStatements.join('\n') + '\n';
return remountAndWriteAtomic(RPiConfigBackend.bootConfigPath, confStr);
}
public isSupportedConfig(configName: string): boolean {
return !_.includes(RPiConfigBackend.forbiddenConfigKeys, configName);
}
public isBootConfigVar(envVar: string): boolean {
return _.startsWith(envVar, RPiConfigBackend.bootConfigVarPrefix);
}
public processConfigVarName(envVar: string): string {
return envVar.replace(RPiConfigBackend.bootConfigVarRegex, '$2');
}
public processConfigVarValue(key: string, value: string): string | string[] {
if (_.includes(RPiConfigBackend.arrayConfigKeys, key)) {
if (!_.startsWith(value, '"')) {
return [ value ];
} else {
return JSON.parse(`[${value}]`);
}
}
return value;
}
public createConfigVarName(configName: string): string {
return RPiConfigBackend.bootConfigVarPrefix + configName;
}
}
export class ExtlinuxConfigBackend extends DeviceConfigBackend {
private static bootConfigVarPrefix = `${constants.hostConfigVarPrefix}EXTLINUX_`;
private static bootConfigPath = `${bootMountPoint}/extlinux/extlinux.conf`;
public static bootConfigVarRegex = new RegExp('(' + _.escapeRegExp(ExtlinuxConfigBackend.bootConfigVarPrefix) + ')(.+)');
private static suppportedConfigKeys = [
'isolcpus',
];
public matches(deviceType: string): boolean {
return _.startsWith(deviceType, 'jetson-tx');
}
public getBootConfig(): Promise<ConfigOptions> {
return Promise.resolve(fs.readFile(ExtlinuxConfigBackend.bootConfigPath, 'utf-8'))
.then((confStr) => {
const parsedBootFile = ExtlinuxConfigBackend.parseExtlinuxFile(confStr);
// First find the default label name
const defaultLabel = _.find(parsedBootFile.globals, (_v, l) => {
if (l === 'DEFAULT') {
return true;
}
return false;
});
if (defaultLabel == null) {
throw new Error('Could not find default entry for extlinux.conf file');
}
const labelEntry = parsedBootFile.labels[defaultLabel];
if (labelEntry == null) {
throw new Error(`Cannot find default label entry (label: ${defaultLabel}) for extlinux.conf file`);
}
// All configuration options come from the `APPEND` directive in the default label entry
const appendEntry = labelEntry.APPEND;
if (appendEntry == null) {
throw new Error('Could not find APPEND directive in default extlinux.conf boot entry');
}
const conf: ConfigOptions = { };
const values = appendEntry.split(' ');
for(const value of values) {
const parts = value.split('=');
if (this.isSupportedConfig(parts[0])) {
if (parts.length !== 2) {
throw new Error(`Could not parse extlinux configuration entry: ${values} [value with error: ${value}]`);
}
conf[parts[0]] = parts[1];
}
}
return conf;
});
}
public setBootConfig(opts: ConfigOptions): Promise<void> {
// First get a representation of the configuration file, with all resin-supported configuration removed
return Promise.resolve(fs.readFile(ExtlinuxConfigBackend.bootConfigPath))
.then((data) => {
const extlinuxFile = ExtlinuxConfigBackend.parseExtlinuxFile(data.toString());
const defaultLabel = extlinuxFile.globals.DEFAULT;
if (defaultLabel == null) {
throw new Error('Could not find DEFAULT directive entry in extlinux.conf');
}
const defaultEntry = extlinuxFile.labels[defaultLabel];
if (defaultEntry == null) {
throw new Error(`Could not find default extlinux.conf entry: ${defaultLabel}`);
}
if (defaultEntry.APPEND == null) {
throw new Error(`extlinux.conf APPEND directive not found for default entry: ${defaultLabel}, not sure how to proceed!`);
}
const appendLine = _.filter(defaultEntry.APPEND.split(' '), (entry) => {
const lhs = entry.split('=');
return !this.isSupportedConfig(lhs[0]);
});
// Apply the new configuration to the "plain" append line above
_.each(opts, (value, key) => {
appendLine.push(`${key}=${value}`);
});
defaultEntry.APPEND = appendLine.join(' ');
const extlinuxString = ExtlinuxConfigBackend.extlinuxFileToString(extlinuxFile);
return remountAndWriteAtomic(ExtlinuxConfigBackend.bootConfigPath, extlinuxString);
});
}
public isSupportedConfig(configName: string): boolean {
return _.includes(ExtlinuxConfigBackend.suppportedConfigKeys, configName);
}
public isBootConfigVar(envVar: string): boolean {
return _.startsWith(envVar, ExtlinuxConfigBackend.bootConfigVarPrefix);
}
public processConfigVarName(envVar: string): string {
return envVar.replace(ExtlinuxConfigBackend.bootConfigVarRegex, '$2');
}
public processConfigVarValue(_key: string, value: string): string {
return value;
}
public createConfigVarName(configName: string): string {
return ExtlinuxConfigBackend.bootConfigVarPrefix + configName;
}
private static parseExtlinuxFile(confStr: string): ExtLinuxFile {
const file: ExtLinuxFile = {
globals: { },
labels: { },
};
// Firstly split by line and filter any comments and empty lines
let lines = confStr.split(/\r?\n/);
lines = _.filter(lines, (l) => {
const trimmed = _.trimStart(l);
return trimmed !== '' && !_.startsWith(trimmed, '#');
});
let lastLabel = '';
for (const line of lines) {
const match = line.match(/^\s*(\w+)\s?(.*)$/);
if (match == null) {
console.log('Warning - Could not read extlinux entry: ${line}');
continue;
}
let directive = match[1].toUpperCase();
let value = match[2];
// Special handling for the MENU directive
if (directive === 'MENU') {
const parts = value.split(' ');
directive = 'MENU ' + parts[0];
value = parts.slice(1).join(' ');
}
if (directive !== 'LABEL') {
if (lastLabel === '') {
// Global options
file.globals[directive] = value;
} else {
// Label specific options
file.labels[lastLabel][directive] = value;
}
} else {
lastLabel = value;
file.labels[lastLabel] = { };
}
}
return file;
}
private static extlinuxFileToString(file: ExtLinuxFile): string {
let ret = '';
_.each(file.globals, (value, directive) => {
ret += `${directive} ${value}\n`;
});
_.each(file.labels, (directives, key) => {
ret += `LABEL ${key}\n`;
_.each(directives, (value, directive) => {
ret += `${directive} ${value}\n`;
});
});
return ret;
}
}

View File

@ -1,376 +1,68 @@
import * as Promise from 'bluebird';
import * as childProcessSync from 'child_process';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { fs } from 'mz';
import * as constants from './constants'; import {
import * as fsUtils from './fs-utils'; ConfigOptions,
DeviceConfigBackend,
ExtlinuxConfigBackend,
RPiConfigBackend,
} from './config-backend';
import { EnvVarObject } from './types'; import { EnvVarObject } from './types';
const childProcess: any = Promise.promisifyAll(childProcessSync);
export interface ConfigOptions { const configBackends = [
[key: string]: string | string[]; new ExtlinuxConfigBackend(),
} new RPiConfigBackend(),
export interface ExtLinuxFile {
labels: {
[labelName: string]: {
[directive: string]: string;
};
};
globals: { [directive: string]: string };
}
export const rpiArrayConfigKeys = [
'dtparam',
'dtoverlay',
'device_tree_param',
'device_tree_overlay',
]; ];
export const extlinuxSupportedConfig = [
'isolcpus',
];
export const hostConfigConfigVarPrefix = 'RESIN_HOST_';
export const rPiBootConfigPrefix = hostConfigConfigVarPrefix + 'CONFIG_';
export const extlinuxBootConfigPrefix = hostConfigConfigVarPrefix + 'EXTLINUX_';
export const rPiConfigRegex = new RegExp('(' + _.escapeRegExp(rPiBootConfigPrefix) + ')(.+)');
export const extlinuxConfigRegex = new RegExp('(' + _.escapeRegExp(extlinuxBootConfigPrefix) + ')(.+)');
export const bootMountPoint = constants.rootMountPoint + constants.bootMountPoint;
export function isRPiDeviceType(deviceType: string): boolean {
return _.startsWith(deviceType, 'raspberry') || deviceType === 'fincm3';
}
export function isExtlinuxDeviceType(deviceType: string): boolean {
return _.startsWith(deviceType, 'jetson-tx');
}
export function isConfigDeviceType(deviceType: string): boolean { export function isConfigDeviceType(deviceType: string): boolean {
return isRPiDeviceType(deviceType) || isExtlinuxDeviceType(deviceType); return getConfigBackend(deviceType) != null;
} }
export function bootConfigVarPrefix(deviceType: string): string { export function getConfigBackend(deviceType: string): DeviceConfigBackend | undefined {
if (isRPiDeviceType(deviceType)) { return _.find(configBackends, (backend) => backend.matches(deviceType));
return rPiBootConfigPrefix;
} else if (isExtlinuxDeviceType(deviceType)) {
return extlinuxBootConfigPrefix;
} else {
throw new Error(`No boot config var prefix for device type: ${deviceType}`);
}
} }
export function bootConfigVarRegex(deviceType: string): RegExp { export function envToBootConfig(
if (isRPiDeviceType(deviceType)) { configBackend: DeviceConfigBackend | null,
return rPiConfigRegex; env: EnvVarObject,
} else if (isExtlinuxDeviceType(deviceType)) { ): ConfigOptions {
return extlinuxConfigRegex;
} else {
throw new Error(`No boot config var regex for device type: ${deviceType}`);
}
}
export function envToBootConfig(deviceType: string, env: EnvVarObject): ConfigOptions { if (configBackend == null) {
if (!isConfigDeviceType(deviceType)) {
return { }; return { };
} }
const prefix = bootConfigVarPrefix(deviceType);
const regex = bootConfigVarRegex(deviceType);
let parsedEnv = _.pickBy(env, (_val: string, key: string) => { return _(env)
return _.startsWith(key, prefix); .pickBy((_val, key) => configBackend.isBootConfigVar(key))
}); .mapKeys((_val, key) => configBackend.processConfigVarName(key))
parsedEnv = _.mapKeys(parsedEnv, (_val, key) => { .mapValues((val, key) => configBackend.processConfigVarValue(key, val || ''))
return key.replace(regex, '$2'); .value();
});
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 { export function bootConfigToEnv(
const prefix = bootConfigVarPrefix(deviceType); configBackend: DeviceConfigBackend,
const confWithEnvKeys = _.mapKeys(config, (_val, key) => { config: ConfigOptions,
return prefix + key; ): EnvVarObject {
});
return _.mapValues(confWithEnvKeys, (val) => { return _(config)
if (_.isArray(val)) { .mapKeys((_val, key) => configBackend.createConfigVarName(key))
return JSON.stringify(val).replace(/^\[(.*)\]$/, '$1'); .mapValues((val) => {
} if (_.isArray(val)) {
return val; return JSON.stringify(val).replace(/^\[(.*)\]$/, '$1');
}); }
return val;
})
.value();
} }
export function filterConfigKeys( export function filterConfigKeys(
deviceType: string, configBackend: DeviceConfigBackend | null,
allowedKeys: string[], allowedKeys: string[],
conf: { [key: string]: any }, conf: { [key: string]: any },
): { [key: string]: any } { ): { [key: string]: any } {
let isConfigType: boolean = false; const isConfigType = configBackend != null;
let prefix: string;
if (isConfigDeviceType(deviceType)) {
prefix = bootConfigVarPrefix(deviceType);
isConfigType = true;
}
return _.pickBy(conf, (_v, k) => { return _.pickBy(conf, (_v, k) => {
return _.includes(allowedKeys, k) || (isConfigType && _.startsWith(k, prefix)); return _.includes(allowedKeys, k) || (isConfigType && configBackend!.isBootConfigVar(k));
}); });
} }
export function getBootConfigPath(deviceType: string): string {
if (isRPiDeviceType(deviceType)) {
return getRpiBootConfig();
} else if (isExtlinuxDeviceType(deviceType)) {
return getExtlinuxBootConfig();
} else {
throw new Error(`No boot config exists for device type: ${deviceType}`);
}
}
function getRpiBootConfig(): string {
return bootMountPoint + '/config.txt';
}
function getExtlinuxBootConfig(): string {
return bootMountPoint + '/extlinux/extlinux.conf';
}
export function parseBootConfig(deviceType: string, conf: string): ConfigOptions {
if (isRPiDeviceType(deviceType)) {
return parseRpiBootConfig(conf);
} else if (isExtlinuxDeviceType(deviceType)) {
return parseExtlinuxBootConfig(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 parseExtlinuxBootConfig(confStr: string): ConfigOptions {
const parsedBootFile = parseExtlinuxFile(confStr);
// First find the default label name
const defaultLabel = _.find(parsedBootFile.globals, (_v, l) => {
if (l === 'DEFAULT') {
return true;
}
return false;
});
if (defaultLabel == null) {
throw new Error('Could not find default entry for extlinux.conf file');
}
const labelEntry = parsedBootFile.labels[defaultLabel];
if (labelEntry == null) {
throw new Error(`Cannot find default label entry (label: ${defaultLabel}) for extlinux.conf file`);
}
// All configuration options come from the `APPEND` directive in the default label entry
const appendEntry = labelEntry.APPEND;
if (appendEntry == null) {
throw new Error('Could not find APPEND directive in default extlinux.conf boot entry');
}
const conf: ConfigOptions = { };
const values = appendEntry.split(' ');
for(const value of values) {
const parts = value.split('=');
if (isSupportedExtlinuxConfig(parts[0])) {
conf[parts[0]] = parts[1];
}
}
return conf;
}
export function parseExtlinuxFile(confStr: string): ExtLinuxFile {
const file: ExtLinuxFile = {
globals: { },
labels: { },
};
// Firstly split by line and filter any comments and empty lines
let lines = confStr.split(/\r?\n/);
lines = _.filter(lines, (l) => {
const trimmed = _.trimStart(l);
return trimmed !== '' && !_.startsWith(trimmed, '#');
});
let lastLabel = '';
for (const line of lines) {
const match = line.match(/^\s*(\w+)\s?(.*)$/);
if (match == null) {
console.log('Warning - Could not read extlinux entry: ${line}');
continue;
}
let directive = match[1].toUpperCase();
let value = match[2];
// Special handling for the MENU directive
if (directive === 'MENU') {
const parts = value.split(' ');
directive = 'MENU ' + parts[0];
value = parts.slice(1).join(' ');
}
if (directive !== 'LABEL') {
if (lastLabel === '') {
// Global options
file.globals[directive] = value;
} else {
// Label specific options
file.labels[lastLabel][directive] = value;
}
} else {
lastLabel = value;
file.labels[lastLabel] = { };
}
}
return file;
}
export function setBootConfig(deviceType: string, target: ConfigOptions): Promise<void> {
if (isRPiDeviceType(deviceType)) {
return setRpiBootConfig(target);
} else if (isExtlinuxDeviceType(deviceType)) {
return setExtlinuxBootConfig(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 setExtlinuxBootConfig(target: ConfigOptions): Promise<void> {
// First get a representation of the configuration file, with all resin-supported configuration removed
return Promise.resolve(fs.readFile(getExtlinuxBootConfig()))
.then((data) => {
const extlinuxFile = parseExtlinuxFile(data.toString());
const defaultLabel = extlinuxFile.globals.DEFAULT;
if (defaultLabel == null) {
throw new Error('Could not find DEFAULT directive entry in extlinux.conf');
}
const defaultEntry = extlinuxFile.labels[defaultLabel];
if (defaultEntry == null) {
throw new Error(`Could not find default extlinux.conf entry: ${defaultLabel}`);
}
if (defaultEntry.APPEND == null) {
throw new Error(`extlinux.conf APPEND directive not found for default entry: ${defaultLabel}, not sure how to proceed!`);
}
const appendLine = _.filter(defaultEntry.APPEND.split(' '), (entry) => {
const lhs = entry.split('=');
return !isSupportedExtlinuxConfig(lhs[0]);
});
// Apply the new configuration to the "plain" append line above
_.each(target, (value, key) => {
appendLine.push(`${key}=${value}`);
});
defaultEntry.APPEND = appendLine.join(' ');
const extlinuxString = extlinuxFileToString(extlinuxFile);
return remountAndWriteAtomic(getExtlinuxBootConfig(), extlinuxString);
});
}
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();
}
function isSupportedExtlinuxConfig(configName: string): boolean {
// Currently the supported configuration values come in the form key=value
return _.includes(extlinuxSupportedConfig, configName);
}
function extlinuxFileToString(file: ExtLinuxFile): string {
let ret = '';
_.each(file.globals, (value, directive) => {
ret += `${directive} ${value}\n`;
});
_.each(file.labels, (directives, key) => {
ret += `LABEL ${key}\n`;
_.each(directives, (value, directive) => {
ret += `${directive} ${value}\n`;
});
});
return ret;
}

View File

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

View File

@ -9,6 +9,7 @@ prepare = require './lib/prepare'
DeviceState = require '../src/device-state' DeviceState = require '../src/device-state'
DB = require('../src/db') DB = require('../src/db')
Config = require('../src/config') Config = require('../src/config')
{ RPiConfigBackend } = require('../src/lib/config-backend')
Service = require '../src/compose/service' Service = require '../src/compose/service'
@ -208,6 +209,7 @@ describe 'deviceState', ->
err = new Error() err = new Error()
err.statusCode = 404 err.statusCode = 404
throw err throw err
@deviceState.deviceConfig.configBackend = new RPiConfigBackend()
@db.init() @db.init()
.then => .then =>
@config.init() @config.init()

View File

@ -1,13 +1,18 @@
Promise = require 'bluebird' Promise = require 'bluebird'
prepare = require './lib/prepare' { fs } = require 'mz'
m = require 'mochainon' m = require 'mochainon'
{ expect } = m.chai { expect } = m.chai
{ stub, spy } = m.sinon { stub, spy } = m.sinon
prepare = require './lib/prepare'
fsUtils = require '../src/lib/fs-utils' fsUtils = require '../src/lib/fs-utils'
DeviceConfig = require '../src/device-config' DeviceConfig = require '../src/device-config'
{ ExtlinuxConfigBackend, RPiConfigBackend } = require '../src/lib/config-backend'
extlinuxBackend = new ExtlinuxConfigBackend()
rpiConfigBackend = new RPiConfigBackend()
childProcess = require 'child_process' childProcess = require 'child_process'
@ -25,7 +30,8 @@ describe 'DeviceConfig', ->
# Test that the format for special values like initramfs and array variables is parsed correctly # Test that the format for special values like initramfs and array variables is parsed correctly
it 'allows getting boot config with getBootConfig', -> it 'allows getting boot config with getBootConfig', ->
stub(@deviceConfig, 'readBootConfig').resolves('\
stub(fs, 'readFile').resolves('\
initramfs initramf.gz 0x00800000\n\ initramfs initramf.gz 0x00800000\n\
dtparam=i2c=on\n\ dtparam=i2c=on\n\
dtparam=audio=on\n\ dtparam=audio=on\n\
@ -33,9 +39,9 @@ describe 'DeviceConfig', ->
dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13\n\ dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13\n\
foobar=baz\n\ foobar=baz\n\
') ')
@deviceConfig.getBootConfig('raspberry-pi') @deviceConfig.getBootConfig(rpiConfigBackend)
.then (conf) => .then (conf) ->
@deviceConfig.readBootConfig.restore() fs.readFile.restore()
expect(conf).to.deep.equal({ expect(conf).to.deep.equal({
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000' RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"' RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"'
@ -44,7 +50,7 @@ describe 'DeviceConfig', ->
}) })
it 'properly reads a real config.txt file', -> it 'properly reads a real config.txt file', ->
@deviceConfig.getBootConfig('raspberrypi3') @deviceConfig.getBootConfig(rpiConfigBackend)
.then (conf) -> .then (conf) ->
expect(conf).to.deep.equal({ expect(conf).to.deep.equal({
RESIN_HOST_CONFIG_dtparam: '"i2c_arm=on","spi=on","audio=on"' RESIN_HOST_CONFIG_dtparam: '"i2c_arm=on","spi=on","audio=on"'
@ -69,7 +75,7 @@ describe 'DeviceConfig', ->
RESIN_HOST_CONFIG_foobar: 'baz' RESIN_HOST_CONFIG_foobar: 'baz'
} }
promise = Promise.try => promise = Promise.try =>
@deviceConfig.bootConfigChangeRequired('raspberry-pi', current, target) @deviceConfig.bootConfigChangeRequired(rpiConfigBackend, current, target)
expect(promise).to.be.rejected expect(promise).to.be.rejected
promise.catch (err) => promise.catch (err) =>
expect(@fakeLogger.logSystemMessage).to.be.calledOnce expect(@fakeLogger.logSystemMessage).to.be.calledOnce
@ -92,7 +98,7 @@ describe 'DeviceConfig', ->
RESIN_HOST_CONFIG_foobar: 'baz' RESIN_HOST_CONFIG_foobar: 'baz'
} }
promise = Promise.try => promise = Promise.try =>
@deviceConfig.bootConfigChangeRequired('raspberry-pi', current, target) @deviceConfig.bootConfigChangeRequired(rpiConfigBackend, current, target)
expect(promise).to.eventually.equal(false) expect(promise).to.eventually.equal(false)
promise.then => promise.then =>
expect(@fakeLogger.logSystemMessage).to.not.be.called expect(@fakeLogger.logSystemMessage).to.not.be.called
@ -115,10 +121,10 @@ describe 'DeviceConfig', ->
RESIN_HOST_CONFIG_foobaz: 'bar' RESIN_HOST_CONFIG_foobaz: 'bar'
} }
promise = Promise.try => promise = Promise.try =>
@deviceConfig.bootConfigChangeRequired('raspberry-pi', current, target) @deviceConfig.bootConfigChangeRequired(rpiConfigBackend, current, target)
expect(promise).to.eventually.equal(true) expect(promise).to.eventually.equal(true)
promise.then => promise.then =>
@deviceConfig.setBootConfig('raspberry-pi', target) @deviceConfig.setBootConfig(rpiConfigBackend, target)
.then => .then =>
expect(childProcess.execAsync).to.be.calledOnce expect(childProcess.execAsync).to.be.calledOnce
expect(@fakeLogger.logSystemMessage).to.be.calledTwice expect(@fakeLogger.logSystemMessage).to.be.calledTwice
@ -148,10 +154,10 @@ describe 'DeviceConfig', ->
} }
promise = Promise.try => promise = Promise.try =>
@deviceConfig.bootConfigChangeRequired('jetson-tx2', current, target) @deviceConfig.bootConfigChangeRequired(extlinuxBackend, current, target)
expect(promise).to.eventually.equal(true) expect(promise).to.eventually.equal(true)
promise.then => promise.then =>
@deviceConfig.setBootConfig('jetson-tx2', target) @deviceConfig.setBootConfig(extlinuxBackend, target)
.then => .then =>
expect(childProcess.execAsync).to.be.calledOnce expect(childProcess.execAsync).to.be.calledOnce
expect(@fakeLogger.logSystemMessage).to.be.calledTwice expect(@fakeLogger.logSystemMessage).to.be.calledTwice

View File

@ -1,7 +1,14 @@
m = require 'mochainon' m = require 'mochainon'
{ expect } = m.chai { expect } = m.chai
{ stub } = m.sinon
{ fs } = require 'mz'
configUtils = require '../src/lib/config-utils' configUtils = require '../src/lib/config-utils'
{ ExtlinuxConfigBackend, RPiConfigBackend } = require '../src/lib/config-backend'
extlinuxBackend = new ExtlinuxConfigBackend()
rpiBackend = new RPiConfigBackend()
describe 'Config Utilities', -> describe 'Config Utilities', ->
@ -10,7 +17,7 @@ describe 'Config Utilities', ->
describe 'Env <-> Config', -> describe 'Env <-> Config', ->
it 'correctly transforms environments to boot config objects', -> it 'correctly transforms environments to boot config objects', ->
bootConfig = configUtils.envToBootConfig('raspberry-pi', { bootConfig = configUtils.envToBootConfig(rpiBackend, {
RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000' RESIN_HOST_CONFIG_initramfs: 'initramf.gz 0x00800000'
RESIN_HOST_CONFIG_dtparam: '"i2c=on","audio=on"' 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_dtoverlay: '"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"'
@ -38,7 +45,7 @@ describe 'Config Utilities', ->
APPEND ${cbootargs} ${resin_kernel_root} ro rootwait APPEND ${cbootargs} ${resin_kernel_root} ro rootwait
''' '''
parsed = configUtils.parseExtlinuxFile(text) parsed = ExtlinuxConfigBackend.parseExtlinuxFile(text)
expect(parsed.globals).to.have.property('DEFAULT').that.equals('primary') expect(parsed.globals).to.have.property('DEFAULT').that.equals('primary')
expect(parsed.globals).to.have.property('TIMEOUT').that.equals('30') expect(parsed.globals).to.have.property('TIMEOUT').that.equals('30')
expect(parsed.globals).to.have.property('MENU TITLE').that.equals('Boot Options') expect(parsed.globals).to.have.property('MENU TITLE').that.equals('Boot Options')
@ -64,7 +71,7 @@ describe 'Config Utilities', ->
APPEND test4 APPEND test4
''' '''
parsed = configUtils.parseExtlinuxFile(text) parsed = ExtlinuxConfigBackend.parseExtlinuxFile(text)
expect(parsed.labels).to.have.property('primary').that.deep.equals({ expect(parsed.labels).to.have.property('primary').that.deep.equals({
LINUX: 'test1' LINUX: 'test1'
APPEND: 'test2' APPEND: 'test2'
@ -87,9 +94,11 @@ describe 'Config Utilities', ->
APPEND ${cbootargs} ${resin_kernel_root} ro rootwait isolcpus=3 APPEND ${cbootargs} ${resin_kernel_root} ro rootwait isolcpus=3
''' '''
parsed = configUtils.parseExtlinuxBootConfig(text) stub(fs, 'readFile').resolves(text)
parsed = extlinuxBackend.getBootConfig()
expect(parsed).to.have.property('isolcpus').that.equals('3') expect(parsed).to.eventually.have.property('isolcpus').that.equals('3')
fs.readFile.restore()
text = ''' text = '''
@ -103,7 +112,10 @@ describe 'Config Utilities', ->
LINUX /Image LINUX /Image
APPEND ${cbootargs} ${resin_kernel_root} ro rootwait isolcpus=3,4,5 APPEND ${cbootargs} ${resin_kernel_root} ro rootwait isolcpus=3,4,5
''' '''
stub(fs, 'readFile').resolves(text)
parsed = configUtils.parseExtlinuxBootConfig(text) parsed = extlinuxBackend.getBootConfig()
expect(parsed).to.have.property('isolcpus').that.equals('3,4,5') fs.readFile.restore()
expect(parsed).to.eventually.have.property('isolcpus').that.equals('3,4,5')