mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-02-06 02:59:55 +00:00
Merge pull request #2379 from balena-os/support-jetson-power-fan-configs
Support jetson power fan configs
This commit is contained in:
commit
e085013548
@ -41,6 +41,12 @@ export abstract class ConfigBackend {
|
|||||||
// Example an empty string should return null.
|
// Example an empty string should return null.
|
||||||
public abstract createConfigVarName(configName: string): string | null;
|
public abstract createConfigVarName(configName: string): string | null;
|
||||||
|
|
||||||
|
// Is a reboot required for the given config options?
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||||
|
public async isRebootRequired(_opts: ConfigOptions): Promise<boolean> {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Allow a chosen config backend to be initialised
|
// Allow a chosen config backend to be initialised
|
||||||
public async initialise(): Promise<ConfigBackend> {
|
public async initialise(): Promise<ConfigBackend> {
|
||||||
return this;
|
return this;
|
||||||
|
@ -70,14 +70,15 @@ function isBaseParam(dtparam: string): boolean {
|
|||||||
* - {BALENA|RESIN}_HOST_CONFIG_gpio = value | "value" | "value1","value2"
|
* - {BALENA|RESIN}_HOST_CONFIG_gpio = value | "value" | "value1","value2"
|
||||||
*/
|
*/
|
||||||
export class ConfigTxt extends ConfigBackend {
|
export class ConfigTxt extends ConfigBackend {
|
||||||
private static bootConfigVarPrefix = `${constants.hostConfigVarPrefix}CONFIG_`;
|
private static PREFIX = `${constants.hostConfigVarPrefix}CONFIG_`;
|
||||||
private static bootConfigPath = hostUtils.pathOnBoot('config.txt');
|
private static PATH = hostUtils.pathOnBoot('config.txt');
|
||||||
|
private static REGEX = new RegExp(
|
||||||
public static bootConfigVarRegex = new RegExp(
|
'(?:' + _.escapeRegExp(ConfigTxt.PREFIX) + ')(.+)',
|
||||||
'(?:' + _.escapeRegExp(ConfigTxt.bootConfigVarPrefix) + ')(.+)',
|
|
||||||
);
|
);
|
||||||
|
// These keys are not config.txt keys and are managed by the power-fan backend.
|
||||||
private static forbiddenConfigKeys = [
|
private static UNSUPPORTED_KEYS = ['power_mode', 'fan_profile'];
|
||||||
|
// These keys are config.txt keys, but are not mutable by the Supervisor.
|
||||||
|
private static FORBIDDEN_KEYS = [
|
||||||
'disable_commandline_tags',
|
'disable_commandline_tags',
|
||||||
'cmdline',
|
'cmdline',
|
||||||
'kernel',
|
'kernel',
|
||||||
@ -89,7 +90,7 @@ export class ConfigTxt extends ConfigBackend {
|
|||||||
'device_tree_address',
|
'device_tree_address',
|
||||||
'init_emmc_clock',
|
'init_emmc_clock',
|
||||||
'avoid_safe_mode',
|
'avoid_safe_mode',
|
||||||
];
|
].concat(ConfigTxt.UNSUPPORTED_KEYS);
|
||||||
|
|
||||||
public async matches(deviceType: string): Promise<boolean> {
|
public async matches(deviceType: string): Promise<boolean> {
|
||||||
return (
|
return (
|
||||||
@ -109,11 +110,8 @@ export class ConfigTxt extends ConfigBackend {
|
|||||||
public async getBootConfig(): Promise<ConfigOptions> {
|
public async getBootConfig(): Promise<ConfigOptions> {
|
||||||
let configContents = '';
|
let configContents = '';
|
||||||
|
|
||||||
if (await exists(ConfigTxt.bootConfigPath)) {
|
if (await exists(ConfigTxt.PATH)) {
|
||||||
configContents = await hostUtils.readFromBoot(
|
configContents = await hostUtils.readFromBoot(ConfigTxt.PATH, 'utf-8');
|
||||||
ConfigTxt.bootConfigPath,
|
|
||||||
'utf-8',
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
return {};
|
return {};
|
||||||
}
|
}
|
||||||
@ -227,19 +225,29 @@ export class ConfigTxt extends ConfigBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const confStr = `${confStatements.join('\n')}\n`;
|
const confStr = `${confStatements.join('\n')}\n`;
|
||||||
await hostUtils.writeToBoot(ConfigTxt.bootConfigPath, confStr);
|
await hostUtils.writeToBoot(ConfigTxt.PATH, confStr);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static stripPrefix(name: string): string {
|
||||||
|
if (!name.startsWith(ConfigTxt.PREFIX)) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return name.substring(ConfigTxt.PREFIX.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isSupportedConfig(configName: string): boolean {
|
public isSupportedConfig(configName: string): boolean {
|
||||||
return !ConfigTxt.forbiddenConfigKeys.includes(configName);
|
return !ConfigTxt.FORBIDDEN_KEYS.includes(configName);
|
||||||
}
|
}
|
||||||
|
|
||||||
public isBootConfigVar(envVar: string): boolean {
|
public isBootConfigVar(envVar: string): boolean {
|
||||||
return envVar.startsWith(ConfigTxt.bootConfigVarPrefix);
|
return (
|
||||||
|
envVar.startsWith(ConfigTxt.PREFIX) &&
|
||||||
|
!ConfigTxt.UNSUPPORTED_KEYS.includes(ConfigTxt.stripPrefix(envVar))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public processConfigVarName(envVar: string): string {
|
public processConfigVarName(envVar: string): string {
|
||||||
return envVar.replace(ConfigTxt.bootConfigVarRegex, '$1');
|
return envVar.replace(ConfigTxt.REGEX, '$1');
|
||||||
}
|
}
|
||||||
|
|
||||||
public processConfigVarValue(key: string, value: string): string | string[] {
|
public processConfigVarValue(key: string, value: string): string | string[] {
|
||||||
@ -254,7 +262,7 @@ export class ConfigTxt extends ConfigBackend {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public createConfigVarName(configName: string): string {
|
public createConfigVarName(configName: string): string {
|
||||||
return ConfigTxt.bootConfigVarPrefix + configName;
|
return ConfigTxt.PREFIX + configName;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure that the balena-fin overlay is defined in the target configuration
|
// Ensure that the balena-fin overlay is defined in the target configuration
|
||||||
|
@ -4,6 +4,8 @@ import { ConfigTxt } from './config-txt';
|
|||||||
import { ConfigFs } from './config-fs';
|
import { ConfigFs } from './config-fs';
|
||||||
import { Odmdata } from './odmdata';
|
import { Odmdata } from './odmdata';
|
||||||
import { SplashImage } from './splash-image';
|
import { SplashImage } from './splash-image';
|
||||||
|
import { PowerFanConfig } from './power-fan';
|
||||||
|
import { configJsonBackend } from '..';
|
||||||
|
|
||||||
export const allBackends = [
|
export const allBackends = [
|
||||||
new Extlinux(),
|
new Extlinux(),
|
||||||
@ -12,6 +14,7 @@ export const allBackends = [
|
|||||||
new ConfigFs(),
|
new ConfigFs(),
|
||||||
new Odmdata(),
|
new Odmdata(),
|
||||||
new SplashImage(),
|
new SplashImage(),
|
||||||
|
new PowerFanConfig(configJsonBackend),
|
||||||
];
|
];
|
||||||
|
|
||||||
export function matchesAnyBootConfig(envVar: string): boolean {
|
export function matchesAnyBootConfig(envVar: string): boolean {
|
||||||
|
170
src/config/backends/power-fan.ts
Normal file
170
src/config/backends/power-fan.ts
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
import { isRight } from 'fp-ts/lib/Either';
|
||||||
|
import Reporter from 'io-ts-reporters';
|
||||||
|
import * as t from 'io-ts';
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
|
import { ConfigBackend } from './backend';
|
||||||
|
import type { ConfigOptions } from './backend';
|
||||||
|
import { schemaTypes } from '../schema-type';
|
||||||
|
import log from '../../lib/supervisor-console';
|
||||||
|
import * as constants from '../../lib/constants';
|
||||||
|
|
||||||
|
type ConfigJsonBackend = {
|
||||||
|
get: (key: 'os') => Promise<unknown>;
|
||||||
|
set: (opts: { os: Record<string, any> }) => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A backend to handle Jetson power and fan control
|
||||||
|
*
|
||||||
|
* Supports:
|
||||||
|
* - {BALENA|RESIN}_HOST_CONFIG_power_mode = "low" | "mid" | "high" | "default" |"$MODE_ID"
|
||||||
|
* - {BALENA|RESIN}_HOST_CONFIG_fan_profile = "quiet" | "cool" | "default" |"$MODE_ID"
|
||||||
|
*/
|
||||||
|
export class PowerFanConfig extends ConfigBackend {
|
||||||
|
private static readonly CONFIGS = new Set(['power_mode', 'fan_profile']);
|
||||||
|
private static readonly PREFIX = `${constants.hostConfigVarPrefix}CONFIG_`;
|
||||||
|
private static readonly SCHEMA = t.exact(
|
||||||
|
t.partial({
|
||||||
|
power: t.exact(
|
||||||
|
t.partial({
|
||||||
|
mode: t.string,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
fan: t.exact(
|
||||||
|
t.partial({
|
||||||
|
profile: t.string,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
private readonly configJson: ConfigJsonBackend;
|
||||||
|
public constructor(configJson: ConfigJsonBackend) {
|
||||||
|
super();
|
||||||
|
this.configJson = configJson;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static stripPrefix(name: string): string {
|
||||||
|
if (!name.startsWith(PowerFanConfig.PREFIX)) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
return name.substring(PowerFanConfig.PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async matches(deviceType: string): Promise<boolean> {
|
||||||
|
// We only support Jetpack 6 devices for now, which includes all Orin devices
|
||||||
|
// except for jetson-orin-nx-xv3 which is still on Jetpack 5 as of OS v5.1.36
|
||||||
|
return new Set([
|
||||||
|
'jetson-agx-orin-devkit',
|
||||||
|
'jetson-agx-orin-devkit-64gb',
|
||||||
|
'jetson-orin-nano-devkit-nvme',
|
||||||
|
'jetson-orin-nano-seeed-j3010',
|
||||||
|
'jetson-orin-nx-seeed-j4012',
|
||||||
|
'jetson-orin-nx-xavier-nx-devkit',
|
||||||
|
]).has(deviceType);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getBootConfig(): Promise<ConfigOptions> {
|
||||||
|
// Get raw config.json contents
|
||||||
|
let rawConf: unknown;
|
||||||
|
try {
|
||||||
|
rawConf = await this.configJson.get('os');
|
||||||
|
} catch (e: unknown) {
|
||||||
|
log.error(
|
||||||
|
`Failed to read config.json while getting power / fan configs: ${(e as Error).message ?? e}`,
|
||||||
|
);
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode to power fan schema from object type, filtering out unrelated values
|
||||||
|
const powerFanConfig = PowerFanConfig.SCHEMA.decode(rawConf);
|
||||||
|
|
||||||
|
if (isRight(powerFanConfig)) {
|
||||||
|
const conf = powerFanConfig.right;
|
||||||
|
return {
|
||||||
|
...(conf.power?.mode != null && {
|
||||||
|
power_mode: conf.power.mode,
|
||||||
|
}),
|
||||||
|
...(conf.fan?.profile != null && {
|
||||||
|
fan_profile: conf.fan.profile,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async setBootConfig(opts: ConfigOptions): Promise<void> {
|
||||||
|
// Read raw configs for "os" key from config.json
|
||||||
|
let rawConf;
|
||||||
|
try {
|
||||||
|
rawConf = await this.configJson.get('os');
|
||||||
|
} catch (err: unknown) {
|
||||||
|
log.error(`${(err as Error).message ?? err}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decode to "os" object type while leaving in unrelated values
|
||||||
|
const maybeCurrentConf = schemaTypes.os.type.decode(rawConf);
|
||||||
|
if (!isRight(maybeCurrentConf)) {
|
||||||
|
log.error(
|
||||||
|
'Failed to decode current os config:',
|
||||||
|
Reporter.report(maybeCurrentConf),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Current config could be undefined if there's no os key in config.json, so default to empty object
|
||||||
|
const conf = maybeCurrentConf.right ?? {};
|
||||||
|
|
||||||
|
// Update or delete power mode
|
||||||
|
if ('power_mode' in opts) {
|
||||||
|
conf.power = {
|
||||||
|
mode: opts.power_mode,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
delete conf?.power;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update or delete fan profile
|
||||||
|
if ('fan_profile' in opts) {
|
||||||
|
conf.fan = {
|
||||||
|
profile: opts.fan_profile,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
delete conf?.fan;
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.configJson.set({ os: conf });
|
||||||
|
}
|
||||||
|
|
||||||
|
public isSupportedConfig = (name: string): boolean => {
|
||||||
|
return PowerFanConfig.CONFIGS.has(PowerFanConfig.stripPrefix(name));
|
||||||
|
};
|
||||||
|
|
||||||
|
public isBootConfigVar(envVar: string): boolean {
|
||||||
|
return PowerFanConfig.CONFIGS.has(PowerFanConfig.stripPrefix(envVar));
|
||||||
|
}
|
||||||
|
|
||||||
|
public async isRebootRequired(opts: ConfigOptions): Promise<boolean> {
|
||||||
|
const supportedOpts = _.pickBy(
|
||||||
|
_.mapKeys(opts, (_value, key) => PowerFanConfig.stripPrefix(key)),
|
||||||
|
(_value, key) => this.isSupportedConfig(key),
|
||||||
|
);
|
||||||
|
const current = await this.getBootConfig();
|
||||||
|
// A reboot is only required if the power mode is changing
|
||||||
|
return current.power_mode !== supportedOpts.power_mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public processConfigVarName(envVar: string): string {
|
||||||
|
return PowerFanConfig.stripPrefix(envVar).toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
public processConfigVarValue(_key: string, value: string): string {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
public createConfigVarName(name: string): string | null {
|
||||||
|
return `${PowerFanConfig.PREFIX}${name}`;
|
||||||
|
}
|
||||||
|
}
|
@ -3,7 +3,6 @@ import _ from 'lodash';
|
|||||||
|
|
||||||
import * as constants from '../lib/constants';
|
import * as constants from '../lib/constants';
|
||||||
import * as hostUtils from '../lib/host-utils';
|
import * as hostUtils from '../lib/host-utils';
|
||||||
import * as osRelease from '../lib/os-release';
|
|
||||||
import { takeGlobalLockRO, takeGlobalLockRW } from '../lib/process-lock';
|
import { takeGlobalLockRO, takeGlobalLockRW } from '../lib/process-lock';
|
||||||
import type * as Schema from './schema';
|
import type * as Schema from './schema';
|
||||||
|
|
||||||
@ -12,17 +11,20 @@ export default class ConfigJsonConfigBackend {
|
|||||||
private readonly writeLockConfigJson: () => Bluebird.Disposer<() => void>;
|
private readonly writeLockConfigJson: () => Bluebird.Disposer<() => void>;
|
||||||
|
|
||||||
private readonly schema: Schema.Schema;
|
private readonly schema: Schema.Schema;
|
||||||
|
/**
|
||||||
|
* @deprecated configPath is only set by legacy tests
|
||||||
|
*/
|
||||||
private readonly configPath?: string;
|
private readonly configPath?: string;
|
||||||
|
|
||||||
private cache: { [key: string]: unknown } = {};
|
private cache: { [key: string]: unknown } = {};
|
||||||
|
|
||||||
private readonly init = _.once(async () =>
|
private readonly init = _.once(async () => {
|
||||||
Object.assign(this.cache, await this.read()),
|
Object.assign(this.cache, await this.read());
|
||||||
);
|
});
|
||||||
|
|
||||||
public constructor(schema: Schema.Schema, configPath?: string) {
|
public constructor(schema: Schema.Schema, configPath?: string) {
|
||||||
this.configPath = configPath;
|
|
||||||
this.schema = schema;
|
this.schema = schema;
|
||||||
|
this.configPath = configPath;
|
||||||
|
|
||||||
this.writeLockConfigJson = () =>
|
this.writeLockConfigJson = () =>
|
||||||
takeGlobalLockRW('config.json').disposer((release) => release());
|
takeGlobalLockRW('config.json').disposer((release) => release());
|
||||||
@ -37,14 +39,10 @@ export default class ConfigJsonConfigBackend {
|
|||||||
await Bluebird.using(this.writeLockConfigJson(), async () => {
|
await Bluebird.using(this.writeLockConfigJson(), async () => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
_.forOwn(keyVals, (value, key: T) => {
|
_.forOwn(keyVals, (value, key: T) => {
|
||||||
if (this.cache[key] !== value) {
|
if (this.schema[key] != null && !_.isEqual(this.cache[key], value)) {
|
||||||
this.cache[key] = value;
|
this.cache[key] = value;
|
||||||
|
|
||||||
if (
|
if (value == null && this.schema[key].removeIfNull) {
|
||||||
value == null &&
|
|
||||||
this.schema[key] != null &&
|
|
||||||
this.schema[key].removeIfNull
|
|
||||||
) {
|
|
||||||
delete this.cache[key];
|
delete this.cache[key];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,15 +55,14 @@ export default class ConfigJsonConfigBackend {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public async get(key: string): Promise<unknown> {
|
public async get(key: Schema.SchemaKey): Promise<unknown> {
|
||||||
await this.init();
|
await this.init();
|
||||||
return Bluebird.using(
|
return Bluebird.using(this.readLockConfigJson(), async () =>
|
||||||
this.readLockConfigJson(),
|
structuredClone(this.cache[key]),
|
||||||
async () => this.cache[key],
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async remove(key: string) {
|
public async remove(key: Schema.SchemaKey) {
|
||||||
await this.init();
|
await this.init();
|
||||||
return Bluebird.using(this.writeLockConfigJson(), async () => {
|
return Bluebird.using(this.writeLockConfigJson(), async () => {
|
||||||
let changed = false;
|
let changed = false;
|
||||||
@ -91,6 +88,12 @@ export default class ConfigJsonConfigBackend {
|
|||||||
return JSON.parse(await hostUtils.readFromBoot(filename, 'utf-8'));
|
return JSON.parse(await hostUtils.readFromBoot(filename, 'utf-8'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @deprecated Either read the config.json path from lib/constants, or
|
||||||
|
* pass a validated path to the constructor and fail if no path is passed.
|
||||||
|
* TODO: Remove this once api-binder tests are migrated. The only
|
||||||
|
* time configPath is passed to the constructor is in the legacy tests.
|
||||||
|
*/
|
||||||
private async path(): Promise<string> {
|
private async path(): Promise<string> {
|
||||||
// TODO: Remove this once api-binder tests are migrated. The only
|
// TODO: Remove this once api-binder tests are migrated. The only
|
||||||
// time configPath is passed to the constructor is in the legacy tests.
|
// time configPath is passed to the constructor is in the legacy tests.
|
||||||
@ -98,11 +101,6 @@ export default class ConfigJsonConfigBackend {
|
|||||||
return this.configPath;
|
return this.configPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
const osVersion = await osRelease.getOSVersion(constants.hostOSVersionPath);
|
|
||||||
if (osVersion == null) {
|
|
||||||
throw new Error('Failed to detect OS version!');
|
|
||||||
}
|
|
||||||
|
|
||||||
// The default path in the boot partition
|
// The default path in the boot partition
|
||||||
return constants.configJsonPath;
|
return constants.configJsonPath;
|
||||||
}
|
}
|
||||||
|
@ -84,6 +84,10 @@ export const schemaTypes = {
|
|||||||
type: t.string,
|
type: t.string,
|
||||||
default: NullOrUndefined,
|
default: NullOrUndefined,
|
||||||
},
|
},
|
||||||
|
os: {
|
||||||
|
type: t.union([t.record(t.string, t.any), t.undefined]),
|
||||||
|
default: NullOrUndefined,
|
||||||
|
},
|
||||||
|
|
||||||
// Database types
|
// Database types
|
||||||
name: {
|
name: {
|
||||||
|
@ -84,6 +84,11 @@ export const schema = {
|
|||||||
mutable: false,
|
mutable: false,
|
||||||
removeIfNull: false,
|
removeIfNull: false,
|
||||||
},
|
},
|
||||||
|
os: {
|
||||||
|
source: 'config.json',
|
||||||
|
mutable: true,
|
||||||
|
removeIfNull: false,
|
||||||
|
},
|
||||||
|
|
||||||
name: {
|
name: {
|
||||||
source: 'db',
|
source: 'db',
|
||||||
|
@ -529,18 +529,21 @@ async function getBackendSteps(
|
|||||||
const { deviceType } = await config.getMany(['deviceType']);
|
const { deviceType } = await config.getMany(['deviceType']);
|
||||||
|
|
||||||
// Check for required bootConfig changes
|
// Check for required bootConfig changes
|
||||||
|
let rebootRequired = false;
|
||||||
for (const backend of backends) {
|
for (const backend of backends) {
|
||||||
if (changeRequired(backend, current, target, deviceType)) {
|
if (changeRequired(backend, current, target, deviceType)) {
|
||||||
steps.push({
|
steps.push({
|
||||||
action: 'setBootConfig',
|
action: 'setBootConfig',
|
||||||
target,
|
target,
|
||||||
});
|
});
|
||||||
|
rebootRequired =
|
||||||
|
(await backend.isRebootRequired(target)) || rebootRequired;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
// All backend steps require a reboot
|
// All backend steps require a reboot except fan control
|
||||||
...(steps.length > 0
|
...(steps.length > 0 && rebootRequired
|
||||||
? [{ action: 'setRebootBreadcrumb' } as ConfigStep]
|
? [{ action: 'setRebootBreadcrumb' } as ConfigStep]
|
||||||
: []),
|
: []),
|
||||||
...steps,
|
...steps,
|
||||||
|
203
test/integration/config/configJson.spec.ts
Normal file
203
test/integration/config/configJson.spec.ts
Normal file
@ -0,0 +1,203 @@
|
|||||||
|
import { expect } from 'chai';
|
||||||
|
import type { TestFs } from 'mocha-pod';
|
||||||
|
import { testfs } from 'mocha-pod';
|
||||||
|
import { promises as fs } from 'fs';
|
||||||
|
|
||||||
|
import ConfigJsonConfigBackend from '~/src/config/configJson';
|
||||||
|
import { schema } from '~/src/config/schema';
|
||||||
|
|
||||||
|
describe('ConfigJsonConfigBackend', () => {
|
||||||
|
const CONFIG_PATH = '/mnt/boot/config.json';
|
||||||
|
const os = {
|
||||||
|
power: {
|
||||||
|
mode: 'high',
|
||||||
|
},
|
||||||
|
fan: {
|
||||||
|
profile: 'cool',
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
connectivity: {
|
||||||
|
uri: 'https://api.balena-cloud.com/connectivity-check',
|
||||||
|
interval: '300',
|
||||||
|
response: 'optional value in the response',
|
||||||
|
},
|
||||||
|
wifi: {
|
||||||
|
randomMacAddressScan: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
udevRules: {
|
||||||
|
'56': 'ENV{ID_FS_LABEL_ENC}=="resin-root*", IMPORT{program}="resin_update_state_probe $devnode", SYMLINK+="disk/by-state/$env{RESIN_UPDATE_STATE}"',
|
||||||
|
'64': 'ACTION!="add|change", GOTO="modeswitch_rules_end"\nKERNEL=="ttyACM*", ATTRS{idVendor}=="1546", ATTRS{idProduct}=="1146", TAG+="systemd", ENV{SYSTEMD_WANTS}="u-blox-switch@\'%E{DEVNAME}\'.service"\nLBEL="modeswitch_rules_end"\n',
|
||||||
|
},
|
||||||
|
sshKeys: [
|
||||||
|
'ssh-rsa AAAAB3Nza...M2JB balena@macbook-pro',
|
||||||
|
'ssh-rsa AAAAB3Nza...nFTQ balena@zenbook',
|
||||||
|
],
|
||||||
|
};
|
||||||
|
let configJsonConfigBackend: ConfigJsonConfigBackend;
|
||||||
|
let tfs: TestFs.Enabled;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
configJsonConfigBackend = new ConfigJsonConfigBackend(schema);
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await tfs.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get primitive values for config.json key', async () => {
|
||||||
|
tfs = await testfs({
|
||||||
|
[CONFIG_PATH]: JSON.stringify({
|
||||||
|
apiEndpoint: 'foo',
|
||||||
|
deviceId: 123,
|
||||||
|
persistentLogging: true,
|
||||||
|
}),
|
||||||
|
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
|
||||||
|
}).enable();
|
||||||
|
|
||||||
|
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('foo');
|
||||||
|
expect(await configJsonConfigBackend.get('deviceId')).to.equal(123);
|
||||||
|
expect(await configJsonConfigBackend.get('persistentLogging')).to.equal(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get object values for config.json "os" key', async () => {
|
||||||
|
tfs = await testfs({
|
||||||
|
[CONFIG_PATH]: JSON.stringify({
|
||||||
|
os,
|
||||||
|
}),
|
||||||
|
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
|
||||||
|
}).enable();
|
||||||
|
|
||||||
|
expect(await configJsonConfigBackend.get('os')).to.deep.equal(os);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should get object values for config.json "os" key while "os" is empty', async () => {
|
||||||
|
tfs = await testfs({
|
||||||
|
[CONFIG_PATH]: JSON.stringify({}),
|
||||||
|
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
|
||||||
|
}).enable();
|
||||||
|
|
||||||
|
expect(await configJsonConfigBackend.get('os')).to.be.undefined;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set primitive values for config.json key', async () => {
|
||||||
|
tfs = await testfs({
|
||||||
|
[CONFIG_PATH]: JSON.stringify({
|
||||||
|
apiEndpoint: 'foo',
|
||||||
|
deviceId: 123,
|
||||||
|
persistentLogging: true,
|
||||||
|
}),
|
||||||
|
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
|
||||||
|
}).enable();
|
||||||
|
|
||||||
|
await configJsonConfigBackend.set({
|
||||||
|
apiEndpoint: 'bar',
|
||||||
|
deviceId: 456,
|
||||||
|
persistentLogging: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('bar');
|
||||||
|
expect(await configJsonConfigBackend.get('deviceId')).to.equal(456);
|
||||||
|
expect(await configJsonConfigBackend.get('persistentLogging')).to.equal(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set object values for config.json "os" key', async () => {
|
||||||
|
tfs = await testfs({
|
||||||
|
[CONFIG_PATH]: JSON.stringify({
|
||||||
|
os,
|
||||||
|
}),
|
||||||
|
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
|
||||||
|
}).enable();
|
||||||
|
|
||||||
|
const newOs = {
|
||||||
|
power: {
|
||||||
|
mode: 'low',
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
wifi: {
|
||||||
|
randomMacAddressScan: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
udevRules: {
|
||||||
|
'56': 'ENV{ID_FS_LABEL_ENC}=="resin-root*", IMPORT{program}="resin_update_state_probe $devnode", SYMLINK+="disk/by-state/$env{RESIN_UPDATE_STATE}"',
|
||||||
|
},
|
||||||
|
sshKeys: ['ssh-rsa AAAAB3Nza...M2JB balena@macbook-pro'],
|
||||||
|
};
|
||||||
|
|
||||||
|
await configJsonConfigBackend.set({
|
||||||
|
os: newOs,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await configJsonConfigBackend.get('os')).to.deep.equal(newOs);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set object values for config.json "os" key while "os" is empty', async () => {
|
||||||
|
tfs = await testfs({
|
||||||
|
[CONFIG_PATH]: JSON.stringify({}),
|
||||||
|
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
|
||||||
|
}).enable();
|
||||||
|
|
||||||
|
await configJsonConfigBackend.set({
|
||||||
|
os,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await configJsonConfigBackend.get('os')).to.deep.equal(os);
|
||||||
|
});
|
||||||
|
|
||||||
|
// The following test cases may be unnecessary as they test cases where another party
|
||||||
|
// writes to config.json directly (instead of through setting config vars on the API).
|
||||||
|
it('should get cached value even if actual value has changed', async () => {
|
||||||
|
tfs = await testfs({
|
||||||
|
[CONFIG_PATH]: JSON.stringify({
|
||||||
|
apiEndpoint: 'foo',
|
||||||
|
}),
|
||||||
|
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
|
||||||
|
}).enable();
|
||||||
|
|
||||||
|
// The cached value should be returned
|
||||||
|
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('foo');
|
||||||
|
|
||||||
|
// Change the value in the file
|
||||||
|
await fs.writeFile(
|
||||||
|
CONFIG_PATH,
|
||||||
|
JSON.stringify({
|
||||||
|
apiEndpoint: 'bar',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unintended behavior: the cached value should not be overwritten
|
||||||
|
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('foo');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should set value and refresh cache to equal new value', async () => {
|
||||||
|
tfs = await testfs({
|
||||||
|
[CONFIG_PATH]: JSON.stringify({
|
||||||
|
apiEndpoint: 'foo',
|
||||||
|
}),
|
||||||
|
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
|
||||||
|
}).enable();
|
||||||
|
|
||||||
|
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('foo');
|
||||||
|
|
||||||
|
await fs.writeFile(
|
||||||
|
CONFIG_PATH,
|
||||||
|
JSON.stringify({
|
||||||
|
apiEndpoint: 'bar',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Unintended behavior: cached value should not have been updated
|
||||||
|
// as the change was not written to config.json by the Supervisor
|
||||||
|
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('foo');
|
||||||
|
|
||||||
|
await configJsonConfigBackend.set({
|
||||||
|
apiEndpoint: 'baz',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await configJsonConfigBackend.get('apiEndpoint')).to.equal('baz');
|
||||||
|
});
|
||||||
|
});
|
577
test/integration/config/power-fan.spec.ts
Normal file
577
test/integration/config/power-fan.spec.ts
Normal file
@ -0,0 +1,577 @@
|
|||||||
|
import { expect } from 'chai';
|
||||||
|
import { stripIndent } from 'common-tags';
|
||||||
|
import { testfs } from 'mocha-pod';
|
||||||
|
import type { SinonStub } from 'sinon';
|
||||||
|
|
||||||
|
import { PowerFanConfig } from '~/src/config/backends/power-fan';
|
||||||
|
import { Extlinux } from '~/src/config/backends/extlinux';
|
||||||
|
import { ExtraUEnv } from '~/src/config/backends/extra-uEnv';
|
||||||
|
import { ConfigTxt } from '~/src/config/backends/config-txt';
|
||||||
|
import { ConfigFs } from '~/src/config/backends/config-fs';
|
||||||
|
import { Odmdata } from '~/src/config/backends/odmdata';
|
||||||
|
import { SplashImage } from '~/src/config/backends/splash-image';
|
||||||
|
import ConfigJsonConfigBackend from '~/src/config/configJson';
|
||||||
|
import { schema } from '~/src/config/schema';
|
||||||
|
import * as hostUtils from '~/lib/host-utils';
|
||||||
|
import log from '~/lib/supervisor-console';
|
||||||
|
|
||||||
|
const SUPPORTED_DEVICE_TYPES = [
|
||||||
|
'jetson-agx-orin-devkit',
|
||||||
|
'jetson-agx-orin-devkit-64gb',
|
||||||
|
'jetson-orin-nano-devkit-nvme',
|
||||||
|
'jetson-orin-nano-seeed-j3010',
|
||||||
|
'jetson-orin-nx-seeed-j4012',
|
||||||
|
'jetson-orin-nx-xavier-nx-devkit',
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNSUPPORTED_DEVICE_TYPES = ['jetson-orin-nx-xv3'];
|
||||||
|
|
||||||
|
describe('config/power-fan', () => {
|
||||||
|
const CONFIG_PATH = hostUtils.pathOnBoot('config.json');
|
||||||
|
const generateConfigJsonBackend = () => new ConfigJsonConfigBackend(schema);
|
||||||
|
let powerFanConf: PowerFanConfig;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await testfs({
|
||||||
|
'/mnt/root/etc/os-release': testfs.from('test/data/etc/os-release'),
|
||||||
|
}).enable();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
await testfs.restore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('only matches supported devices', async () => {
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
for (const deviceType of SUPPORTED_DEVICE_TYPES) {
|
||||||
|
expect(await powerFanConf.matches(deviceType)).to.be.true;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const deviceType of UNSUPPORTED_DEVICE_TYPES) {
|
||||||
|
expect(await powerFanConf.matches(deviceType)).to.be.false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly gets boot configs from config.json', async () => {
|
||||||
|
const getConfigJson = (powerMode: string, fanProfile: string) => {
|
||||||
|
return stripIndent`
|
||||||
|
{
|
||||||
|
"os": {
|
||||||
|
"extra": "field",
|
||||||
|
"power": {
|
||||||
|
"mode": "${powerMode}"
|
||||||
|
},
|
||||||
|
"fan": {
|
||||||
|
"profile": "${fanProfile}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const powerMode of ['low', 'mid', 'high', 'custom_power']) {
|
||||||
|
for (const fanProfile of ['quiet', 'default', 'cool', 'custom_fan']) {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: getConfigJson(powerMode, fanProfile),
|
||||||
|
}).enable();
|
||||||
|
|
||||||
|
// ConfigJsonConfigBackend uses a cache, so setting a Supervisor-managed value
|
||||||
|
// directly in config.json (thus circumventing ConfigJsonConfigBackend)
|
||||||
|
// will not be reflected in the ConfigJsonConfigBackend instance.
|
||||||
|
// We need to create a new instance which will recreate the cache
|
||||||
|
// in order to get the latest value.
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
expect(await powerFanConf.getBootConfig()).to.deep.equal({
|
||||||
|
power_mode: powerMode,
|
||||||
|
fan_profile: fanProfile,
|
||||||
|
});
|
||||||
|
|
||||||
|
await testfs.restore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly gets boot configs if power mode is not set', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: stripIndent`
|
||||||
|
{
|
||||||
|
"os": {
|
||||||
|
"extra": "field",
|
||||||
|
"fan": {
|
||||||
|
"profile": "quiet"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
const config = await powerFanConf.getBootConfig();
|
||||||
|
expect(config).to.deep.equal({
|
||||||
|
fan_profile: 'quiet',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly gets boot configs if fan profile is not set', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: stripIndent`
|
||||||
|
{
|
||||||
|
"os": {
|
||||||
|
"extra": "field",
|
||||||
|
"power": {
|
||||||
|
"mode": "low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
const config = await powerFanConf.getBootConfig();
|
||||||
|
expect(config).to.deep.equal({
|
||||||
|
power_mode: 'low',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('correctly gets boot configs if no relevant boot configs are set', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: stripIndent`
|
||||||
|
{
|
||||||
|
"os": {
|
||||||
|
"extra": "field"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
const config = await powerFanConf.getBootConfig();
|
||||||
|
expect(config).to.deep.equal({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores unrelated fields in config.json when getting boot configs', async () => {
|
||||||
|
const configStr = stripIndent`
|
||||||
|
{
|
||||||
|
"apiEndpoint": "https://api.balena-cloud.com",
|
||||||
|
"uuid": "deadbeef",
|
||||||
|
"os": {
|
||||||
|
"power": {
|
||||||
|
"mode": "low",
|
||||||
|
"extra": "field"
|
||||||
|
},
|
||||||
|
"extra2": "field2",
|
||||||
|
"fan": {
|
||||||
|
"profile": "quiet",
|
||||||
|
"extra3": "field3"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"connectivity": {
|
||||||
|
"uri": "https://api.balena-cloud.com/connectivity-check",
|
||||||
|
"interval": "300",
|
||||||
|
"response": "optional value in the response"
|
||||||
|
},
|
||||||
|
"wifi": {
|
||||||
|
"randomMacAddressScan": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: configStr,
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
const config = await powerFanConf.getBootConfig();
|
||||||
|
expect(config).to.deep.equal({
|
||||||
|
power_mode: 'low',
|
||||||
|
fan_profile: 'quiet',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Check that unrelated fields are unchanged
|
||||||
|
const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8');
|
||||||
|
expect(configJson).to.equal(configStr);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets boot configs in config.json while current config is empty', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: '{}',
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
expect(await powerFanConf.getBootConfig()).to.deep.equal({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets boot configs in config.json', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: stripIndent`
|
||||||
|
{
|
||||||
|
"os": {
|
||||||
|
"extra": "field"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
expect(await powerFanConf.getBootConfig()).to.deep.equal({});
|
||||||
|
|
||||||
|
await powerFanConf.setBootConfig({
|
||||||
|
power_mode: 'low',
|
||||||
|
fan_profile: 'quiet',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await powerFanConf.getBootConfig()).to.deep.equal({
|
||||||
|
power_mode: 'low',
|
||||||
|
fan_profile: 'quiet',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity check that config.json is updated
|
||||||
|
const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8');
|
||||||
|
expect(configJson).to.deep.equal(
|
||||||
|
JSON.stringify({
|
||||||
|
os: {
|
||||||
|
extra: 'field',
|
||||||
|
power: {
|
||||||
|
mode: 'low',
|
||||||
|
},
|
||||||
|
fan: {
|
||||||
|
profile: 'quiet',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets boot configs in config.json while removing any unspecified boot configs', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: stripIndent`
|
||||||
|
{
|
||||||
|
"os": {
|
||||||
|
"extra": "field",
|
||||||
|
"power": {
|
||||||
|
"mode": "low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
await powerFanConf.setBootConfig({
|
||||||
|
fan_profile: 'cool',
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await powerFanConf.getBootConfig();
|
||||||
|
expect(config).to.deep.equal({
|
||||||
|
fan_profile: 'cool',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity check that power mode is removed
|
||||||
|
const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8');
|
||||||
|
expect(configJson).to.deep.equal(
|
||||||
|
JSON.stringify({
|
||||||
|
os: {
|
||||||
|
extra: 'field',
|
||||||
|
fan: {
|
||||||
|
profile: 'cool',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets boot configs in config.json while current config is empty', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: '{}',
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
await powerFanConf.setBootConfig({
|
||||||
|
power_mode: 'low',
|
||||||
|
fan_profile: 'quiet',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await powerFanConf.getBootConfig()).to.deep.equal({
|
||||||
|
power_mode: 'low',
|
||||||
|
fan_profile: 'quiet',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity check that config.json is updated
|
||||||
|
const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8');
|
||||||
|
expect(configJson).to.deep.equal(
|
||||||
|
JSON.stringify({
|
||||||
|
os: {
|
||||||
|
power: {
|
||||||
|
mode: 'low',
|
||||||
|
},
|
||||||
|
fan: {
|
||||||
|
profile: 'quiet',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sets boot configs in config.json while current and target config are empty', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: '{}',
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
await powerFanConf.setBootConfig({});
|
||||||
|
|
||||||
|
expect(await powerFanConf.getBootConfig()).to.deep.equal({});
|
||||||
|
|
||||||
|
// Sanity check that config.json is empty
|
||||||
|
const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8');
|
||||||
|
expect(configJson).to.deep.equal(JSON.stringify({ os: {} }));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles setting configs correctly when target configs are empty string', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: stripIndent`
|
||||||
|
{
|
||||||
|
"os": {
|
||||||
|
"extra": "field",
|
||||||
|
"power": {
|
||||||
|
"mode": "low"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
await powerFanConf.setBootConfig({
|
||||||
|
fan_profile: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const config = await powerFanConf.getBootConfig();
|
||||||
|
expect(config).to.deep.equal({
|
||||||
|
fan_profile: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity check that config.json is updated
|
||||||
|
const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8');
|
||||||
|
expect(configJson).to.deep.equal(
|
||||||
|
JSON.stringify({
|
||||||
|
os: {
|
||||||
|
extra: 'field',
|
||||||
|
fan: {
|
||||||
|
profile: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch fields besides os.power and os.fan in config.json when setting boot configs', async () => {
|
||||||
|
await testfs({
|
||||||
|
// Note that extra fields in os.power and os.fan are removed when setting, as os.power
|
||||||
|
// and os.fan are considered managed by the Supervisor.
|
||||||
|
[CONFIG_PATH]: stripIndent`
|
||||||
|
{
|
||||||
|
"apiEndpoint": "https://api.balena-cloud.com",
|
||||||
|
"uuid": "deadbeef",
|
||||||
|
"os": {
|
||||||
|
"power": {
|
||||||
|
"mode": "low",
|
||||||
|
"extra": "field"
|
||||||
|
},
|
||||||
|
"extra2": "field2",
|
||||||
|
"fan": {
|
||||||
|
"profile": "quiet",
|
||||||
|
"extra3": "field3"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"connectivity": {
|
||||||
|
"uri": "https://api.balena-cloud.com/connectivity-check",
|
||||||
|
"interval": "300",
|
||||||
|
"response": "optional value in the response"
|
||||||
|
},
|
||||||
|
"wifi": {
|
||||||
|
"randomMacAddressScan": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
await powerFanConf.setBootConfig({
|
||||||
|
power_mode: 'high',
|
||||||
|
fan_profile: 'cool',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(await powerFanConf.getBootConfig()).to.deep.equal({
|
||||||
|
power_mode: 'high',
|
||||||
|
fan_profile: 'cool',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sanity check that os.power and os.fan are updated
|
||||||
|
const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8');
|
||||||
|
expect(configJson).to.deep.equal(
|
||||||
|
JSON.stringify({
|
||||||
|
apiEndpoint: 'https://api.balena-cloud.com',
|
||||||
|
uuid: 'deadbeef',
|
||||||
|
os: {
|
||||||
|
power: {
|
||||||
|
// Extra fields in os.power are removed when setting
|
||||||
|
mode: 'high',
|
||||||
|
},
|
||||||
|
extra2: 'field2',
|
||||||
|
fan: {
|
||||||
|
// Extra fields in os.fan are removed when setting
|
||||||
|
profile: 'cool',
|
||||||
|
},
|
||||||
|
network: {
|
||||||
|
connectivity: {
|
||||||
|
uri: 'https://api.balena-cloud.com/connectivity-check',
|
||||||
|
interval: '300',
|
||||||
|
response: 'optional value in the response',
|
||||||
|
},
|
||||||
|
wifi: {
|
||||||
|
randomMacAddressScan: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not touch fields besides os.power and os.fan in config.json when removing boot configs', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: stripIndent`
|
||||||
|
{
|
||||||
|
"apiEndpoint": "https://api.balena-cloud.com",
|
||||||
|
"uuid": "deadbeef",
|
||||||
|
"os": {
|
||||||
|
"power": {
|
||||||
|
"mode": "low",
|
||||||
|
"extra": "field"
|
||||||
|
},
|
||||||
|
"extra2": "field2",
|
||||||
|
"fan": {
|
||||||
|
"profile": "quiet",
|
||||||
|
"extra3": "field3"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"connectivity": {
|
||||||
|
"uri": "https://api.balena-cloud.com/connectivity-check",
|
||||||
|
"interval": "300",
|
||||||
|
"response": "optional value in the response"
|
||||||
|
},
|
||||||
|
"wifi": {
|
||||||
|
"randomMacAddressScan": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
await powerFanConf.setBootConfig({});
|
||||||
|
|
||||||
|
expect(await powerFanConf.getBootConfig()).to.deep.equal({});
|
||||||
|
|
||||||
|
// Sanity check that os.power and os.fan are removed
|
||||||
|
const configJson = await hostUtils.readFromBoot(CONFIG_PATH, 'utf-8');
|
||||||
|
expect(configJson).to.deep.equal(
|
||||||
|
JSON.stringify({
|
||||||
|
apiEndpoint: 'https://api.balena-cloud.com',
|
||||||
|
uuid: 'deadbeef',
|
||||||
|
os: {
|
||||||
|
extra2: 'field2',
|
||||||
|
network: {
|
||||||
|
connectivity: {
|
||||||
|
uri: 'https://api.balena-cloud.com/connectivity-check',
|
||||||
|
interval: '300',
|
||||||
|
response: 'optional value in the response',
|
||||||
|
},
|
||||||
|
wifi: {
|
||||||
|
randomMacAddressScan: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object with warning if config.json cannot be parsed', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: 'not json',
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
(log.error as SinonStub).resetHistory();
|
||||||
|
|
||||||
|
const config = await powerFanConf.getBootConfig();
|
||||||
|
expect(config).to.deep.equal({});
|
||||||
|
expect(log.error as SinonStub).to.have.been.calledWithMatch(
|
||||||
|
'Failed to read config.json while getting power / fan configs:',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object if boot config does not have the right schema', async () => {
|
||||||
|
await testfs({
|
||||||
|
[CONFIG_PATH]: stripIndent`
|
||||||
|
{
|
||||||
|
"os": {
|
||||||
|
"power": "not an object",
|
||||||
|
"fan": "also not an object",
|
||||||
|
"extra": "field"
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
}).enable();
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
expect(await powerFanConf.getBootConfig()).to.deep.equal({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is the only config backend that supports power mode and fan profile', () => {
|
||||||
|
const otherBackends = [
|
||||||
|
new Extlinux(),
|
||||||
|
new ExtraUEnv(),
|
||||||
|
new ConfigTxt(),
|
||||||
|
new ConfigFs(),
|
||||||
|
new Odmdata(),
|
||||||
|
new SplashImage(),
|
||||||
|
];
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
|
||||||
|
for (const config of ['power_mode', 'fan_profile']) {
|
||||||
|
for (const backend of otherBackends) {
|
||||||
|
expect(backend.isBootConfigVar(`HOST_CONFIG_${config}`)).to.be.false;
|
||||||
|
expect(backend.isSupportedConfig(config)).to.be.false;
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(powerFanConf.isBootConfigVar(`HOST_CONFIG_${config}`)).to.be.true;
|
||||||
|
expect(powerFanConf.isSupportedConfig(config)).to.be.true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('converts supported config vars to boot configs regardless of case', () => {
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
for (const config of ['power_mode', 'fan_profile']) {
|
||||||
|
expect(
|
||||||
|
powerFanConf.processConfigVarName(`HOST_CONFIG_${config}`),
|
||||||
|
).to.equal(config);
|
||||||
|
expect(
|
||||||
|
powerFanConf.processConfigVarName(
|
||||||
|
`HOST_CONFIG_${config.toUpperCase()}`,
|
||||||
|
),
|
||||||
|
).to.equal(config);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows any value for power mode and fan profile', () => {
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
for (const config of ['power_mode', 'fan_profile']) {
|
||||||
|
expect(powerFanConf.processConfigVarValue(config, 'any value')).to.equal(
|
||||||
|
'any value',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates supported config vars from boot configs', () => {
|
||||||
|
powerFanConf = new PowerFanConfig(generateConfigJsonBackend());
|
||||||
|
for (const config of ['power_mode', 'fan_profile']) {
|
||||||
|
expect(powerFanConf.createConfigVarName(config)).to.equal(
|
||||||
|
`HOST_CONFIG_${config}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
@ -7,6 +7,8 @@ import { Extlinux } from '~/src/config/backends/extlinux';
|
|||||||
import { ConfigTxt } from '~/src/config/backends/config-txt';
|
import { ConfigTxt } from '~/src/config/backends/config-txt';
|
||||||
import { ConfigFs } from '~/src/config/backends/config-fs';
|
import { ConfigFs } from '~/src/config/backends/config-fs';
|
||||||
import { SplashImage } from '~/src/config/backends/splash-image';
|
import { SplashImage } from '~/src/config/backends/splash-image';
|
||||||
|
import { PowerFanConfig } from '~/src/config/backends/power-fan';
|
||||||
|
import { configJsonBackend } from '~/src/config';
|
||||||
import type { ConfigBackend } from '~/src/config/backends/backend';
|
import type { ConfigBackend } from '~/src/config/backends/backend';
|
||||||
|
|
||||||
import * as hostUtils from '~/lib/host-utils';
|
import * as hostUtils from '~/lib/host-utils';
|
||||||
@ -63,6 +65,7 @@ const BACKENDS: Record<string, ConfigBackend> = {
|
|||||||
configtxt: new ConfigTxt(),
|
configtxt: new ConfigTxt(),
|
||||||
configfs: new ConfigFs(),
|
configfs: new ConfigFs(),
|
||||||
splashImage: new SplashImage(),
|
splashImage: new SplashImage(),
|
||||||
|
powerFan: new PowerFanConfig(configJsonBackend),
|
||||||
};
|
};
|
||||||
|
|
||||||
const CONFIGS = {
|
const CONFIGS = {
|
||||||
@ -123,4 +126,14 @@ const CONFIGS = {
|
|||||||
// ssdt: ['spidev1,1']
|
// ssdt: ['spidev1,1']
|
||||||
// },
|
// },
|
||||||
// },
|
// },
|
||||||
|
powerFan: {
|
||||||
|
envVars: {
|
||||||
|
HOST_CONFIG_power_mode: 'low',
|
||||||
|
HOST_CONFIG_fan_profile: 'quiet',
|
||||||
|
},
|
||||||
|
bootConfig: {
|
||||||
|
power_mode: 'low',
|
||||||
|
fan_profile: 'quiet',
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
Loading…
x
Reference in New Issue
Block a user