config: Support loading SSDT via ConfigFS

Change-type: patch
Signed-off-by: Rich Bayliss <rich@balena.io>
This commit is contained in:
Rich Bayliss 2020-01-29 15:41:27 +00:00
parent 23de9e90e7
commit e0d2bdfaa9
No known key found for this signature in database
GPG Key ID: E53C4B4D18499E1A
4 changed files with 332 additions and 12 deletions

View File

@ -1,10 +1,12 @@
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 Logger from '../logger';
export interface ConfigOptions {
[key: string]: string | string[];
@ -32,7 +34,13 @@ async function remountAndWriteAtomic(
await writeFileAtomic(file, data);
}
export interface BackendOptions {
logger?: Logger;
}
export abstract class DeviceConfigBackend {
protected options: BackendOptions = {};
// Does this config backend support the given device type?
public abstract matches(deviceType: string): boolean;
@ -64,6 +72,12 @@ export abstract class DeviceConfigBackend {
// 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(opts: BackendOptions): Promise<DeviceConfigBackend> {
this.options = { ...this.options, ...opts };
return this;
}
}
export class RPiConfigBackend extends DeviceConfigBackend {
@ -424,3 +438,223 @@ export class ExtlinuxConfigBackend extends DeviceConfigBackend {
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 (this.options.logger) {
this.options.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(
opts: BackendOptions,
): Promise<ConfigfsConfigBackend> {
try {
await super.initialise(opts);
// 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);
if (this.options.logger) {
this.options.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}`;
}
}

View File

@ -2,21 +2,32 @@ import * as _ from 'lodash';
import { EnvVarObject } from '../lib/types';
import {
BackendOptions,
ConfigfsConfigBackend,
ConfigOptions,
DeviceConfigBackend,
ExtlinuxConfigBackend,
RPiConfigBackend,
} from './backend';
const configBackends = [new ExtlinuxConfigBackend(), new RPiConfigBackend()];
const configBackends = [
new ExtlinuxConfigBackend(),
new RPiConfigBackend(),
new ConfigfsConfigBackend(),
];
export function isConfigDeviceType(deviceType: string): boolean {
return getConfigBackend(deviceType) != null;
}
export function getConfigBackend(
export const initialiseConfigBackend = async (
deviceType: string,
): DeviceConfigBackend | undefined {
opts: BackendOptions,
) => {
const backend = getConfigBackend(deviceType);
if (backend) {
await backend.initialise(opts);
return backend;
}
};
function getConfigBackend(deviceType: string): DeviceConfigBackend | undefined {
return _.find(configBackends, backend => backend.matches(deviceType));
}

View File

@ -223,8 +223,10 @@ export class DeviceConfig {
return this.configBackend;
}
const dt = await this.config.get('deviceType');
this.configBackend = configUtils.getConfigBackend(dt) || null;
this.configBackend =
(await configUtils.initialiseConfigBackend(dt, {
logger: this.logger,
})) ?? null;
return this.configBackend;
}

View File

@ -1,5 +1,5 @@
Promise = require 'bluebird'
{ fs } = require 'mz'
{ fs, child_process } = require 'mz'
{ expect } = require './lib/chai-config'
{ stub, spy } = require 'sinon'
@ -13,8 +13,6 @@ fsUtils = require '../src/lib/fs-utils'
extlinuxBackend = new ExtlinuxConfigBackend()
rpiConfigBackend = new RPiConfigBackend()
{ child_process } = require 'mz'
describe 'DeviceConfig', ->
before ->
prepare()
@ -276,5 +274,80 @@ describe 'DeviceConfig', ->
'raspberrypi4-64'
)).to.equal(false)
describe 'ConfigFS', ->
before ->
fakeConfig = {
get: (key) ->
Promise.try ->
return 'up-board' if key == 'deviceType'
throw new Error('Unknown fake config key')
}
@upboardConfig = new DeviceConfig({ logger: @fakeLogger, db: @fakeDB, config: fakeConfig })
stub(child_process, 'exec').resolves()
stub(fs, 'exists').callsFake ->
return true
stub(fs, 'mkdir').resolves()
stub(fs, 'readdir').callsFake ->
return []
stub(fs, 'readFile').callsFake (file) ->
return JSON.stringify({
ssdt: ['spidev1,1']
}) if file == 'test/data/mnt/boot/configfs.json'
return ''
stub(fsUtils, 'writeFileAtomic').resolves()
Promise.try =>
@upboardConfig.getConfigBackend()
.then (backend) =>
@upboardConfigBackend = backend
expect(@upboardConfigBackend).is.not.null
expect(child_process.exec.callCount).to.equal(3, 'exec not called enough times')
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.callCount).to.equal(2)
expect(fs.readFile.callCount).to.equal(4)
it 'should correctly write the configfs.json file', ->
current = {
}
target = {
HOST_CONFIGFS_ssdt: 'spidev1,1'
}
@fakeLogger.logSystemMessage.resetHistory()
child_process.exec.resetHistory()
fs.exists.resetHistory()
fs.mkdir.resetHistory()
fs.readdir.resetHistory()
fs.readFile.resetHistory()
Promise.try =>
expect(@upboardConfigBackend).is.not.null
@upboardConfig.bootConfigChangeRequired(@upboardConfigBackend, current, target)
.then =>
@upboardConfig.setBootConfig(@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(@fakeLogger.logSystemMessage).to.be.calledTwice
expect(@fakeLogger.logSystemMessage.getCall(1).args[2]).to.equal('Apply boot config success')
after ->
child_process.exec.restore()
fs.exists.restore()
fs.mkdir.restore()
fs.readdir.restore()
fs.readFile.restore()
fsUtils.writeFileAtomic.restore()
@fakeLogger.logSystemMessage.resetHistory()
# This will require stubbing device.reboot, gosuper.post, config.get/set
it 'applies the target state'