Merge pull request #1436 from balena-io/1206-odmdata-confugration

added support for configuring ODMDATA
This commit is contained in:
bulldozer-balena[bot] 2020-09-02 09:05:08 +00:00 committed by GitHub
commit 0334669e1f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 629 additions and 18 deletions

View File

@ -4,12 +4,14 @@ import { Extlinux } from './extlinux';
import { ExtraUEnv } from './extra-uEnv';
import { ConfigTxt } from './config-txt';
import { ConfigFs } from './config-fs';
import { Odmdata } from './odmdata';
export const allBackends = [
new Extlinux(),
new ExtraUEnv(),
new ConfigTxt(),
new ConfigFs(),
new Odmdata(),
];
export function matchesAnyBootConfig(envVar: string): boolean {

View File

@ -0,0 +1,247 @@
import * as _ from 'lodash';
import { promises as fs } from 'fs';
import { ConfigOptions, ConfigBackend } from './backend';
import * as constants from '../../lib/constants';
import log from '../../lib/supervisor-console';
import { ODMDataError } from '../../lib/errors';
/**
* A backend to handle ODMDATA configuration
*
* Supports:
* - {BALENA|RESIN}_HOST_CONFIG_odmdata_configuration = value | "value"
*/
export class Odmdata extends ConfigBackend {
private static bootConfigVarPrefix = `${constants.hostConfigVarPrefix}ODMDATA_`;
private static bootConfigPath = `${constants.rootMountPoint}/dev/mmcblk0boot0`;
private static bootConfigLockPath = `${constants.rootMountPoint}/sys/block/mmcblk0boot0/force_ro`;
private static supportedConfigs = ['configuration'];
private BYTE_OFFSETS = [1659, 5243, 18043];
private CONFIG_BYTES = [
0x0 /* Config Option #1 */,
0x1 /* Config Option #2 */,
0x6 /* Config Option #3 */,
0x7 /* Config Option #4 */,
0x2 /* Config Option #5 */,
0x3 /* Config Option #6 */,
];
private CONFIG_BUFFER = Buffer.from(this.CONFIG_BYTES);
public static bootConfigVarRegex = new RegExp(
'(?:' + _.escapeRegExp(Odmdata.bootConfigVarPrefix) + ')(.+)',
);
public async matches(deviceType: string): Promise<boolean> {
return deviceType.endsWith('-tx2');
}
public async getBootConfig(): Promise<ConfigOptions> {
// Get config buffer from bootConfigPath
const confBuffer = await this.readBootConfigPath();
// Parse ConfigOptions from bootConfigPath buffer
return this.parseOptions(confBuffer);
}
public async setBootConfig(opts: ConfigOptions): Promise<void> {
log.info('Attempting to configure ODMDATA.');
// 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;
});
// Check that there is a configuration mode
if (!supportedOptions.configuration) {
log.info('No changes made to ODMDATA configuration.');
return;
}
// Check that configuration mode is supported
const mode = parseInt(supportedOptions.configuration as string, 10);
if (mode < 1 || mode > 6) {
log.error(`Configuration mode of: ${mode} is not supported.`);
log.info('No changes made to ODMDATA configuration.');
return;
}
log.info(`Setting ODMDATA Configuration Mode #${mode}.`);
// bootConfigPath is a hardware partition that is READ ONLY
// We must set bootConfigLockPath to false so we can write to this partition.
await this.setReadOnly(false);
try {
const BYTE_INDEX = mode - 1;
// Write this byte to each BYTE_OFFSETS in bootConfigPath
for (const POSITION of this.BYTE_OFFSETS) {
await this.setByteAtOffset(this.CONFIG_BUFFER, BYTE_INDEX, POSITION);
}
} catch (e) {
log.error('Failed to set configuration mode.', e);
throw e;
} finally {
// Lock RO access
await this.setReadOnly(true);
}
log.info(`Successfully set ODMDATA Configuration Mode #${mode}.`);
}
public isSupportedConfig(config: string): boolean {
return Odmdata.supportedConfigs.includes(config);
}
public isBootConfigVar(envVar: string): boolean {
return envVar.startsWith(Odmdata.bootConfigVarPrefix);
}
public processConfigVarName(envVar: string): string | null {
const name = envVar.replace(Odmdata.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 `${Odmdata.bootConfigVarPrefix}${configName}`;
}
private parseOptions(optionsBuffer: Buffer): ConfigOptions {
log.debug('Attempting to parse ODMDATA from Buffer.');
// Check that all the values in the buffer match
if (
!(
optionsBuffer.readUInt8(0) === optionsBuffer.readUInt8(1) &&
optionsBuffer.readUInt8(1) === optionsBuffer.readUInt8(2)
)
) {
log.error(
'Unable to parse ODMDATA configuration. Data at offsets do not match.',
);
throw new ODMDataError(
'Unable to parse ODMDATA configuration. Data at offsets do not match.',
);
}
// Find the configuration given the optionsBuffer
const configIndex = this.CONFIG_BYTES.reduce(
(currentIndex: number, _config: number, index: number) => {
if (
this.CONFIG_BUFFER.readUInt8(index) === optionsBuffer.readUInt8(0)
) {
return index;
}
return currentIndex;
},
null,
);
// Check if we found a configuration we support
if (configIndex === null) {
log.error(
`ODMDATA is set with an unsupported byte: 0x${optionsBuffer.readUInt8(
0,
)}`,
);
throw new ODMDataError(
'Unable to parse ODMDATA configuration. Unsupported configuration byte set.',
);
}
// Return supported configuration number currently set
log.debug(`Parsed Configuration Mode #${configIndex + 1} for ODMDATA.`);
return {
configuration: `${configIndex + 1}`,
};
}
private async setByteAtOffset(
buffer: Buffer,
byteIndex: number,
position: number,
): Promise<void> {
// Obtain a file handle on bootConfigPath
const fileHandle = await this.getFileHandle(Odmdata.bootConfigPath);
// Length is always 1 because this function is for only writing single byte values at a time.
const LENGTH = 1;
try {
await fileHandle.write(buffer, byteIndex, LENGTH, position);
} catch (e) {
log.error(`Issue writing to '${Odmdata.bootConfigPath}'`, e);
throw e;
} finally {
if (fileHandle) {
await fileHandle.close();
}
}
}
private async getFileHandle(
file: string,
flags = 'r+', // Open file for reading and writing. An exception occurs if the file does not exist.
): Promise<fs.FileHandle> {
try {
return await fs.open(file, flags);
} catch (e) {
switch (e.code) {
case 'ENOENT':
log.error(`File not found at: ${file}`);
throw new ODMDataError(`File not found at: ${file}`);
case 'EACCES':
log.error(`Permission denied when opening '${file}'`);
throw new ODMDataError(`Permission denied when opening '${file}'`);
default:
log.error(`Unknown error when opening '${file}'`, e);
throw new ODMDataError(`Unknown error when opening '${file}'`);
}
}
}
private async readBootConfigPath(): Promise<Buffer> {
// Create a buffer to store config byte values
const valuesBuffer = Buffer.alloc(3);
// Obtain a file handle on bootConfigPath
const fileHandle = await this.getFileHandle(Odmdata.bootConfigPath);
// Set single byte values in buffer at ODMDATA offsets
try {
for (let offset = 0; offset < 3; offset++) {
await fileHandle.read(
valuesBuffer,
offset,
1,
this.BYTE_OFFSETS[offset],
);
}
} catch (e) {
log.error(`Issue reading '${Odmdata.bootConfigPath}'`, e);
throw e;
} finally {
if (fileHandle) {
await fileHandle.close();
}
}
return valuesBuffer;
}
private async setReadOnly(value: boolean): Promise<void> {
// Normalize boolean input to binary output
const OUTPUT = value === true ? '1' : '0';
// Obtain a file handle on bootConfigLockPath
const fileHandle = await this.getFileHandle(Odmdata.bootConfigLockPath);
// Write RO flag to lock file
try {
await fileHandle.write(OUTPUT);
} catch (e) {
log.error(`Issue writing to '${Odmdata.bootConfigLockPath}'`, e);
throw e;
} finally {
if (fileHandle) {
await fileHandle.close();
}
}
}
}

