Support setting device/fleet configuration in extra_uEnv.txt

Closes: 
Change-Type: minor
Signed-off-by: Miguel Casqueira <miguel@balena.io>
This commit is contained in:
Miguel Casqueira 2020-07-16 22:16:41 -04:00
parent e97faad77b
commit cac2e3612c
13 changed files with 875 additions and 21 deletions

97
package-lock.json generated
View File

@ -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": {

View File

@ -28,6 +28,7 @@
"private": true,
"dependencies": {
"dbus": "^1.0.7",
"semver": "^7.3.2",
"sqlite3": "^4.1.1"
},
"engines": {

View File

@ -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<DeviceConfigBackend> {

View File

@ -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<ConfigOptions> {
@ -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',
);
}

View File

@ -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<EntryKey, Entry> = {
custom_fdt_file: { key: 'custom_fdt_file', collection: false },
extra_os_cmdline: { key: 'extra_os_cmdline', collection: true },
};
private static supportedConfigs: Dictionary<Entry> = {
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<ConfigOptions> {
// 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<void> {
// 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<string> {
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<string, string> {
// Reduce ConfigOptions into a Map that joins collections
return Object.entries(configs).reduce(
(configMap: Map<string, string>, [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}` : '')
);
}
}

View File

@ -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<DeviceConfigBackend | undefined> {
// 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(

View File

@ -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) {

View File

@ -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 {}

View File

@ -59,6 +59,12 @@ export function getOSSemver(path: string): Promise<string | undefined> {
return getOSReleaseField(path, 'VERSION');
}
export async function getMetaOSRelease(
path: string,
): Promise<string | undefined> {
return getOSReleaseField(path, 'META_BALENA_VERSION');
}
const L4T_REGEX = /^.*-l4t-r(\d+\.\d+(\.?\d+)?).*$/;
export async function getL4tVersion(): Promise<string | undefined> {
// We call `uname -r` on the host, and look for l4t

View File

@ -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,
},
];

View File

@ -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,
},
];

View File

@ -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');
});
});

View File

@ -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"