From 7a27b6c671b36f28c15cd4d47a456fa98c7b3db1 Mon Sep 17 00:00:00 2001 From: Miguel Casqueira Date: Wed, 10 Jun 2020 14:50:26 -0400 Subject: [PATCH 1/3] Refactor device-config tests styling Signed-off-by: Miguel Casqueira --- test/13-device-config.spec.ts | 576 +++++++++++++++++++--------------- test/17-config-utils.spec.ts | 141 +-------- 2 files changed, 336 insertions(+), 381 deletions(-) diff --git a/test/13-device-config.spec.ts b/test/13-device-config.spec.ts index 1440447a..1633e838 100644 --- a/test/13-device-config.spec.ts +++ b/test/13-device-config.spec.ts @@ -1,45 +1,54 @@ 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, + RPiConfigBackend, + DeviceConfigBackend, +} from '../src/config/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\ @@ -48,70 +57,53 @@ describe('DeviceConfig', function () { dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13\n\ foobar=baz\n\ `); - 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 +118,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 +143,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\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\ + ` + '\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 +181,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,8 +210,108 @@ foobaz=bar\n\ }); }); - describe('Extlinux files', () => - it('should correctly write to extlinux.conf files', function () { + describe('Extlinux files', () => { + it('should parse a extlinux.conf file', () => { + const text = stripIndent`\ + 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'); + }); + + it('should parse multiple service entries', () => { + const text = stripIndent`\ + 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', () => { + let text = stripIndent`\ + 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 = stripIndent`\ + 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'); + }); + + it('should correctly write to extlinux.conf files', async () => { stub(fsUtils, 'writeFileAtomic').resolves(); stub(child_process, 'exec').resolves(); @@ -243,44 +320,37 @@ foobaz=bar\n\ HOST_EXTLINUX_isolcpus: '2', }; - 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\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\ + ` + '\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 +371,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 +390,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 +399,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 +411,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 +433,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 +471,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 +492,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 +519,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'); }); diff --git a/test/17-config-utils.spec.ts b/test/17-config-utils.spec.ts index cfbaa9f9..cfd9fd68 100644 --- a/test/17-config-utils.spec.ts +++ b/test/17-config-utils.spec.ts @@ -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/backend'; -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', }); }); - })); + }); +}); From 3098abeca572f4b30d9fa0fb5c10651975d1e2f8 Mon Sep 17 00:00:00 2001 From: Miguel Casqueira Date: Thu, 11 Jun 2020 00:03:48 -0400 Subject: [PATCH 2/3] Refactor backends into seperate files and added tests for extlinux Signed-off-by: Miguel Casqueira --- src/config/backend.ts | 595 +--------------------------- src/config/backends/config-fs.ts | 231 +++++++++++ src/config/backends/extlinux.ts | 251 ++++++++++++ src/config/backends/raspberry-pi.ts | 153 +++++++ src/config/utils.ts | 11 +- test/05-device-state.spec.ts | 2 +- test/13-device-config.spec.ts | 8 +- test/17-config-utils.spec.ts | 2 +- test/27-extlinux-config.spec.ts | 206 ++++++++++ 9 files changed, 853 insertions(+), 606 deletions(-) create mode 100644 src/config/backends/config-fs.ts create mode 100644 src/config/backends/extlinux.ts create mode 100644 src/config/backends/raspberry-pi.ts create mode 100644 test/27-extlinux-config.spec.ts diff --git a/src/config/backend.ts b/src/config/backend.ts index acf40f86..e9ba758d 100644 --- a/src/config/backend.ts +++ b/src/config/backend.ts @@ -1,29 +1,16 @@ import * as _ from 'lodash'; -import { child_process, fs } from 'mz'; -import * as path from 'path'; +import { child_process } from 'mz'; import * as constants from '../lib/constants'; import { writeFileAtomic } from '../lib/fs-utils'; -import log from '../lib/supervisor-console'; -import * as logger from '../logger'; +export const bootMountPoint = `${constants.rootMountPoint}${constants.bootMountPoint}`; 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( +export async function remountAndWriteAtomic( file: string, data: string, ): Promise { @@ -72,579 +59,3 @@ export abstract class 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 { - 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 { - 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 { - 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 { - // 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; - -/** - * 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 (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 { - // 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(): Promise { - 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 { - 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/backends/config-fs.ts b/src/config/backends/config-fs.ts new file mode 100644 index 00000000..9b35828c --- /dev/null +++ b/src/config/backends/config-fs.ts @@ -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 for ACPI SSDT loading + * + * Supports: + * - {BALENA|RESIN}_HOST_CONFIGFS_ssdt = value | "value" | "value1","value2" + */ + +type ConfigfsConfig = Dictionary; + +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 (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 { + // 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(): Promise { + 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 { + 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/backends/extlinux.ts b/src/config/backends/extlinux.ts new file mode 100644 index 00000000..dcf8cdbe --- /dev/null +++ b/src/config/backends/extlinux.ts @@ -0,0 +1,251 @@ +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 ConfigFS host configuration for ACPI SSDT loading + * + * Supports: + * - {BALENA|RESIN}_HOST_CONFIGFS_ssdt = value | "value" | "value1","value2" + */ + +interface ExtlinuxFile { + labels: { + [labelName: string]: { + [directive: string]: string; + }; + }; + globals: { [directive: string]: string }; +} + +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 supportedConfigKeys = ['isolcpus']; + + public matches(deviceType: string): boolean { + return _.startsWith(deviceType, 'jetson-tx'); + } + + public async getBootConfig(): Promise { + 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 { + // 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.supportedConfigKeys, 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; + } +} diff --git a/src/config/backends/raspberry-pi.ts b/src/config/backends/raspberry-pi.ts new file mode 100644 index 00000000..2f87fca6 --- /dev/null +++ b/src/config/backends/raspberry-pi.ts @@ -0,0 +1,153 @@ +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 ConfigFS host configuration for ACPI SSDT loading + * + * Supports: + * - {BALENA|RESIN}_HOST_CONFIGFS_ssdt = 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 _.startsWith(deviceType, 'raspberry') || deviceType === 'fincm3'; + } + + public async getBootConfig(): Promise { + 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 { + 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; + } +} diff --git a/src/config/utils.ts b/src/config/utils.ts index 30a72afe..3e23c22d 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -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 './backend'; const configBackends = [ new ExtlinuxConfigBackend(), diff --git a/test/05-device-state.spec.ts b/test/05-device-state.spec.ts index f9934051..46323c0e 100644 --- a/test/05-device-state.spec.ts +++ b/test/05-device-state.spec.ts @@ -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'; diff --git a/test/13-device-config.spec.ts b/test/13-device-config.spec.ts index 1633e838..fce8e702 100644 --- a/test/13-device-config.spec.ts +++ b/test/13-device-config.spec.ts @@ -8,11 +8,9 @@ import * as config from '../src/config'; import { DeviceConfig } from '../src/device-config'; import * as fsUtils from '../src/lib/fs-utils'; import * as logger from '../src/logger'; -import { - ExtlinuxConfigBackend, - RPiConfigBackend, - DeviceConfigBackend, -} from '../src/config/backend'; +import { ExtlinuxConfigBackend } from '../src/config/backends/extlinux'; +import { RPiConfigBackend } from '../src/config/backends/raspberry-pi'; +import { DeviceConfigBackend } from '../src/config/backend'; import prepare = require('./lib/prepare'); const extlinuxBackend = new ExtlinuxConfigBackend(); diff --git a/test/17-config-utils.spec.ts b/test/17-config-utils.spec.ts index cfd9fd68..116d658d 100644 --- a/test/17-config-utils.spec.ts +++ b/test/17-config-utils.spec.ts @@ -1,6 +1,6 @@ import { expect } from './lib/chai-config'; import * as configUtils from '../src/config/utils'; -import { RPiConfigBackend } from '../src/config/backend'; +import { RPiConfigBackend } from '../src/config/backends/raspberry-pi'; const rpiBackend = new RPiConfigBackend(); diff --git a/test/27-extlinux-config.spec.ts b/test/27-extlinux-config.spec.ts new file mode 100644 index 00000000..a93356a8 --- /dev/null +++ b/test/27-extlinux-config.spec.ts @@ -0,0 +1,206 @@ +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('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 + await expect(backend.getBootConfig()).to.eventually.be.rejectedWith( + 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\n + TIMEOUT 30\n + MENU TITLE Boot Options\n + LABEL primary\n + MENU LABEL primary Image\n + LINUX /Image + APPEND ro rootwait isolcpus=0,4 + `); + + await expect(backend.getBootConfig()).to.eventually.deep.equal({ + isolcpus: '0,4', + }); + + // 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({ + isolcpus: '2', + randomValueBut: 'that_is_ok', // The backend just sets what it is told. validation is ended in device-config.ts + }); + + expect(fsUtils.writeFileAtomic).to.be.calledWith( + './test/data/mnt/boot/extlinux/extlinux.conf', + stripIndent`\ + 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 randomValueBut=that_is_ok\n\ + ` + '\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: '', 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_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_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: '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\n + MENU TITLE Boot Options\n + LABEL primary\n + MENU LABEL primary Image\n + LINUX /Image + APPEND ro rootwait isolcpus=0,4 + `, + reason: 'Could not find default entry for extlinux.conf file', + }, + { + contents: stripIndent` + DEFAULT typo_oops\n + TIMEOUT 30\n + MENU TITLE Boot Options\n + LABEL primary\n + MENU LABEL primary Image\n + LINUX /Image + APPEND ro rootwait isolcpus=0,4 + `, + reason: + 'Cannot find default label entry (label: typo_oops) for extlinux.conf file', + }, + { + contents: stripIndent` + DEFAULT primary\n + TIMEOUT 30\n + MENU TITLE Boot Options\n + LABEL primary\n + MENU LABEL primary Image\n + LINUX /Image + `, + reason: + 'Could not find APPEND directive in default extlinux.conf boot entry', + }, + { + contents: stripIndent` + DEFAULT primary\n + TIMEOUT 30\n + MENU TITLE Boot Options\n + LABEL primary\n + MENU LABEL primary Image\n + LINUX /Image + APPEND ro rootwait isolcpus=0,4=woops + `, + reason: + 'Could not parse extlinux configuration entry: ro,rootwait,isolcpus=0,4=woops [value with error: isolcpus=0,4=woops]', + }, +]; From 59fc589eb23bdf8c79c677734d2792b93380c94c Mon Sep 17 00:00:00 2001 From: Miguel Casqueira Date: Tue, 16 Jun 2020 16:05:42 -0400 Subject: [PATCH 3/3] Added support for configuring FDT directive in extlinux.conf Change-type: minor Signed-off-by: Miguel Casqueira --- src/config/{ => backends}/backend.ts | 8 +- src/config/backends/config-fs.ts | 10 +- src/config/backends/extlinux-file.ts | 166 +++++++++++++++++++++ src/config/backends/extlinux.ts | 211 ++++++++++++++------------- src/config/backends/raspberry-pi.ts | 46 +++--- src/config/utils.ts | 2 +- src/device-config.ts | 2 +- src/lib/errors.ts | 4 + test/13-device-config.spec.ts | 146 +++--------------- test/27-extlinux-config.spec.ts | 204 ++++++++++++++++++++------ test/29-append-directive.spec.ts | 117 +++++++++++++++ test/30-fdt-directive.spec.ts | 68 +++++++++ 12 files changed, 680 insertions(+), 304 deletions(-) rename src/config/{ => backends}/backend.ts (94%) create mode 100644 src/config/backends/extlinux-file.ts create mode 100644 test/29-append-directive.spec.ts create mode 100644 test/30-fdt-directive.spec.ts diff --git a/src/config/backend.ts b/src/config/backends/backend.ts similarity index 94% rename from src/config/backend.ts rename to src/config/backends/backend.ts index e9ba758d..85d9b200 100644 --- a/src/config/backend.ts +++ b/src/config/backends/backend.ts @@ -1,15 +1,15 @@ import * as _ from 'lodash'; import { child_process } from 'mz'; -import * as constants from '../lib/constants'; -import { writeFileAtomic } from '../lib/fs-utils'; - -export const bootMountPoint = `${constants.rootMountPoint}${constants.bootMountPoint}`; +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, diff --git a/src/config/backends/config-fs.ts b/src/config/backends/config-fs.ts index 9b35828c..6382317b 100644 --- a/src/config/backends/config-fs.ts +++ b/src/config/backends/config-fs.ts @@ -7,13 +7,13 @@ import { DeviceConfigBackend, bootMountPoint, remountAndWriteAtomic, -} from '../backend'; +} 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 for ACPI SSDT loading + * A backend to handle ConfigFS host configuration * * Supports: * - {BALENA|RESIN}_HOST_CONFIGFS_ssdt = value | "value" | "value1","value2" @@ -94,7 +94,7 @@ export class ConfigfsConfigBackend extends DeviceConfigBackend { `AML: ${oemId.trim()} ${oemTableId.trim()} (Rev ${oemRevision.trim()})`, ); } catch (e) { - log.error(e); + log.error('Issue while loading AML ${aml}', e); } return true; } @@ -121,7 +121,7 @@ export class ConfigfsConfigBackend extends DeviceConfigBackend { } private async loadConfiguredSsdt(config: ConfigfsConfig): Promise { - if (_.isArray(config['ssdt'])) { + if (Array.isArray(config['ssdt'])) { log.info('Loading configured SSDTs'); for (const aml of config['ssdt']) { await this.loadAML(aml); @@ -169,7 +169,7 @@ export class ConfigfsConfigBackend extends DeviceConfigBackend { // see which SSDTs we have configured... const ssdt = config['ssdt']; - if (_.isArray(ssdt) && ssdt.length > 0) { + if (Array.isArray(ssdt) && ssdt.length > 0) { // we have some... options['ssdt'] = ssdt; } diff --git a/src/config/backends/extlinux-file.ts b/src/config/backends/extlinux-file.ts new file mode 100644 index 00000000..e9f75319 --- /dev/null +++ b/src/config/backends/extlinux-file.ts @@ -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; + } +} diff --git a/src/config/backends/extlinux.ts b/src/config/backends/extlinux.ts index dcf8cdbe..81fcf351 100644 --- a/src/config/backends/extlinux.ts +++ b/src/config/backends/extlinux.ts @@ -6,38 +6,45 @@ import { DeviceConfigBackend, bootMountPoint, remountAndWriteAtomic, -} from '../backend'; +} 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 ConfigFS host configuration for ACPI SSDT loading + * A backend to handle extlinux host configuration * * Supports: - * - {BALENA|RESIN}_HOST_CONFIGFS_ssdt = value | "value" | "value1","value2" + * - {BALENA|RESIN}_HOST_EXTLINUX_isolcpus = value | "value" | "value1","value2" + * - {BALENA|RESIN}_HOST_EXTLINUX_fdt = value | "value" */ -interface ExtlinuxFile { - labels: { - [labelName: string]: { - [directive: string]: string; - }; - }; - globals: { [directive: string]: string }; -} - 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']; - public static bootConfigVarRegex = new RegExp( - '(' + _.escapeRegExp(ExtlinuxConfigBackend.bootConfigVarPrefix) + ')(.+)', + private fdtDirective = new FDTDirective(); + private appendDirective = new AppendDirective( + // Pass in list of supportedConfigValues that APPEND can have + ExtlinuxConfigBackend.supportedConfigValues.filter( + (v) => !this.isDirective(v), + ), ); - private static supportedConfigKeys = ['isolcpus']; + public static bootConfigVarRegex = new RegExp( + '(?:' + _.escapeRegExp(ExtlinuxConfigBackend.bootConfigVarPrefix) + ')(.+)', + ); public matches(deviceType: string): boolean { - return _.startsWith(deviceType, 'jetson-tx'); + return deviceType.startsWith('jetson-tx'); } public async getBootConfig(): Promise { @@ -51,59 +58,38 @@ export class ExtlinuxConfigBackend extends DeviceConfigBackend { } 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( + throw new ExtLinuxParseError( 'Could not find extlinux file. Device is possibly bricked', ); } + // Parse ExtlinuxFile from file contents 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; - }); + // Get default label to know which label entry to parse + const defaultLabel = ExtlinuxConfigBackend.findDefaultLabel(parsedBootFile); - if (defaultLabel == null) { - throw new Error('Could not find default entry for extlinux.conf file'); - } + // Get the label entry we will parse + const labelEntry = ExtlinuxConfigBackend.getLabelEntry( + parsedBootFile, + defaultLabel, + ); - const labelEntry = parsedBootFile.labels[defaultLabel]; + // Parse APPEND directive and filter out unsupported values + const appendConfig = _.pickBy( + this.appendDirective.parse(labelEntry), + (_value, key) => this.isSupportedConfig(key), + ); - if (labelEntry == null) { - throw new Error( - `Cannot find default label entry (label: ${defaultLabel}) for extlinux.conf file`, - ); - } + // Parse FDT directive + const fdtConfig = this.fdtDirective.parse(labelEntry); - // 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; + return { + ...appendConfig, + ...fdtConfig, + }; } public async setBootConfig(opts: ConfigOptions): Promise { @@ -123,60 +109,55 @@ export class ExtlinuxConfigBackend extends DeviceConfigBackend { ); } - 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, + // Parse ExtlinuxFile from file contents + const parsedBootFile = ExtlinuxConfigBackend.parseExtlinuxFile( + confContents, ); - await remountAndWriteAtomic( + // 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, - extlinuxString, + ExtlinuxConfigBackend.extlinuxFileToString(parsedBootFile), ); } public isSupportedConfig(configName: string): boolean { - return _.includes(ExtlinuxConfigBackend.supportedConfigKeys, configName); + return ExtlinuxConfigBackend.supportedConfigValues.includes(configName); } public isBootConfigVar(envVar: string): boolean { - return _.startsWith(envVar, ExtlinuxConfigBackend.bootConfigVarPrefix); + return envVar.startsWith(ExtlinuxConfigBackend.bootConfigVarPrefix); } public processConfigVarName(envVar: string): string { - return envVar.replace(ExtlinuxConfigBackend.bootConfigVarRegex, '$2'); + return envVar.replace(ExtlinuxConfigBackend.bootConfigVarRegex, '$1'); } public processConfigVarValue(_key: string, value: string): string { @@ -187,19 +168,20 @@ export class ExtlinuxConfigBackend extends DeviceConfigBackend { 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: {}, }; - // 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, '#'); - }); - + // Split by line and filter any comments and empty lines + const lines = confStr.split(/(?:\r?\n[\s#]*)+/); let lastLabel = ''; for (const line of lines) { @@ -248,4 +230,23 @@ export class ExtlinuxConfigBackend extends DeviceConfigBackend { }); 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; + } } diff --git a/src/config/backends/raspberry-pi.ts b/src/config/backends/raspberry-pi.ts index 2f87fca6..a568813c 100644 --- a/src/config/backends/raspberry-pi.ts +++ b/src/config/backends/raspberry-pi.ts @@ -6,15 +6,19 @@ import { DeviceConfigBackend, bootMountPoint, remountAndWriteAtomic, -} from '../backend'; +} from './backend'; import * as constants from '../../lib/constants'; import log from '../../lib/supervisor-console'; /** - * A backend to handle ConfigFS host configuration for ACPI SSDT loading + * A backend to handle Raspberry Pi host configuration * * Supports: - * - {BALENA|RESIN}_HOST_CONFIGFS_ssdt = value | "value" | "value1","value2" + * - {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 { @@ -22,7 +26,7 @@ export class RPiConfigBackend extends DeviceConfigBackend { private static bootConfigPath = `${bootMountPoint}/config.txt`; public static bootConfigVarRegex = new RegExp( - '(' + _.escapeRegExp(RPiConfigBackend.bootConfigVarPrefix) + ')(.+)', + '(?:' + _.escapeRegExp(RPiConfigBackend.bootConfigVarPrefix) + ')(.+)', ); private static arrayConfigKeys = [ @@ -47,7 +51,7 @@ export class RPiConfigBackend extends DeviceConfigBackend { ]; public matches(deviceType: string): boolean { - return _.startsWith(deviceType, 'raspberry') || deviceType === 'fincm3'; + return deviceType.startsWith('raspberry') || deviceType === 'fincm3'; } public async getBootConfig(): Promise { @@ -68,20 +72,20 @@ export class RPiConfigBackend extends DeviceConfigBackend { for (const configStr of configStatements) { // Don't show warnings for comments and empty lines const trimmed = _.trimStart(configStr); - if (_.startsWith(trimmed, '#') || trimmed === '') { + if (trimmed.startsWith('#') || trimmed === '') { continue; } let keyValue = /^([^=]+)=(.*)$/.exec(configStr); if (keyValue != null) { const [, key, value] = keyValue; - if (!_.includes(RPiConfigBackend.arrayConfigKeys, key)) { + if (!RPiConfigBackend.arrayConfigKeys.includes(key)) { conf[key] = value; } else { if (conf[key] == null) { conf[key] = []; } const confArr = conf[key]; - if (!_.isArray(confArr)) { + if (!Array.isArray(confArr)) { throw new Error( `Expected '${key}' to have a config array but got ${typeof confArr}`, ); @@ -105,40 +109,34 @@ export class RPiConfigBackend extends DeviceConfigBackend { } public async setBootConfig(opts: ConfigOptions): Promise { - let confStatements: string[] = []; - - _.each(opts, (value, key) => { + const confStatements = _.flatMap(opts, (value, key) => { if (key === 'initramfs') { - confStatements.push(`${key} ${value}`); - } else if (_.isArray(value)) { - confStatements = confStatements.concat( - _.map(value, (entry) => `${key}=${entry}`), - ); + return `${key} ${value}`; + } else if (Array.isArray(value)) { + return value.map((entry) => `${key}=${entry}`); } else { - confStatements.push(`${key}=${value}`); + return `${key}=${value}`; } }); - const confStr = `${confStatements.join('\n')}\n`; - await remountAndWriteAtomic(RPiConfigBackend.bootConfigPath, confStr); } public isSupportedConfig(configName: string): boolean { - return !_.includes(RPiConfigBackend.forbiddenConfigKeys, configName); + return !RPiConfigBackend.forbiddenConfigKeys.includes(configName); } public isBootConfigVar(envVar: string): boolean { - return _.startsWith(envVar, RPiConfigBackend.bootConfigVarPrefix); + return envVar.startsWith(RPiConfigBackend.bootConfigVarPrefix); } public processConfigVarName(envVar: string): string { - return envVar.replace(RPiConfigBackend.bootConfigVarRegex, '$2'); + return envVar.replace(RPiConfigBackend.bootConfigVarRegex, '$1'); } public processConfigVarValue(key: string, value: string): string | string[] { - if (_.includes(RPiConfigBackend.arrayConfigKeys, key)) { - if (!_.startsWith(value, '"')) { + if (RPiConfigBackend.arrayConfigKeys.includes(key)) { + if (!value.startsWith('"')) { return [value]; } else { return JSON.parse(`[${value}]`); diff --git a/src/config/utils.ts b/src/config/utils.ts index 3e23c22d..9ae79ddb 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -4,7 +4,7 @@ import { EnvVarObject } from '../lib/types'; import { ExtlinuxConfigBackend } from './backends/extlinux'; import { RPiConfigBackend } from './backends/raspberry-pi'; import { ConfigfsConfigBackend } from './backends/config-fs'; -import { ConfigOptions, DeviceConfigBackend } from './backend'; +import { ConfigOptions, DeviceConfigBackend } from './backends/backend'; const configBackends = [ new ExtlinuxConfigBackend(), diff --git a/src/device-config.ts b/src/device-config.ts index dfbab1a9..8170b995 100644 --- a/src/device-config.ts +++ b/src/device-config.ts @@ -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'; diff --git a/src/lib/errors.ts b/src/lib/errors.ts index b8195b12..1a8a3052 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -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 {} diff --git a/test/13-device-config.spec.ts b/test/13-device-config.spec.ts index fce8e702..30b3e30a 100644 --- a/test/13-device-config.spec.ts +++ b/test/13-device-config.spec.ts @@ -10,7 +10,7 @@ import * as fsUtils from '../src/lib/fs-utils'; 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/backend'; +import { DeviceConfigBackend } from '../src/config/backends/backend'; import prepare = require('./lib/prepare'); const extlinuxBackend = new ExtlinuxConfigBackend(); @@ -48,12 +48,12 @@ describe('Device Backend Config', () => { // 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 `); await expect( @@ -154,13 +154,13 @@ describe('Device Backend Config', () => { 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\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\ + 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 ); @@ -209,106 +209,6 @@ describe('Device Backend Config', () => { }); describe('Extlinux files', () => { - it('should parse a extlinux.conf file', () => { - const text = stripIndent`\ - 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'); - }); - - it('should parse multiple service entries', () => { - const text = stripIndent`\ - 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', () => { - let text = stripIndent`\ - 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 = stripIndent`\ - 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'); - }); - it('should correctly write to extlinux.conf files', async () => { stub(fsUtils, 'writeFileAtomic').resolves(); stub(child_process, 'exec').resolves(); @@ -316,6 +216,7 @@ describe('Device Backend Config', () => { const current = {}; const target = { HOST_EXTLINUX_isolcpus: '2', + HOST_EXTLINUX_fdt: '/boot/mycustomdtb.dtb', }; expect( @@ -330,14 +231,15 @@ describe('Device Backend Config', () => { 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\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\ + 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 ); diff --git a/test/27-extlinux-config.spec.ts b/test/27-extlinux-config.spec.ts index a93356a8..1f306144 100644 --- a/test/27-extlinux-config.spec.ts +++ b/test/27-extlinux-config.spec.ts @@ -6,9 +6,122 @@ import { expect } from './lib/chai-config'; import * as fsUtils from '../src/lib/fs-utils'; import { ExtlinuxConfigBackend } from '../src/config/backends/extlinux'; -describe('EXTLINUX Configuration', () => { +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 }, @@ -35,9 +148,11 @@ describe('EXTLINUX Configuration', () => { // Stub bad config stub(fs, 'readFile').resolves(badConfig.contents); // Expect correct rejection from the given bad config - await expect(backend.getBootConfig()).to.eventually.be.rejectedWith( - badConfig.reason, - ); + try { + await backend.getBootConfig(); + } catch (e) { + expect(e.message).to.equal(badConfig.reason); + } // Restore stub (fs.readFile as SinonStub).restore(); } @@ -49,17 +164,19 @@ describe('EXTLINUX Configuration', () => { // Stub readFile to return a config that has supported values stub(fs, 'readFile').resolves(stripIndent` - DEFAULT primary\n - TIMEOUT 30\n - MENU TITLE Boot Options\n - LABEL primary\n - MENU LABEL primary Image\n - LINUX /Image + 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 @@ -71,20 +188,21 @@ describe('EXTLINUX Configuration', () => { stub(child_process, 'exec').resolves(); await backend.setBootConfig({ + fdt: '/boot/mycustomdtb.dtb', isolcpus: '2', - randomValueBut: 'that_is_ok', // The backend just sets what it is told. validation is ended in device-config.ts }); expect(fsUtils.writeFileAtomic).to.be.calledWith( './test/data/mnt/boot/extlinux/extlinux.conf', - stripIndent`\ - 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 randomValueBut=that_is_ok\n\ + 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 ); @@ -96,6 +214,7 @@ describe('EXTLINUX Configuration', () => { 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 @@ -107,6 +226,7 @@ describe('EXTLINUX Configuration', () => { 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 }, @@ -121,6 +241,7 @@ describe('EXTLINUX Configuration', () => { 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_' }, @@ -144,6 +265,7 @@ describe('EXTLINUX Configuration', () => { 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' }, @@ -156,10 +278,10 @@ describe('EXTLINUX Configuration', () => { const MALFORMED_CONFIGS = [ { contents: stripIndent` - TIMEOUT 30\n - MENU TITLE Boot Options\n - LABEL primary\n - MENU LABEL primary Image\n + TIMEOUT 30 + MENU TITLE Boot Options + LABEL primary + MENU LABEL primary Image LINUX /Image APPEND ro rootwait isolcpus=0,4 `, @@ -167,24 +289,23 @@ const MALFORMED_CONFIGS = [ }, { contents: stripIndent` - DEFAULT typo_oops\n - TIMEOUT 30\n - MENU TITLE Boot Options\n - LABEL primary\n - MENU LABEL primary Image\n + 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 default label entry (label: typo_oops) for extlinux.conf file', + reason: 'Cannot find label entry (label: typo_oops) for extlinux.conf file', }, { contents: stripIndent` - DEFAULT primary\n - TIMEOUT 30\n - MENU TITLE Boot Options\n - LABEL primary\n - MENU LABEL primary Image\n + DEFAULT primary + TIMEOUT 30 + MENU TITLE Boot Options + LABEL primary + MENU LABEL primary Image LINUX /Image `, reason: @@ -192,15 +313,14 @@ const MALFORMED_CONFIGS = [ }, { contents: stripIndent` - DEFAULT primary\n - TIMEOUT 30\n - MENU TITLE Boot Options\n - LABEL primary\n - MENU LABEL primary Image\n + DEFAULT primary + TIMEOUT 30 + MENU TITLE Boot Options + LABEL primary + MENU LABEL primary Image LINUX /Image APPEND ro rootwait isolcpus=0,4=woops `, - reason: - 'Could not parse extlinux configuration entry: ro,rootwait,isolcpus=0,4=woops [value with error: isolcpus=0,4=woops]', + reason: 'Unable to parse invalid value: isolcpus=0,4=woops', }, ]; diff --git a/test/29-append-directive.spec.ts b/test/29-append-directive.spec.ts new file mode 100644 index 00000000..0dbe3a65 --- /dev/null +++ b/test/29-append-directive.spec.ts @@ -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 ]', + }, +]; diff --git a/test/30-fdt-directive.spec.ts b/test/30-fdt-directive.spec.ts new file mode 100644 index 00000000..c3f06348 --- /dev/null +++ b/test/30-fdt-directive.spec.ts @@ -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: ', + }, +];