diff --git a/src/device-config.coffee b/src/device-config.coffee index 6b635c7d..b31133be 100644 --- a/src/device-config.coffee +++ b/src/device-config.coffee @@ -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 diff --git a/src/lib/config-backend.ts b/src/lib/config-backend.ts new file mode 100644 index 00000000..231f9265 --- /dev/null +++ b/src/lib/config-backend.ts @@ -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 { + // 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; + + // A function to take a set of options and flush to the configuration + // file/backend + public abstract setBootConfig(opts: ConfigOptions): Promise; + + // 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 { + 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 { + 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 { + 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 { + // 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; + } + +} diff --git a/src/lib/config-utils.ts b/src/lib/config-utils.ts index 87b92304..1497a8be 100644 --- a/src/lib/config-utils.ts +++ b/src/lib/config-utils.ts @@ -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 { - 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 { - 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 { - // 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 { - // 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; -} diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 2c5ca2a8..6e757b8e 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -40,6 +40,7 @@ const constants = { 'io.resin.supervised': 'true', }, bootBlockDevice: '/dev/mmcblk0p1', + hostConfigVarPrefix: 'RESIN_HOST_', }; if (process.env.DOCKER_HOST == null) { diff --git a/test/05-device-state.spec.coffee b/test/05-device-state.spec.coffee index 716fa8e6..871dc9b5 100644 --- a/test/05-device-state.spec.coffee +++ b/test/05-device-state.spec.coffee @@ -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() diff --git a/test/13-device-config.spec.coffee b/test/13-device-config.spec.coffee index f464a314..0de8924c 100644 --- a/test/13-device-config.spec.coffee +++ b/test/13-device-config.spec.coffee @@ -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 diff --git a/test/16-config-utils.spec.coffee b/test/16-config-utils.spec.coffee index 41ac1df8..99fca614 100644 --- a/test/16-config-utils.spec.coffee +++ b/test/16-config-utils.spec.coffee @@ -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')