diff --git a/src/config/backend.ts b/src/config/backend.ts index e47003aa..7da5a310 100644 --- a/src/config/backend.ts +++ b/src/config/backend.ts @@ -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 { + 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; + +/** + * 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 { + const acpiTablesDir = path.join(this.ConfigfsMountPoint, 'acpi/table'); + return await fs.readdir(acpiTablesDir); + } + + private async loadAML(aml: string): Promise { + 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 { + // 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 { + await remountAndWriteAtomic(this.ConfigFilePath, JSON.stringify(config)); + } + + private async loadConfiguredSsdt(config: ConfigfsConfig): Promise { + 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 { + 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 { + 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 { + // 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}`; + } +} diff --git a/src/config/utils.ts b/src/config/utils.ts index e857ed9f..550ccbad 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -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)); } diff --git a/src/device-config.ts b/src/device-config.ts index 0c0f3e28..33a56219 100644 --- a/src/device-config.ts +++ b/src/device-config.ts @@ -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; } diff --git a/test/13-device-config.spec.coffee b/test/13-device-config.spec.coffee index 6ef758e3..a775ef0d 100644 --- a/test/13-device-config.spec.coffee +++ b/test/13-device-config.spec.coffee @@ -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'