mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-04-02 17:01:17 +00:00
Auto-merge for PR #672 via VersionBot
Add support for extlinux configuration files and isolcpus config option
This commit is contained in:
commit
1f3a9ca0f0
@ -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]
|
||||
|
@ -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": {
|
||||
|
@ -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
370
src/lib/config-backend.ts
Normal 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
68
src/lib/config-utils.ts
Normal 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));
|
||||
});
|
||||
}
|
@ -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
3
src/lib/fs-utils.d.ts
vendored
Normal 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>;
|
@ -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()
|
||||
|
@ -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'
|
||||
|
121
test/16-config-utils.spec.coffee
Normal file
121
test/16-config-utils.spec.coffee
Normal 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')
|
7
test/data/mnt/boot/extlinux/extlinux.conf
Normal file
7
test/data/mnt/boot/extlinux/extlinux.conf
Normal 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
|
Loading…
x
Reference in New Issue
Block a user