View File

@ -8,11 +8,13 @@ import * as dbus from './lib/dbus';
import { EnvVarObject } from './lib/types';
import { UnitNotLoadedError } from './lib/errors';
import { checkInt, checkTruthy } from './lib/validation';
import log from './lib/supervisor-console';
import { DeviceStatus } from './types/state';
import * as configUtils from './config/utils';
import { SchemaTypeKey } from './config/schema-type';
import { matchesAnyBootConfig } from './config/backends';
import { ConfigOptions, ConfigBackend } from './config/backends/backend';
import { Odmdata } from './config/backends/odmdata';
const vpnServiceName = 'openvpn';
@ -339,9 +341,8 @@ export function resetRateLimits() {
});
}
// Exported for tests
export function bootConfigChangeRequired(
configBackend: ConfigBackend | null,
configBackend: ConfigBackend,
current: Dictionary<string>,
target: Dictionary<string>,
deviceType: string,
@ -352,23 +353,39 @@ export function bootConfigChangeRequired(
// Some devices require specific overlays, here we apply them
ensureRequiredOverlay(deviceType, targetBootConfig);
// Search for any unsupported values
_.each(targetBootConfig, (value, key) => {
if (
!configBackend.isSupportedConfig(key) &&
currentBootConfig[key] !== value
) {
const err = `Attempt to change blacklisted config value ${key}`;
logger.logSystemMessage(err, { error: err }, 'Apply boot config error');
throw new Error(err);
}
});
if (!_.isEqual(currentBootConfig, targetBootConfig)) {
_.each(targetBootConfig, (value, key) => {
// Ignore null check because we can't get here if configBackend is null
if (!configBackend!.isSupportedConfig(key)) {
if (currentBootConfig[key] !== value) {
const err = `Attempt to change blacklisted config value ${key}`;
logger.logSystemMessage(
err,
{ error: err },
'Apply boot config error',
);
throw new Error(err);
}
// Check if the only difference is the targetBootConfig not containing a special case
const SPECIAL_CASE = 'configuration'; // ODMDATA Mode for TX2 devices
if (!(SPECIAL_CASE in targetBootConfig)) {
// Create a copy to modify
const targetCopy = _.cloneDeep(targetBootConfig);
// Add current value to simulate if the value was set in the cloud on provision
targetCopy[SPECIAL_CASE] = currentBootConfig[SPECIAL_CASE];
if (_.isEqual(targetCopy, currentBootConfig)) {
// This proves the only difference is ODMDATA configuration is not set in target config.
// This special case is to allow devices that upgrade to SV with ODMDATA support
// and have no set a ODMDATA configuration in the cloud yet.
// Normally on provision this value would have been sent to the cloud.
return false; // (no change is required)
}
});
}
// Change is required because configs do not match
return true;
}
// Return false (no change is required)
return false;
}
@ -480,14 +497,14 @@ export async function getRequiredSteps(
const backends = await getConfigBackends();
// Check for required bootConfig changes
backends.forEach((backend) => {
if (bootConfigChangeRequired(backend, current, target, deviceType)) {
for (const backend of backends) {
if (changeRequired(backend, current, target, deviceType)) {
steps.push({
action: 'setBootConfig',
target,
});
}
});
}
// Check if there is either no steps, or they are all
// noops, and we need to reboot. We want to do this
@ -504,6 +521,43 @@ export async function getRequiredSteps(
return steps;
}
function changeRequired(
configBackend: ConfigBackend,
currentConfig: Dictionary<string>,
targetConfig: Dictionary<string>,
deviceType: string,
): boolean {
let aChangeIsRequired = false;
try {
aChangeIsRequired = bootConfigChangeRequired(
configBackend,
currentConfig,
targetConfig,
deviceType,
);
} catch (e) {
switch (e) {
case 'Value missing from target configuration.':
if (configBackend instanceof Odmdata) {
// In this special case, devices with ODMDATA support may have
// empty configuration options in the target if they upgraded to a SV
// version with ODMDATA support and didn't set a value in the cloud.
// If this is the case then we will update the cloud with the device's
// current config and then continue without an error
aChangeIsRequired = false;
} else {
log.debug(`
The device has a configuration setting that the cloud does not have set.\nNo configurations for this backend will be set.`);
// Set changeRequired to false so we do not get stuck in a loop trying to fix this mismatch
aChangeIsRequired = false;
}
default:
throw e;
}
}
return aChangeIsRequired;
}
export function executeStepAction(
step: ConfigStep,
opts: DeviceActionExecutorOpts,

View File

@ -133,3 +133,9 @@ export class FDTDirectiveError extends TypedError {}
* This can be things like missing config files or config files we cannot write to.
*/
export class ExtraUEnvError extends TypedError {}
/**
* Generic error thrown when something goes wrong with handling the ODMDATA backend.
* This can be things like missing config files or config files we cannot write to.
*/
export class ODMDataError extends TypedError {}

View File

@ -8,10 +8,13 @@ import * as fsUtils from '../src/lib/fs-utils';
import * as logger from '../src/logger';
import { Extlinux } from '../src/config/backends/extlinux';
import { ConfigTxt } from '../src/config/backends/config-txt';
import { Odmdata } from '../src/config/backends/odmdata';
import prepare = require('./lib/prepare');
const extlinuxBackend = new Extlinux();
const configTxtBackend = new ConfigTxt();
const odmdataBackend = new Odmdata();
describe('Device Backend Config', () => {
let logSpy: SinonSpy;
@ -310,6 +313,29 @@ describe('Device Backend Config', () => {
});
});
describe('ODMDATA', () => {
it('requires change when target is different', () => {
expect(
deviceConfig.bootConfigChangeRequired(
odmdataBackend,
{ HOST_ODMDATA_configuration: '2' },
{ HOST_ODMDATA_configuration: '5' },
'jetson-tx2',
),
).to.equal(true);
});
it('requires change when no target is set', () => {
expect(
deviceConfig.bootConfigChangeRequired(
odmdataBackend,
{ HOST_ODMDATA_configuration: '2' },
{},
'jetson-tx2',
),
).to.equal(false);
});
});
// describe('ConfigFS', () => {
// const upboardConfig = new DeviceConfig();
// let upboardConfigBackend: ConfigBackend | null;

View File

@ -0,0 +1,276 @@
import { SinonStub, stub } from 'sinon';
import { promises as fs } from 'fs';
import { resolve } from 'path';
import { expect } from './lib/chai-config';
import Log from '../src/lib/supervisor-console';
import { Odmdata } from '../src/config/backends/odmdata';
describe('ODMDATA Configuration', () => {
const backend = new Odmdata();
let logWarningStub: SinonStub;
let logErrorStub: SinonStub;
// @ts-ignore accessing private vluae
const previousConfigPath = Odmdata.bootConfigPath;
const testConfigPath = resolve(__dirname, 'data/boot0.img');
before(() => {
// @ts-ignore setting value of private variable
Odmdata.bootConfigPath = testConfigPath;
});
after(() => {
// @ts-ignore setting value of private variable
Odmdata.bootConfigPath = previousConfigPath;
});
beforeEach(() => {
logWarningStub = stub(Log, 'warn');
logErrorStub = stub(Log, 'error');
});
afterEach(() => {
logWarningStub.restore();
logErrorStub.restore();
});
it('only matches supported devices', async () => {
for (const { deviceType, match } of MATCH_TESTS) {
await expect(backend.matches(deviceType)).to.eventually.equal(match);
}
});
it('logs error when unable to open boot config file', async () => {
const logs = [
{
error: { code: 'ENOENT' },
message: `File not found at: ${testConfigPath}`,
},
{
error: { code: 'EACCES' },
message: `Permission denied when opening '${testConfigPath}'`,
},
{
error: { code: 'UNKNOWN ISSUE' }, // not a real code
message: `Unknown error when opening '${testConfigPath}'`,
},
];
const openFileStub = stub(fs, 'open');
for (const log of logs) {
// Stub openFileStub with specific error
openFileStub.rejects(log.error);
try {
// @ts-ignore accessing private value
await backend.getFileHandle(testConfigPath);
} catch (e) {
// noop
}
// Check that correct message was logged
expect(logErrorStub.lastCall?.args[0]).to.equal(log.message);
}
openFileStub.restore();
});
it('should parse configuration options from bootConfigPath', async () => {
// Restore openFile so test actually uses testConfigPath
await expect(backend.getBootConfig()).to.eventually.deep.equal({
configuration: '2',
});
});
it('correctly parses configuration mode', async () => {
for (const config of CONFIG_MODES) {
// @ts-ignore accessing private value
expect(backend.parseOptions(config.buffer)).to.deep.equal({
configuration: config.mode,
});
}
});
it('logs error for malformed configuration mode', async () => {
// Logs when configuration mode is unknown
try {
// @ts-ignore accessing private value
backend.parseOptions(Buffer.from([0x9, 0x9, 0x9]));
} catch (e) {
// noop
}
// Check that correct message was logged
expect(logErrorStub.lastCall?.lastArg).to.equal(
'ODMDATA is set with an unsupported byte: 0x9',
);
// Logs when bytes don't match
try {
// @ts-ignore accessing private value
backend.parseOptions(Buffer.from([0x1, 0x0, 0x0]));
} catch (e) {
// noop
}
// Check that correct message was logged
expect(logErrorStub.lastCall?.lastArg).to.equal(
'Unable to parse ODMDATA configuration. Data at offsets do not match.',
);
});
it('unlock/lock bootConfigPath RO access', async () => {
const writeSpy = stub().resolves();
// @ts-ignore accessing private value
const handleStub = stub(backend, 'getFileHandle').resolves({
write: writeSpy,
close: async (): Promise<void> => {
// noop
},
});
// @ts-ignore accessing private value
await backend.setReadOnly(false); // Try to unlock
expect(writeSpy).to.be.calledWith('0');
// @ts-ignore accessing private value
await backend.setReadOnly(true); // Try to lock
expect(writeSpy).to.be.calledWith('1');
handleStub.restore();
});
it('sets new config values', async () => {
// @ts-ignore accessing private value
const setROStub = stub(backend, 'setReadOnly');
setROStub.resolves();
// Get current config
const originalConfig = await backend.getBootConfig();
try {
// Sets a new configuration
await backend.setBootConfig({
configuration: '4',
});
// Check that new configuration was set correctly
await expect(backend.getBootConfig()).to.eventually.deep.equal({
configuration: '4',
});
} finally {
// Restore previous value
await backend.setBootConfig(originalConfig);
setROStub.restore();
}
});
it('only allows supported configuration modes', () => {
[
{ configName: 'configuration', supported: true },
{ configName: 'mode', supported: false },
{ configName: '', supported: false },
].forEach(({ configName, supported }) =>
expect(backend.isSupportedConfig(configName)).to.equal(supported),
);
});
it('correctly detects boot config variables', () => {
[
{ config: 'HOST_ODMDATA_configuration', valid: true },
{ config: 'ODMDATA_configuration', valid: false },
{ config: 'HOST_CONFIG_odmdata_configuration', valid: false },
{ config: 'HOST_EXTLINUX_rootwait', valid: false },
{ config: '', valid: false },
].forEach(({ config, valid }) =>
expect(backend.isBootConfigVar(config)).to.equal(valid),
);
});
it('converts variable to backend formatted name', () => {
[
{ input: 'HOST_ODMDATA_configuration', output: 'configuration' },
{ input: 'HOST_ODMDATA_', 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: 'configuration', output: 'HOST_ODMDATA_configuration' },
{ input: '', output: null },
].forEach(({ input, output }) =>
expect(backend.createConfigVarName(input)).to.equal(output),
);
});
});
const CONFIG_MODES = [
{
mode: '1',
buffer: Buffer.from([0x0, 0x0, 0x0]),
},
{
mode: '2',
buffer: Buffer.from([0x1, 0x1, 0x1]),
},
{
mode: '3',
buffer: Buffer.from([0x6, 0x6, 0x6]),
},
{
mode: '4',
buffer: Buffer.from([0x7, 0x7, 0x7]),
},
{
mode: '5',
buffer: Buffer.from([0x2, 0x2, 0x2]),
},
{
mode: '6',
buffer: Buffer.from([0x3, 0x3, 0x3]),
},
];
const MATCH_TESTS = [
{
deviceType: 'blackboard-tx2',
match: true,
},
{
deviceType: 'jetson-tx2',
match: true,
},
{
deviceType: 'n510-tx2',
match: true,
},
{
deviceType: 'orbitty-tx2',
match: true,
},
{
deviceType: 'spacely-tx2',
match: true,
},
{
deviceType: 'srd3-tx2',
match: true,
},
{
deviceType: 'raspberry-pi',
match: false,
},
{
deviceType: 'up-board',
match: false,
},
{
deviceType: '',
match: false,
},
];

BIN
test/data/boot0.img Normal file

Binary file not shown.