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
No known key found for this signature in database
GPG Key ID: 69264F9C923F55C1
7 changed files with 498 additions and 421 deletions

View File

@ -1,30 +1,11 @@
Promise = require 'bluebird'
_ = require 'lodash'
fs = Promise.promisifyAll(require('fs'))
systemd = require './lib/systemd'
{ checkTruthy, checkInt } = require './lib/validation'
{ UnitNotLoadedError } = require './lib/errors'
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'
module.exports = class DeviceConfig
@ -64,11 +45,20 @@ module.exports = class DeviceConfig
.tapCatch (err) =>
@logger.logConfigChange(logValue, { err })
setBootConfig: (step) =>
@config.get('deviceType')
.then (deviceType) =>
@setBootConfig(deviceType, step.target)
@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
@ -82,10 +72,10 @@ module.exports = class DeviceConfig
.then ([ devConfig ]) =>
return Promise.all [
JSON.parse(devConfig.targetValues)
@config.get('deviceType')
@getConfigBackend()
]
.then ([ conf, deviceType ]) =>
conf = configUtils.filterConfigKeys(deviceType, @validKeys, conf)
.then ([ conf, configBackend ]) =>
conf = configUtils.filterConfigKeys(configBackend, @validKeys, conf)
if initial or !conf.RESIN_SUPERVISOR_VPN_CONTROL?
conf.RESIN_SUPERVISOR_VPN_CONTROL = 'true'
for own k, { envVarName, defaultValue } of @configKeys
@ -93,11 +83,14 @@ module.exports = class DeviceConfig
return conf
getCurrent: =>
@config.getMany([ 'deviceType' ].concat(_.keys(@configKeys)))
.then (conf) =>
Promise.all [
@config.getMany([ 'deviceType' ].concat(_.keys(@configKeys)))
@getConfigBackend()
]
.then ([ conf, configBackend ]) =>
Promise.join(
@getVPNEnabled()
@getBootConfig(conf.deviceType)
@getBootConfig(configBackend)
(vpnStatus, bootConfig) =>
currentConf = {
RESIN_SUPERVISOR_VPN_CONTROL: (vpnStatus ? 'true').toString()
@ -113,15 +106,17 @@ module.exports = class DeviceConfig
RESIN_SUPERVISOR_VPN_CONTROL: 'true'
}, _.mapValues(_.mapKeys(@configKeys, 'envVarName'), 'defaultValue'))
bootConfigChangeRequired: (deviceType, current, target) =>
targetBootConfig = configUtils.envToBootConfig(deviceType, target)
currentBootConfig = configUtils.envToBootConfig(deviceType, current)
bootConfigChangeRequired: (configBackend, current, target) =>
targetBootConfig = configUtils.envToBootConfig(configBackend, target)
currentBootConfig = configUtils.envToBootConfig(configBackend, current)
if !_.isEqual(currentBootConfig, targetBootConfig)
for key in forbiddenConfigKeys
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)
_.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
@ -129,8 +124,11 @@ module.exports = class DeviceConfig
current = _.clone(currentState.local?.config ? {})
target = _.clone(targetState.local?.config ? {})
steps = []
@config.getMany([ 'deviceType', 'offlineMode' ])
.then ({ deviceType, offlineMode }) =>
Promise.all [
@config.getMany([ 'deviceType', 'offlineMode' ])
@getConfigBackend()
]
.then ([{ deviceType, offlineMode }, configBackend ]) =>
configChanges = {}
humanReadableConfigChanges = {}
match = {
@ -160,7 +158,7 @@ module.exports = class DeviceConfig
action: 'setVPNEnabled'
target: target['RESIN_SUPERVISOR_VPN_CONTROL']
})
if @bootConfigChangeRequired(deviceType, current, target)
if @bootConfigChangeRequired(configBackend, current, target)
steps.push({
action: 'setBootConfig'
target
@ -177,27 +175,22 @@ module.exports = class DeviceConfig
executeStepAction: (step, opts) =>
@actionExecutors[step.action](step, opts)
readBootConfig: (deviceType) ->
fs.readFileAsync(configUtils.getBootConfigPath(deviceType), 'utf8')
getBootConfig: (deviceType) =>
Promise.try =>
if !configUtils.isConfigDeviceType(deviceType)
getBootConfig: (configBackend) ->
Promise.try ->
if !configBackend?
return {}
@readBootConfig(deviceType)
.then (configTxt) ->
configBackend.getBootConfig()
.then (config) ->
return configUtils.bootConfigToEnv(configBackend, config)
config = configUtils.parseBootConfig(deviceType, configTxt)
return configUtils.bootConfigToEnv(deviceType, config)
setBootConfig: (deviceType, target) =>
setBootConfig: (configBackend, target) =>
Promise.try =>
if !configUtils.isConfigDeviceType(deviceType)
if !configBackend?
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')
configUtils.setBootConfig(deviceType, conf)
configBackend.setBootConfig(conf)
.then =>
@logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success')
@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 { fs } from 'mz';
import * as constants from './constants';
import * as fsUtils from './fs-utils';
import {
ConfigOptions,
DeviceConfigBackend,
ExtlinuxConfigBackend,
RPiConfigBackend,
} from './config-backend';
import { EnvVarObject } from './types';
const childProcess: any = Promise.promisifyAll(childProcessSync);
export interface ConfigOptions {
[key: string]: string | string[];
}
export interface ExtLinuxFile {
labels: {
[labelName: string]: {
[directive: string]: string;
};
};
globals: { [directive: string]: string };
}
export const rpiArrayConfigKeys = [
'dtparam',
'dtoverlay',
'device_tree_param',
'device_tree_overlay',
const configBackends = [
new ExtlinuxConfigBackend(),
new RPiConfigBackend(),
];
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 {
return isRPiDeviceType(deviceType) || isExtlinuxDeviceType(deviceType);
return getConfigBackend(deviceType) != null;
}
export function bootConfigVarPrefix(deviceType: string): string {
if (isRPiDeviceType(deviceType)) {
return rPiBootConfigPrefix;
} else if (isExtlinuxDeviceType(deviceType)) {
return extlinuxBootConfigPrefix;
} else {
throw new Error(`No boot config var prefix for device type: ${deviceType}`);
}
export function getConfigBackend(deviceType: string): DeviceConfigBackend | undefined {
return _.find(configBackends, (backend) => backend.matches(deviceType));
}
export function bootConfigVarRegex(deviceType: string): RegExp {
if (isRPiDeviceType(deviceType)) {
return rPiConfigRegex;
} else if (isExtlinuxDeviceType(deviceType)) {
return extlinuxConfigRegex;
} else {
throw new Error(`No boot config var regex for device type: ${deviceType}`);
}
}
export function envToBootConfig(
configBackend: DeviceConfigBackend | null,
env: EnvVarObject,
): ConfigOptions {
export function envToBootConfig(deviceType: string, env: EnvVarObject): ConfigOptions {
if (!isConfigDeviceType(deviceType)) {
if (configBackend == null) {
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;
return _(env)
.pickBy((_val, key) => configBackend.isBootConfigVar(key))
.mapKeys((_val, key) => configBackend.processConfigVarName(key))
.mapValues((val, key) => configBackend.processConfigVarValue(key, val || ''))
.value();
}
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 bootConfigToEnv(
configBackend: DeviceConfigBackend,
config: ConfigOptions,
): EnvVarObject {
return _(config)
.mapKeys((_val, key) => configBackend.createConfigVarName(key))
.mapValues((val) => {
if (_.isArray(val)) {
return JSON.stringify(val).replace(/^\[(.*)\]$/, '$1');
}
return val;
})
.value();
}
export function filterConfigKeys(
deviceType: string,
configBackend: DeviceConfigBackend | null,
allowedKeys: string[],
conf: { [key: string]: any },
): { [key: string]: any } {
let isConfigType: boolean = false;
let prefix: string;
if (isConfigDeviceType(deviceType)) {
prefix = bootConfigVarPrefix(deviceType);
isConfigType = true;
}
const isConfigType = configBackend != null;
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',
},
bootBlockDevice: '/dev/mmcblk0p1',
hostConfigVarPrefix: 'RESIN_HOST_',
};
if (process.env.DOCKER_HOST == null) {

View File

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

View File

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

View File

@ -1,7 +1,14 @@
m = require 'mochainon'
{ expect } = m.chai
{ stub } = m.sinon
{ fs } = require 'mz'
configUtils = require '../src/lib/config-utils'
{ ExtlinuxConfigBackend, RPiConfigBackend } = require '../src/lib/config-backend'
extlinuxBackend = new ExtlinuxConfigBackend()
rpiBackend = new RPiConfigBackend()
describe 'Config Utilities', ->
@ -10,7 +17,7 @@ describe 'Config Utilities', ->
describe 'Env <-> Config', ->
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_dtparam: '"i2c=on","audio=on"'
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
'''
parsed = configUtils.parseExtlinuxFile(text)
parsed = ExtlinuxConfigBackend.parseExtlinuxFile(text)
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('MENU TITLE').that.equals('Boot Options')
@ -64,7 +71,7 @@ describe 'Config Utilities', ->
APPEND test4
'''
parsed = configUtils.parseExtlinuxFile(text)
parsed = ExtlinuxConfigBackend.parseExtlinuxFile(text)
expect(parsed.labels).to.have.property('primary').that.deep.equals({
LINUX: 'test1'
APPEND: 'test2'
@ -87,9 +94,11 @@ describe 'Config Utilities', ->
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 = '''
@ -103,7 +112,10 @@ describe 'Config Utilities', ->
LINUX /Image
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')