mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-01-31 08:25:36 +00:00
Merge pull request #1371 from balena-io/1207-add-extlinux-fdt-config-support
Support setting FDT entry in extlinux.conf
This commit is contained in:
commit
711d4058df
@ -1,650 +0,0 @@
|
||||
import * as _ from 'lodash';
|
||||
import { child_process, fs } from 'mz';
|
||||
import * as path from 'path';
|
||||
|
||||
import * as constants from '../lib/constants';
|
||||
import { writeFileAtomic } from '../lib/fs-utils';
|
||||
|
||||
import log from '../lib/supervisor-console';
|
||||
import * as logger from '../logger';
|
||||
|
||||
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}`;
|
||||
|
||||
async function remountAndWriteAtomic(
|
||||
file: string,
|
||||
data: string,
|
||||
): Promise<void> {
|
||||
// Here's the dangerous part:
|
||||
await child_process.exec(
|
||||
`mount -t vfat -o remount,rw ${constants.bootBlockDevice} ${bootMountPoint}`,
|
||||
);
|
||||
await writeFileAtomic(file, data);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Allow a chosen config backend to be initialised
|
||||
public async initialise(): Promise<DeviceConfigBackend> {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
'gpio',
|
||||
];
|
||||
private static forbiddenConfigKeys = [
|
||||
'disable_commandline_tags',
|
||||
'cmdline',
|
||||
'kernel',
|
||||
'kernel_address',
|
||||
'kernel_old',
|
||||
'ramfsfile',
|
||||
'ramfsaddr',
|
||||
'initramfs',
|
||||
'device_tree_address',
|
||||
'init_emmc_clock',
|
||||
'avoid_safe_mode',
|
||||
];
|
||||
|
||||
public matches(deviceType: string): boolean {
|
||||
return _.startsWith(deviceType, 'raspberry') || deviceType === 'fincm3';
|
||||
}
|
||||
|
||||
public async getBootConfig(): Promise<ConfigOptions> {
|
||||
let configContents = '';
|
||||
|
||||
if (await fs.exists(RPiConfigBackend.bootConfigPath)) {
|
||||
configContents = await fs.readFile(
|
||||
RPiConfigBackend.bootConfigPath,
|
||||
'utf-8',
|
||||
);
|
||||
} else {
|
||||
await fs.writeFile(RPiConfigBackend.bootConfigPath, '');
|
||||
}
|
||||
|
||||
const conf: ConfigOptions = {};
|
||||
const configStatements = configContents.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] = [];
|
||||
}
|
||||
const confArr = conf[key];
|
||||
if (!_.isArray(confArr)) {
|
||||
throw new Error(
|
||||
`Expected '${key}' to have a config array but got ${typeof confArr}`,
|
||||
);
|
||||
}
|
||||
confArr.push(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try the next regex instead
|
||||
keyValue = /^(initramfs) (.+)/.exec(configStr);
|
||||
if (keyValue != null) {
|
||||
const [, key, value] = keyValue;
|
||||
conf[key] = value;
|
||||
} else {
|
||||
log.warn(`Could not parse config.txt entry: ${configStr}. Ignoring.`);
|
||||
}
|
||||
}
|
||||
|
||||
return conf;
|
||||
}
|
||||
|
||||
public async 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`;
|
||||
|
||||
await 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 async getBootConfig(): Promise<ConfigOptions> {
|
||||
let confContents: string;
|
||||
|
||||
try {
|
||||
confContents = await fs.readFile(
|
||||
ExtlinuxConfigBackend.bootConfigPath,
|
||||
'utf-8',
|
||||
);
|
||||
} catch {
|
||||
// In the rare case where the user might have deleted extlinux conf file between linux boot and supervisor boot
|
||||
// We do not have any backup to fallback too; warn the user of a possible brick
|
||||
throw new Error(
|
||||
'Could not find extlinux file. Device is possibly bricked',
|
||||
);
|
||||
}
|
||||
|
||||
const parsedBootFile = ExtlinuxConfigBackend.parseExtlinuxFile(
|
||||
confContents,
|
||||
);
|
||||
|
||||
// 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 async setBootConfig(opts: ConfigOptions): Promise<void> {
|
||||
// First get a representation of the configuration file, with all balena-supported configuration removed
|
||||
let confContents: string;
|
||||
|
||||
try {
|
||||
confContents = await fs.readFile(
|
||||
ExtlinuxConfigBackend.bootConfigPath,
|
||||
'utf-8',
|
||||
);
|
||||
} catch {
|
||||
// In the rare case where the user might have deleted extlinux conf file between linux boot and supervisor boot
|
||||
// We do not have any backup to fallback too; warn the user of a possible brick
|
||||
throw new Error(
|
||||
'Could not find extlinux file. Device is possibly bricked',
|
||||
);
|
||||
}
|
||||
|
||||
const extlinuxFile = ExtlinuxConfigBackend.parseExtlinuxFile(
|
||||
confContents.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,
|
||||
);
|
||||
|
||||
await 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) {
|
||||
log.warn(`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;
|
||||
}
|
||||
}
|
||||
|
||||
export type ConfigfsConfig = Dictionary<string[]>;
|
||||
|
||||
/**
|
||||
* A backend to handle ConfigFS host configuration for ACPI SSDT loading
|
||||
*
|
||||
* Supports:
|
||||
* - {BALENA|RESIN}_HOST_CONFIGFS_ssdt = value | "value" | "value1","value2"
|
||||
*/
|
||||
export class ConfigfsConfigBackend extends DeviceConfigBackend {
|
||||
private readonly SystemAmlFiles = path.join(
|
||||
constants.rootMountPoint,
|
||||
'boot/acpi-tables',
|
||||
);
|
||||
private readonly ConfigFilePath = path.join(bootMountPoint, 'configfs.json'); // use constant for mount path, rename to ssdt.txt
|
||||
private readonly ConfigfsMountPoint = path.join(
|
||||
constants.rootMountPoint,
|
||||
'sys/kernel/config',
|
||||
);
|
||||
private readonly ConfigVarNamePrefix = `${constants.hostConfigVarPrefix}CONFIGFS_`;
|
||||
|
||||
// supported backend for the following device types...
|
||||
public static readonly SupportedDeviceTypes = ['up-board'];
|
||||
private static readonly BootConfigVars = ['ssdt'];
|
||||
|
||||
private stripPrefix(name: string): string {
|
||||
if (!name.startsWith(this.ConfigVarNamePrefix)) {
|
||||
return name;
|
||||
}
|
||||
return name.substr(this.ConfigVarNamePrefix.length);
|
||||
}
|
||||
|
||||
private async listLoadedAcpiTables(): Promise<string[]> {
|
||||
const acpiTablesDir = path.join(this.ConfigfsMountPoint, 'acpi/table');
|
||||
return await fs.readdir(acpiTablesDir);
|
||||
}
|
||||
|
||||
private async loadAML(aml: string): Promise<boolean> {
|
||||
if (!aml) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const amlSrcPath = path.join(this.SystemAmlFiles, `${aml}.aml`);
|
||||
// log to system log if the AML doesn't exist...
|
||||
if (!(await fs.exists(amlSrcPath))) {
|
||||
log.error(`Missing AML for \'${aml}\'. Unable to load.`);
|
||||
if (logger) {
|
||||
logger.logSystemMessage(
|
||||
`Missing AML for \'${aml}\'. Unable to load.`,
|
||||
{ aml, path: amlSrcPath },
|
||||
'Load AML error',
|
||||
false,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const amlDstPath = path.join(this.ConfigfsMountPoint, 'acpi/table', aml);
|
||||
try {
|
||||
const loadedTables = await this.listLoadedAcpiTables();
|
||||
|
||||
if (loadedTables.indexOf(aml) < 0) {
|
||||
await fs.mkdir(amlDstPath);
|
||||
}
|
||||
|
||||
log.info(`Loading AML ${aml}`);
|
||||
// we use `cat` here as this didn't work when using `cp` and all
|
||||
// examples of this loading mechanism use `cat`.
|
||||
await child_process.exec(
|
||||
`cat ${amlSrcPath} > ${path.join(amlDstPath, 'aml')}`,
|
||||
);
|
||||
|
||||
const [oemId, oemTableId, oemRevision] = await Promise.all([
|
||||
fs.readFile(path.join(amlDstPath, 'oem_id'), 'utf8'),
|
||||
fs.readFile(path.join(amlDstPath, 'oem_table_id'), 'utf8'),
|
||||
fs.readFile(path.join(amlDstPath, 'oem_revision'), 'utf8'),
|
||||
]);
|
||||
|
||||
log.info(
|
||||
`AML: ${oemId.trim()} ${oemTableId.trim()} (Rev ${oemRevision.trim()})`,
|
||||
);
|
||||
} catch (e) {
|
||||
log.error(e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async readConfigJSON(): Promise<ConfigfsConfig> {
|
||||
// if we don't yet have a config file, just return an empty result...
|
||||
if (!(await fs.exists(this.ConfigFilePath))) {
|
||||
log.info('Empty ConfigFS config file');
|
||||
return {};
|
||||
}
|
||||
|
||||
// read the config file...
|
||||
try {
|
||||
const content = await fs.readFile(this.ConfigFilePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch (err) {
|
||||
log.error('Unable to deserialise ConfigFS configuration.', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async writeConfigJSON(config: ConfigfsConfig): Promise<void> {
|
||||
await remountAndWriteAtomic(this.ConfigFilePath, JSON.stringify(config));
|
||||
}
|
||||
|
||||
private async loadConfiguredSsdt(config: ConfigfsConfig): Promise<void> {
|
||||
if (_.isArray(config['ssdt'])) {
|
||||
log.info('Loading configured SSDTs');
|
||||
for (const aml of config['ssdt']) {
|
||||
await this.loadAML(aml);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async initialise(): Promise<ConfigfsConfigBackend> {
|
||||
try {
|
||||
await super.initialise();
|
||||
|
||||
// load the acpi_configfs module...
|
||||
await child_process.exec('modprobe acpi_configfs');
|
||||
|
||||
// read the existing config file...
|
||||
const config = await this.readConfigJSON();
|
||||
|
||||
// write the config back out (reformatting it)
|
||||
await this.writeConfigJSON(config);
|
||||
|
||||
// load the configured SSDT AMLs...
|
||||
await this.loadConfiguredSsdt(config);
|
||||
log.success('Initialised ConfigFS');
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
await logger.initialized;
|
||||
logger.logSystemMessage(
|
||||
'Unable to initialise ConfigFS',
|
||||
{ error },
|
||||
'ConfigFS initialisation error',
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public matches(deviceType: string): boolean {
|
||||
return ConfigfsConfigBackend.SupportedDeviceTypes.includes(deviceType);
|
||||
}
|
||||
|
||||
public async getBootConfig(): Promise<ConfigOptions> {
|
||||
const options: ConfigOptions = {};
|
||||
|
||||
// read the config file...
|
||||
const config = await this.readConfigJSON();
|
||||
|
||||
// see which SSDTs we have configured...
|
||||
const ssdt = config['ssdt'];
|
||||
if (_.isArray(ssdt) && ssdt.length > 0) {
|
||||
// we have some...
|
||||
options['ssdt'] = ssdt;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
public async setBootConfig(opts: ConfigOptions): Promise<void> {
|
||||
// read the config file...
|
||||
const config = await this.readConfigJSON();
|
||||
|
||||
// see if the target state defined some SSDTs...
|
||||
const ssdtKey = `${this.ConfigVarNamePrefix}ssdt`;
|
||||
if (opts[ssdtKey]) {
|
||||
// it did, so update the config with theses...
|
||||
config['ssdt'] = _.castArray(opts[ssdtKey]);
|
||||
} else {
|
||||
// it did not, so remove any existing SSDTs from the config...
|
||||
delete config['ssdt'];
|
||||
}
|
||||
|
||||
// store the new config to disk...
|
||||
await this.writeConfigJSON(config);
|
||||
}
|
||||
|
||||
public isSupportedConfig(name: string): boolean {
|
||||
return ConfigfsConfigBackend.BootConfigVars.includes(
|
||||
this.stripPrefix(name),
|
||||
);
|
||||
}
|
||||
|
||||
public isBootConfigVar(name: string): boolean {
|
||||
return ConfigfsConfigBackend.BootConfigVars.includes(
|
||||
this.stripPrefix(name),
|
||||
);
|
||||
}
|
||||
|
||||
public processConfigVarName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
public processConfigVarValue(name: string, value: string): string | string[] {
|
||||
switch (this.stripPrefix(name)) {
|
||||
case 'ssdt':
|
||||
// value could be a single value, so just add to an array and return...
|
||||
if (!value.startsWith('"')) {
|
||||
return [value];
|
||||
} else {
|
||||
// or, it could be parsable as the content of a JSON array; "value" | "value1","value2"
|
||||
return value.split(',').map((v) => v.replace('"', '').trim());
|
||||
}
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public createConfigVarName(name: string): string {
|
||||
return `${this.ConfigVarNamePrefix}${name}`;
|
||||
}
|
||||
}
|
61
src/config/backends/backend.ts
Normal file
61
src/config/backends/backend.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import * as _ from 'lodash';
|
||||
import { child_process } from 'mz';
|
||||
|
||||
import * as constants from '../../lib/constants';
|
||||
import { writeFileAtomic } from '../../lib/fs-utils';
|
||||
|
||||
export interface ConfigOptions {
|
||||
[key: string]: string | string[];
|
||||
}
|
||||
|
||||
export const bootMountPoint = `${constants.rootMountPoint}${constants.bootMountPoint}`;
|
||||
|
||||
export async function remountAndWriteAtomic(
|
||||
file: string,
|
||||
data: string,
|
||||
): Promise<void> {
|
||||
// Here's the dangerous part:
|
||||
await child_process.exec(
|
||||
`mount -t vfat -o remount,rw ${constants.bootBlockDevice} ${bootMountPoint}`,
|
||||
);
|
||||
await writeFileAtomic(file, data);
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
// Allow a chosen config backend to be initialised
|
||||
public async initialise(): Promise<DeviceConfigBackend> {
|
||||
return this;
|
||||
}
|
||||
}
|
231
src/config/backends/config-fs.ts
Normal file
231
src/config/backends/config-fs.ts
Normal file
@ -0,0 +1,231 @@
|
||||
import * as _ from 'lodash';
|
||||
import { child_process, fs } from 'mz';
|
||||
import * as path from 'path';
|
||||
|
||||
import {
|
||||
ConfigOptions,
|
||||
DeviceConfigBackend,
|
||||
bootMountPoint,
|
||||
remountAndWriteAtomic,
|
||||
} from './backend';
|
||||
import * as constants from '../../lib/constants';
|
||||
import * as logger from '../../logger';
|
||||
import log from '../../lib/supervisor-console';
|
||||
|
||||
/**
|
||||
* A backend to handle ConfigFS host configuration
|
||||
*
|
||||
* Supports:
|
||||
* - {BALENA|RESIN}_HOST_CONFIGFS_ssdt = value | "value" | "value1","value2"
|
||||
*/
|
||||
|
||||
type ConfigfsConfig = Dictionary<string[]>;
|
||||
|
||||
export class ConfigfsConfigBackend extends DeviceConfigBackend {
|
||||
private readonly SystemAmlFiles = path.join(
|
||||
constants.rootMountPoint,
|
||||
'boot/acpi-tables',
|
||||
);
|
||||
private readonly ConfigFilePath = path.join(bootMountPoint, 'configfs.json'); // use constant for mount path, rename to ssdt.txt
|
||||
private readonly ConfigfsMountPoint = path.join(
|
||||
constants.rootMountPoint,
|
||||
'sys/kernel/config',
|
||||
);
|
||||
private readonly ConfigVarNamePrefix = `${constants.hostConfigVarPrefix}CONFIGFS_`;
|
||||
|
||||
// supported backend for the following device types...
|
||||
public static readonly SupportedDeviceTypes = ['up-board'];
|
||||
private static readonly BootConfigVars = ['ssdt'];
|
||||
|
||||
private stripPrefix(name: string): string {
|
||||
if (!name.startsWith(this.ConfigVarNamePrefix)) {
|
||||
return name;
|
||||
}
|
||||
return name.substr(this.ConfigVarNamePrefix.length);
|
||||
}
|
||||
|
||||
private async listLoadedAcpiTables(): Promise<string[]> {
|
||||
const acpiTablesDir = path.join(this.ConfigfsMountPoint, 'acpi/table');
|
||||
return await fs.readdir(acpiTablesDir);
|
||||
}
|
||||
|
||||
private async loadAML(aml: string): Promise<boolean> {
|
||||
if (!aml) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const amlSrcPath = path.join(this.SystemAmlFiles, `${aml}.aml`);
|
||||
// log to system log if the AML doesn't exist...
|
||||
if (!(await fs.exists(amlSrcPath))) {
|
||||
log.error(`Missing AML for \'${aml}\'. Unable to load.`);
|
||||
if (logger) {
|
||||
logger.logSystemMessage(
|
||||
`Missing AML for \'${aml}\'. Unable to load.`,
|
||||
{ aml, path: amlSrcPath },
|
||||
'Load AML error',
|
||||
false,
|
||||
);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
const amlDstPath = path.join(this.ConfigfsMountPoint, 'acpi/table', aml);
|
||||
try {
|
||||
const loadedTables = await this.listLoadedAcpiTables();
|
||||
|
||||
if (loadedTables.indexOf(aml) < 0) {
|
||||
await fs.mkdir(amlDstPath);
|
||||
}
|
||||
|
||||
log.info(`Loading AML ${aml}`);
|
||||
// we use `cat` here as this didn't work when using `cp` and all
|
||||
// examples of this loading mechanism use `cat`.
|
||||
await child_process.exec(
|
||||
`cat ${amlSrcPath} > ${path.join(amlDstPath, 'aml')}`,
|
||||
);
|
||||
|
||||
const [oemId, oemTableId, oemRevision] = await Promise.all([
|
||||
fs.readFile(path.join(amlDstPath, 'oem_id'), 'utf8'),
|
||||
fs.readFile(path.join(amlDstPath, 'oem_table_id'), 'utf8'),
|
||||
fs.readFile(path.join(amlDstPath, 'oem_revision'), 'utf8'),
|
||||
]);
|
||||
|
||||
log.info(
|
||||
`AML: ${oemId.trim()} ${oemTableId.trim()} (Rev ${oemRevision.trim()})`,
|
||||
);
|
||||
} catch (e) {
|
||||
log.error('Issue while loading AML ${aml}', e);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private async readConfigJSON(): Promise<ConfigfsConfig> {
|
||||
// if we don't yet have a config file, just return an empty result...
|
||||
if (!(await fs.exists(this.ConfigFilePath))) {
|
||||
log.info('Empty ConfigFS config file');
|
||||
return {};
|
||||
}
|
||||
|
||||
// read the config file...
|
||||
try {
|
||||
const content = await fs.readFile(this.ConfigFilePath, 'utf8');
|
||||
return JSON.parse(content);
|
||||
} catch (err) {
|
||||
log.error('Unable to deserialise ConfigFS configuration.', err);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private async writeConfigJSON(config: ConfigfsConfig): Promise<void> {
|
||||
await remountAndWriteAtomic(this.ConfigFilePath, JSON.stringify(config));
|
||||
}
|
||||
|
||||
private async loadConfiguredSsdt(config: ConfigfsConfig): Promise<void> {
|
||||
if (Array.isArray(config['ssdt'])) {
|
||||
log.info('Loading configured SSDTs');
|
||||
for (const aml of config['ssdt']) {
|
||||
await this.loadAML(aml);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public async initialise(): Promise<ConfigfsConfigBackend> {
|
||||
try {
|
||||
await super.initialise();
|
||||
|
||||
// load the acpi_configfs module...
|
||||
await child_process.exec('modprobe acpi_configfs');
|
||||
|
||||
// read the existing config file...
|
||||
const config = await this.readConfigJSON();
|
||||
|
||||
// write the config back out (reformatting it)
|
||||
await this.writeConfigJSON(config);
|
||||
|
||||
// load the configured SSDT AMLs...
|
||||
await this.loadConfiguredSsdt(config);
|
||||
log.success('Initialised ConfigFS');
|
||||
} catch (error) {
|
||||
log.error(error);
|
||||
await logger.initialized;
|
||||
logger.logSystemMessage(
|
||||
'Unable to initialise ConfigFS',
|
||||
{ error },
|
||||
'ConfigFS initialisation error',
|
||||
);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
public matches(deviceType: string): boolean {
|
||||
return ConfigfsConfigBackend.SupportedDeviceTypes.includes(deviceType);
|
||||
}
|
||||
|
||||
public async getBootConfig(): Promise<ConfigOptions> {
|
||||
const options: ConfigOptions = {};
|
||||
|
||||
// read the config file...
|
||||
const config = await this.readConfigJSON();
|
||||
|
||||
// see which SSDTs we have configured...
|
||||
const ssdt = config['ssdt'];
|
||||
if (Array.isArray(ssdt) && ssdt.length > 0) {
|
||||
// we have some...
|
||||
options['ssdt'] = ssdt;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
public async setBootConfig(opts: ConfigOptions): Promise<void> {
|
||||
// read the config file...
|
||||
const config = await this.readConfigJSON();
|
||||
|
||||
// see if the target state defined some SSDTs...
|
||||
const ssdtKey = `${this.ConfigVarNamePrefix}ssdt`;
|
||||
if (opts[ssdtKey]) {
|
||||
// it did, so update the config with theses...
|
||||
config['ssdt'] = _.castArray(opts[ssdtKey]);
|
||||
} else {
|
||||
// it did not, so remove any existing SSDTs from the config...
|
||||
delete config['ssdt'];
|
||||
}
|
||||
|
||||
// store the new config to disk...
|
||||
await this.writeConfigJSON(config);
|
||||
}
|
||||
|
||||
public isSupportedConfig(name: string): boolean {
|
||||
return ConfigfsConfigBackend.BootConfigVars.includes(
|
||||
this.stripPrefix(name),
|
||||
);
|
||||
}
|
||||
|
||||
public isBootConfigVar(name: string): boolean {
|
||||
return ConfigfsConfigBackend.BootConfigVars.includes(
|
||||
this.stripPrefix(name),
|
||||
);
|
||||
}
|
||||
|
||||
public processConfigVarName(name: string): string {
|
||||
return name;
|
||||
}
|
||||
|
||||
public processConfigVarValue(name: string, value: string): string | string[] {
|
||||
switch (this.stripPrefix(name)) {
|
||||
case 'ssdt':
|
||||
// value could be a single value, so just add to an array and return...
|
||||
if (!value.startsWith('"')) {
|
||||
return [value];
|
||||
} else {
|
||||
// or, it could be parsable as the content of a JSON array; "value" | "value1","value2"
|
||||
return value.split(',').map((v) => v.replace('"', '').trim());
|
||||
}
|
||||
default:
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
public createConfigVarName(name: string): string {
|
||||
return `${this.ConfigVarNamePrefix}${name}`;
|
||||
}
|
||||
}
|
166
src/config/backends/extlinux-file.ts
Normal file
166
src/config/backends/extlinux-file.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import { ConfigOptions } from './backend';
|
||||
import {
|
||||
ExtLinuxParseError,
|
||||
AppendDirectiveError,
|
||||
FDTDirectiveError,
|
||||
} from '../../lib/errors';
|
||||
|
||||
export interface ExtlinuxFile {
|
||||
globals: Directive;
|
||||
labels: Label;
|
||||
}
|
||||
|
||||
export interface Label {
|
||||
[labelName: string]: Directive;
|
||||
}
|
||||
|
||||
export interface Directive {
|
||||
[directive: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* ConfigurableDirective
|
||||
*
|
||||
* This class abstraction is the blueprint used to create new directives in extlinux
|
||||
* that we would want to be able to parse (get the value) and generate (create a value).
|
||||
*
|
||||
*/
|
||||
export abstract class ConfigurableDirective {
|
||||
// Parses the values for this directive
|
||||
public abstract parse(directives: Directive): ConfigOptions;
|
||||
// Return the value to be set for this directive using the provided ConfigOptions
|
||||
public abstract generate(opts: ConfigOptions, existingValue?: string): string;
|
||||
}
|
||||
|
||||
/**
|
||||
* AppendDirective
|
||||
*
|
||||
* Add one or more options to the kernel command line.
|
||||
*
|
||||
*/
|
||||
export class AppendDirective extends ConfigurableDirective {
|
||||
private supportedConfigValues: string[];
|
||||
|
||||
public constructor(supportedConfigValues: string[]) {
|
||||
super();
|
||||
this.supportedConfigValues = supportedConfigValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a APPEND directive string into a ConfigOptions
|
||||
*
|
||||
* Example:
|
||||
* parse({ APPEND: "ro rootwait isolcpus=0,4" })
|
||||
* -> { 'ro': '', 'rootwait': '', 'isolcpus': '0,4' }
|
||||
*
|
||||
*/
|
||||
public parse(directives: Directive): ConfigOptions {
|
||||
// Check that there is an APPEND directive to parse
|
||||
if (directives.APPEND == null) {
|
||||
throw new ExtLinuxParseError(
|
||||
'Could not find APPEND directive in default extlinux.conf boot entry',
|
||||
);
|
||||
}
|
||||
// Parse all the key and values into ConfigOptions
|
||||
return directives.APPEND.split(' ').reduce(
|
||||
(configOpts: ConfigOptions, appendValue: string) => {
|
||||
// Break this append config into key and value
|
||||
const [KEY, VALUE = '', more] = appendValue.split('=', 3);
|
||||
if (!KEY) {
|
||||
return configOpts; // No value to set so return
|
||||
} else if (more != null) {
|
||||
// APPEND value is not formatted correctly
|
||||
// Example: isolcpus=3=2 (more then 1 value being set)
|
||||
throw new AppendDirectiveError(
|
||||
`Unable to parse invalid value: ${appendValue}`,
|
||||
);
|
||||
}
|
||||
// Return key value pair with existing configs
|
||||
return { [KEY]: VALUE, ...configOpts };
|
||||
},
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a string value for APPEND directive given a ConfigOptions
|
||||
*
|
||||
* Keys in existingValue that are also in the provided ConfigOptions
|
||||
* will be replaced with those from opts.
|
||||
*
|
||||
* Example:
|
||||
* generate({ isolcpus: '0,4' })
|
||||
* -> 'isolcpus=0,4'
|
||||
*
|
||||
*/
|
||||
public generate(opts: ConfigOptions, existingValue: string = ''): string {
|
||||
// Parse current append line and remove whitelisted values
|
||||
// We remove whitelisted values to avoid duplicates
|
||||
const appendLine = existingValue.split(' ').filter((entry) => {
|
||||
const lhs = entry.split('=', 1);
|
||||
return !this.supportedConfigValues.includes(lhs[0]);
|
||||
});
|
||||
// Add new configurations values to the provided append line
|
||||
return appendLine
|
||||
.concat(
|
||||
_.map(opts, (value, key) => {
|
||||
if (key.includes('=') || value.includes('=')) {
|
||||
throw new AppendDirectiveError(
|
||||
`One of the values being set contains an invalid character: [ value: ${value}, key: ${key} ]`,
|
||||
);
|
||||
} else if (!value) {
|
||||
// Example: rootwait (config without a value)
|
||||
return `${key}`;
|
||||
} else {
|
||||
// Example: isolcpus=2,3 (config with a value)
|
||||
return `${key}=${value}`;
|
||||
}
|
||||
}),
|
||||
)
|
||||
.join(' ')
|
||||
.trim();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* FDTDirective
|
||||
*
|
||||
* Configure the location of Device Tree Binary
|
||||
*
|
||||
*/
|
||||
export class FDTDirective extends ConfigurableDirective {
|
||||
/**
|
||||
* Parses a FDT directive string into a ConfigOptions
|
||||
*
|
||||
* Example:
|
||||
* parse({ FDT: '/boot/mycustomdtb.dtb' })
|
||||
* -> { 'fdt': '/boot/mycustomdtb.dtb' }
|
||||
*
|
||||
*/
|
||||
public parse(directives: Directive): ConfigOptions {
|
||||
// NOTE: We normalize FDT to lowercase fdt
|
||||
return directives.FDT ? { fdt: directives.FDT } : {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a string value for FDT directive given a ConfigOptions
|
||||
*
|
||||
* Example:
|
||||
* generate({ fdt: '/boot/mycustomdtb.dtb' })
|
||||
* -> '/boot/mycustomdtb.dtb'
|
||||
*
|
||||
*/
|
||||
public generate(opts: ConfigOptions): string {
|
||||
if (typeof opts.fdt !== 'string') {
|
||||
throw new FDTDirectiveError(
|
||||
`Cannot set FDT of non-string value: ${opts.fdt}`,
|
||||
);
|
||||
}
|
||||
if (opts.fdt.length === 0) {
|
||||
throw new FDTDirectiveError('Cannot set FDT of an empty value.');
|
||||
}
|
||||
return opts.fdt;
|
||||
}
|
||||
}
|
252
src/config/backends/extlinux.ts
Normal file
252
src/config/backends/extlinux.ts
Normal file
@ -0,0 +1,252 @@
|
||||
import * as _ from 'lodash';
|
||||
import { fs } from 'mz';
|
||||
|
||||
import {
|
||||
ConfigOptions,
|
||||
DeviceConfigBackend,
|
||||
bootMountPoint,
|
||||
remountAndWriteAtomic,
|
||||
} from './backend';
|
||||
import {
|
||||
ExtlinuxFile,
|
||||
Directive,
|
||||
AppendDirective,
|
||||
FDTDirective,
|
||||
} from './extlinux-file';
|
||||
import * as constants from '../../lib/constants';
|
||||
import log from '../../lib/supervisor-console';
|
||||
import { ExtLinuxParseError } from '../../lib/errors';
|
||||
|
||||
/**
|
||||
* A backend to handle extlinux host configuration
|
||||
*
|
||||
* Supports:
|
||||
* - {BALENA|RESIN}_HOST_EXTLINUX_isolcpus = value | "value" | "value1","value2"
|
||||
* - {BALENA|RESIN}_HOST_EXTLINUX_fdt = value | "value"
|
||||
*/
|
||||
|
||||
export class ExtlinuxConfigBackend extends DeviceConfigBackend {
|
||||
private static bootConfigVarPrefix = `${constants.hostConfigVarPrefix}EXTLINUX_`;
|
||||
private static bootConfigPath = `${bootMountPoint}/extlinux/extlinux.conf`;
|
||||
private static supportedConfigValues = ['isolcpus', 'fdt'];
|
||||
private static supportedDirectives = ['APPEND', 'FDT'];
|
||||
|
||||
private fdtDirective = new FDTDirective();
|
||||
private appendDirective = new AppendDirective(
|
||||
// Pass in list of supportedConfigValues that APPEND can have
|
||||
ExtlinuxConfigBackend.supportedConfigValues.filter(
|
||||
(v) => !this.isDirective(v),
|
||||
),
|
||||
);
|
||||
|
||||
public static bootConfigVarRegex = new RegExp(
|
||||
'(?:' + _.escapeRegExp(ExtlinuxConfigBackend.bootConfigVarPrefix) + ')(.+)',
|
||||
);
|
||||
|
||||
public matches(deviceType: string): boolean {
|
||||
return deviceType.startsWith('jetson-tx');
|
||||
}
|
||||
|
||||
public async getBootConfig(): Promise<ConfigOptions> {
|
||||
let confContents: string;
|
||||
|
||||
try {
|
||||
confContents = await fs.readFile(
|
||||
ExtlinuxConfigBackend.bootConfigPath,
|
||||
'utf-8',
|
||||
);
|
||||
} catch {
|
||||
// In the rare case where the user might have deleted extlinux conf file between linux boot and supervisor boot
|
||||
// We do not have any backup to fallback too; warn the user of a possible brick
|
||||
throw new ExtLinuxParseError(
|
||||
'Could not find extlinux file. Device is possibly bricked',
|
||||
);
|
||||
}
|
||||
|
||||
// Parse ExtlinuxFile from file contents
|
||||
const parsedBootFile = ExtlinuxConfigBackend.parseExtlinuxFile(
|
||||
confContents,
|
||||
);
|
||||
|
||||
// Get default label to know which label entry to parse
|
||||
const defaultLabel = ExtlinuxConfigBackend.findDefaultLabel(parsedBootFile);
|
||||
|
||||
// Get the label entry we will parse
|
||||
const labelEntry = ExtlinuxConfigBackend.getLabelEntry(
|
||||
parsedBootFile,
|
||||
defaultLabel,
|
||||
);
|
||||
|
||||
// Parse APPEND directive and filter out unsupported values
|
||||
const appendConfig = _.pickBy(
|
||||
this.appendDirective.parse(labelEntry),
|
||||
(_value, key) => this.isSupportedConfig(key),
|
||||
);
|
||||
|
||||
// Parse FDT directive
|
||||
const fdtConfig = this.fdtDirective.parse(labelEntry);
|
||||
|
||||
return {
|
||||
...appendConfig,
|
||||
...fdtConfig,
|
||||
};
|
||||
}
|
||||
|
||||
public async setBootConfig(opts: ConfigOptions): Promise<void> {
|
||||
// First get a representation of the configuration file, with all balena-supported configuration removed
|
||||
let confContents: string;
|
||||
|
||||
try {
|
||||
confContents = await fs.readFile(
|
||||
ExtlinuxConfigBackend.bootConfigPath,
|
||||
'utf-8',
|
||||
);
|
||||
} catch {
|
||||
// In the rare case where the user might have deleted extlinux conf file between linux boot and supervisor boot
|
||||
// We do not have any backup to fallback too; warn the user of a possible brick
|
||||
throw new Error(
|
||||
'Could not find extlinux file. Device is possibly bricked',
|
||||
);
|
||||
}
|
||||
|
||||
// Parse ExtlinuxFile from file contents
|
||||
const parsedBootFile = ExtlinuxConfigBackend.parseExtlinuxFile(
|
||||
confContents,
|
||||
);
|
||||
|
||||
// Get default label to know which label entry to edit
|
||||
const defaultLabel = ExtlinuxConfigBackend.findDefaultLabel(parsedBootFile);
|
||||
|
||||
// Get the label entry we will edit
|
||||
const defaultEntry = ExtlinuxConfigBackend.getLabelEntry(
|
||||
parsedBootFile,
|
||||
defaultLabel,
|
||||
);
|
||||
|
||||
// Set `FDT` directive if a value is provided
|
||||
if (opts.fdt) {
|
||||
defaultEntry.FDT = this.fdtDirective.generate(opts);
|
||||
}
|
||||
|
||||
// Remove unsupported options
|
||||
const appendOptions = _.pickBy(
|
||||
opts,
|
||||
// supportedConfigValues has values AND directives so we must filter directives out
|
||||
(_value, key) => this.isSupportedConfig(key) && !this.isDirective(key),
|
||||
);
|
||||
|
||||
// Add config values to `APPEND` directive
|
||||
defaultEntry.APPEND = this.appendDirective.generate(
|
||||
appendOptions,
|
||||
defaultEntry.APPEND,
|
||||
);
|
||||
|
||||
// Write new extlinux configuration
|
||||
return await remountAndWriteAtomic(
|
||||
ExtlinuxConfigBackend.bootConfigPath,
|
||||
ExtlinuxConfigBackend.extlinuxFileToString(parsedBootFile),
|
||||
);
|
||||
}
|
||||
|
||||
public isSupportedConfig(configName: string): boolean {
|
||||
return ExtlinuxConfigBackend.supportedConfigValues.includes(configName);
|
||||
}
|
||||
|
||||
public isBootConfigVar(envVar: string): boolean {
|
||||
return envVar.startsWith(ExtlinuxConfigBackend.bootConfigVarPrefix);
|
||||
}
|
||||
|
||||
public processConfigVarName(envVar: string): string {
|
||||
return envVar.replace(ExtlinuxConfigBackend.bootConfigVarRegex, '$1');
|
||||
}
|
||||
|
||||
public processConfigVarValue(_key: string, value: string): string {
|
||||
return value;
|
||||
}
|
||||
|
||||
public createConfigVarName(configName: string): string {
|
||||
return `${ExtlinuxConfigBackend.bootConfigVarPrefix}${configName}`;
|
||||
}
|
||||
|
||||
private isDirective(configName: string): boolean {
|
||||
return ExtlinuxConfigBackend.supportedDirectives.includes(
|
||||
configName.toUpperCase(),
|
||||
);
|
||||
}
|
||||
|
||||
private static parseExtlinuxFile(confStr: string): ExtlinuxFile {
|
||||
const file: ExtlinuxFile = {
|
||||
globals: {},
|
||||
labels: {},
|
||||
};
|
||||
|
||||
// Split by line and filter any comments and empty lines
|
||||
const lines = confStr.split(/(?:\r?\n[\s#]*)+/);
|
||||
let lastLabel = '';
|
||||
|
||||
for (const line of lines) {
|
||||
const match = line.match(/^\s*(\w+)\s?(.*)$/);
|
||||
if (match == null) {
|
||||
log.warn(`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;
|
||||
}
|
||||
|
||||
private static findDefaultLabel(file: ExtlinuxFile): string {
|
||||
if (!file.globals.DEFAULT) {
|
||||
throw new ExtLinuxParseError(
|
||||
'Could not find default entry for extlinux.conf file',
|
||||
);
|
||||
}
|
||||
return file.globals.DEFAULT;
|
||||
}
|
||||
|
||||
private static getLabelEntry(file: ExtlinuxFile, label: string): Directive {
|
||||
const labelEntry = file.labels[label];
|
||||
if (labelEntry == null) {
|
||||
throw new ExtLinuxParseError(
|
||||
`Cannot find label entry (label: ${label}) for extlinux.conf file`,
|
||||
);
|
||||
}
|
||||
return labelEntry;
|
||||
}
|
||||
}
|
151
src/config/backends/raspberry-pi.ts
Normal file
151
src/config/backends/raspberry-pi.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import * as _ from 'lodash';
|
||||
import { fs } from 'mz';
|
||||
|
||||
import {
|
||||
ConfigOptions,
|
||||
DeviceConfigBackend,
|
||||
bootMountPoint,
|
||||
remountAndWriteAtomic,
|
||||
} from './backend';
|
||||
import * as constants from '../../lib/constants';
|
||||
import log from '../../lib/supervisor-console';
|
||||
|
||||
/**
|
||||
* A backend to handle Raspberry Pi host configuration
|
||||
*
|
||||
* Supports:
|
||||
* - {BALENA|RESIN}_HOST_CONFIG_dtparam = value | "value" | "value1","value2"
|
||||
* - {BALENA|RESIN}_HOST_CONFIG_dtoverlay = value | "value" | "value1","value2"
|
||||
* - {BALENA|RESIN}_HOST_CONFIG_device_tree_param = value | "value" | "value1","value2"
|
||||
* - {BALENA|RESIN}_HOST_CONFIG_device_tree_overlay = value | "value" | "value1","value2"
|
||||
* - {BALENA|RESIN}_HOST_CONFIG_gpio = value | "value" | "value1","value2"
|
||||
*/
|
||||
|
||||
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',
|
||||
'gpio',
|
||||
];
|
||||
private static forbiddenConfigKeys = [
|
||||
'disable_commandline_tags',
|
||||
'cmdline',
|
||||
'kernel',
|
||||
'kernel_address',
|
||||
'kernel_old',
|
||||
'ramfsfile',
|
||||
'ramfsaddr',
|
||||
'initramfs',
|
||||
'device_tree_address',
|
||||
'init_emmc_clock',
|
||||
'avoid_safe_mode',
|
||||
];
|
||||
|
||||
public matches(deviceType: string): boolean {
|
||||
return deviceType.startsWith('raspberry') || deviceType === 'fincm3';
|
||||
}
|
||||
|
||||
public async getBootConfig(): Promise<ConfigOptions> {
|
||||
let configContents = '';
|
||||
|
||||
if (await fs.exists(RPiConfigBackend.bootConfigPath)) {
|
||||
configContents = await fs.readFile(
|
||||
RPiConfigBackend.bootConfigPath,
|
||||
'utf-8',
|
||||
);
|
||||
} else {
|
||||
await fs.writeFile(RPiConfigBackend.bootConfigPath, '');
|
||||
}
|
||||
|
||||
const conf: ConfigOptions = {};
|
||||
const configStatements = configContents.split(/\r?\n/);
|
||||
|
||||
for (const configStr of configStatements) {
|
||||
// Don't show warnings for comments and empty lines
|
||||
const trimmed = _.trimStart(configStr);
|
||||
if (trimmed.startsWith('#') || trimmed === '') {
|
||||
continue;
|
||||
}
|
||||
let keyValue = /^([^=]+)=(.*)$/.exec(configStr);
|
||||
if (keyValue != null) {
|
||||
const [, key, value] = keyValue;
|
||||
if (!RPiConfigBackend.arrayConfigKeys.includes(key)) {
|
||||
conf[key] = value;
|
||||
} else {
|
||||
if (conf[key] == null) {
|
||||
conf[key] = [];
|
||||
}
|
||||
const confArr = conf[key];
|
||||
if (!Array.isArray(confArr)) {
|
||||
throw new Error(
|
||||
`Expected '${key}' to have a config array but got ${typeof confArr}`,
|
||||
);
|
||||
}
|
||||
confArr.push(value);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Try the next regex instead
|
||||
keyValue = /^(initramfs) (.+)/.exec(configStr);
|
||||
if (keyValue != null) {
|
||||
const [, key, value] = keyValue;
|
||||
conf[key] = value;
|
||||
} else {
|
||||
log.warn(`Could not parse config.txt entry: ${configStr}. Ignoring.`);
|
||||
}
|
||||
}
|
||||
|
||||
return conf;
|
||||
}
|
||||
|
||||
public async setBootConfig(opts: ConfigOptions): Promise<void> {
|
||||
const confStatements = _.flatMap(opts, (value, key) => {
|
||||
if (key === 'initramfs') {
|
||||
return `${key} ${value}`;
|
||||
} else if (Array.isArray(value)) {
|
||||
return value.map((entry) => `${key}=${entry}`);
|
||||
} else {
|
||||
return `${key}=${value}`;
|
||||
}
|
||||
});
|
||||
const confStr = `${confStatements.join('\n')}\n`;
|
||||
await remountAndWriteAtomic(RPiConfigBackend.bootConfigPath, confStr);
|
||||
}
|
||||
|
||||
public isSupportedConfig(configName: string): boolean {
|
||||
return !RPiConfigBackend.forbiddenConfigKeys.includes(configName);
|
||||
}
|
||||
|
||||
public isBootConfigVar(envVar: string): boolean {
|
||||
return envVar.startsWith(RPiConfigBackend.bootConfigVarPrefix);
|
||||
}
|
||||
|
||||
public processConfigVarName(envVar: string): string {
|
||||
return envVar.replace(RPiConfigBackend.bootConfigVarRegex, '$1');
|
||||
}
|
||||
|
||||
public processConfigVarValue(key: string, value: string): string | string[] {
|
||||
if (RPiConfigBackend.arrayConfigKeys.includes(key)) {
|
||||
if (!value.startsWith('"')) {
|
||||
return [value];
|
||||
} else {
|
||||
return JSON.parse(`[${value}]`);
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
public createConfigVarName(configName: string): string {
|
||||
return RPiConfigBackend.bootConfigVarPrefix + configName;
|
||||
}
|
||||
}
|
@ -1,13 +1,10 @@
|
||||
import * as _ from 'lodash';
|
||||
|
||||
import { EnvVarObject } from '../lib/types';
|
||||
import {
|
||||
ConfigfsConfigBackend,
|
||||
ConfigOptions,
|
||||
DeviceConfigBackend,
|
||||
ExtlinuxConfigBackend,
|
||||
RPiConfigBackend,
|
||||
} from './backend';
|
||||
import { ExtlinuxConfigBackend } from './backends/extlinux';
|
||||
import { RPiConfigBackend } from './backends/raspberry-pi';
|
||||
import { ConfigfsConfigBackend } from './backends/config-fs';
|
||||
import { ConfigOptions, DeviceConfigBackend } from './backends/backend';
|
||||
|
||||
const configBackends = [
|
||||
new ExtlinuxConfigBackend(),
|
||||
|
@ -6,7 +6,7 @@ import { SchemaTypeKey } from './config/schema-type';
|
||||
import * as db from './db';
|
||||
import * as logger from './logger';
|
||||
|
||||
import { ConfigOptions, DeviceConfigBackend } from './config/backend';
|
||||
import { ConfigOptions, DeviceConfigBackend } from './config/backends/backend';
|
||||
import * as configUtils from './config/utils';
|
||||
import * as dbus from './lib/dbus';
|
||||
import { UnitNotLoadedError } from './lib/errors';
|
||||
|
@ -106,3 +106,7 @@ export class ContractViolationError extends TypedError {
|
||||
export class AppsJsonParseError extends TypedError {}
|
||||
export class DatabaseParseError extends TypedError {}
|
||||
export class BackupError extends TypedError {}
|
||||
|
||||
export class ExtLinuxParseError extends TypedError {}
|
||||
export class AppendDirectiveError extends TypedError {}
|
||||
export class FDTDirectiveError extends TypedError {}
|
||||
|
@ -10,7 +10,7 @@ import Log from '../src/lib/supervisor-console';
|
||||
import * as dockerUtils from '../src/lib/docker-utils';
|
||||
import * as config from '../src/config';
|
||||
import * as images from '../src/compose/images';
|
||||
import { RPiConfigBackend } from '../src/config/backend';
|
||||
import { RPiConfigBackend } from '../src/config/backends/raspberry-pi';
|
||||
import DeviceState from '../src/device-state';
|
||||
import { loadTargetFromFile } from '../src/device-state/preload';
|
||||
import Service from '../src/compose/service';
|
||||
|
@ -1,117 +1,107 @@
|
||||
import { Promise } from 'bluebird';
|
||||
import { stripIndent } from 'common-tags';
|
||||
import { child_process, fs } from 'mz';
|
||||
import { SinonSpy, SinonStub, stub } from 'sinon';
|
||||
import { SinonSpy, SinonStub, stub, spy } from 'sinon';
|
||||
|
||||
import { expect } from './lib/chai-config';
|
||||
import * as config from '../src/config';
|
||||
import { ExtlinuxConfigBackend, RPiConfigBackend } from '../src/config/backend';
|
||||
import { DeviceConfig } from '../src/device-config';
|
||||
import * as fsUtils from '../src/lib/fs-utils';
|
||||
import { expect } from './lib/chai-config';
|
||||
|
||||
import * as logger from '../src/logger';
|
||||
|
||||
import { ExtlinuxConfigBackend } from '../src/config/backends/extlinux';
|
||||
import { RPiConfigBackend } from '../src/config/backends/raspberry-pi';
|
||||
import { DeviceConfigBackend } from '../src/config/backends/backend';
|
||||
import prepare = require('./lib/prepare');
|
||||
|
||||
const extlinuxBackend = new ExtlinuxConfigBackend();
|
||||
const rpiConfigBackend = new RPiConfigBackend();
|
||||
|
||||
describe('DeviceConfig', function () {
|
||||
before(async function () {
|
||||
describe('Device Backend Config', () => {
|
||||
let deviceConfig: DeviceConfig;
|
||||
const logSpy = spy(logger, 'logSystemMessage');
|
||||
|
||||
before(async () => {
|
||||
await prepare();
|
||||
this.fakeConfig = {
|
||||
get(key: string) {
|
||||
return Promise.try(function () {
|
||||
if (key === 'deviceType') {
|
||||
return 'raspberrypi3';
|
||||
} else {
|
||||
throw new Error('Unknown fake config key');
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
this.logStub = stub(logger, 'logSystemMessage');
|
||||
return (this.deviceConfig = new DeviceConfig());
|
||||
deviceConfig = new DeviceConfig();
|
||||
});
|
||||
|
||||
after(function () {
|
||||
this.logStub.restore();
|
||||
after(() => {
|
||||
logSpy.restore();
|
||||
});
|
||||
|
||||
// Test that the format for special values like initramfs and array variables is parsed correctly
|
||||
it('allows getting boot config with getBootConfig', function () {
|
||||
afterEach(() => {
|
||||
logSpy.resetHistory();
|
||||
});
|
||||
|
||||
it('correctly parses a config.txt file', async () => {
|
||||
// Will try to parse /test/data/mnt/boot/config.txt
|
||||
await expect(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.getBootConfig(rpiConfigBackend),
|
||||
).to.eventually.deep.equal({
|
||||
HOST_CONFIG_dtparam: '"i2c_arm=on","spi=on","audio=on"',
|
||||
HOST_CONFIG_enable_uart: '1',
|
||||
HOST_CONFIG_disable_splash: '1',
|
||||
HOST_CONFIG_avoid_warnings: '1',
|
||||
HOST_CONFIG_gpu_mem: '16',
|
||||
});
|
||||
|
||||
// Stub readFile to return a config that has initramfs and array variables
|
||||
stub(fs, 'readFile').resolves(stripIndent`
|
||||
initramfs initramf.gz 0x00800000\n\
|
||||
dtparam=i2c=on\n\
|
||||
dtparam=audio=on\n\
|
||||
dtoverlay=ads7846\n\
|
||||
dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13\n\
|
||||
foobar=baz\n\
|
||||
initramfs initramf.gz 0x00800000
|
||||
dtparam=i2c=on
|
||||
dtparam=audio=on
|
||||
dtoverlay=ads7846
|
||||
dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13
|
||||
foobar=baz
|
||||
`);
|
||||
return this.deviceConfig
|
||||
.getBootConfig(rpiConfigBackend)
|
||||
.then(function (conf: any) {
|
||||
(fs.readFile as SinonStub).restore();
|
||||
return expect(conf).to.deep.equal({
|
||||
HOST_CONFIG_initramfs: 'initramf.gz 0x00800000',
|
||||
HOST_CONFIG_dtparam: '"i2c=on","audio=on"',
|
||||
HOST_CONFIG_dtoverlay:
|
||||
'"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"',
|
||||
HOST_CONFIG_foobar: 'baz',
|
||||
});
|
||||
});
|
||||
|
||||
await expect(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.getBootConfig(rpiConfigBackend),
|
||||
).to.eventually.deep.equal({
|
||||
HOST_CONFIG_initramfs: 'initramf.gz 0x00800000',
|
||||
HOST_CONFIG_dtparam: '"i2c=on","audio=on"',
|
||||
HOST_CONFIG_dtoverlay:
|
||||
'"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"',
|
||||
HOST_CONFIG_foobar: 'baz',
|
||||
});
|
||||
|
||||
// Restore stub
|
||||
(fs.readFile as SinonStub).restore();
|
||||
});
|
||||
|
||||
it('properly reads a real config.txt file', function () {
|
||||
return this.deviceConfig.getBootConfig(rpiConfigBackend).then((conf: any) =>
|
||||
expect(conf).to.deep.equal({
|
||||
HOST_CONFIG_dtparam: '"i2c_arm=on","spi=on","audio=on"',
|
||||
HOST_CONFIG_enable_uart: '1',
|
||||
HOST_CONFIG_disable_splash: '1',
|
||||
HOST_CONFIG_avoid_warnings: '1',
|
||||
HOST_CONFIG_gpu_mem: '16',
|
||||
}),
|
||||
it('does not allow setting forbidden keys', async () => {
|
||||
const current = {
|
||||
HOST_CONFIG_initramfs: 'initramf.gz 0x00800000',
|
||||
HOST_CONFIG_dtparam: '"i2c=on","audio=on"',
|
||||
HOST_CONFIG_dtoverlay:
|
||||
'"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"',
|
||||
HOST_CONFIG_foobar: 'baz',
|
||||
};
|
||||
// Create another target with only change being initramfs which is blacklisted
|
||||
const target = {
|
||||
...current,
|
||||
HOST_CONFIG_initramfs: 'initramf.gz 0x00810000',
|
||||
};
|
||||
|
||||
expect(() =>
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.bootConfigChangeRequired(rpiConfigBackend, current, target),
|
||||
).to.throw('Attempt to change blacklisted config value initramfs');
|
||||
|
||||
// Check if logs were called
|
||||
expect(logSpy).to.be.calledOnce;
|
||||
expect(logSpy).to.be.calledWith(
|
||||
'Attempt to change blacklisted config value initramfs',
|
||||
{
|
||||
error: 'Attempt to change blacklisted config value initramfs',
|
||||
},
|
||||
'Apply boot config error',
|
||||
);
|
||||
});
|
||||
|
||||
// Test that the format for special values like initramfs and array variables is preserved
|
||||
it('does not allow setting forbidden keys', function () {
|
||||
const current = {
|
||||
HOST_CONFIG_initramfs: 'initramf.gz 0x00800000',
|
||||
HOST_CONFIG_dtparam: '"i2c=on","audio=on"',
|
||||
HOST_CONFIG_dtoverlay:
|
||||
'"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"',
|
||||
HOST_CONFIG_foobar: 'baz',
|
||||
};
|
||||
const target = {
|
||||
HOST_CONFIG_initramfs: 'initramf.gz 0x00810000',
|
||||
HOST_CONFIG_dtparam: '"i2c=on","audio=on"',
|
||||
HOST_CONFIG_dtoverlay:
|
||||
'"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"',
|
||||
HOST_CONFIG_foobar: 'baz',
|
||||
};
|
||||
const promise = Promise.try(() => {
|
||||
return this.deviceConfig.bootConfigChangeRequired(
|
||||
rpiConfigBackend,
|
||||
current,
|
||||
target,
|
||||
);
|
||||
});
|
||||
expect(promise).to.be.rejected;
|
||||
return promise.catch((_err) => {
|
||||
expect(this.logStub).to.be.calledOnce;
|
||||
expect(this.logStub).to.be.calledWith(
|
||||
'Attempt to change blacklisted config value initramfs',
|
||||
{
|
||||
error: 'Attempt to change blacklisted config value initramfs',
|
||||
},
|
||||
'Apply boot config error',
|
||||
);
|
||||
return this.logStub.resetHistory();
|
||||
});
|
||||
});
|
||||
|
||||
it('does not try to change config.txt if it should not change', function () {
|
||||
it('does not try to change config.txt if it should not change', async () => {
|
||||
const current = {
|
||||
HOST_CONFIG_initramfs: 'initramf.gz 0x00800000',
|
||||
HOST_CONFIG_dtparam: '"i2c=on","audio=on"',
|
||||
@ -126,21 +116,15 @@ describe('DeviceConfig', function () {
|
||||
'"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"',
|
||||
HOST_CONFIG_foobar: 'baz',
|
||||
};
|
||||
const promise = Promise.try(() => {
|
||||
return this.deviceConfig.bootConfigChangeRequired(
|
||||
rpiConfigBackend,
|
||||
current,
|
||||
target,
|
||||
);
|
||||
});
|
||||
expect(promise).to.eventually.equal(false);
|
||||
return promise.then(() => {
|
||||
expect(this.logStub).to.not.be.called;
|
||||
return this.logStub.resetHistory();
|
||||
});
|
||||
|
||||
expect(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.bootConfigChangeRequired(rpiConfigBackend, current, target),
|
||||
).to.equal(false);
|
||||
expect(logSpy).to.not.be.called;
|
||||
});
|
||||
|
||||
it('writes the target config.txt', function () {
|
||||
it('writes the target config.txt', async () => {
|
||||
stub(fsUtils, 'writeFileAtomic').resolves();
|
||||
stub(child_process, 'exec').resolves();
|
||||
const current = {
|
||||
@ -157,44 +141,37 @@ describe('DeviceConfig', function () {
|
||||
HOST_CONFIG_foobar: 'bat',
|
||||
HOST_CONFIG_foobaz: 'bar',
|
||||
};
|
||||
const promise = Promise.try(() => {
|
||||
return this.deviceConfig.bootConfigChangeRequired(
|
||||
rpiConfigBackend,
|
||||
current,
|
||||
target,
|
||||
);
|
||||
});
|
||||
expect(promise).to.eventually.equal(true);
|
||||
return promise.then(() => {
|
||||
return this.deviceConfig
|
||||
.setBootConfig(rpiConfigBackend, target)
|
||||
.then(() => {
|
||||
expect(child_process.exec).to.be.calledOnce;
|
||||
expect(this.logStub).to.be.calledTwice;
|
||||
expect(this.logStub.getCall(1).args[2]).to.equal(
|
||||
'Apply boot config success',
|
||||
);
|
||||
expect(fsUtils.writeFileAtomic).to.be.calledWith(
|
||||
'./test/data/mnt/boot/config.txt',
|
||||
`\
|
||||
initramfs initramf.gz 0x00800000\n\
|
||||
dtparam=i2c=on\n\
|
||||
dtparam=audio=off\n\
|
||||
dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13\n\
|
||||
foobar=bat\n\
|
||||
foobaz=bar\n\
|
||||
`,
|
||||
);
|
||||
(fsUtils.writeFileAtomic as SinonStub).restore();
|
||||
(child_process.exec as SinonStub).restore();
|
||||
return this.logStub.resetHistory();
|
||||
});
|
||||
});
|
||||
|
||||
expect(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.bootConfigChangeRequired(rpiConfigBackend, current, target),
|
||||
).to.equal(true);
|
||||
|
||||
// @ts-ignore accessing private value
|
||||
await deviceConfig.setBootConfig(rpiConfigBackend, target);
|
||||
expect(child_process.exec).to.be.calledOnce;
|
||||
expect(logSpy).to.be.calledTwice;
|
||||
expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success');
|
||||
expect(fsUtils.writeFileAtomic).to.be.calledWith(
|
||||
'./test/data/mnt/boot/config.txt',
|
||||
stripIndent`
|
||||
initramfs initramf.gz 0x00800000
|
||||
dtparam=i2c=on
|
||||
dtparam=audio=off
|
||||
dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13
|
||||
foobar=bat
|
||||
foobaz=bar
|
||||
` + '\n', // add newline because stripIndent trims last newline
|
||||
);
|
||||
|
||||
// Restore stubs
|
||||
(fsUtils.writeFileAtomic as SinonStub).restore();
|
||||
(child_process.exec as SinonStub).restore();
|
||||
});
|
||||
|
||||
it('accepts RESIN_ and BALENA_ variables', function () {
|
||||
return this.deviceConfig
|
||||
.formatConfigKeys({
|
||||
it('accepts RESIN_ and BALENA_ variables', async () => {
|
||||
return expect(
|
||||
deviceConfig.formatConfigKeys({
|
||||
FOO: 'bar',
|
||||
BAR: 'baz',
|
||||
RESIN_HOST_CONFIG_foo: 'foobaz',
|
||||
@ -202,19 +179,17 @@ foobaz=bar\n\
|
||||
RESIN_HOST_CONFIG_other: 'val',
|
||||
BALENA_HOST_CONFIG_baz: 'bad',
|
||||
BALENA_SUPERVISOR_POLL_INTERVAL: '100',
|
||||
})
|
||||
.then((filteredConf: any) =>
|
||||
expect(filteredConf).to.deep.equal({
|
||||
HOST_CONFIG_foo: 'foobar',
|
||||
HOST_CONFIG_other: 'val',
|
||||
HOST_CONFIG_baz: 'bad',
|
||||
SUPERVISOR_POLL_INTERVAL: '100',
|
||||
}),
|
||||
);
|
||||
}),
|
||||
).to.eventually.deep.equal({
|
||||
HOST_CONFIG_foo: 'foobar',
|
||||
HOST_CONFIG_other: 'val',
|
||||
HOST_CONFIG_baz: 'bad',
|
||||
SUPERVISOR_POLL_INTERVAL: '100',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns default configuration values', function () {
|
||||
const conf = this.deviceConfig.getDefaults();
|
||||
it('returns default configuration values', () => {
|
||||
const conf = deviceConfig.getDefaults();
|
||||
return expect(conf).to.deep.equal({
|
||||
SUPERVISOR_VPN_CONTROL: 'true',
|
||||
SUPERVISOR_POLL_INTERVAL: '60000',
|
||||
@ -233,54 +208,49 @@ foobaz=bar\n\
|
||||
});
|
||||
});
|
||||
|
||||
describe('Extlinux files', () =>
|
||||
it('should correctly write to extlinux.conf files', function () {
|
||||
describe('Extlinux files', () => {
|
||||
it('should correctly write to extlinux.conf files', async () => {
|
||||
stub(fsUtils, 'writeFileAtomic').resolves();
|
||||
stub(child_process, 'exec').resolves();
|
||||
|
||||
const current = {};
|
||||
const target = {
|
||||
HOST_EXTLINUX_isolcpus: '2',
|
||||
HOST_EXTLINUX_fdt: '/boot/mycustomdtb.dtb',
|
||||
};
|
||||
|
||||
const promise = Promise.try(() => {
|
||||
return this.deviceConfig.bootConfigChangeRequired(
|
||||
extlinuxBackend,
|
||||
current,
|
||||
target,
|
||||
);
|
||||
});
|
||||
expect(promise).to.eventually.equal(true);
|
||||
return promise.then(() => {
|
||||
return this.deviceConfig
|
||||
.setBootConfig(extlinuxBackend, target)
|
||||
.then(() => {
|
||||
expect(child_process.exec).to.be.calledOnce;
|
||||
expect(this.logStub).to.be.calledTwice;
|
||||
expect(this.logStub.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 as SinonStub).restore();
|
||||
(child_process.exec as SinonStub).restore();
|
||||
return this.logStub.resetHistory();
|
||||
});
|
||||
});
|
||||
}));
|
||||
expect(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.bootConfigChangeRequired(extlinuxBackend, current, target),
|
||||
).to.equal(true);
|
||||
|
||||
describe('Balena fin', function () {
|
||||
it('should always add the balena-fin dtoverlay', function () {
|
||||
// @ts-ignore accessing private value
|
||||
await deviceConfig.setBootConfig(extlinuxBackend, target);
|
||||
expect(child_process.exec).to.be.calledOnce;
|
||||
expect(logSpy).to.be.calledTwice;
|
||||
expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success');
|
||||
expect(fsUtils.writeFileAtomic).to.be.calledWith(
|
||||
'./test/data/mnt/boot/extlinux/extlinux.conf',
|
||||
stripIndent`
|
||||
DEFAULT primary
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
MENU LABEL primary Image
|
||||
LINUX /Image
|
||||
APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2
|
||||
FDT /boot/mycustomdtb.dtb
|
||||
` + '\n', // add newline because stripIndent trims last newline
|
||||
);
|
||||
|
||||
// Restore stubs
|
||||
(fsUtils.writeFileAtomic as SinonStub).restore();
|
||||
(child_process.exec as SinonStub).restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Balena fin', () => {
|
||||
it('should always add the balena-fin dtoverlay', () => {
|
||||
expect(
|
||||
(DeviceConfig as any).ensureRequiredOverlay('fincm3', {}),
|
||||
).to.deep.equal({ dtoverlay: ['balena-fin'] });
|
||||
@ -301,16 +271,17 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\
|
||||
dtoverlay: 'test',
|
||||
}),
|
||||
).to.deep.equal({ dtoverlay: ['test', 'balena-fin'] });
|
||||
return expect(
|
||||
expect(
|
||||
(DeviceConfig as any).ensureRequiredOverlay('fincm3', {
|
||||
dtoverlay: ['test'],
|
||||
}),
|
||||
).to.deep.equal({ dtoverlay: ['test', 'balena-fin'] });
|
||||
});
|
||||
|
||||
return it('should not cause a config change when the cloud does not specify the balena-fin overlay', function () {
|
||||
it('should not cause a config change when the cloud does not specify the balena-fin overlay', () => {
|
||||
expect(
|
||||
this.deviceConfig.bootConfigChangeRequired(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.bootConfigChangeRequired(
|
||||
rpiConfigBackend,
|
||||
{ HOST_CONFIG_dtoverlay: '"test","balena-fin"' },
|
||||
{ HOST_CONFIG_dtoverlay: '"test"' },
|
||||
@ -319,7 +290,8 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\
|
||||
).to.equal(false);
|
||||
|
||||
expect(
|
||||
this.deviceConfig.bootConfigChangeRequired(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.bootConfigChangeRequired(
|
||||
rpiConfigBackend,
|
||||
{ HOST_CONFIG_dtoverlay: '"test","balena-fin"' },
|
||||
{ HOST_CONFIG_dtoverlay: 'test' },
|
||||
@ -327,8 +299,9 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\
|
||||
),
|
||||
).to.equal(false);
|
||||
|
||||
return expect(
|
||||
this.deviceConfig.bootConfigChangeRequired(
|
||||
expect(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.bootConfigChangeRequired(
|
||||
rpiConfigBackend,
|
||||
{ HOST_CONFIG_dtoverlay: '"test","test2","balena-fin"' },
|
||||
{ HOST_CONFIG_dtoverlay: '"test","test2"' },
|
||||
@ -338,8 +311,8 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\
|
||||
});
|
||||
});
|
||||
|
||||
describe('Raspberry pi4', function () {
|
||||
it('should always add the vc4-fkms-v3d dtoverlay', function () {
|
||||
describe('Raspberry pi4', () => {
|
||||
it('should always add the vc4-fkms-v3d dtoverlay', () => {
|
||||
expect(
|
||||
(DeviceConfig as any).ensureRequiredOverlay('raspberrypi4-64', {}),
|
||||
).to.deep.equal({ dtoverlay: ['vc4-fkms-v3d'] });
|
||||
@ -360,34 +333,35 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\
|
||||
dtoverlay: 'test',
|
||||
}),
|
||||
).to.deep.equal({ dtoverlay: ['test', 'vc4-fkms-v3d'] });
|
||||
return expect(
|
||||
expect(
|
||||
(DeviceConfig as any).ensureRequiredOverlay('raspberrypi4-64', {
|
||||
dtoverlay: ['test'],
|
||||
}),
|
||||
).to.deep.equal({ dtoverlay: ['test', 'vc4-fkms-v3d'] });
|
||||
});
|
||||
|
||||
return it('should not cause a config change when the cloud does not specify the pi4 overlay', function () {
|
||||
it('should not cause a config change when the cloud does not specify the pi4 overlay', () => {
|
||||
expect(
|
||||
this.deviceConfig.bootConfigChangeRequired(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.bootConfigChangeRequired(
|
||||
rpiConfigBackend,
|
||||
{ HOST_CONFIG_dtoverlay: '"test","vc4-fkms-v3d"' },
|
||||
{ HOST_CONFIG_dtoverlay: '"test"' },
|
||||
'raspberrypi4-64',
|
||||
),
|
||||
).to.equal(false);
|
||||
|
||||
expect(
|
||||
this.deviceConfig.bootConfigChangeRequired(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.bootConfigChangeRequired(
|
||||
rpiConfigBackend,
|
||||
{ HOST_CONFIG_dtoverlay: '"test","vc4-fkms-v3d"' },
|
||||
{ HOST_CONFIG_dtoverlay: 'test' },
|
||||
'raspberrypi4-64',
|
||||
),
|
||||
).to.equal(false);
|
||||
|
||||
return expect(
|
||||
this.deviceConfig.bootConfigChangeRequired(
|
||||
expect(
|
||||
// @ts-ignore accessing private value
|
||||
deviceConfig.bootConfigChangeRequired(
|
||||
rpiConfigBackend,
|
||||
{ HOST_CONFIG_dtoverlay: '"test","test2","vc4-fkms-v3d"' },
|
||||
{ HOST_CONFIG_dtoverlay: '"test","test2"' },
|
||||
@ -397,23 +371,18 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\
|
||||
});
|
||||
});
|
||||
|
||||
describe('ConfigFS', function () {
|
||||
before(function () {
|
||||
stub(config, 'get').callsFake((key) => {
|
||||
return Promise.try(function () {
|
||||
if (key === 'deviceType') {
|
||||
return 'up-board';
|
||||
}
|
||||
throw new Error('Unknown fake config key');
|
||||
});
|
||||
});
|
||||
this.upboardConfig = new DeviceConfig();
|
||||
describe('ConfigFS', () => {
|
||||
const upboardConfig = new DeviceConfig();
|
||||
let upboardConfigBackend: DeviceConfigBackend | null;
|
||||
|
||||
before(async () => {
|
||||
stub(child_process, 'exec').resolves();
|
||||
stub(fs, 'exists').callsFake(() => Promise.resolve(true));
|
||||
stub(fs, 'exists').resolves(true);
|
||||
stub(fs, 'mkdir').resolves();
|
||||
stub(fs, 'readdir').callsFake(() => Promise.resolve([]));
|
||||
stub(fs, 'readFile').callsFake(function (file) {
|
||||
stub(fs, 'readdir').resolves([]);
|
||||
stub(fsUtils, 'writeFileAtomic').resolves();
|
||||
|
||||
stub(fs, 'readFile').callsFake((file) => {
|
||||
if (file === 'test/data/mnt/boot/configfs.json') {
|
||||
return Promise.resolve(
|
||||
JSON.stringify({
|
||||
@ -423,21 +392,26 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\
|
||||
}
|
||||
return Promise.resolve('');
|
||||
});
|
||||
stub(fsUtils, 'writeFileAtomic').resolves();
|
||||
|
||||
return Promise.try(() => {
|
||||
return this.upboardConfig.getConfigBackend();
|
||||
}).then((backend) => {
|
||||
this.upboardConfigBackend = backend;
|
||||
expect(this.upboardConfigBackend).is.not.null;
|
||||
return expect((child_process.exec as SinonSpy).callCount).to.equal(
|
||||
3,
|
||||
'exec not called enough times',
|
||||
);
|
||||
stub(config, 'get').callsFake((key) => {
|
||||
return Promise.try(() => {
|
||||
if (key === 'deviceType') {
|
||||
return 'up-board';
|
||||
}
|
||||
throw new Error('Unknown fake config key');
|
||||
});
|
||||
});
|
||||
|
||||
// @ts-ignore accessing private value
|
||||
upboardConfigBackend = await upboardConfig.getConfigBackend();
|
||||
expect(upboardConfigBackend).is.not.null;
|
||||
expect((child_process.exec as SinonSpy).callCount).to.equal(
|
||||
3,
|
||||
'exec not called enough times',
|
||||
);
|
||||
});
|
||||
|
||||
after(function () {
|
||||
after(() => {
|
||||
(child_process.exec as SinonStub).restore();
|
||||
(fs.exists as SinonStub).restore();
|
||||
(fs.mkdir as SinonStub).restore();
|
||||
@ -445,62 +419,50 @@ APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2\n\
|
||||
(fs.readFile as SinonStub).restore();
|
||||
(fsUtils.writeFileAtomic as SinonStub).restore();
|
||||
(config.get as SinonStub).restore();
|
||||
this.logStub.resetHistory();
|
||||
});
|
||||
|
||||
it('should correctly load the configfs.json file', function () {
|
||||
it('should correctly load the configfs.json file', () => {
|
||||
expect(child_process.exec).to.be.calledWith('modprobe acpi_configfs');
|
||||
expect(child_process.exec).to.be.calledWith(
|
||||
'cat test/data/boot/acpi-tables/spidev1,1.aml > test/data/sys/kernel/config/acpi/table/spidev1,1/aml',
|
||||
);
|
||||
|
||||
expect((fs.exists as SinonSpy).callCount).to.equal(2);
|
||||
return expect((fs.readFile as SinonSpy).callCount).to.equal(4);
|
||||
expect((fs.readFile as SinonSpy).callCount).to.equal(4);
|
||||
});
|
||||
|
||||
it('should correctly write the configfs.json file', function () {
|
||||
it('should correctly write the configfs.json file', async () => {
|
||||
const current = {};
|
||||
const target = {
|
||||
HOST_CONFIGFS_ssdt: 'spidev1,1',
|
||||
};
|
||||
|
||||
this.logStub.resetHistory();
|
||||
(child_process.exec as SinonSpy).resetHistory();
|
||||
(fs.exists as SinonSpy).resetHistory();
|
||||
(fs.mkdir as SinonSpy).resetHistory();
|
||||
(fs.readdir as SinonSpy).resetHistory();
|
||||
(fs.readFile as SinonSpy).resetHistory();
|
||||
|
||||
return Promise.try(() => {
|
||||
expect(this.upboardConfigBackend).is.not.null;
|
||||
return this.upboardConfig.bootConfigChangeRequired(
|
||||
this.upboardConfigBackend,
|
||||
current,
|
||||
target,
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
return this.upboardConfig.setBootConfig(
|
||||
this.upboardConfigBackend,
|
||||
target,
|
||||
);
|
||||
})
|
||||
.then(() => {
|
||||
expect(child_process.exec).to.be.calledOnce;
|
||||
expect(fsUtils.writeFileAtomic).to.be.calledWith(
|
||||
'test/data/mnt/boot/configfs.json',
|
||||
JSON.stringify({
|
||||
ssdt: ['spidev1,1'],
|
||||
}),
|
||||
);
|
||||
expect(this.logStub).to.be.calledTwice;
|
||||
return expect(this.logStub.getCall(1).args[2]).to.equal(
|
||||
'Apply boot config success',
|
||||
);
|
||||
});
|
||||
// @ts-ignore accessing private value
|
||||
upboardConfig.bootConfigChangeRequired(
|
||||
upboardConfigBackend,
|
||||
current,
|
||||
target,
|
||||
);
|
||||
// @ts-ignore accessing private value
|
||||
await upboardConfig.setBootConfig(upboardConfigBackend, target);
|
||||
|
||||
expect(child_process.exec).to.be.calledOnce;
|
||||
expect(fsUtils.writeFileAtomic).to.be.calledWith(
|
||||
'test/data/mnt/boot/configfs.json',
|
||||
JSON.stringify({
|
||||
ssdt: ['spidev1,1'],
|
||||
}),
|
||||
);
|
||||
expect(logSpy).to.be.calledTwice;
|
||||
expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success');
|
||||
});
|
||||
});
|
||||
|
||||
// This will require stubbing device.reboot, gosuper.post, config.get/set
|
||||
return it('applies the target state');
|
||||
it('applies the target state');
|
||||
});
|
||||
|
@ -1,132 +1,25 @@
|
||||
import { expect } from './lib/chai-config';
|
||||
import { stub } from 'sinon';
|
||||
import { fs } from 'mz';
|
||||
import * as configUtils from '../src/config/utils';
|
||||
import { ExtlinuxConfigBackend, RPiConfigBackend } from '../src/config/backend';
|
||||
import { RPiConfigBackend } from '../src/config/backends/raspberry-pi';
|
||||
|
||||
const extlinuxBackend = new ExtlinuxConfigBackend();
|
||||
const rpiBackend = new RPiConfigBackend();
|
||||
|
||||
describe('Config Utilities', () =>
|
||||
describe('Boot config utilities', function () {
|
||||
describe('Env <-> Config', () =>
|
||||
it('correctly transforms environments to boot config objects', function () {
|
||||
const bootConfig = configUtils.envToBootConfig(rpiBackend, {
|
||||
HOST_CONFIG_initramfs: 'initramf.gz 0x00800000',
|
||||
HOST_CONFIG_dtparam: '"i2c=on","audio=on"',
|
||||
HOST_CONFIG_dtoverlay:
|
||||
'"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"',
|
||||
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', function () {
|
||||
it('should parse a extlinux.conf file', function () {
|
||||
const 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\
|
||||
`;
|
||||
|
||||
// @ts-ignore accessing private method
|
||||
const 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');
|
||||
const { primary } = parsed.labels;
|
||||
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');
|
||||
describe('Config Utilities', () => {
|
||||
describe('Boot config', () => {
|
||||
it('correctly transforms environments to boot config objects', () => {
|
||||
const bootConfig = configUtils.envToBootConfig(rpiBackend, {
|
||||
HOST_CONFIG_initramfs: 'initramf.gz 0x00800000',
|
||||
HOST_CONFIG_dtparam: '"i2c=on","audio=on"',
|
||||
HOST_CONFIG_dtoverlay:
|
||||
'"ads7846","lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"',
|
||||
HOST_CONFIG_foobar: 'baz',
|
||||
});
|
||||
|
||||
it('should parse multiple service entries', function () {
|
||||
const text = `\
|
||||
DEFAULT primary
|
||||
# Comment
|
||||
TIMEOUT 30
|
||||
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
LINUX test1
|
||||
APPEND test2
|
||||
LABEL secondary
|
||||
LINUX test3
|
||||
APPEND test4\
|
||||
`;
|
||||
|
||||
// @ts-ignore accessing private method
|
||||
const 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', function () {
|
||||
let 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\
|
||||
`;
|
||||
|
||||
let readFileStub = stub(fs, 'readFile').resolves(text);
|
||||
let parsed = extlinuxBackend.getBootConfig();
|
||||
|
||||
expect(parsed).to.eventually.have.property('isolcpus').that.equals('3');
|
||||
readFileStub.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\
|
||||
`;
|
||||
readFileStub = stub(fs, 'readFile').resolves(text);
|
||||
|
||||
parsed = extlinuxBackend.getBootConfig();
|
||||
|
||||
readFileStub.restore();
|
||||
|
||||
expect(parsed)
|
||||
.to.eventually.have.property('isolcpus')
|
||||
.that.equals('3,4,5');
|
||||
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',
|
||||
});
|
||||
});
|
||||
}));
|
||||
});
|
||||
});
|
||||
|
326
test/27-extlinux-config.spec.ts
Normal file
326
test/27-extlinux-config.spec.ts
Normal file
@ -0,0 +1,326 @@
|
||||
import { child_process, fs } from 'mz';
|
||||
import { stripIndent } from 'common-tags';
|
||||
import { SinonStub, stub } from 'sinon';
|
||||
|
||||
import { expect } from './lib/chai-config';
|
||||
import * as fsUtils from '../src/lib/fs-utils';
|
||||
import { ExtlinuxConfigBackend } from '../src/config/backends/extlinux';
|
||||
|
||||
describe('Extlinux Configuration', () => {
|
||||
const backend = new ExtlinuxConfigBackend();
|
||||
|
||||
it('should parse a extlinux.conf file', () => {
|
||||
const text = stripIndent`\
|
||||
DEFAULT primary
|
||||
# CommentExtlinux files
|
||||
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
MENU LABEL primary Image
|
||||
LINUX /Image
|
||||
FDT /boot/mycustomdtb.dtb
|
||||
APPEND \${cbootargs} \${resin_kernel_root} ro rootwait\
|
||||
`;
|
||||
|
||||
// @ts-ignore accessing private method
|
||||
const 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');
|
||||
const { primary } = parsed.labels;
|
||||
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('FDT')
|
||||
.that.equals('/boot/mycustomdtb.dtb');
|
||||
expect(primary)
|
||||
.to.have.property('APPEND')
|
||||
.that.equals('${cbootargs} ${resin_kernel_root} ro rootwait');
|
||||
});
|
||||
|
||||
it('should parse multiple service entries', () => {
|
||||
const text = stripIndent`\
|
||||
DEFAULT primary
|
||||
# Comment
|
||||
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
LINUX test1
|
||||
FDT /boot/mycustomdtb.dtb
|
||||
APPEND test2
|
||||
LABEL secondary
|
||||
LINUX test3
|
||||
FDT /boot/mycustomdtb.dtb
|
||||
APPEND test4\
|
||||
`;
|
||||
|
||||
// @ts-ignore accessing private method
|
||||
const parsed = ExtlinuxConfigBackend.parseExtlinuxFile(text);
|
||||
expect(parsed.labels).to.have.property('primary').that.deep.equals({
|
||||
LINUX: 'test1',
|
||||
FDT: '/boot/mycustomdtb.dtb',
|
||||
APPEND: 'test2',
|
||||
});
|
||||
expect(parsed.labels).to.have.property('secondary').that.deep.equals({
|
||||
LINUX: 'test3',
|
||||
FDT: '/boot/mycustomdtb.dtb',
|
||||
APPEND: 'test4',
|
||||
});
|
||||
});
|
||||
|
||||
it('should parse configuration options from an extlinux.conf file', async () => {
|
||||
let text = stripIndent`\
|
||||
DEFAULT primary
|
||||
# Comment
|
||||
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
MENU LABEL primary Image
|
||||
LINUX /Image
|
||||
FDT /boot/mycustomdtb.dtb
|
||||
APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=3\
|
||||
`;
|
||||
|
||||
let readFileStub = stub(fs, 'readFile').resolves(text);
|
||||
let parsed = backend.getBootConfig();
|
||||
|
||||
await expect(parsed)
|
||||
.to.eventually.have.property('isolcpus')
|
||||
.that.equals('3');
|
||||
await expect(parsed)
|
||||
.to.eventually.have.property('fdt')
|
||||
.that.equals('/boot/mycustomdtb.dtb');
|
||||
readFileStub.restore();
|
||||
|
||||
text = stripIndent`\
|
||||
DEFAULT primary
|
||||
# Comment
|
||||
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
MENU LABEL primary Image
|
||||
LINUX /Image
|
||||
FDT /boot/mycustomdtb.dtb
|
||||
APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=3,4,5\
|
||||
`;
|
||||
readFileStub = stub(fs, 'readFile').resolves(text);
|
||||
|
||||
parsed = backend.getBootConfig();
|
||||
|
||||
readFileStub.restore();
|
||||
|
||||
await expect(parsed)
|
||||
.to.eventually.have.property('isolcpus')
|
||||
.that.equals('3,4,5');
|
||||
});
|
||||
|
||||
it('only matches supported devices', () => {
|
||||
[
|
||||
{ deviceType: 'jetson-tx', supported: true },
|
||||
{ deviceType: 'raspberry', supported: false },
|
||||
{ deviceType: 'fincm3', supported: false },
|
||||
{ deviceType: 'up-board', supported: false },
|
||||
].forEach(({ deviceType, supported }) =>
|
||||
expect(backend.matches(deviceType)).to.equal(supported),
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when cannot find extlinux.conf', async () => {
|
||||
// Stub readFile to reject much like if the file didn't exist
|
||||
stub(fs, 'readFile').rejects();
|
||||
await expect(backend.getBootConfig()).to.eventually.be.rejectedWith(
|
||||
'Could not find extlinux file. Device is possibly bricked',
|
||||
);
|
||||
// Restore stub
|
||||
(fs.readFile as SinonStub).restore();
|
||||
});
|
||||
|
||||
it('throws error for malformed extlinux.conf', async () => {
|
||||
for (const badConfig of MALFORMED_CONFIGS) {
|
||||
// Stub bad config
|
||||
stub(fs, 'readFile').resolves(badConfig.contents);
|
||||
// Expect correct rejection from the given bad config
|
||||
try {
|
||||
await backend.getBootConfig();
|
||||
} catch (e) {
|
||||
expect(e.message).to.equal(badConfig.reason);
|
||||
}
|
||||
// Restore stub
|
||||
(fs.readFile as SinonStub).restore();
|
||||
}
|
||||
});
|
||||
|
||||
it('parses supported config values from bootConfigPath', async () => {
|
||||
// Will try to parse /test/data/mnt/boot/extlinux/extlinux.conf
|
||||
await expect(backend.getBootConfig()).to.eventually.deep.equal({}); // None of the values are supported so returns empty
|
||||
|
||||
// Stub readFile to return a config that has supported values
|
||||
stub(fs, 'readFile').resolves(stripIndent`
|
||||
DEFAULT primary
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
MENU LABEL primary Image
|
||||
LINUX /Image
|
||||
FDT /boot/mycustomdtb.dtb
|
||||
APPEND ro rootwait isolcpus=0,4
|
||||
`);
|
||||
|
||||
await expect(backend.getBootConfig()).to.eventually.deep.equal({
|
||||
isolcpus: '0,4',
|
||||
fdt: '/boot/mycustomdtb.dtb',
|
||||
});
|
||||
|
||||
// Restore stub
|
||||
(fs.readFile as SinonStub).restore();
|
||||
});
|
||||
|
||||
it('sets new config values', async () => {
|
||||
stub(fsUtils, 'writeFileAtomic').resolves();
|
||||
stub(child_process, 'exec').resolves();
|
||||
|
||||
await backend.setBootConfig({
|
||||
fdt: '/boot/mycustomdtb.dtb',
|
||||
isolcpus: '2',
|
||||
});
|
||||
|
||||
expect(fsUtils.writeFileAtomic).to.be.calledWith(
|
||||
'./test/data/mnt/boot/extlinux/extlinux.conf',
|
||||
stripIndent`
|
||||
DEFAULT primary
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
MENU LABEL primary Image
|
||||
LINUX /Image
|
||||
APPEND \${cbootargs} \${resin_kernel_root} ro rootwait isolcpus=2
|
||||
FDT /boot/mycustomdtb.dtb
|
||||
` + '\n', // add newline because stripIndent trims last newline
|
||||
);
|
||||
|
||||
// Restore stubs
|
||||
(fsUtils.writeFileAtomic as SinonStub).restore();
|
||||
(child_process.exec as SinonStub).restore();
|
||||
});
|
||||
|
||||
it('only allows supported configuration options', () => {
|
||||
[
|
||||
{ configName: 'isolcpus', supported: true },
|
||||
{ configName: 'fdt', supported: true },
|
||||
{ configName: '', supported: false },
|
||||
{ configName: 'ro', supported: false }, // not allowed to configure
|
||||
{ configName: 'rootwait', supported: false }, // not allowed to configure
|
||||
].forEach(({ configName, supported }) =>
|
||||
expect(backend.isSupportedConfig(configName)).to.equal(supported),
|
||||
);
|
||||
});
|
||||
|
||||
it('correctly detects boot config variables', () => {
|
||||
[
|
||||
{ config: 'HOST_EXTLINUX_isolcpus', valid: true },
|
||||
{ config: 'HOST_EXTLINUX_fdt', valid: true },
|
||||
{ config: 'HOST_EXTLINUX_rootwait', valid: true },
|
||||
{ config: 'HOST_EXTLINUX_5', valid: true },
|
||||
// TO-DO: { config: 'HOST_EXTLINUX', valid: false },
|
||||
// TO-DO: { config: 'HOST_EXTLINUX_', valid: false },
|
||||
{ config: 'DEVICE_EXTLINUX_isolcpus', valid: false },
|
||||
{ config: 'isolcpus', valid: false },
|
||||
].forEach(({ config, valid }) =>
|
||||
expect(backend.isBootConfigVar(config)).to.equal(valid),
|
||||
);
|
||||
});
|
||||
|
||||
it('converts variable to backend formatted name', () => {
|
||||
[
|
||||
{ input: 'HOST_EXTLINUX_isolcpus', output: 'isolcpus' },
|
||||
{ input: 'HOST_EXTLINUX_fdt', output: 'fdt' },
|
||||
{ input: 'HOST_EXTLINUX_rootwait', output: 'rootwait' },
|
||||
{ input: 'HOST_EXTLINUX_something_else', output: 'something_else' },
|
||||
{ input: 'HOST_EXTLINUX_', output: 'HOST_EXTLINUX_' },
|
||||
{ input: 'HOST_EXTLINUX_ ', output: ' ' },
|
||||
{ input: 'ROOT_EXTLINUX_isolcpus', output: 'ROOT_EXTLINUX_isolcpus' },
|
||||
].forEach(({ input, output }) =>
|
||||
expect(backend.processConfigVarName(input)).to.equal(output),
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes variable value', () => {
|
||||
[
|
||||
{ input: { key: 'key', value: 'value' }, output: 'value' },
|
||||
].forEach(({ input, output }) =>
|
||||
expect(backend.processConfigVarValue(input.key, input.value)).to.equal(
|
||||
output,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the environment name for config variable', () => {
|
||||
[
|
||||
{ input: 'isolcpus', output: 'HOST_EXTLINUX_isolcpus' },
|
||||
{ input: 'fdt', output: 'HOST_EXTLINUX_fdt' },
|
||||
{ input: 'rootwait', output: 'HOST_EXTLINUX_rootwait' },
|
||||
{ input: '', output: 'HOST_EXTLINUX_' },
|
||||
{ input: '5', output: 'HOST_EXTLINUX_5' },
|
||||
].forEach(({ input, output }) =>
|
||||
expect(backend.createConfigVarName(input)).to.equal(output),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const MALFORMED_CONFIGS = [
|
||||
{
|
||||
contents: stripIndent`
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
MENU LABEL primary Image
|
||||
LINUX /Image
|
||||
APPEND ro rootwait isolcpus=0,4
|
||||
`,
|
||||
reason: 'Could not find default entry for extlinux.conf file',
|
||||
},
|
||||
{
|
||||
contents: stripIndent`
|
||||
DEFAULT typo_oops
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
MENU LABEL primary Image
|
||||
LINUX /Image
|
||||
APPEND ro rootwait isolcpus=0,4
|
||||
`,
|
||||
reason: 'Cannot find label entry (label: typo_oops) for extlinux.conf file',
|
||||
},
|
||||
{
|
||||
contents: stripIndent`
|
||||
DEFAULT primary
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
MENU LABEL primary Image
|
||||
LINUX /Image
|
||||
`,
|
||||
reason:
|
||||
'Could not find APPEND directive in default extlinux.conf boot entry',
|
||||
},
|
||||
{
|
||||
contents: stripIndent`
|
||||
DEFAULT primary
|
||||
TIMEOUT 30
|
||||
MENU TITLE Boot Options
|
||||
LABEL primary
|
||||
MENU LABEL primary Image
|
||||
LINUX /Image
|
||||
APPEND ro rootwait isolcpus=0,4=woops
|
||||
`,
|
||||
reason: 'Unable to parse invalid value: isolcpus=0,4=woops',
|
||||
},
|
||||
];
|
117
test/29-append-directive.spec.ts
Normal file
117
test/29-append-directive.spec.ts
Normal file
@ -0,0 +1,117 @@
|
||||
import { AppendDirective } from '../src/config/backends/extlinux-file';
|
||||
import { expect } from './lib/chai-config';
|
||||
|
||||
describe('APPEND directive', () => {
|
||||
const supportedConfigValues = ['isolcpus'];
|
||||
const directive = new AppendDirective(supportedConfigValues);
|
||||
|
||||
it('parses valid APPEND value', () => {
|
||||
VALID_VALUES.forEach(({ input, output }) =>
|
||||
expect(directive.parse(input)).to.deep.equal(output),
|
||||
);
|
||||
});
|
||||
|
||||
it('errors when parsing invalid APPEND value', () => {
|
||||
INVALID_VALUES.forEach(({ input, reason }) =>
|
||||
// @ts-expect-error
|
||||
expect(() => directive.parse(input)).to.throw(reason),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates new string from existing string', () => {
|
||||
expect(
|
||||
directive.generate(
|
||||
{
|
||||
isolcpus: '2',
|
||||
},
|
||||
'ro rootwait',
|
||||
),
|
||||
).to.deep.equal('ro rootwait isolcpus=2');
|
||||
});
|
||||
|
||||
it('generates string from existing string (replaces values)', () => {
|
||||
expect(
|
||||
directive.generate(
|
||||
{
|
||||
isolcpus: '2,4',
|
||||
},
|
||||
'ro rootwait isolcpus=2',
|
||||
),
|
||||
).to.deep.equal('ro rootwait isolcpus=2,4');
|
||||
});
|
||||
|
||||
it('generates string from nothing', () => {
|
||||
expect(
|
||||
directive.generate({
|
||||
isolcpus: '2,4',
|
||||
}),
|
||||
).to.deep.equal('isolcpus=2,4');
|
||||
});
|
||||
|
||||
it('generates string from nothing', () => {
|
||||
expect(
|
||||
directive.generate({
|
||||
rootwait: '',
|
||||
ro: '',
|
||||
isolcpus: '2,4',
|
||||
}),
|
||||
).to.deep.equal('rootwait ro isolcpus=2,4');
|
||||
});
|
||||
|
||||
it('errors when generating with invalid ConfigOptions', () => {
|
||||
INVALID_CONFIGS_OPTIONS.forEach(({ input, reason }) =>
|
||||
expect(() => directive.generate(input)).to.throw(reason),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const VALID_VALUES = [
|
||||
{
|
||||
input: {
|
||||
APPEND: '${cbootargs} ${resin_kernel_root} ro rootwait isolcpus=2',
|
||||
},
|
||||
output: {
|
||||
'${cbootargs}': '',
|
||||
'${resin_kernel_root}': '',
|
||||
ro: '',
|
||||
rootwait: '',
|
||||
isolcpus: '2',
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
APPEND: '',
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
APPEND: 'isolcpus=2,4',
|
||||
},
|
||||
output: { isolcpus: '2,4' },
|
||||
},
|
||||
];
|
||||
|
||||
const INVALID_VALUES = [
|
||||
{
|
||||
input: {},
|
||||
reason:
|
||||
'Could not find APPEND directive in default extlinux.conf boot entry',
|
||||
},
|
||||
{
|
||||
input: {
|
||||
APPEND: 'isolcpus=2=4',
|
||||
},
|
||||
reason: 'Unable to parse invalid value: isolcpus=2=4',
|
||||
},
|
||||
];
|
||||
|
||||
const INVALID_CONFIGS_OPTIONS = [
|
||||
{
|
||||
input: {
|
||||
isolcpus: '2,4=',
|
||||
},
|
||||
reason:
|
||||
'One of the values being set contains an invalid character: [ value: 2,4=, key: isolcpus ]',
|
||||
},
|
||||
];
|
68
test/30-fdt-directive.spec.ts
Normal file
68
test/30-fdt-directive.spec.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import { FDTDirective } from '../src/config/backends/extlinux-file';
|
||||
import { expect } from './lib/chai-config';
|
||||
|
||||
describe('FDT directive', () => {
|
||||
const directive = new FDTDirective();
|
||||
|
||||
it('parses valid FDT value', () => {
|
||||
VALID_VALUES.forEach(({ input, output }) =>
|
||||
// @ts-ignore input with no FDT can still be parsed
|
||||
expect(directive.parse(input)).to.deep.equal(output),
|
||||
);
|
||||
});
|
||||
|
||||
it('generates value from valid ConfigOptions', () => {
|
||||
expect(
|
||||
directive.generate({
|
||||
fdt: '/boot/mycustomdtb.dtb',
|
||||
}),
|
||||
).to.deep.equal('/boot/mycustomdtb.dtb');
|
||||
});
|
||||
|
||||
it('errors when generating with invalid ConfigOptions', () => {
|
||||
INVALID_CONFIGS_OPTIONS.forEach(({ input, reason }) =>
|
||||
// @ts-expect-error
|
||||
expect(() => directive.generate(input)).to.throw(reason),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
const VALID_VALUES = [
|
||||
{
|
||||
input: {
|
||||
FDT: '/boot/mycustomdtb.dtb',
|
||||
},
|
||||
output: {
|
||||
fdt: '/boot/mycustomdtb.dtb',
|
||||
},
|
||||
},
|
||||
{
|
||||
input: {
|
||||
FDT: '',
|
||||
},
|
||||
output: {},
|
||||
},
|
||||
{
|
||||
input: {},
|
||||
output: {},
|
||||
},
|
||||
];
|
||||
|
||||
const INVALID_CONFIGS_OPTIONS = [
|
||||
{
|
||||
input: {
|
||||
fdt: '',
|
||||
},
|
||||
reason: 'Cannot set FDT of an empty value.',
|
||||
},
|
||||
{
|
||||
input: {
|
||||
fdt: null,
|
||||
},
|
||||
reason: 'Cannot set FDT of non-string value: null',
|
||||
},
|
||||
{
|
||||
input: {},
|
||||
reason: 'Cannot set FDT of non-string value: ',
|
||||
},
|
||||
];
|
Loading…
x
Reference in New Issue
Block a user