Auto-merge for PR #672 via VersionBot

Add support for extlinux configuration files and isolcpus config option
This commit is contained in:
resin-io-versionbot[bot] 2018-06-06 14:12:34 +00:00 committed by GitHub
commit 1f3a9ca0f0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 685 additions and 144 deletions

View File

@ -4,6 +4,13 @@ All notable changes to this project will be documented in this file
automatically by Versionist. DO NOT EDIT THIS FILE MANUALLY!
This project adheres to [Semantic Versioning](http://semver.org/).
## v7.10.0 - 2018-06-06
* Move config backend code out to classes which implement a common base #672 [Cameron Diver]
* Add support for extlinux configuration files #672 [Cameron Diver]
* Move boot config related code to config-utils module #672 [Cameron Diver]
* Add types for fs-utils module #672 [Cameron Diver]
## v7.9.1 - 2018-05-29
* Update update-locking documentation #667 [Cameron Diver]

View File

@ -1,7 +1,7 @@
{
"name": "resin-supervisor",
"description": "This is resin.io's Supervisor, a program that runs on IoT devices and has the task of running user Apps (which are Docker containers), and updating them as Resin's API informs it to.",
"version": "7.9.1",
"version": "7.10.0",
"license": "Apache-2.0",
"repository": {
"type": "git",
@ -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,44 +1,13 @@
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'
hostConfigConfigVarPrefix = 'RESIN_HOST_'
bootConfigEnvVarPrefix = hostConfigConfigVarPrefix + 'CONFIG_'
bootBlockDevice = '/dev/mmcblk0p1'
bootMountPoint = constants.rootMountPoint + constants.bootMountPoint
bootConfigPath = bootMountPoint + '/config.txt'
configRegex = new RegExp('(' + _.escapeRegExp(bootConfigEnvVarPrefix) + ')(.+)')
forbiddenConfigKeys = [
'disable_commandline_tags'
'cmdline'
'kernel'
'kernel_address'
'kernel_old'
'ramfsfile'
'ramfsaddr'
'initramfs'
'device_tree_address'
'init_uart_baud'
'init_uart_clock'
'init_emmc_clock'
'boot_delay'
'boot_delay_ms'
'avoid_safe_mode'
]
arrayConfigKeys = [ 'dtparam', 'dtoverlay', 'device_tree_param', 'device_tree_overlay' ]
configUtils = require './lib/config-utils'
vpnServiceName = 'openvpn-resin'
isRPiDeviceType = (deviceType) ->
_.startsWith(deviceType, 'raspberry') or deviceType == 'fincm3'
module.exports = class DeviceConfig
constructor: ({ @db, @config, @logger }) ->
@rebootRequired = false
@ -76,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
@ -89,16 +67,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)
@getConfigBackend()
]
.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
@ -106,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()
@ -126,15 +106,17 @@ module.exports = class DeviceConfig
RESIN_SUPERVISOR_VPN_CONTROL: 'true'
}, _.mapValues(_.mapKeys(@configKeys, 'envVarName'), 'defaultValue'))
bootConfigChangeRequired: (deviceType, current, target) =>
targetBootConfig = @envToBootConfig(target)
currentBootConfig = @envToBootConfig(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
@ -142,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 = {
@ -173,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
@ -190,75 +175,22 @@ 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')
getBootConfig: (deviceType) =>
Promise.try =>
if !isRPiDeviceType(deviceType)
getBootConfig: (configBackend) ->
Promise.try ->
if !configBackend?
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)
configBackend.getBootConfig()
.then (config) ->
return configUtils.bootConfigToEnv(configBackend, config)
setBootConfig: (deviceType, target) =>
setBootConfig: (configBackend, target) =>
Promise.try =>
conf = @envToBootConfig(target)
if !isRPiDeviceType(deviceType)
if !configBackend?
return false
conf = configUtils.envToBootConfig(configBackend, 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')
configBackend.setBootConfig(conf)
.then =>
@logger.logSystemMessage("Applied boot config: #{JSON.stringify(conf)}", {}, 'Apply boot config success')
@rebootRequired = true

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

@ -0,0 +1,370 @@
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;
}
}

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

