diff --git a/package-lock.json b/package-lock.json index d94617bf..3d886c7b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -186,6 +186,12 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", "dev": true + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true } } }, @@ -3812,6 +3818,14 @@ "lodash": "^4.0.0", "request": "^2.65.0", "semver": "^5.3.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "docker-toolbelt": { @@ -5370,6 +5384,14 @@ "requires": { "pify": "^4.0.1", "semver": "^5.6.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "pify": { @@ -5499,6 +5521,14 @@ "schema-utils": "1.0.0", "semver": "^5.6.0", "tapable": "^1.0.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "form-data": { @@ -8340,6 +8370,14 @@ "dev": true, "requires": { "semver": "^5.4.1" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "node-libs-browser": { @@ -8417,6 +8455,11 @@ "safe-buffer": "^5.1.2", "yallist": "^3.0.0" } + }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" } } }, @@ -8720,6 +8763,14 @@ "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "execa": { @@ -8817,6 +8868,14 @@ "registry-auth-token": "^3.0.1", "registry-url": "^3.0.3", "semver": "^5.1.0" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "pako": { @@ -9878,9 +9937,9 @@ "dev": true }, "semver": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/semver/-/semver-5.6.0.tgz", - "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==" + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.2.tgz", + "integrity": "sha512-OrOb32TeeambH6UrhtShmF7CRDqhL6/5XpPNp2DuRH6+9QLw/orhp72j87v8Qa1ScDkvrrBNpZcDejAirJmfXQ==" }, "semver-compare": { "version": "1.0.0", @@ -9895,6 +9954,14 @@ "dev": true, "requires": { "semver": "^5.0.3" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + } } }, "semver-regex": { @@ -11275,6 +11342,12 @@ "path-parse": "^1.0.6" } }, + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==", + "dev": true + }, "tslib": { "version": "1.13.0", "resolved": "https://registry.npmjs.org/tslib/-/tslib-1.13.0.tgz", @@ -11875,6 +11948,7 @@ "anymatch": "^2.0.0", "async-each": "^1.0.1", "braces": "^2.3.2", + "fsevents": "^1.2.7", "glob-parent": "^3.1.0", "inherits": "^2.0.3", "is-binary-path": "^1.0.0", @@ -11885,6 +11959,16 @@ "upath": "^1.1.1" } }, + "fsevents": { + "version": "1.2.13", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", + "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", + "dev": true, + "optional": true, + "requires": { + "nan": "^2.12.1" + } + }, "glob-parent": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-3.1.0.tgz", @@ -12070,6 +12154,13 @@ "semver": "^5.5.0", "shebang-command": "^1.2.0", "which": "^1.2.9" + }, + "dependencies": { + "semver": { + "version": "5.7.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.1.tgz", + "integrity": "sha512-sauaDf/PZdVgrLTNYHRtpXa1iRiKcaebiKQ1BJdpQlWH2lCvexQdX55snPFyK7QzpudqbCI0qXFfOasHdyNDGQ==" + } } }, "emoji-regex": { diff --git a/package.json b/package.json index 1dbef12e..def343b5 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "private": true, "dependencies": { "dbus": "^1.0.7", + "semver": "^7.3.2", "sqlite3": "^4.1.1" }, "engines": { diff --git a/src/config/backends/backend.ts b/src/config/backends/backend.ts index 85d9b200..7bf21b88 100644 --- a/src/config/backends/backend.ts +++ b/src/config/backends/backend.ts @@ -23,7 +23,7 @@ export async function remountAndWriteAtomic( export abstract class DeviceConfigBackend { // Does this config backend support the given device type? - public abstract matches(deviceType: string): boolean; + public abstract matches(deviceType: string, metaRelease?: string): boolean; // A function which reads and parses the configuration options from // specific boot config @@ -42,7 +42,7 @@ export abstract class DeviceConfigBackend { // Convert a configuration environment variable to a config backend // variable - public abstract processConfigVarName(envVar: string): string; + public abstract processConfigVarName(envVar: string): string | null; // Process the value if the environment variable, ready to be written to // the backend @@ -52,7 +52,10 @@ export abstract class DeviceConfigBackend { ): string | string[]; // Return the env var name for this config option - public abstract createConfigVarName(configName: string): string; + // In situations when the configName is not valid the backend is unable + // to create the varName equivelant so null is returned. + // Example an empty string should return null. + public abstract createConfigVarName(configName: string): string | null; // Allow a chosen config backend to be initialised public async initialise(): Promise { diff --git a/src/config/backends/extlinux.ts b/src/config/backends/extlinux.ts index 81fcf351..663e15da 100644 --- a/src/config/backends/extlinux.ts +++ b/src/config/backends/extlinux.ts @@ -1,5 +1,6 @@ import * as _ from 'lodash'; import { fs } from 'mz'; +import * as semver from 'semver'; import { ConfigOptions, @@ -15,7 +16,7 @@ import { } from './extlinux-file'; import * as constants from '../../lib/constants'; import log from '../../lib/supervisor-console'; -import { ExtLinuxParseError } from '../../lib/errors'; +import { ExtLinuxEnvError, ExtLinuxParseError } from '../../lib/errors'; /** * A backend to handle extlinux host configuration @@ -43,8 +44,13 @@ export class ExtlinuxConfigBackend extends DeviceConfigBackend { '(?:' + _.escapeRegExp(ExtlinuxConfigBackend.bootConfigVarPrefix) + ')(.+)', ); - public matches(deviceType: string): boolean { - return deviceType.startsWith('jetson-tx'); + public matches(deviceType: string, metaRelease: string | undefined): boolean { + return ( + // Only test metaRelease with Jetson devices + deviceType.startsWith('jetson-') && + typeof metaRelease === 'string' && + semver.lt(metaRelease, constants.extLinuxReadOnly) + ); } public async getBootConfig(): Promise { @@ -58,7 +64,7 @@ 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 ExtLinuxParseError( + throw new ExtLinuxEnvError( 'Could not find extlinux file. Device is possibly bricked', ); } @@ -104,7 +110,7 @@ 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 ExtLinuxEnvError( 'Could not find extlinux file. Device is possibly bricked', ); } diff --git a/src/config/backends/extra-uEnv.ts b/src/config/backends/extra-uEnv.ts new file mode 100644 index 00000000..5cb9d88d --- /dev/null +++ b/src/config/backends/extra-uEnv.ts @@ -0,0 +1,272 @@ +import * as _ from 'lodash'; +import { fs } from 'mz'; +import * as semver from 'semver'; + +import { + ConfigOptions, + DeviceConfigBackend, + bootMountPoint, + remountAndWriteAtomic, +} from './backend'; +import * as constants from '../../lib/constants'; +import log from '../../lib/supervisor-console'; +import { ExtraUEnvError } from '../../lib/errors'; + +/** + * Entry describes the configurable items in an extra_uEnv file + * + * @collection - This describes if the value can be a list of items seperated by space character. + * + */ + +type Entry = { + key: EntryKey; + collection: boolean; +}; + +// Types of entries supported in a extra_uEnv file +type EntryKey = 'custom_fdt_file' | 'extra_os_cmdline'; + +// Splits a string from a file into lines +const LINE_REGEX = /(?:\r?\n[\s#]*)+/; + +// Splits a line into key value pairs on `=` +const OPTION_REGEX = /^\s*(\w+)=(.*)$/; + +/** + * A backend to handle host configuration with extra_uEnv + * + * Supports: + * - {BALENA|RESIN}_HOST_EXTLINUX_isolcpus = value | "value" | "value1","value2" + * - {BALENA|RESIN}_HOST_EXTLINUX_fdt = value | "value" + */ + +export class ExtraUEnvConfigBackend extends DeviceConfigBackend { + private static bootConfigVarPrefix = `${constants.hostConfigVarPrefix}EXTLINUX_`; + private static bootConfigPath = `${bootMountPoint}/extra_uEnv.txt`; + + private static entries: Record = { + custom_fdt_file: { key: 'custom_fdt_file', collection: false }, + extra_os_cmdline: { key: 'extra_os_cmdline', collection: true }, + }; + + private static supportedConfigs: Dictionary = { + fdt: ExtraUEnvConfigBackend.entries['custom_fdt_file'], + isolcpus: ExtraUEnvConfigBackend.entries['extra_os_cmdline'], + }; + + public static bootConfigVarRegex = new RegExp( + '(?:' + + _.escapeRegExp(ExtraUEnvConfigBackend.bootConfigVarPrefix) + + ')(.+)', + ); + + public matches(deviceType: string, metaRelease: string | undefined): boolean { + return ( + deviceType === 'intel-nuc' || + // Test metaRelease for Jetson devices + (deviceType.startsWith('jetson') && + // Assume metaRelease is greater than or equal to EXTRA_SUPPORT if undefined + (typeof metaRelease === 'undefined' || + semver.gte(metaRelease, constants.extLinuxReadOnly))) + ); + } + + public async getBootConfig(): Promise { + // Get config contents at bootConfigPath + const confContents = await ExtraUEnvConfigBackend.readBootConfigPath(); + + // Parse ConfigOptions from bootConfigPath contents + const parsedConfigFile = ExtraUEnvConfigBackend.parseOptions(confContents); + + // Filter out unsupported values + return _.pickBy(parsedConfigFile, (_value, key) => + this.isSupportedConfig(key), + ); + } + + public async setBootConfig(opts: ConfigOptions): Promise { + // Filter out unsupported options + const supportedOptions = _.pickBy(opts, (value, key) => { + if (!this.isSupportedConfig(key)) { + log.warn(`Not setting unsupported value: { ${key}: ${value} }`); + return false; + } + return true; + }); + + // Write new extra_uEnv configuration + return await remountAndWriteAtomic( + ExtraUEnvConfigBackend.bootConfigPath, + ExtraUEnvConfigBackend.configToString(supportedOptions), + ); + } + + public isSupportedConfig(config: string): boolean { + return config in ExtraUEnvConfigBackend.supportedConfigs; + } + + public isBootConfigVar(envVar: string): boolean { + return envVar.startsWith(ExtraUEnvConfigBackend.bootConfigVarPrefix); + } + + public processConfigVarName(envVar: string): string | null { + const name = envVar.replace( + ExtraUEnvConfigBackend.bootConfigVarRegex, + '$1', + ); + if (name === envVar) { + return null; + } + return name; + } + + public processConfigVarValue(_key: string, value: string): string { + return value; + } + + public createConfigVarName(configName: string): string | null { + if (configName === '') { + return null; + } + return `${ExtraUEnvConfigBackend.bootConfigVarPrefix}${configName}`; + } + + private static parseOptions(configFile: string): ConfigOptions { + // Exit early if configFile is empty + if (configFile.length === 0) { + return {}; + } + // Split by line and filter any comments and empty lines + const lines = configFile.split(LINE_REGEX); + // Reduce lines to ConfigOptions + return lines.reduce((options: ConfigOptions, line: string) => { + const optionValues = line.match(OPTION_REGEX); + if (optionValues == null) { + log.warn(`Could not read extra_uEnv entry: ${line}`); + return options; + } + // Merge new option with existing options + return { + ...ExtraUEnvConfigBackend.parseOption(optionValues), + ...options, + }; + }, {}); + } + + private static parseOption(optionArray: string[]): ConfigOptions { + const [, KEY, VALUE] = optionArray; + // Check if this key's value is a collection + if (ExtraUEnvConfigBackend.entries[KEY as EntryKey]?.collection) { + // Return split collection of options + return ExtraUEnvConfigBackend.parseOptionCollection(VALUE); + } + // Find the option that belongs to this entry + const optionKey = _.findKey( + ExtraUEnvConfigBackend.supportedConfigs, + (config) => config.key === KEY, + ); + // Check if we found a corresponding option for this entry + if (typeof optionKey !== 'string') { + log.warn(`Could not parse unsupported option: ${optionArray[0]}`); + return {}; + } + return { [optionKey]: VALUE }; + } + + private static parseOptionCollection( + collectionString: string, + ): ConfigOptions { + return ( + collectionString + // Split collection into individual options + .split(' ') + // Reduce list of option strings into ConfigOptions object + .reduce((options: ConfigOptions, option: string) => { + // Match optionValues to key=value regex + const optionValues = option.match(OPTION_REGEX); + // Check if option is only a key + if (optionValues === null) { + if (option !== '') { + return { [option]: '', ...options }; + } else { + log.warn(`Unable to set empty value option: ${option}`); + return options; + } + } + const [, KEY, VALUE] = optionValues; + // Merge new option with existing options + return { [KEY]: VALUE, ...options }; + }, {}) + ); + } + + private static async readBootConfigPath(): Promise { + try { + return await fs.readFile(ExtraUEnvConfigBackend.bootConfigPath, 'utf-8'); + } catch { + // In the rare case where the user might have deleted extra_uEnv conf file between linux boot and supervisor boot + // We do not have any backup to fallback too; warn the user of a possible brick + log.error( + `Unable to read extra_uEnv file at: ${ExtraUEnvConfigBackend.bootConfigPath}`, + ); + throw new ExtraUEnvError( + 'Could not find extra_uEnv file. Device is possibly bricked', + ); + } + } + + private static configToString(configs: ConfigOptions): string { + // Get Map of ConfigOptions object + const configMap = ExtraUEnvConfigBackend.configToMap(configs); + // Iterator over configMap and concat to configString + let configString = ''; + for (const [key, value] of configMap) { + // Append new config + configString += `${key}=${value}\n`; + } + return configString; + } + + private static configToMap(configs: ConfigOptions): Map { + // Reduce ConfigOptions into a Map that joins collections + return Object.entries(configs).reduce( + (configMap: Map, [configKey, configValue]) => { + const { + key: ENTRY_KEY, + collection: ENTRY_IS_COLLECTION, + } = ExtraUEnvConfigBackend.supportedConfigs[configKey]; + // Check if we have to build the value for the entry + if (ENTRY_IS_COLLECTION) { + return configMap.set( + ENTRY_KEY, + ExtraUEnvConfigBackend.appendToCollection( + configMap.get(ENTRY_KEY), + configKey, + configValue, + ), + ); + } + // Set the value of this config + return configMap.set(ENTRY_KEY, `${configValue}`); + }, + // Start with empty Map + new Map(), + ); + } + + private static appendToCollection( + collection: string = '', + key: string, + value: string | string[], + ) { + return ( + // Start with existing collection and add a space + (collection !== '' ? `${collection} ` : '') + + // Append new key + key + + // Append value it's if not empty string + (value !== '' ? `=${value}` : '') + ); + } +} diff --git a/src/config/utils.ts b/src/config/utils.ts index 9ae79ddb..fb92a762 100644 --- a/src/config/utils.ts +++ b/src/config/utils.ts @@ -1,27 +1,37 @@ import * as _ from 'lodash'; +import * as constants from '../lib/constants'; +import { getMetaOSRelease } from '../lib/os-release'; import { EnvVarObject } from '../lib/types'; import { ExtlinuxConfigBackend } from './backends/extlinux'; +import { ExtraUEnvConfigBackend } from './backends/extra-uEnv'; import { RPiConfigBackend } from './backends/raspberry-pi'; import { ConfigfsConfigBackend } from './backends/config-fs'; import { ConfigOptions, DeviceConfigBackend } from './backends/backend'; const configBackends = [ new ExtlinuxConfigBackend(), + new ExtraUEnvConfigBackend(), new RPiConfigBackend(), new ConfigfsConfigBackend(), ]; export const initialiseConfigBackend = async (deviceType: string) => { - const backend = getConfigBackend(deviceType); + const backend = await getConfigBackend(deviceType); if (backend) { await backend.initialise(); return backend; } }; -function getConfigBackend(deviceType: string): DeviceConfigBackend | undefined { - return _.find(configBackends, (backend) => backend.matches(deviceType)); +async function getConfigBackend( + deviceType: string, +): Promise { + // Some backends are only supported by certain release versions so pass in metaRelease + const metaRelease = await getMetaOSRelease(constants.hostOSVersionPath); + return _.find(configBackends, (backend) => + backend.matches(deviceType, metaRelease), + ); } export function envToBootConfig( diff --git a/src/lib/constants.ts b/src/lib/constants.ts index 543c8e47..e2523136 100644 --- a/src/lib/constants.ts +++ b/src/lib/constants.ts @@ -65,6 +65,8 @@ const constants = { // (this number is used as an upper bound when generating // a random jitter) maxApiJitterDelay: 60 * 1000, + // The OS version when extlinux moved to READ ONLY partition + extLinuxReadOnly: '2.47.0', }; if (process.env.DOCKER_HOST == null) { diff --git a/src/lib/errors.ts b/src/lib/errors.ts index 1a8a3052..ece43d33 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -107,6 +107,29 @@ export class AppsJsonParseError extends TypedError {} export class DatabaseParseError extends TypedError {} export class BackupError extends TypedError {} +/** + * Thrown if we cannot parse an extlinux file. + */ export class ExtLinuxParseError extends TypedError {} + +/** + * Thrown if there is a problem with the environment of which extlinux config is in. + * This can be things like missing config files or config files we cannot write to. + */ +export class ExtLinuxEnvError extends TypedError {} + +/** + * Thrown if we cannot parse the APPEND directive from a extlinux file + */ export class AppendDirectiveError extends TypedError {} + +/** + * Thrown if we cannot parse the FDT directive from a extlinux file + */ export class FDTDirectiveError extends TypedError {} + +/** + * Generic error thrown when something goes wrong with handling the ExtraUEnv backend. + * This can be things like missing config files or config files we cannot write to. + */ +export class ExtraUEnvError extends TypedError {} diff --git a/src/lib/os-release.ts b/src/lib/os-release.ts index 80ac78aa..b66a51f8 100644 --- a/src/lib/os-release.ts +++ b/src/lib/os-release.ts @@ -59,6 +59,12 @@ export function getOSSemver(path: string): Promise { return getOSReleaseField(path, 'VERSION'); } +export async function getMetaOSRelease( + path: string, +): Promise { + return getOSReleaseField(path, 'META_BALENA_VERSION'); +} + const L4T_REGEX = /^.*-l4t-r(\d+\.\d+(\.?\d+)?).*$/; export async function getL4tVersion(): Promise { // We call `uname -r` on the host, and look for l4t diff --git a/test/27-extlinux-config.spec.ts b/test/27-extlinux-config.spec.ts index 1f306144..87bd97f5 100644 --- a/test/27-extlinux-config.spec.ts +++ b/test/27-extlinux-config.spec.ts @@ -123,13 +123,8 @@ describe('Extlinux Configuration', () => { }); 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), + MATCH_TESTS.forEach(({ deviceType, metaRelease, supported }) => + expect(backend.matches(deviceType, metaRelease)).to.equal(supported), ); }); @@ -324,3 +319,84 @@ const MALFORMED_CONFIGS = [ reason: 'Unable to parse invalid value: isolcpus=0,4=woops', }, ]; + +const SUPPORTED_VERSION = '2.45.0'; // or less +const UNSUPPORTED_VERSION = '2.47.0'; // or greater + +const MATCH_TESTS = [ + { + deviceType: 'jetson-tx1', + metaRelease: SUPPORTED_VERSION, + supported: true, + }, + { + deviceType: 'jetson-tx2', + metaRelease: SUPPORTED_VERSION, + supported: true, + }, + { + deviceType: 'jetson-tx2', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'jetson-nano', + metaRelease: SUPPORTED_VERSION, + supported: true, + }, + { + deviceType: 'jetson-nano', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'jetson-xavier', + metaRelease: SUPPORTED_VERSION, + supported: true, + }, + { + deviceType: 'jetson-xavier', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'intel-nuc', + metaRelease: SUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'intel-nuc', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'raspberry', + metaRelease: SUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'raspberry', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'fincm3', + metaRelease: SUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'fincm3', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'up-board', + metaRelease: SUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'up-board', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, +]; diff --git a/test/31-extra-uenv-config.spec.ts b/test/31-extra-uenv-config.spec.ts new file mode 100644 index 00000000..1b7d4814 --- /dev/null +++ b/test/31-extra-uenv-config.spec.ts @@ -0,0 +1,317 @@ +import { child_process, fs } from 'mz'; +import { stripIndent } from 'common-tags'; +import { SinonStub, spy, stub } from 'sinon'; + +import { expect } from './lib/chai-config'; +import * as fsUtils from '../src/lib/fs-utils'; +import Log from '../src/lib/supervisor-console'; +import { ExtraUEnvConfigBackend } from '../src/config/backends/extra-uEnv'; + +describe('extra_uEnv Configuration', () => { + const backend = new ExtraUEnvConfigBackend(); + let readFileStub: SinonStub; + + beforeEach(() => { + readFileStub = stub(fs, 'readFile'); + }); + + afterEach(() => { + readFileStub.restore(); + }); + + it('should parse extra_uEnv string', () => { + const fileContents = stripIndent`\ + custom_fdt_file=mycustom.dtb + extra_os_cmdline=isolcpus=3,4 splash console=tty0 + `; + // @ts-ignore accessing private method + const parsed = ExtraUEnvConfigBackend.parseOptions(fileContents); + expect(parsed).to.deep.equal({ + fdt: 'mycustom.dtb', + isolcpus: '3,4', + splash: '', + console: 'tty0', + }); + }); + + it('should only parse supported configuration options from bootConfigPath', async () => { + readFileStub.resolves(stripIndent`\ + custom_fdt_file=mycustom.dtb + extra_os_cmdline=isolcpus=3,4 + `); + + await expect(backend.getBootConfig()).to.eventually.deep.equal({ + fdt: 'mycustom.dtb', + isolcpus: '3,4', + }); + + // Add other options that will get filtered out because they aren't supported + readFileStub.resolves(stripIndent`\ + custom_fdt_file=mycustom.dtb + extra_os_cmdline=isolcpus=3,4 console=tty0 splash + `); + + await expect(backend.getBootConfig()).to.eventually.deep.equal({ + fdt: 'mycustom.dtb', + isolcpus: '3,4', + }); + + // Stub with no supported values + readFileStub.resolves(stripIndent`\ + fdt=something_else + isolcpus + 123.12=5 + `); + + await expect(backend.getBootConfig()).to.eventually.deep.equal({}); + }); + + it('only matches supported devices', () => { + MATCH_TESTS.forEach(({ deviceType, metaRelease, supported }) => + expect(backend.matches(deviceType, metaRelease)).to.equal(supported), + ); + }); + + it('errors when cannot find extra_uEnv.txt', async () => { + // Stub readFile to reject much like if the file didn't exist + readFileStub.rejects(); + await expect(backend.getBootConfig()).to.eventually.be.rejectedWith( + 'Could not find extra_uEnv file. Device is possibly bricked', + ); + }); + + it('logs warning for malformed extra_uEnv.txt', async () => { + spy(Log, 'warn'); + for (const badConfig of MALFORMED_CONFIGS) { + // Stub bad config + readFileStub.resolves(badConfig.contents); + // Expect warning log from the given bad config + await backend.getBootConfig(); + // @ts-ignore + expect(Log.warn.lastCall?.lastArg).to.equal(badConfig.reason); + } + // @ts-ignore + Log.warn.restore(); + }); + + it('sets new config values', async () => { + stub(fsUtils, 'writeFileAtomic').resolves(); + stub(child_process, 'exec').resolves(); + const logWarningStub = spy(Log, 'warn'); + + // This config contains a value set from something else + // We to make sure the Supervisor is enforcing the source of truth (the cloud) + // So after setting new values this unsupported/not set value should be gone + readFileStub.resolves(stripIndent`\ + extra_os_cmdline=rootwait isolcpus=3,4 + other_service=set_this_value + `); + + // Sets config with mix of supported and not supported values + await backend.setBootConfig({ + fdt: '/boot/mycustomdtb.dtb', + isolcpus: '2', + console: 'tty0', // not supported so won't be set + }); + + expect(fsUtils.writeFileAtomic).to.be.calledWith( + './test/data/mnt/boot/extra_uEnv.txt', + 'custom_fdt_file=/boot/mycustomdtb.dtb\nextra_os_cmdline=isolcpus=2\n', + ); + + expect(logWarningStub.lastCall?.lastArg).to.equal( + 'Not setting unsupported value: { console: tty0 }', + ); + + // Restore stubs + (fsUtils.writeFileAtomic as SinonStub).restore(); + (child_process.exec as SinonStub).restore(); + logWarningStub.restore(); + }); + + it('sets new config values containing collections', async () => { + stub(fsUtils, 'writeFileAtomic').resolves(); + stub(child_process, 'exec').resolves(); + const logWarningStub = spy(Log, 'warn'); + + // @ts-ignore accessing private value + const previousSupportedConfigs = ExtraUEnvConfigBackend.supportedConfigs; + // Stub isSupportedConfig so we can confirm collections work + // @ts-ignore accessing private value + ExtraUEnvConfigBackend.supportedConfigs = { + fdt: { key: 'custom_fdt_file', collection: false }, + isolcpus: { key: 'extra_os_cmdline', collection: true }, + console: { key: 'extra_os_cmdline', collection: true }, + splash: { key: 'extra_os_cmdline', collection: true }, + }; + + // Set config again + await backend.setBootConfig({ + fdt: '/boot/mycustomdtb.dtb', + isolcpus: '2', // collection entry so should be concatted to other collections of this entry + console: 'tty0', // collection entry so should be concatted to other collections of this entry + 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', + 'custom_fdt_file=/boot/mycustomdtb.dtb\nextra_os_cmdline=isolcpus=2 console=tty0 splash\n', + ); + + // Restore stubs + (fsUtils.writeFileAtomic as SinonStub).restore(); + (child_process.exec as SinonStub).restore(); + logWarningStub.restore(); + // @ts-ignore accessing private value + ExtraUEnvConfigBackend.supportedConfigs = previousSupportedConfigs; + }); + + it('only allows supported configuration options', () => { + [ + { configName: 'fdt', supported: true }, + { configName: 'isolcpus', supported: true }, + { configName: 'custom_fdt_file', supported: false }, + { configName: 'splash', supported: false }, + { configName: '', supported: false }, + ].forEach(({ configName, supported }) => + expect(backend.isSupportedConfig(configName)).to.equal(supported), + ); + }); + + it('correctly detects boot config variables', () => { + [ + { config: 'HOST_EXTLINUX_isolcpus', valid: true }, + { config: 'HOST_EXTLINUX_fdt', valid: true }, + { config: 'HOST_EXTLINUX_rootwait', valid: true }, + { config: 'HOST_EXTLINUX_5', valid: true }, + { config: 'DEVICE_EXTLINUX_isolcpus', valid: false }, + { config: 'isolcpus', valid: false }, + ].forEach(({ config, valid }) => + expect(backend.isBootConfigVar(config)).to.equal(valid), + ); + }); + + it('converts variable to backend formatted name', () => { + [ + { input: 'HOST_EXTLINUX_isolcpus', output: 'isolcpus' }, + { input: 'HOST_EXTLINUX_fdt', output: 'fdt' }, + { input: 'HOST_EXTLINUX_', output: null }, + { input: 'value', output: null }, + ].forEach(({ input, output }) => + expect(backend.processConfigVarName(input)).to.equal(output), + ); + }); + + it('normalizes variable value', () => { + [ + { input: { key: 'key', value: 'value' }, output: 'value' }, + ].forEach(({ input, output }) => + expect(backend.processConfigVarValue(input.key, input.value)).to.equal( + output, + ), + ); + }); + + it('returns the environment name for config variable', () => { + [ + { input: 'isolcpus', output: 'HOST_EXTLINUX_isolcpus' }, + { input: 'fdt', output: 'HOST_EXTLINUX_fdt' }, + { input: 'rootwait', output: 'HOST_EXTLINUX_rootwait' }, + { input: '', output: null }, + ].forEach(({ input, output }) => + expect(backend.createConfigVarName(input)).to.equal(output), + ); + }); +}); + +const MALFORMED_CONFIGS = [ + { + contents: stripIndent` + custom_fdt_file=mycustom.dtb + extra_os_cmdline=isolcpus=3,4 + another_value + `, + reason: 'Could not read extra_uEnv entry: another_value', + }, +]; + +const SUPPORTED_VERSION = '2.47.0'; // or greater +const UNSUPPORTED_VERSION = '2.45.0'; // or less + +const MATCH_TESTS = [ + { + deviceType: 'jetson-tx1', + metaRelease: SUPPORTED_VERSION, + supported: true, + }, + { + deviceType: 'jetson-tx2', + metaRelease: SUPPORTED_VERSION, + supported: true, + }, + { + deviceType: 'jetson-tx2', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'jetson-nano', + metaRelease: SUPPORTED_VERSION, + supported: true, + }, + { + deviceType: 'jetson-nano', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'jetson-xavier', + metaRelease: SUPPORTED_VERSION, + supported: true, + }, + { + deviceType: 'jetson-xavier', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'intel-nuc', + metaRelease: SUPPORTED_VERSION, + supported: true, + }, + { + deviceType: 'intel-nuc', + metaRelease: UNSUPPORTED_VERSION, + supported: true, + }, + { + deviceType: 'raspberry', + metaRelease: SUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'raspberry', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'fincm3', + metaRelease: SUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'fincm3', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'up-board', + metaRelease: SUPPORTED_VERSION, + supported: false, + }, + { + deviceType: 'up-board', + metaRelease: UNSUPPORTED_VERSION, + supported: false, + }, +]; diff --git a/test/32-os-release.spec.ts b/test/32-os-release.spec.ts new file mode 100644 index 00000000..15853dba --- /dev/null +++ b/test/32-os-release.spec.ts @@ -0,0 +1,35 @@ +import { expect } from 'chai'; + +import * as osRelease from '../src/lib/os-release'; + +const OS_RELEASE_PATH = 'test/data/etc/os-release-tx2'; + +describe('OS Release Information', () => { + it('gets pretty name', async () => { + // Try to get PRETTY_NAME + await expect(osRelease.getOSVersion(OS_RELEASE_PATH)).to.eventually.equal( + 'balenaOS 2.45.1+rev3', + ); + }); + + it('gets variant', async () => { + // Try to get VARIANT_ID + await expect(osRelease.getOSVariant(OS_RELEASE_PATH)).to.eventually.equal( + 'prod', + ); + }); + + it('gets version', async () => { + // Try to get VERSION + await expect(osRelease.getOSSemver(OS_RELEASE_PATH)).to.eventually.equal( + '2.45.1+rev3', + ); + }); + + it('gets meta release version', async () => { + // Try to get META_BALENA_VERSIONS + await expect( + osRelease.getMetaOSRelease(OS_RELEASE_PATH), + ).to.eventually.equal('2.45.1'); + }); +}); diff --git a/test/data/etc/os-release-tx2 b/test/data/etc/os-release-tx2 new file mode 100644 index 00000000..90e5b2af --- /dev/null +++ b/test/data/etc/os-release-tx2 @@ -0,0 +1,12 @@ +ID="balena-os" +NAME="balenaOS" +VERSION="2.45.1+rev3" +VERSION_ID="2.45.1+rev3" +PRETTY_NAME="balenaOS 2.45.1+rev3" +MACHINE="jetson-tx2" +VARIANT="Production" +VARIANT_ID="prod" +META_BALENA_VERSION="2.45.1" +RESIN_BOARD_REV="13bd883" +META_RESIN_REV="0c90c7e" +SLUG="jetson-tx2" \ No newline at end of file