From c04955354a76ab32ab5dc52062fdf72d668f7c3b Mon Sep 17 00:00:00 2001 From: Felipe Lalanne Date: Wed, 26 Jan 2022 18:56:08 -0300 Subject: [PATCH] Use write + sync when writing configs to /mnt/boot This commit updates all backends that write to /mnt/boot to do it through a new `lib/host-utils` module. Writes are now done using write + sync as rename is not an atomic operation in vfat. The change also applies for writes through the `/v1/host-config` endpoint. Finally this change includes some improvements on tests. Change-type: patch --- src/config/backends/backend.ts | 16 - src/config/backends/config-fs.ts | 12 +- src/config/backends/config-txt.ts | 14 +- src/config/backends/extlinux.ts | 14 +- src/config/backends/extra-uEnv.ts | 12 +- src/config/backends/splash-image.ts | 12 +- src/host-config.ts | 7 +- src/lib/host-utils.ts | 23 + test/03-config.spec.ts | 22 +- test/12-device-config.spec.ts | 968 ++++++++++++++++------------ test/27-extlinux-config.spec.ts | 10 +- test/33-extra-uenv-config.spec.ts | 20 +- test/40-target-state.spec.ts | 8 +- test/41-device-api-v1.spec.ts | 6 +- test/42-device-api-v2.spec.ts | 2 +- test/43-splash-image.spec.ts | 30 +- test/data/testconfig-apibinder.json | 2 +- test/lib/mocked-device-api.ts | 6 +- 18 files changed, 646 insertions(+), 538 deletions(-) create mode 100644 src/lib/host-utils.ts diff --git a/src/config/backends/backend.ts b/src/config/backends/backend.ts index d21fc1dc..1fbfe3dc 100644 --- a/src/config/backends/backend.ts +++ b/src/config/backends/backend.ts @@ -1,25 +1,9 @@ import * as _ from 'lodash'; -import * as constants from '../../lib/constants'; -import { writeFileAtomic, exec } 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 | Buffer, -): Promise { - // Here's the dangerous part: - await exec( - `mount -t vfat -o remount,rw ${constants.bootBlockDevice} ${bootMountPoint}`, - ); - await writeFileAtomic(file, data); -} - export abstract class ConfigBackend { // Does this config backend support the given device type? public abstract matches( diff --git a/src/config/backends/config-fs.ts b/src/config/backends/config-fs.ts index 55872d6f..edffa3f0 100644 --- a/src/config/backends/config-fs.ts +++ b/src/config/backends/config-fs.ts @@ -2,13 +2,9 @@ import * as _ from 'lodash'; import { promises as fs } from 'fs'; import * as path from 'path'; -import { - ConfigOptions, - ConfigBackend, - bootMountPoint, - remountAndWriteAtomic, -} from './backend'; +import { ConfigOptions, ConfigBackend } from './backend'; import { exec, exists } from '../../lib/fs-utils'; +import * as hostUtils from '../../lib/host-utils'; import * as constants from '../../lib/constants'; import * as logger from '../../logger'; import log from '../../lib/supervisor-console'; @@ -27,7 +23,7 @@ export class ConfigFs extends ConfigBackend { constants.rootMountPoint, 'boot/acpi-tables', ); - private readonly ConfigFilePath = path.join(bootMountPoint, 'configfs.json'); // use constant for mount path, rename to ssdt.txt + private readonly ConfigFilePath = hostUtils.pathOnBoot('configfs.json'); private readonly ConfigfsMountPoint = path.join( constants.rootMountPoint, 'sys/kernel/config', @@ -116,7 +112,7 @@ export class ConfigFs extends ConfigBackend { } private async writeConfigJSON(config: ConfigfsConfig): Promise { - await remountAndWriteAtomic(this.ConfigFilePath, JSON.stringify(config)); + await hostUtils.writeToBoot(this.ConfigFilePath, JSON.stringify(config)); } private async loadConfiguredSsdt(config: ConfigfsConfig): Promise { diff --git a/src/config/backends/config-txt.ts b/src/config/backends/config-txt.ts index e4dc4268..77334162 100644 --- a/src/config/backends/config-txt.ts +++ b/src/config/backends/config-txt.ts @@ -1,15 +1,11 @@ import * as _ from 'lodash'; import { promises as fs } from 'fs'; -import { - ConfigOptions, - ConfigBackend, - bootMountPoint, - remountAndWriteAtomic, -} from './backend'; +import { ConfigOptions, ConfigBackend } from './backend'; import * as constants from '../../lib/constants'; import log from '../../lib/supervisor-console'; import { exists } from '../../lib/fs-utils'; +import * as hostUtils from '../../lib/host-utils'; /** * A backend to handle Raspberry Pi host configuration @@ -24,7 +20,7 @@ import { exists } from '../../lib/fs-utils'; export class ConfigTxt extends ConfigBackend { private static bootConfigVarPrefix = `${constants.hostConfigVarPrefix}CONFIG_`; - private static bootConfigPath = `${bootMountPoint}/config.txt`; + private static bootConfigPath = hostUtils.pathOnBoot(`config.txt`); public static bootConfigVarRegex = new RegExp( '(?:' + _.escapeRegExp(ConfigTxt.bootConfigVarPrefix) + ')(.+)', @@ -70,7 +66,7 @@ export class ConfigTxt extends ConfigBackend { if (await exists(ConfigTxt.bootConfigPath)) { configContents = await fs.readFile(ConfigTxt.bootConfigPath, 'utf-8'); } else { - await fs.writeFile(ConfigTxt.bootConfigPath, ''); + await hostUtils.writeToBoot(ConfigTxt.bootConfigPath, ''); } const conf: ConfigOptions = {}; @@ -126,7 +122,7 @@ export class ConfigTxt extends ConfigBackend { } }); const confStr = `${confStatements.join('\n')}\n`; - await remountAndWriteAtomic(ConfigTxt.bootConfigPath, confStr); + await hostUtils.writeToBoot(ConfigTxt.bootConfigPath, confStr); } public isSupportedConfig(configName: string): boolean { diff --git a/src/config/backends/extlinux.ts b/src/config/backends/extlinux.ts index 4c5b3ad7..47ad108f 100644 --- a/src/config/backends/extlinux.ts +++ b/src/config/backends/extlinux.ts @@ -2,12 +2,7 @@ import * as _ from 'lodash'; import { promises as fs } from 'fs'; import * as semver from 'semver'; -import { - ConfigOptions, - ConfigBackend, - bootMountPoint, - remountAndWriteAtomic, -} from './backend'; +import { ConfigOptions, ConfigBackend } from './backend'; import { ExtlinuxFile, Directive, @@ -17,6 +12,7 @@ import { import * as constants from '../../lib/constants'; import log from '../../lib/supervisor-console'; import { ExtLinuxEnvError, ExtLinuxParseError } from '../../lib/errors'; +import * as hostUtils from '../../lib/host-utils'; // The OS version when extlinux moved to READ ONLY partition const EXTLINUX_READONLY = '2.47.0'; @@ -31,7 +27,9 @@ const EXTLINUX_READONLY = '2.47.0'; export class Extlinux extends ConfigBackend { private static bootConfigVarPrefix = `${constants.hostConfigVarPrefix}EXTLINUX_`; - private static bootConfigPath = `${bootMountPoint}/extlinux/extlinux.conf`; + private static bootConfigPath = hostUtils.pathOnBoot( + `extlinux/extlinux.conf`, + ); private static supportedConfigValues = ['isolcpus', 'fdt']; private static supportedDirectives = ['APPEND', 'FDT']; @@ -136,7 +134,7 @@ export class Extlinux extends ConfigBackend { ); // Write new extlinux configuration - return await remountAndWriteAtomic( + return await hostUtils.writeToBoot( Extlinux.bootConfigPath, Extlinux.extlinuxFileToString(parsedBootFile), ); diff --git a/src/config/backends/extra-uEnv.ts b/src/config/backends/extra-uEnv.ts index 4b9316a3..8662cd42 100644 --- a/src/config/backends/extra-uEnv.ts +++ b/src/config/backends/extra-uEnv.ts @@ -1,16 +1,12 @@ import * as _ from 'lodash'; import { promises as fs } from 'fs'; -import { - ConfigOptions, - ConfigBackend, - bootMountPoint, - remountAndWriteAtomic, -} from './backend'; +import { ConfigOptions, ConfigBackend } from './backend'; import * as constants from '../../lib/constants'; import log from '../../lib/supervisor-console'; import { ExtraUEnvError } from '../../lib/errors'; import { exists } from '../../lib/fs-utils'; +import * as hostUtils from '../../lib/host-utils'; /** * Entry describes the configurable items in an extra_uEnv file @@ -43,7 +39,7 @@ const OPTION_REGEX = /^\s*(\w+)=(.*)$/; export class ExtraUEnv extends ConfigBackend { private static bootConfigVarPrefix = `${constants.hostConfigVarPrefix}EXTLINUX_`; - private static bootConfigPath = `${bootMountPoint}/extra_uEnv.txt`; + private static bootConfigPath = hostUtils.pathOnBoot(`extra_uEnv.txt`); private static entries: Record = { custom_fdt_file: { key: 'custom_fdt_file', collection: false }, @@ -94,7 +90,7 @@ export class ExtraUEnv extends ConfigBackend { }); // Write new extra_uEnv configuration - return await remountAndWriteAtomic( + return await hostUtils.writeToBoot( ExtraUEnv.bootConfigPath, ExtraUEnv.configToString(supportedOptions), ); diff --git a/src/config/backends/splash-image.ts b/src/config/backends/splash-image.ts index 784e2436..9a5bc119 100644 --- a/src/config/backends/splash-image.ts +++ b/src/config/backends/splash-image.ts @@ -5,16 +5,12 @@ import * as path from 'path'; import * as constants from '../../lib/constants'; import { exists } from '../../lib/fs-utils'; +import * as hostUtils from '../../lib/host-utils'; import log from '../../lib/supervisor-console'; -import { - bootMountPoint, - ConfigBackend, - ConfigOptions, - remountAndWriteAtomic, -} from './backend'; +import { ConfigBackend, ConfigOptions } from './backend'; export class SplashImage extends ConfigBackend { - private static readonly BASEPATH = path.join(bootMountPoint, 'splash'); + private static readonly BASEPATH = hostUtils.pathOnBoot('splash'); private static readonly DEFAULT = path.join( SplashImage.BASEPATH, 'balena-logo-default.png', @@ -86,7 +82,7 @@ export class SplashImage extends ConfigBackend { const buffer = Buffer.from(image, 'base64'); if (this.isPng(buffer)) { // Write the buffer to the given location - await remountAndWriteAtomic(where, buffer); + await hostUtils.writeToBoot(where, buffer); } else { throw new Error('Splash image should be a base64 encoded PNG image'); } diff --git a/src/host-config.ts b/src/host-config.ts index 6706c245..c7cbc492 100644 --- a/src/host-config.ts +++ b/src/host-config.ts @@ -8,7 +8,8 @@ import * as config from './config'; import * as constants from './lib/constants'; import * as dbus from './lib/dbus'; import { ENOENT, InternalInconsistencyError } from './lib/errors'; -import { writeFileAtomic, mkdirp, unlinkAll } from './lib/fs-utils'; +import { mkdirp, unlinkAll } from './lib/fs-utils'; +import { writeToBoot } from './lib/host-utils'; import * as updateLock from './lib/update-lock'; const redsocksHeader = stripIndent` @@ -133,7 +134,7 @@ async function setProxy(maybeConf: ProxyConfig | null): Promise { const conf = maybeConf as ProxyConfig; await mkdirp(proxyBasePath); if (_.isArray(conf.noProxy)) { - await writeFileAtomic(noProxyPath, conf.noProxy.join('\n')); + await writeToBoot(noProxyPath, conf.noProxy.join('\n')); } let currentConf: ProxyConfig | undefined; @@ -150,7 +151,7 @@ async function setProxy(maybeConf: ProxyConfig | null): Promise { ${generateRedsocksConfEntries({ ...currentConf, ...conf })} ${redsocksFooter} `; - await writeFileAtomic(redsocksConfPath, redsocksConf); + await writeToBoot(redsocksConfPath, redsocksConf); } // restart balena-proxy-config if it is loaded and NOT PartOf redsocks-conf.target diff --git a/src/lib/host-utils.ts b/src/lib/host-utils.ts new file mode 100644 index 00000000..2375b990 --- /dev/null +++ b/src/lib/host-utils.ts @@ -0,0 +1,23 @@ +import * as path from 'path'; +import * as constants from './constants'; +import * as fsUtils from './fs-utils'; + +// Returns an absolute path starting from the hostOS root partition +// This path is accessible from within the Supervisor container +export function pathOnRoot(relPath: string) { + return path.join(constants.rootMountPoint, relPath); +} + +// Returns an absolute path starting from the hostOS boot partition +// This path is accessible from within the Supervisor container +export function pathOnBoot(relPath: string) { + return pathOnRoot(path.join(constants.bootMountPoint, relPath)); +} + +// Receives an absolute path for a file under the boot partition (e.g. `/mnt/root/mnt/boot/config.txt`) +// and writes the given data. This function uses the best effort to write a file trying to minimize corruption +// due to a power cut. Given that the boot partition is a vfat filesystem, this means +// using write + sync +export async function writeToBoot(file: string, data: string | Buffer) { + return await fsUtils.writeAndSyncFile(file, data); +} diff --git a/test/03-config.spec.ts b/test/03-config.spec.ts index 72eb5052..a156320f 100644 --- a/test/03-config.spec.ts +++ b/test/03-config.spec.ts @@ -124,8 +124,10 @@ describe('Config', () => { }); describe('Config data sources', () => { - after(() => { + afterEach(() => { // Clean up memoized values + fnSchema.deviceArch.clear(); + fnSchema.deviceType.clear(); }); it('should obtain deviceArch from device-type.json', async () => { @@ -177,27 +179,28 @@ describe('Config', () => { }), ); + // Make a first call to get the value to be memoized + await conf.get('deviceType'); + await conf.get('deviceArch'); + expect(fs.readFile).to.be.called; + (fs.readFile as SinonStub).resetHistory(); + const deviceArch = await conf.get('deviceArch'); expect(deviceArch).to.equal(arch); - // The result should still be memoized from the - // call on the previous test + // The result should still be memoized from the previous call expect(fs.readFile).to.not.be.called; const deviceType = await conf.get('deviceType'); expect(deviceType).to.equal(slug); - // The result should still be memoized from the - // call on the previous test + // The result should still be memoized from the previous call expect(fs.readFile).to.not.be.called; (fs.readFile as SinonStub).restore(); }); it('should not memoize errors when reading deviceArch', (done) => { - // Clean up memoized value - fnSchema.deviceArch.clear(); - // File not found stub(fs, 'readFile').throws('File not found'); @@ -225,9 +228,6 @@ describe('Config', () => { }); it('should not memoize errors when reading deviceType', (done) => { - // Clean up memoized value - fnSchema.deviceType.clear(); - // File not found stub(fs, 'readFile').throws('File not found'); diff --git a/test/12-device-config.spec.ts b/test/12-device-config.spec.ts index dd94d27f..586c69c3 100644 --- a/test/12-device-config.spec.ts +++ b/test/12-device-config.spec.ts @@ -1,7 +1,7 @@ import { stripIndent } from 'common-tags'; import { promises as fs } from 'fs'; import * as path from 'path'; -import { SinonStub, stub, spy, SinonSpy } from 'sinon'; +import { SinonStub, stub, spy, SinonSpy, restore } from 'sinon'; import { expect } from 'chai'; import * as deviceConfig from '../src/device-config'; @@ -13,7 +13,8 @@ import { Odmdata } from '../src/config/backends/odmdata'; import { ConfigFs } from '../src/config/backends/config-fs'; import { SplashImage } from '../src/config/backends/splash-image'; import * as constants from '../src/lib/constants'; -import * as config from '../src/config'; +import log from '../src/lib/supervisor-console'; +import { fnSchema } from '../src/config/functions'; import prepare = require('./lib/prepare'); import mock = require('mock-fs'); @@ -27,256 +28,357 @@ const splashImageBackend = new SplashImage(); // TODO: Since the getBootConfig method is simple enough // these tests could probably be removed if each backend has its own // test and the src/config/utils module is properly tested. -describe('Device Backend Config', () => { +describe('device-config', () => { + const bootMountPoint = path.join( + constants.rootMountPoint, + constants.bootMountPoint, + ); + const configJson = 'test/data/config.json'; + const configFsJson = path.join(bootMountPoint, 'configfs.json'); + const configTxt = path.join(bootMountPoint, 'config.txt'); + const deviceTypeJson = path.join(bootMountPoint, 'device-type.json'); + const osRelease = path.join(constants.rootMountPoint, '/etc/os-release'); + let logSpy: SinonSpy; before(async () => { + // disable log output during testing + stub(log, 'debug'); + stub(log, 'warn'); + stub(log, 'info'); + stub(log, 'event'); + stub(log, 'success'); logSpy = spy(logger, 'logSystemMessage'); await prepare(); + + // clear memoized data from config + fnSchema.deviceType.clear(); + fnSchema.deviceArch.clear(); }); after(() => { - logSpy.restore(); + restore(); + // clear memoized data from config + fnSchema.deviceType.clear(); + fnSchema.deviceArch.clear(); }); afterEach(() => { + // Restore stubs 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(configTxtBackend), - ).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 - dtparam=i2c=on - dtparam=audio=on - dtoverlay=ads7846 - dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13 - foobar=baz - `); - - await expect( - // @ts-ignore accessing private value - deviceConfig.getBootConfig(configTxtBackend), - ).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('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(configTxtBackend, 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', - ); - }); - - 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"', - 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 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( - // @ts-ignore accessing private value - deviceConfig.bootConfigChangeRequired(configTxtBackend, current, target), - ).to.equal(false); - expect(logSpy).to.not.be.called; - }); - - it('writes the target config.txt', async () => { - stub(fsUtils, 'writeFileAtomic').resolves(); - stub(fsUtils, 'exec').resolves(); - 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 0x00800000', - HOST_CONFIG_dtparam: '"i2c=on","audio=off"', - HOST_CONFIG_dtoverlay: '"lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"', - HOST_CONFIG_foobar: 'bat', - HOST_CONFIG_foobaz: 'bar', - }; - - expect( - // @ts-ignore accessing private value - deviceConfig.bootConfigChangeRequired(configTxtBackend, current, target), - ).to.equal(true); - - // @ts-ignore accessing private value - await deviceConfig.setBootConfig(configTxtBackend, target); - expect(fsUtils.exec).to.be.calledOnce; - expect(logSpy).to.be.calledTwice; - expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success'); - expect(fsUtils.writeFileAtomic).to.be.calledWith( - './test/data/mnt/boot/config.txt', - stripIndent` - initramfs initramf.gz 0x00800000 - dtparam=i2c=on - dtparam=audio=off - dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13 - foobar=bat - foobaz=bar - ` + '\n', // add newline because stripIndent trims last newline - ); - - // Restore stubs - (fsUtils.writeFileAtomic as SinonStub).restore(); - (fsUtils.exec as SinonStub).restore(); - }); - - it('ensures required fields are written to config.txt', async () => { - stub(fsUtils, 'writeFileAtomic').resolves(); - stub(fsUtils, 'exec').resolves(); - stub(config, 'get').withArgs('deviceType').resolves('fincm3'); - 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 0x00800000', - HOST_CONFIG_dtparam: '"i2c=on","audio=off"', - HOST_CONFIG_dtoverlay: '"lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"', - HOST_CONFIG_foobar: 'bat', - HOST_CONFIG_foobaz: 'bar', - }; - - expect( - // @ts-ignore accessing private value - deviceConfig.bootConfigChangeRequired(configTxtBackend, current, target), - ).to.equal(true); - - // @ts-ignore accessing private value - await deviceConfig.setBootConfig(configTxtBackend, target); - expect(fsUtils.exec).to.be.calledOnce; - expect(logSpy).to.be.calledTwice; - expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success'); - expect(fsUtils.writeFileAtomic).to.be.calledWith( - './test/data/mnt/boot/config.txt', - stripIndent` - initramfs initramf.gz 0x00800000 - dtparam=i2c=on - dtparam=audio=off - dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13 - dtoverlay=balena-fin - foobar=bat - foobaz=bar - ` + '\n', // add newline because stripIndent trims last newline - ); - - // Restore stubs - (fsUtils.writeFileAtomic as SinonStub).restore(); - (fsUtils.exec as SinonStub).restore(); - (config.get as SinonStub).restore(); - }); - - it('accepts RESIN_ and BALENA_ variables', async () => { - return expect( - deviceConfig.formatConfigKeys({ - FOO: 'bar', // should be removed - BAR: 'baz', // should be removed - RESIN_SUPERVISOR_LOCAL_MODE: 'false', // any device - BALENA_SUPERVISOR_OVERRIDE_LOCK: 'false', // any device - BALENA_SUPERVISOR_POLL_INTERVAL: '100', // any device - RESIN_HOST_CONFIG_dtparam: 'i2c_arm=on","spi=on","audio=on', // config.txt backend - RESIN_HOST_CONFIGFS_ssdt: 'spidev1.0', // configfs backend - BALENA_HOST_EXTLINUX_isolcpus: '1,2,3', // extlinux backend - }), - ).to.deep.equal({ - SUPERVISOR_LOCAL_MODE: 'false', - SUPERVISOR_OVERRIDE_LOCK: 'false', - SUPERVISOR_POLL_INTERVAL: '100', - HOST_CONFIG_dtparam: 'i2c_arm=on","spi=on","audio=on', - HOST_CONFIGFS_ssdt: 'spidev1.0', - HOST_EXTLINUX_isolcpus: '1,2,3', + describe('formatConfigKeys', () => { + it('accepts RESIN_ and BALENA_ variables', async () => { + return expect( + deviceConfig.formatConfigKeys({ + FOO: 'bar', // should be removed + BAR: 'baz', // should be removed + RESIN_SUPERVISOR_LOCAL_MODE: 'false', // any device + BALENA_SUPERVISOR_OVERRIDE_LOCK: 'false', // any device + BALENA_SUPERVISOR_POLL_INTERVAL: '100', // any device + RESIN_HOST_CONFIG_dtparam: 'i2c_arm=on","spi=on","audio=on', // config.txt backend + RESIN_HOST_CONFIGFS_ssdt: 'spidev1.0', // configfs backend + BALENA_HOST_EXTLINUX_isolcpus: '1,2,3', // extlinux backend + }), + ).to.deep.equal({ + SUPERVISOR_LOCAL_MODE: 'false', + SUPERVISOR_OVERRIDE_LOCK: 'false', + SUPERVISOR_POLL_INTERVAL: '100', + HOST_CONFIG_dtparam: 'i2c_arm=on","spi=on","audio=on', + HOST_CONFIGFS_ssdt: 'spidev1.0', + HOST_EXTLINUX_isolcpus: '1,2,3', + }); }); }); - it('returns default configuration values', () => { - const conf = deviceConfig.getDefaults(); - return expect(conf).to.deep.equal({ - HOST_FIREWALL_MODE: 'off', - HOST_DISCOVERABILITY: 'true', - SUPERVISOR_VPN_CONTROL: 'true', - SUPERVISOR_POLL_INTERVAL: '900000', - SUPERVISOR_LOCAL_MODE: 'false', - SUPERVISOR_CONNECTIVITY_CHECK: 'true', - SUPERVISOR_LOG_CONTROL: 'true', - SUPERVISOR_DELTA: 'false', - SUPERVISOR_DELTA_REQUEST_TIMEOUT: '59000', - SUPERVISOR_DELTA_APPLY_TIMEOUT: '0', - SUPERVISOR_DELTA_RETRY_COUNT: '30', - SUPERVISOR_DELTA_RETRY_INTERVAL: '10000', - SUPERVISOR_DELTA_VERSION: '2', - SUPERVISOR_INSTANT_UPDATE_TRIGGER: 'true', - SUPERVISOR_OVERRIDE_LOCK: 'false', - SUPERVISOR_PERSISTENT_LOGGING: 'false', - SUPERVISOR_HARDWARE_METRICS: 'true', + describe('getDefaults', () => { + it('returns default configuration values', () => { + const conf = deviceConfig.getDefaults(); + return expect(conf).to.deep.equal({ + HOST_FIREWALL_MODE: 'off', + HOST_DISCOVERABILITY: 'true', + SUPERVISOR_VPN_CONTROL: 'true', + SUPERVISOR_POLL_INTERVAL: '900000', + SUPERVISOR_LOCAL_MODE: 'false', + SUPERVISOR_CONNECTIVITY_CHECK: 'true', + SUPERVISOR_LOG_CONTROL: 'true', + SUPERVISOR_DELTA: 'false', + SUPERVISOR_DELTA_REQUEST_TIMEOUT: '59000', + SUPERVISOR_DELTA_APPLY_TIMEOUT: '0', + SUPERVISOR_DELTA_RETRY_COUNT: '30', + SUPERVISOR_DELTA_RETRY_INTERVAL: '10000', + SUPERVISOR_DELTA_VERSION: '2', + SUPERVISOR_INSTANT_UPDATE_TRIGGER: 'true', + SUPERVISOR_OVERRIDE_LOCK: 'false', + SUPERVISOR_PERSISTENT_LOGGING: 'false', + SUPERVISOR_HARDWARE_METRICS: 'true', + }); }); }); - describe('Extlinux files', () => { + describe('config.txt', () => { + const mockFs = () => { + mock({ + // This is only needed so config.get doesn't fail + [configJson]: JSON.stringify({ deviceType: 'fincm3' }), + [configTxt]: stripIndent` + enable_uart=1 + dtparam=i2c_arm=on + dtparam=spi=on + disable_splash=1 + avoid_warnings=1 + dtparam=audio=on + gpu_mem=16`, + [osRelease]: stripIndent` + PRETTY_NAME="balenaOS 2.88.5+rev1" + META_BALENA_VERSION="2.88.5" + VARIANT_ID="dev"`, + [deviceTypeJson]: JSON.stringify({ + slug: 'fincm3', + arch: 'armv7hf', + }), + }); + }; + + const unmockFs = () => { + mock.restore(); + }; + + beforeEach(() => { + mockFs(); + }); + + afterEach(() => { + // Reset the state of the fs after each test to + // prevent tests leaking into each other + unmockFs(); + }); + + 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(configTxtBackend), + ).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', + }); + + // Update config.txt to include initramfs and array variables + await fs.writeFile( + configTxt, + stripIndent` + 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( + // @ts-ignore accessing private value + deviceConfig.getBootConfig(configTxtBackend), + ).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', + }); + }); + + 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( + configTxtBackend, + 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', + ); + }); + + 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"', + 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 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( + // @ts-ignore accessing private value + deviceConfig.bootConfigChangeRequired( + configTxtBackend, + current, + target, + ), + ).to.equal(false); + expect(logSpy).to.not.be.called; + }); + + it('writes the target config.txt', 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', + }; + const target = { + HOST_CONFIG_initramfs: 'initramf.gz 0x00800000', + HOST_CONFIG_dtparam: '"i2c=on","audio=off"', + HOST_CONFIG_dtoverlay: '"lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"', + HOST_CONFIG_foobar: 'bat', + HOST_CONFIG_foobaz: 'bar', + }; + + expect( + deviceConfig.bootConfigChangeRequired( + configTxtBackend, + current, + target, + 'fincm3', + ), + ).to.equal(true); + + await deviceConfig.setBootConfig(configTxtBackend, target); + expect(logSpy).to.be.calledTwice; + expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success'); + expect(await fs.readFile(configTxt, 'utf-8')).to.equal( + stripIndent` + initramfs initramf.gz 0x00800000 + dtparam=i2c=on + dtparam=audio=off + dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13 + dtoverlay=balena-fin + foobar=bat + foobaz=bar + ` + '\n', // add newline because stripIndent trims last newline + ); + }); + + it('ensures required fields are written to config.txt', 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', + }; + const target = { + HOST_CONFIG_initramfs: 'initramf.gz 0x00800000', + HOST_CONFIG_dtparam: '"i2c=on","audio=off"', + HOST_CONFIG_dtoverlay: '"lirc-rpi,gpio_out_pin=17,gpio_in_pin=13"', + HOST_CONFIG_foobar: 'bat', + HOST_CONFIG_foobaz: 'bar', + }; + + expect( + // @ts-ignore accessing private value + deviceConfig.bootConfigChangeRequired( + configTxtBackend, + current, + target, + ), + ).to.equal(true); + + await deviceConfig.setBootConfig(configTxtBackend, target); + expect(logSpy).to.be.calledTwice; + expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success'); + expect(await fs.readFile(configTxt, 'utf-8')).to.equal( + stripIndent` + initramfs initramf.gz 0x00800000 + dtparam=i2c=on + dtparam=audio=off + dtoverlay=lirc-rpi,gpio_out_pin=17,gpio_in_pin=13 + dtoverlay=balena-fin + foobar=bat + foobaz=bar + ` + '\n', // add newline because stripIndent trims last newline + ); + }); + }); + + describe('extlinux', () => { + const extlinuxConf = path.join(bootMountPoint, 'extlinux/extlinux.conf'); + + const mockFs = () => { + mock({ + // This is only needed so config.get doesn't fail + [configJson]: JSON.stringify({}), + [osRelease]: stripIndent` + PRETTY_NAME="balenaOS 2.88.5+rev1" + META_BALENA_VERSION="2.88.5" + VARIANT_ID="dev" + `, + [deviceTypeJson]: JSON.stringify({ + slug: 'fincm3', + arch: 'armv7hf', + }), + [extlinuxConf]: stripIndent` + DEFAULT primary + TIMEOUT 30 + MENU TITLE Boot Options + LABEL primary + MENU LABEL primary Image + LINUX /Image + APPEND \${cbootargs} \${resin_kernel_root} ro rootwait + `, + }); + }; + + const unmockFs = () => { + mock.restore(); + }; + + beforeEach(() => { + mockFs(); + }); + + afterEach(() => { + // Reset the state of the fs after each test to + // prevent tests leaking into each other + unmockFs(); + }); + it('should correctly write to extlinux.conf files', async () => { - stub(fsUtils, 'writeFileAtomic').resolves(); - stub(fsUtils, 'exec').resolves(); - const current = {}; const target = { HOST_EXTLINUX_isolcpus: '2', @@ -290,11 +392,9 @@ describe('Device Backend Config', () => { // @ts-ignore accessing private value await deviceConfig.setBootConfig(extlinuxBackend, target); - expect(fsUtils.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', + expect(await fs.readFile(extlinuxConf, 'utf-8')).to.equal( stripIndent` DEFAULT primary TIMEOUT 30 @@ -306,10 +406,6 @@ describe('Device Backend Config', () => { FDT /boot/mycustomdtb.dtb ` + '\n', // add newline because stripIndent trims last newline ); - - // Restore stubs - (fsUtils.writeFileAtomic as SinonStub).restore(); - (fsUtils.exec as SinonStub).restore(); }); }); @@ -398,18 +494,66 @@ describe('Device Backend Config', () => { }); }); - describe('ConfigFS files', () => { - it('should correctly write to configfs.json files', async () => { - stub(fsUtils, 'writeFileAtomic').resolves(); - stub(fsUtils, 'exec').resolves(); + describe('config-fs', () => { + const acpiTables = path.join(constants.rootMountPoint, 'boot/acpi-tables'); + const sysKernelAcpiTable = path.join( + constants.rootMountPoint, + 'sys/kernel/config/acpi/table', + ); + const mockFs = () => { + mock({ + // This is only needed so config.get doesn't fail + [configJson]: JSON.stringify({}), + [osRelease]: stripIndent` + PRETTY_NAME="balenaOS 2.88.5+rev1" + META_BALENA_VERSION="2.88.5" + VARIANT_ID="dev" + `, + [configFsJson]: JSON.stringify({ + ssdt: ['spidev1.1'], + }), + + [deviceTypeJson]: JSON.stringify({ + slug: 'fincm3', + arch: 'armv7hf', + }), + [acpiTables]: { + 'spidev1.0.aml': '', + 'spidev1.1.aml': '', + }, + [sysKernelAcpiTable]: { + // Add necessary files to avoid the module reporting an error + 'spidev1.1': { + oem_id: '', + oem_table_id: '', + oem_revision: '', + }, + }, + }); + }; + + const unmockFs = () => { + mock.restore(); + }; + + beforeEach(() => { + mockFs(); + }); + + afterEach(() => { + // Reset the state of the fs after each test to + // prevent tests leaking into each other + unmockFs(); + }); + + it('should correctly write to configfs.json files', async () => { const current = {}; const target = { HOST_CONFIGFS_ssdt: 'spidev1.0', }; expect( - // @ts-ignore accessing private value deviceConfig.bootConfigChangeRequired( configFsBackend, current, @@ -418,56 +562,26 @@ describe('Device Backend Config', () => { ), ).to.equal(true); - // @ts-ignore accessing private value await deviceConfig.setBootConfig(configFsBackend, target); - expect(fsUtils.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/configfs.json', + expect(await fs.readFile(configFsJson, 'utf-8')).to.equal( '{"ssdt":["spidev1.0"]}', ); - - // Restore stubs - (fsUtils.writeFileAtomic as SinonStub).restore(); - (fsUtils.exec as SinonStub).restore(); }); it('should correctly load the configfs.json file', async () => { stub(fsUtils, 'exec').resolves(); - stub(fsUtils, 'writeFileAtomic').resolves(); - stub(fsUtils, 'exists').resolves(true); - stub(fs, 'mkdir').resolves(); - stub(fs, 'readdir').resolves([]); - stub(fs, 'readFile').callsFake((file) => { - if (file === 'test/data/mnt/boot/configfs.json') { - return Promise.resolve( - JSON.stringify({ - ssdt: ['spidev1.1'], - }), - ); - } - return Promise.resolve(''); - }); - await configFsBackend.initialise(); expect(fsUtils.exec).to.be.calledWith('modprobe acpi_configfs'); - expect(fsUtils.exec).to.be.calledWith( - `mount -t vfat -o remount,rw ${constants.bootBlockDevice} ./test/data/mnt/boot`, - ); + + // If the module performs this call, it's because all the prior checks succeeded expect(fsUtils.exec).to.be.calledWith( 'cat test/data/boot/acpi-tables/spidev1.1.aml > test/data/sys/kernel/config/acpi/table/spidev1.1/aml', ); - expect((fsUtils.exists as SinonSpy).callCount).to.equal(2); - expect((fs.readFile as SinonSpy).callCount).to.equal(4); // Restore stubs - (fsUtils.writeFileAtomic as SinonStub).restore(); (fsUtils.exec as SinonStub).restore(); - (fsUtils.exists as SinonStub).restore(); - (fs.mkdir as SinonStub).restore(); - (fs.readdir as SinonStub).restore(); - (fs.readFile as SinonStub).restore(); }); it('requires change when target is different', () => { @@ -533,28 +647,52 @@ describe('Device Backend Config', () => { }); }); - describe('Boot splash image', () => { + describe('splash config', () => { const defaultLogo = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/wQDLA+84AAAACklEQVR4nGNiAAAABgADNjd8qAAAAABJRU5ErkJggg=='; const png = 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABAQMAAAAl21bKAAAAA1BMVEX/TQBcNTh/AAAAAXRSTlPM0jRW/QAAAApJREFUeJxjYgAAAAYAAzY3fKgAAAAASUVORK5CYII='; const uri = `data:image/png;base64,${png}`; + const splash = path.join(bootMountPoint, 'splash'); + + const mockFs = () => { + mock({ + // This is only needed so config.get doesn't fail + [configJson]: JSON.stringify({}), + [osRelease]: stripIndent` + PRETTY_NAME="balenaOS 2.88.5+rev1" + META_BALENA_VERSION="2.88.5" + VARIANT_ID="dev" + `, + [deviceTypeJson]: JSON.stringify({ + slug: 'raspberrypi4-64', + arch: 'aarch64', + }), + [splash]: { + /* empty directory */ + }, + }); + }; + + const unmockFs = () => { + mock.restore(); + }; + beforeEach(() => { - // Setup stubs - stub(fsUtils, 'writeFileAtomic').resolves(); - stub(fsUtils, 'exec').resolves(); + mockFs(); }); afterEach(() => { - // Restore stubs - (fsUtils.writeFileAtomic as SinonStub).restore(); - (fsUtils.exec as SinonStub).restore(); + unmockFs(); }); it('should correctly write to resin-logo.png', async () => { // Devices with balenaOS < 2.51 use resin-logo.png - stub(fs, 'readdir').resolves(['resin-logo.png'] as any); + fs.writeFile( + path.join(splash, 'resin-logo.png'), + Buffer.from(defaultLogo, 'base64'), + ); const current = {}; const target = { @@ -573,20 +711,19 @@ describe('Device Backend Config', () => { ).to.equal(true); await deviceConfig.setBootConfig(splashImageBackend, target); - expect(fsUtils.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.calledOnceWith( - 'test/data/mnt/boot/splash/resin-logo.png', - ); - - // restore the stub - (fs.readdir as SinonStub).restore(); + expect( + await fs.readFile(path.join(splash, 'resin-logo.png'), 'base64'), + ).to.equal(png); }); it('should correctly write to balena-logo.png', async () => { // Devices with balenaOS >= 2.51 use balena-logo.png - stub(fs, 'readdir').resolves(['balena-logo.png'] as any); + fs.writeFile( + path.join(splash, 'balena-logo.png'), + Buffer.from(defaultLogo, 'base64'), + ); const current = {}; const target = { @@ -606,21 +743,15 @@ describe('Device Backend Config', () => { await deviceConfig.setBootConfig(splashImageBackend, target); - expect(fsUtils.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.calledOnceWith( - 'test/data/mnt/boot/splash/balena-logo.png', - ); - - // restore the stub - (fs.readdir as SinonStub).restore(); + expect( + await fs.readFile(path.join(splash, 'balena-logo.png'), 'base64'), + ).to.equal(png); }); it('should correctly write to balena-logo.png if no default logo is found', async () => { // Devices with balenaOS >= 2.51 use balena-logo.png - stub(fs, 'readdir').resolves([]); - const current = {}; const target = { HOST_SPLASH_image: png, @@ -639,15 +770,11 @@ describe('Device Backend Config', () => { await deviceConfig.setBootConfig(splashImageBackend, target); - expect(fsUtils.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.calledOnceWith( - 'test/data/mnt/boot/splash/balena-logo.png', - ); - - // restore the stub - (fs.readdir as SinonStub).restore(); + expect( + await fs.readFile(path.join(splash, 'balena-logo.png'), 'base64'), + ).to.equal(png); }); it('should correctly read the splash logo if different from the default', async () => { @@ -678,137 +805,124 @@ describe('Device Backend Config', () => { readFileStub.restore(); }); }); -}); -describe('getRequiredSteps', () => { - const bootMountPoint = path.join( - constants.rootMountPoint, - constants.bootMountPoint, - ); - const configJson = 'test/data/config.json'; - const configTxt = path.join(bootMountPoint, 'config.txt'); - const deviceTypeJson = path.join(bootMountPoint, 'device-type.json'); - const osRelease = path.join(constants.rootMountPoint, '/etc/os-release'); - const splash = path.join(bootMountPoint, 'splash/balena-logo.png'); + describe('getRequiredSteps', () => { + const splash = path.join(bootMountPoint, 'splash/balena-logo.png'); - // TODO: something like this could be done as a fixture instead of - // doing the file initialisation on 00-init.ts - const mockFs = () => { - mock({ - // This is only needed so config.get doesn't fail - [configJson]: JSON.stringify({}), - [configTxt]: stripIndent` - enable_uart=true - `, - [osRelease]: stripIndent` - PRETTY_NAME="balenaOS 2.88.5+rev1" - META_BALENA_VERSION="2.88.5" - VARIANT_ID="dev" - `, - [deviceTypeJson]: JSON.stringify({ - slug: 'raspberrypi4-64', - arch: 'aarch64', - }), - [splash]: Buffer.from( - 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', - 'base64', - ), + // TODO: something like this could be done as a fixture instead of + // doing the file initialisation on 00-init.ts + const mockFs = () => { + mock({ + // This is only needed so config.get doesn't fail + [configJson]: JSON.stringify({}), + [configTxt]: stripIndent` + enable_uart=true + `, + [osRelease]: stripIndent` + PRETTY_NAME="balenaOS 2.88.5+rev1" + META_BALENA_VERSION="2.88.5" + VARIANT_ID="dev" + `, + [deviceTypeJson]: JSON.stringify({ + slug: 'raspberrypi4-64', + arch: 'aarch64', + }), + [splash]: Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', + 'base64', + ), + }); + }; + + const unmockFs = () => { + mock.restore(); + }; + + beforeEach(() => { + mockFs(); }); - }; - const unmockFs = () => { - mock.restore(); - }; + afterEach(() => { + unmockFs(); + }); - before(() => { - mockFs(); - - // TODO: remove this once the remount on backend.ts is no longer - // necessary - stub(fsUtils, 'exec'); - }); - - after(() => { - unmockFs(); - (fsUtils.exec as SinonStub).restore(); - }); - - it('returns required steps to config.json first if any', async () => { - const steps = await deviceConfig.getRequiredSteps( - { - local: { - config: { - SUPERVISOR_POLL_INTERVAL: 900000, - HOST_CONFIG_enable_uart: true, + it('returns required steps to config.json first if any', async () => { + const steps = await deviceConfig.getRequiredSteps( + { + local: { + config: { + SUPERVISOR_POLL_INTERVAL: 900000, + HOST_CONFIG_enable_uart: true, + }, }, - }, - } as any, - { - local: { - config: { - SUPERVISOR_POLL_INTERVAL: 600000, - HOST_CONFIG_enable_uart: false, + } as any, + { + local: { + config: { + SUPERVISOR_POLL_INTERVAL: 600000, + HOST_CONFIG_enable_uart: false, + }, }, - }, - } as any, - ); - expect(steps.map((s) => s.action)).to.have.members([ - // No reboot is required by this config change - 'changeConfig', - 'noop', // The noop has to be here since there are also changes from config backends - ]); - }); + } as any, + ); + expect(steps.map((s) => s.action)).to.have.members([ + // No reboot is required by this config change + 'changeConfig', + 'noop', // The noop has to be here since there are also changes from config backends + ]); + }); - it('sets the rebooot breadcrumb for config steps that require a reboot', async () => { - const steps = await deviceConfig.getRequiredSteps( - { - local: { - config: { - SUPERVISOR_POLL_INTERVAL: 900000, - SUPERVISOR_PERSISTENT_LOGGING: false, + it('sets the rebooot breadcrumb for config steps that require a reboot', async () => { + const steps = await deviceConfig.getRequiredSteps( + { + local: { + config: { + SUPERVISOR_POLL_INTERVAL: 900000, + SUPERVISOR_PERSISTENT_LOGGING: false, + }, }, - }, - } as any, - { - local: { - config: { - SUPERVISOR_POLL_INTERVAL: 600000, - SUPERVISOR_PERSISTENT_LOGGING: true, + } as any, + { + local: { + config: { + SUPERVISOR_POLL_INTERVAL: 600000, + SUPERVISOR_PERSISTENT_LOGGING: true, + }, }, - }, - } as any, - ); - expect(steps.map((s) => s.action)).to.have.members([ - 'setRebootBreadcrumb', - 'changeConfig', - 'noop', - ]); - }); + } as any, + ); + expect(steps.map((s) => s.action)).to.have.members([ + 'setRebootBreadcrumb', + 'changeConfig', + 'noop', + ]); + }); - it('returns required steps for backends if no steps are required for config.json', async () => { - const steps = await deviceConfig.getRequiredSteps( - { - local: { - config: { - SUPERVISOR_POLL_INTERVAL: 900000, - SUPERVISOR_PERSISTENT_LOGGING: true, - HOST_CONFIG_enable_uart: true, + it('returns required steps for backends if no steps are required for config.json', async () => { + const steps = await deviceConfig.getRequiredSteps( + { + local: { + config: { + SUPERVISOR_POLL_INTERVAL: 900000, + SUPERVISOR_PERSISTENT_LOGGING: true, + HOST_CONFIG_enable_uart: true, + }, }, - }, - } as any, - { - local: { - config: { - SUPERVISOR_POLL_INTERVAL: 900000, - SUPERVISOR_PERSISTENT_LOGGING: true, - HOST_CONFIG_enable_uart: false, + } as any, + { + local: { + config: { + SUPERVISOR_POLL_INTERVAL: 900000, + SUPERVISOR_PERSISTENT_LOGGING: true, + HOST_CONFIG_enable_uart: false, + }, }, - }, - } as any, - ); - expect(steps.map((s) => s.action)).to.have.members([ - 'setRebootBreadcrumb', - 'setBootConfig', - ]); + } as any, + ); + expect(steps.map((s) => s.action)).to.have.members([ + 'setRebootBreadcrumb', + 'setBootConfig', + ]); + }); }); }); diff --git a/test/27-extlinux-config.spec.ts b/test/27-extlinux-config.spec.ts index d0d602f8..14419a76 100644 --- a/test/27-extlinux-config.spec.ts +++ b/test/27-extlinux-config.spec.ts @@ -181,16 +181,15 @@ describe('Extlinux Configuration', () => { }); it('sets new config values', async () => { - stub(fsUtils, 'writeFileAtomic').resolves(); - stub(fsUtils, 'exec').resolves(); + stub(fsUtils, 'writeAndSyncFile').resolves(); await backend.setBootConfig({ fdt: '/boot/mycustomdtb.dtb', isolcpus: '2', }); - expect(fsUtils.writeFileAtomic).to.be.calledWith( - './test/data/mnt/boot/extlinux/extlinux.conf', + expect(fsUtils.writeAndSyncFile).to.be.calledWith( + 'test/data/mnt/boot/extlinux/extlinux.conf', stripIndent` DEFAULT primary TIMEOUT 30 @@ -204,8 +203,7 @@ describe('Extlinux Configuration', () => { ); // Restore stubs - (fsUtils.writeFileAtomic as SinonStub).restore(); - (fsUtils.exec as SinonStub).restore(); + (fsUtils.writeAndSyncFile as SinonStub).restore(); }); it('only allows supported configuration options', () => { diff --git a/test/33-extra-uenv-config.spec.ts b/test/33-extra-uenv-config.spec.ts index 5ee46c6f..12efb649 100644 --- a/test/33-extra-uenv-config.spec.ts +++ b/test/33-extra-uenv-config.spec.ts @@ -108,8 +108,7 @@ describe('extra_uEnv Configuration', () => { }); it('sets new config values', async () => { - stub(fsUtils, 'writeFileAtomic').resolves(); - stub(fsUtils, 'exec').resolves(); + stub(fsUtils, 'writeAndSyncFile').resolves(); const logWarningStub = spy(Log, 'warn'); // This config contains a value set from something else @@ -127,8 +126,8 @@ describe('extra_uEnv Configuration', () => { console: 'tty0', // not supported so won't be set }); - expect(fsUtils.writeFileAtomic).to.be.calledWith( - './test/data/mnt/boot/extra_uEnv.txt', + expect(fsUtils.writeAndSyncFile).to.be.calledWith( + 'test/data/mnt/boot/extra_uEnv.txt', 'custom_fdt_file=/boot/mycustomdtb.dtb\nextra_os_cmdline=isolcpus=2\n', ); @@ -137,14 +136,12 @@ describe('extra_uEnv Configuration', () => { ); // Restore stubs - (fsUtils.writeFileAtomic as SinonStub).restore(); - (fsUtils.exec as SinonStub).restore(); + (fsUtils.writeAndSyncFile as SinonStub).restore(); logWarningStub.restore(); }); it('sets new config values containing collections', async () => { - stub(fsUtils, 'writeFileAtomic').resolves(); - stub(fsUtils, 'exec').resolves(); + stub(fsUtils, 'writeAndSyncFile').resolves(); const logWarningStub = spy(Log, 'warn'); // @ts-ignore accessing private value @@ -166,14 +163,13 @@ describe('extra_uEnv Configuration', () => { splash: '', // collection entry so should be concatted to other collections of this entry }); - expect(fsUtils.writeFileAtomic).to.be.calledWith( - './test/data/mnt/boot/extra_uEnv.txt', + expect(fsUtils.writeAndSyncFile).to.be.calledWith( + 'test/data/mnt/boot/extra_uEnv.txt', 'custom_fdt_file=/boot/mycustomdtb.dtb\nextra_os_cmdline=isolcpus=2 console=tty0 splash\n', ); // Restore stubs - (fsUtils.writeFileAtomic as SinonStub).restore(); - (fsUtils.exec as SinonStub).restore(); + (fsUtils.writeAndSyncFile as SinonStub).restore(); logWarningStub.restore(); // @ts-ignore accessing private value ExtraUEnv.supportedConfigs = previousSupportedConfigs; diff --git a/test/40-target-state.spec.ts b/test/40-target-state.spec.ts index f051e9b0..a55f7020 100644 --- a/test/40-target-state.spec.ts +++ b/test/40-target-state.spec.ts @@ -44,9 +44,11 @@ const req = { }; describe('Target state', () => { - before(() => { + before(async () => { // maxPollTime starts as undefined deviceState.__set__('maxPollTime', 60000); + + stub(deviceState, 'applyStep').resolves(); }); beforeEach(() => { @@ -59,6 +61,10 @@ describe('Target state', () => { (request.getRequestInstance as SinonStub).restore(); }); + after(async () => { + (deviceState.applyStep as SinonStub).restore(); + }); + describe('update', () => { it('should throw if a 304 is received but no local cache exists', async () => { // new request returns 304 diff --git a/test/41-device-api-v1.spec.ts b/test/41-device-api-v1.spec.ts index d36632a8..814bf824 100644 --- a/test/41-device-api-v1.spec.ts +++ b/test/41-device-api-v1.spec.ts @@ -83,6 +83,9 @@ describe('SupervisorAPI [V1 Endpoints]', () => { await deviceState.initialized; await targetStateCache.initialized; + // Do not apply target state + stub(deviceState, 'applyStep').resolves(); + // Stub health checks so we can modify them whenever needed healthCheckStubs = [ stub(apiBinder, 'healthcheck'), @@ -91,7 +94,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { // The mockedAPI contains stubs that might create unexpected results // See the module to know what has been stubbed - api = await mockedAPI.create(); + api = await mockedAPI.create(healthCheckStubs); // Start test API await api.listen( @@ -119,6 +122,7 @@ describe('SupervisorAPI [V1 Endpoints]', () => { throw e; } } + (deviceState.applyStep as SinonStub).restore(); // Restore healthcheck stubs healthCheckStubs.forEach((hc) => hc.restore()); // Remove any test data generated diff --git a/test/42-device-api-v2.spec.ts b/test/42-device-api-v2.spec.ts index 027c8f77..6ba0d197 100644 --- a/test/42-device-api-v2.spec.ts +++ b/test/42-device-api-v2.spec.ts @@ -343,7 +343,7 @@ describe('SupervisorAPI [V2 Endpoints]', () => { lockMock.restore(); }); - it.skip('should return 200 for an existing service', async () => { + it('should return 200 for an existing service', async () => { await mockedDockerode.testWithData( { containers: mockContainers, images: mockImages }, async () => { diff --git a/test/43-splash-image.spec.ts b/test/43-splash-image.spec.ts index f76afd20..3ec1668b 100644 --- a/test/43-splash-image.spec.ts +++ b/test/43-splash-image.spec.ts @@ -15,12 +15,11 @@ describe('Splash image configuration', () => { const uri = `data:image/png;base64,${logo}`; let readDirStub: SinonStub; let readFileStub: SinonStub; - let writeFileAtomicStub: SinonStub; + let writeAndSyncFileStub: SinonStub; beforeEach(() => { // Setup stubs - writeFileAtomicStub = stub(fsUtils, 'writeFileAtomic').resolves(); - stub(fsUtils, 'exec').resolves(); + writeAndSyncFileStub = stub(fsUtils, 'writeAndSyncFile').resolves(); readFileStub = stub(fs, 'readFile').resolves( Buffer.from(logo, 'base64') as any, ); @@ -32,8 +31,7 @@ describe('Splash image configuration', () => { afterEach(() => { // Restore stubs - writeFileAtomicStub.restore(); - (fsUtils.exec as SinonStub).restore(); + writeAndSyncFileStub.restore(); readFileStub.restore(); readDirStub.restore(); }); @@ -50,7 +48,7 @@ describe('Splash image configuration', () => { ); // Should make a copy - expect(writeFileAtomicStub).to.be.calledOnceWith( + expect(writeAndSyncFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/balena-logo-default.png', Buffer.from(logo, 'base64'), ); @@ -178,7 +176,7 @@ describe('Splash image configuration', () => { await backend.setBootConfig({ image: uri }); - expect(writeFileAtomicStub).to.be.calledOnceWith( + expect(writeAndSyncFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/resin-logo.png', Buffer.from(logo, 'base64'), ); @@ -189,7 +187,7 @@ describe('Splash image configuration', () => { await backend.setBootConfig({ image: uri }); - expect(writeFileAtomicStub).to.be.calledOnceWith( + expect(writeAndSyncFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/balena-logo.png', Buffer.from(logo, 'base64'), ); @@ -200,7 +198,7 @@ describe('Splash image configuration', () => { await backend.setBootConfig({ image: uri }); - expect(writeFileAtomicStub).to.be.calledOnceWith( + expect(writeAndSyncFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/balena-logo.png', Buffer.from(logo, 'base64'), ); @@ -211,7 +209,7 @@ describe('Splash image configuration', () => { await backend.setBootConfig({ image: logo }); - expect(writeFileAtomicStub).to.be.calledOnceWith( + expect(writeAndSyncFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/balena-logo.png', Buffer.from(logo, 'base64'), ); @@ -224,7 +222,7 @@ describe('Splash image configuration', () => { expect(readFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/balena-logo-default.png', ); - expect(writeFileAtomicStub).to.be.calledOnceWith( + expect(writeAndSyncFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/balena-logo.png', Buffer.from(defaultLogo, 'base64'), ); @@ -237,7 +235,7 @@ describe('Splash image configuration', () => { expect(readFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/balena-logo-default.png', ); - expect(writeFileAtomicStub).to.be.calledOnceWith( + expect(writeAndSyncFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/resin-logo.png', Buffer.from(defaultLogo, 'base64'), ); @@ -250,7 +248,7 @@ describe('Splash image configuration', () => { expect(readFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/balena-logo-default.png', ); - expect(writeFileAtomicStub).to.be.calledOnceWith( + expect(writeAndSyncFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/balena-logo.png', Buffer.from(defaultLogo, 'base64'), ); @@ -263,7 +261,7 @@ describe('Splash image configuration', () => { expect(readFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/balena-logo-default.png', ); - expect(writeFileAtomicStub).to.be.calledOnceWith( + expect(writeAndSyncFileStub).to.be.calledOnceWith( 'test/data/mnt/boot/splash/resin-logo.png', Buffer.from(defaultLogo, 'base64'), ); @@ -271,12 +269,12 @@ describe('Splash image configuration', () => { it('should throw if arg is not a valid base64 string', async () => { expect(backend.setBootConfig({ image: 'somestring' })).to.be.rejected; - expect(writeFileAtomicStub).to.not.be.called; + expect(writeAndSyncFileStub).to.not.be.called; }); it('should throw if image is not a valid PNG file', async () => { expect(backend.setBootConfig({ image: 'aGVsbG8=' })).to.be.rejected; - expect(writeFileAtomicStub).to.not.be.called; + expect(writeAndSyncFileStub).to.not.be.called; }); }); diff --git a/test/data/testconfig-apibinder.json b/test/data/testconfig-apibinder.json index f22685af..fcde1c16 100644 --- a/test/data/testconfig-apibinder.json +++ b/test/data/testconfig-apibinder.json @@ -1 +1 @@ -{"applicationId":78373,"deviceType":"raspberrypi3","appUpdatePollInterval":3000,"listenPort":2345,"vpnPort":443,"apiEndpoint":"http://0.0.0.0:3000","registryEndpoint":"registry2.resin.io","deltaEndpoint":"https://delta.resin.io","mixpanelToken":"baz","apiKey":"boo","version":"2.0.6+rev3.prod"} +{"applicationId":78373,"deviceType":"raspberrypi4-64","appUpdatePollInterval":3000,"listenPort":2345,"vpnPort":443,"apiEndpoint":"http://0.0.0.0:3000","registryEndpoint":"registry2.resin.io","deltaEndpoint":"https://delta.resin.io","mixpanelToken":"baz","apiKey":"boo","version":"2.0.6+rev3.prod"} diff --git a/test/lib/mocked-device-api.ts b/test/lib/mocked-device-api.ts index cddddbfd..18ef91a8 100644 --- a/test/lib/mocked-device-api.ts +++ b/test/lib/mocked-device-api.ts @@ -125,7 +125,9 @@ const mockedOptions = { * */ -async function create(): Promise { +async function create( + healthchecks = [deviceState.healthcheck, apiBinder.healthcheck], +): Promise { // Get SupervisorAPI construct options await createAPIOpts(); @@ -135,7 +137,7 @@ async function create(): Promise { // Create SupervisorAPI const api = new SupervisorAPI({ routers: [deviceState.router, buildRoutes()], - healthchecks: [deviceState.healthcheck, apiBinder.healthcheck], + healthchecks, }); const macAddress = await config.get('macAddress');