@ -0,0 +1,68 @@
import * as _ from 'lodash';
import {
ConfigOptions,
DeviceConfigBackend,
ExtlinuxConfigBackend,
RPiConfigBackend,
} from './config-backend';
import { EnvVarObject } from './types';
const configBackends = [
new ExtlinuxConfigBackend(),
new RPiConfigBackend(),
];
export function isConfigDeviceType(deviceType: string): boolean {
return getConfigBackend(deviceType) != null;
}
export function getConfigBackend(deviceType: string): DeviceConfigBackend | undefined {
return _.find(configBackends, (backend) => backend.matches(deviceType));
}
export function envToBootConfig(
configBackend: DeviceConfigBackend | null,
env: EnvVarObject,
): ConfigOptions {
if (configBackend == null) {
return { };
}
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(
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(
configBackend: DeviceConfigBackend | null,
allowedKeys: string[],
conf: { [key: string]: any },
): { [key: string]: any } {
const isConfigType = configBackend != null;
return _.pickBy(conf, (_v, k) => {
return _.includes(allowedKeys, k) || (isConfigType && configBackend!.isBootConfigVar(k));
});
}

View File

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

3
src/lib/fs-utils.d.ts vendored Normal file
View File

@ -0,0 +1,3 @@
export function writeAndSyncFile(path: string, data: string): Promise<void>;
export function writeFileAtomic(path: string, data: string): Promise<void>;
export function safeRename(src: string, dest: string): Promise<void>;

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"'
@ -54,19 +60,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 = {
@ -82,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
@ -105,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
@ -128,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,5 +141,39 @@ describe 'DeviceConfig', ->
childProcess.execAsync.restore()
@fakeLogger.logSystemMessage.reset()
describe 'Extlinux files', ->
it 'should correctly write to extlinux.conf files', ->
stub(fsUtils, 'writeFileAtomic').resolves()
stub(childProcess, 'execAsync').resolves()
current = {
}
target = {
RESIN_HOST_EXTLINUX_isolcpus: '2'
}
promise = Promise.try =>
@deviceConfig.bootConfigChangeRequired(extlinuxBackend, current, target)
expect(promise).to.eventually.equal(true)
promise.then =>
@deviceConfig.setBootConfig(extlinuxBackend, target)
.then =>
expect(childProcess.execAsync).to.be.calledOnce
expect(@fakeLogger.logSystemMessage).to.be.calledTwice
expect(@fakeLogger.logSystemMessage.getCall(1).args[2]).to.equal('Apply boot config success')
expect(fsUtils.writeFileAtomic).to.be.calledWith('./test/data/mnt/boot/extlinux/extlinux.conf', '\
DEFAULT primary\n\
TIMEOUT 30\n\
MENU TITLE Boot Options\n\
LABEL primary\n\
MENU LABEL primary Image\n\
LINUX /Image\n\
APPEND ${cbootargs} ${resin_kernel_root} ro rootwait isolcpus=2\n\
')
fsUtils.writeFileAtomic.restore()
childProcess.execAsync.restore()
@fakeLogger.logSystemMessage.reset()
# This will require stubbing device.reboot, gosuper.post, config.get/set
it 'applies the target state'

View File

@ -0,0 +1,121 @@
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', ->
describe 'Boot config utilities', ->
describe 'Env <-> Config', ->
it 'correctly transforms environments to boot config objects', ->
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"'
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'
})
describe 'TX2 boot config utilities', ->
it 'should parse a extlinux.conf file', ->
text = '''
DEFAULT primary
# Comment
TIMEOUT 30
MENU TITLE Boot Options
LABEL primary
MENU LABEL primary Image
LINUX /Image
APPEND ${cbootargs} ${resin_kernel_root} ro rootwait
'''
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')
expect(parsed.labels).to.have.property('primary')
primary = parsed.labels.primary
expect(primary).to.have.property('MENU LABEL').that.equals('primary Image')
expect(primary).to.have.property('LINUX').that.equals('/Image')
expect(primary).to.have.property('APPEND').that.equals('${cbootargs} ${resin_kernel_root} ro rootwait')
it 'should parse multiple service entries', ->
text = '''
DEFAULT primary
# Comment
TIMEOUT 30
MENU TITLE Boot Options
LABEL primary
LINUX test1
APPEND test2
LABEL secondary
LINUX test3
APPEND test4
'''
parsed = ExtlinuxConfigBackend.parseExtlinuxFile(text)
expect(parsed.labels).to.have.property('primary').that.deep.equals({
LINUX: 'test1'
APPEND: 'test2'
})
expect(parsed.labels).to.have.property('secondary').that.deep.equals({
LINUX: 'test3'
APPEND: 'test4'
})
it 'should parse configuration options from an extlinux.conf file', ->
text = '''
DEFAULT primary
# Comment
TIMEOUT 30
MENU TITLE Boot Options
LABEL primary
MENU LABEL primary Image
LINUX /Image
APPEND ${cbootargs} ${resin_kernel_root} ro rootwait isolcpus=3
'''
stub(fs, 'readFile').resolves(text)
parsed = extlinuxBackend.getBootConfig()
expect(parsed).to.eventually.have.property('isolcpus').that.equals('3')
fs.readFile.restore()
text = '''
DEFAULT primary
# Comment
TIMEOUT 30
MENU TITLE Boot Options
LABEL primary
MENU LABEL primary Image
LINUX /Image
APPEND ${cbootargs} ${resin_kernel_root} ro rootwait isolcpus=3,4,5
'''
stub(fs, 'readFile').resolves(text)
parsed = extlinuxBackend.getBootConfig()
fs.readFile.restore()
expect(parsed).to.eventually.have.property('isolcpus').that.equals('3,4,5')

View File

@ -0,0 +1,7 @@
DEFAULT primary
TIMEOUT 30
MENU TITLE Boot Options
LABEL primary
MENU LABEL primary Image
LINUX /Image
APPEND ${cbootargs} ${resin_kernel_root} ro rootwait