Merge pull request #1390 from balena-io/device-config-singleton

Convert device config to singleton and fix await bug in db-format
This commit is contained in:
bulldozer-balena[bot] 2020-07-08 11:44:18 +00:00 committed by GitHub
commit de80aacc2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 702 additions and 735 deletions

View File

@ -10,6 +10,7 @@ import * as url from 'url';
import * as deviceRegister from './lib/register-device';
import * as config from './config';
import * as deviceConfig from './device-config';
import * as eventTracker from './event-tracker';
import { loadBackupFromMigration } from './lib/migration';
@ -639,10 +640,10 @@ export class APIBinder {
);
}
const defaultConfig = this.deviceState.deviceConfig.getDefaults();
const defaultConfig = deviceConfig.getDefaults();
const currentState = await this.deviceState.getCurrentForComparison();
const targetConfig = await this.deviceState.deviceConfig.formatConfigKeys(
const targetConfig = await deviceConfig.formatConfigKeys(
targetConfigUnformatted,
);

View File

@ -7,6 +7,7 @@ import { Service } from '../compose/service';
import Volume from '../compose/volume';
import * as config from '../config';
import * as db from '../db';
import * as deviceConfig from '../device-config';
import * as logger from '../logger';
import * as images from '../compose/images';
import * as volumeManager from '../compose/volume-manager';
@ -465,7 +466,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
});
router.get('/v2/device/vpn', async (_req, res) => {
const conf = await deviceState.deviceConfig.getCurrent();
const conf = await deviceConfig.getCurrent();
// Build VPNInfo
const info = {
enabled: conf.SUPERVISOR_VPN_CONTROL === 'true',

View File

@ -50,12 +50,67 @@ interface DeviceActionExecutors {
setBootConfig: DeviceActionExecutorFn;
}
export class DeviceConfig {
private rebootRequired = false;
private actionExecutors: DeviceActionExecutors;
private configBackend: DeviceConfigBackend | null = null;
let rebootRequired = false;
const actionExecutors: DeviceActionExecutors = {
changeConfig: async (step) => {
try {
if (step.humanReadableTarget) {
logger.logConfigChange(step.humanReadableTarget);
}
if (!_.isObject(step.target)) {
throw new Error('Non-dictionary value passed to changeConfig');
}
// TODO: Change the typing of step so that the types automatically
// work out and we don't need this cast to any
await config.set(step.target as { [key in SchemaTypeKey]: any });
if (step.humanReadableTarget) {
logger.logConfigChange(step.humanReadableTarget, {
success: true,
});
}
if (step.rebootRequired) {
rebootRequired = true;
}
} catch (err) {
if (step.humanReadableTarget) {
logger.logConfigChange(step.humanReadableTarget, {
err,
});
}
throw err;
}
},
setVPNEnabled: async (step, opts = {}) => {
const { initial = false } = opts;
if (!_.isString(step.target)) {
throw new Error('Non-string value passed to setVPNEnabled');
}
const logValue = { SUPERVISOR_VPN_CONTROL: step.target };
if (!initial) {
logger.logConfigChange(logValue);
}
try {
await setVPNEnabled(step.target);
if (!initial) {
logger.logConfigChange(logValue, { success: true });
}
} catch (err) {
logger.logConfigChange(logValue, { err });
throw err;
}
},
setBootConfig: async (step) => {
const $configBackend = await getConfigBackend();
if (!_.isObject(step.target)) {
throw new Error('Non-dictionary passed to DeviceConfig.setBootConfig');
}
await setBootConfig($configBackend, step.target as Dictionary<string>);
},
};
private static readonly configKeys: Dictionary<ConfigOption> = {
let configBackend: DeviceConfigBackend | null = null;
const configKeys: Dictionary<ConfigOption> = {
appUpdatePollInterval: {
envVarName: 'SUPERVISOR_POLL_INTERVAL',
varType: 'int',
@ -132,107 +187,42 @@ export class DeviceConfig {
varType: 'bool',
defaultValue: 'true',
},
};
};
public static validKeys = [
export const validKeys = [
'SUPERVISOR_VPN_CONTROL',
'OVERRIDE_LOCK',
..._.map(DeviceConfig.configKeys, 'envVarName'),
];
..._.map(configKeys, 'envVarName'),
];
private rateLimits: Dictionary<{
const rateLimits: Dictionary<{
duration: number;
lastAttempt: number | null;
}> = {
}> = {
setVPNEnabled: {
// Only try to switch the VPN once an hour
duration: 60 * 60 * 1000,
lastAttempt: null,
},
};
};
public constructor() {
this.actionExecutors = {
changeConfig: async (step) => {
try {
if (step.humanReadableTarget) {
logger.logConfigChange(step.humanReadableTarget);
}
if (!_.isObject(step.target)) {
throw new Error('Non-dictionary value passed to changeConfig');
}
// TODO: Change the typing of step so that the types automatically
// work out and we don't need this cast to any
await config.set(step.target as { [key in SchemaTypeKey]: any });
if (step.humanReadableTarget) {
logger.logConfigChange(step.humanReadableTarget, {
success: true,
});
}
if (step.rebootRequired) {
this.rebootRequired = true;
}
} catch (err) {
if (step.humanReadableTarget) {
logger.logConfigChange(step.humanReadableTarget, {
err,
});
}
throw err;
}
},
setVPNEnabled: async (step, opts = {}) => {
const { initial = false } = opts;
if (!_.isString(step.target)) {
throw new Error('Non-string value passed to setVPNEnabled');
}
const logValue = { SUPERVISOR_VPN_CONTROL: step.target };
if (!initial) {
logger.logConfigChange(logValue);
}
try {
await this.setVPNEnabled(step.target);
if (!initial) {
logger.logConfigChange(logValue, { success: true });
}
} catch (err) {
logger.logConfigChange(logValue, { err });
throw err;
}
},
setBootConfig: async (step) => {
const configBackend = await this.getConfigBackend();
if (!_.isObject(step.target)) {
throw new Error(
'Non-dictionary passed to DeviceConfig.setBootConfig',
);
}
await this.setBootConfig(
configBackend,
step.target as Dictionary<string>,
);
},
};
}
private async getConfigBackend() {
if (this.configBackend != null) {
return this.configBackend;
async function getConfigBackend() {
if (configBackend != null) {
return configBackend;
}
const dt = await config.get('deviceType');
this.configBackend =
(await configUtils.initialiseConfigBackend(dt)) ?? null;
configBackend = (await configUtils.initialiseConfigBackend(dt)) ?? null;
return this.configBackend;
}
return configBackend;
}
public async setTarget(
export async function setTarget(
target: Dictionary<string>,
trx?: db.Transaction,
): Promise<void> {
): Promise<void> {
const $db = trx ?? db.models.bind(db);
const formatted = await this.formatConfigKeys(target);
const formatted = await formatConfigKeys(target);
// check for legacy keys
if (formatted['OVERRIDE_LOCK'] != null) {
formatted['SUPERVISOR_OVERRIDE_LOCK'] = formatted['OVERRIDE_LOCK'];
@ -243,9 +233,11 @@ export class DeviceConfig {
};
await $db('deviceConfig').update(confToUpdate);
}
}
public async getTarget({ initial = false }: { initial?: boolean } = {}) {
export async function getTarget({
initial = false,
}: { initial?: boolean } = {}) {
const [unmanaged, [devConfig]] = await Promise.all([
config.get('unmanaged'),
db.models('deviceConfig').select('targetValues'),
@ -266,25 +258,22 @@ export class DeviceConfig {
_.defaults(
conf,
_(DeviceConfig.configKeys)
.mapKeys('envVarName')
.mapValues('defaultValue')
.value(),
_(configKeys).mapKeys('envVarName').mapValues('defaultValue').value(),
);
return conf;
}
}
public async getCurrent() {
export async function getCurrent() {
const conf = await config.getMany(
['deviceType'].concat(_.keys(DeviceConfig.configKeys)) as SchemaTypeKey[],
['deviceType'].concat(_.keys(configKeys)) as SchemaTypeKey[],
);
const configBackend = await this.getConfigBackend();
const $configBackend = await getConfigBackend();
const [vpnStatus, bootConfig] = await Promise.all([
this.getVPNEnabled(),
this.getBootConfig(configBackend),
getVPNEnabled(),
getBootConfig($configBackend),
]);
const currentConf: Dictionary<string> = {
@ -292,63 +281,57 @@ export class DeviceConfig {
SUPERVISOR_VPN_CONTROL: vpnStatus != null ? vpnStatus.toString() : 'true',
};
for (const key of _.keys(DeviceConfig.configKeys)) {
const { envVarName } = DeviceConfig.configKeys[key];
for (const key of _.keys(configKeys)) {
const { envVarName } = configKeys[key];
const confValue = conf[key as SchemaTypeKey];
currentConf[envVarName] = confValue != null ? confValue.toString() : '';
}
return _.assign(currentConf, bootConfig);
}
}
public async formatConfigKeys(
export async function formatConfigKeys(
conf: Dictionary<string>,
): Promise<Dictionary<any>> {
const backend = await this.getConfigBackend();
return await configUtils.formatConfigKeys(
backend,
DeviceConfig.validKeys,
conf,
);
}
): Promise<Dictionary<any>> {
const backend = await getConfigBackend();
return configUtils.formatConfigKeys(backend, validKeys, conf);
}
public getDefaults() {
export function getDefaults() {
return _.extend(
{
SUPERVISOR_VPN_CONTROL: 'true',
},
_.mapValues(
_.mapKeys(DeviceConfig.configKeys, 'envVarName'),
'defaultValue',
),
_.mapValues(_.mapKeys(configKeys, 'envVarName'), 'defaultValue'),
);
}
}
public resetRateLimits() {
_.each(this.rateLimits, (action) => {
export function resetRateLimits() {
_.each(rateLimits, (action) => {
action.lastAttempt = null;
});
}
}
private bootConfigChangeRequired(
configBackend: DeviceConfigBackend | null,
// Exported for tests
export function bootConfigChangeRequired(
$configBackend: DeviceConfigBackend | null,
current: Dictionary<string>,
target: Dictionary<string>,
deviceType: string,
): boolean {
const targetBootConfig = configUtils.envToBootConfig(configBackend, target);
): boolean {
const targetBootConfig = configUtils.envToBootConfig($configBackend, target);
const currentBootConfig = configUtils.envToBootConfig(
configBackend,
$configBackend,
current,
);
// Some devices require specific overlays, here we apply them
DeviceConfig.ensureRequiredOverlay(deviceType, targetBootConfig);
ensureRequiredOverlay(deviceType, targetBootConfig);
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 (!$configBackend!.isSupportedConfig(key)) {
if (currentBootConfig[key] !== value) {
const err = `Attempt to change blacklisted config value ${key}`;
logger.logSystemMessage(
@ -363,12 +346,12 @@ export class DeviceConfig {
return true;
}
return false;
}
}
public async getRequiredSteps(
export async function getRequiredSteps(
currentState: DeviceStatus,
targetState: { local?: { config?: Dictionary<string> } },
): Promise<ConfigStep[]> {
): Promise<ConfigStep[]> {
const current: Dictionary<string> = _.get(
currentState,
['local', 'config'],
@ -386,37 +369,28 @@ export class DeviceConfig {
'deviceType',
'unmanaged',
]);
const backend = await this.getConfigBackend();
const backend = await getConfigBackend();
const configChanges: Dictionary<string> = {};
const humanReadableConfigChanges: Dictionary<string> = {};
let reboot = false;
_.each(
DeviceConfig.configKeys,
({ envVarName, varType, rebootRequired, defaultValue }, key) => {
configKeys,
(
{ envVarName, varType, rebootRequired: $rebootRequired, defaultValue },
key,
) => {
let changingValue: null | string = null;
// Test if the key is different
if (
!DeviceConfig.configTest(
varType,
current[envVarName],
target[envVarName],
)
) {
if (!configTest(varType, current[envVarName], target[envVarName])) {
// Check that the difference is not due to the variable having an invalid
// value set from the cloud
if (config.valueIsValid(key as SchemaTypeKey, target[envVarName])) {
// Save the change if it is both valid and different
changingValue = target[envVarName];
} else {
if (
!DeviceConfig.configTest(
varType,
current[envVarName],
defaultValue,
)
) {
if (!configTest(varType, current[envVarName], defaultValue)) {
const message = `Warning: Ignoring invalid device configuration value for ${key}, value: ${inspect(
target[envVarName],
)}. Falling back to default (${defaultValue})`;
@ -433,7 +407,7 @@ export class DeviceConfig {
if (changingValue != null) {
configChanges[key] = changingValue;
humanReadableConfigChanges[envVarName] = changingValue;
reboot = rebootRequired || reboot;
reboot = $rebootRequired || reboot;
}
}
},
@ -452,7 +426,7 @@ export class DeviceConfig {
if (
!unmanaged &&
!_.isEmpty(target['SUPERVISOR_VPN_CONTROL']) &&
DeviceConfig.checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL')
checkBoolChanged(current, target, 'SUPERVISOR_VPN_CONTROL')
) {
steps.push({
action: 'setVPNEnabled',
@ -463,9 +437,9 @@ export class DeviceConfig {
const now = Date.now();
steps = _.map(steps, (step) => {
const action = step.action;
if (action in this.rateLimits) {
const lastAttempt = this.rateLimits[action].lastAttempt;
this.rateLimits[action].lastAttempt = now;
if (action in rateLimits) {
const lastAttempt = rateLimits[action].lastAttempt;
rateLimits[action].lastAttempt = now;
// If this step should be rate limited, we replace it with a noop.
// We do this instead of removing it, as we don't actually want the
@ -473,7 +447,7 @@ export class DeviceConfig {
// as it won't reattempt the change until the target state changes
if (
lastAttempt != null &&
Date.now() - lastAttempt < this.rateLimits[action].duration
Date.now() - lastAttempt < rateLimits[action].duration
) {
return { action: 'noop' } as ConfigStep;
}
@ -482,7 +456,7 @@ export class DeviceConfig {
});
// Do we need to change the boot config?
if (this.bootConfigChangeRequired(backend, current, target, deviceType)) {
if (bootConfigChangeRequired(backend, current, target, deviceType)) {
steps.push({
action: 'setBootConfig',
target,
@ -495,39 +469,43 @@ export class DeviceConfig {
// connection, the device will try to start containers
// before any boot config has been applied, which can
// cause problems
if (_.every(steps, { action: 'noop' }) && this.rebootRequired) {
if (_.every(steps, { action: 'noop' }) && rebootRequired) {
steps.push({
action: 'reboot',
});
}
return steps;
}
}
public executeStepAction(step: ConfigStep, opts: DeviceActionExecutorOpts) {
export function executeStepAction(
step: ConfigStep,
opts: DeviceActionExecutorOpts,
) {
if (step.action !== 'reboot' && step.action !== 'noop') {
return this.actionExecutors[step.action](step, opts);
}
return actionExecutors[step.action](step, opts);
}
}
public isValidAction(action: string): boolean {
return _.includes(_.keys(this.actionExecutors), action);
}
export function isValidAction(action: string): boolean {
return _.includes(_.keys(actionExecutors), action);
}
private async getBootConfig(
export async function getBootConfig(
backend: DeviceConfigBackend | null,
): Promise<EnvVarObject> {
): Promise<EnvVarObject> {
if (backend == null) {
return {};
}
const conf = await backend.getBootConfig();
return configUtils.bootConfigToEnv(backend, conf);
}
}
private async setBootConfig(
// Exported for tests
export async function setBootConfig(
backend: DeviceConfigBackend | null,
target: Dictionary<string>,
) {
) {
if (backend == null) {
return false;
}
@ -540,7 +518,7 @@ export class DeviceConfig {
);
// Ensure devices already have required overlays
DeviceConfig.ensureRequiredOverlay(await config.get('deviceType'), conf);
ensureRequiredOverlay(await config.get('deviceType'), conf);
try {
await backend.setBootConfig(conf);
@ -549,7 +527,7 @@ export class DeviceConfig {
{},
'Apply boot config success',
);
this.rebootRequired = true;
rebootRequired = true;
return true;
} catch (err) {
logger.logSystemMessage(
@ -559,9 +537,9 @@ export class DeviceConfig {
);
throw err;
}
}
}
private async getVPNEnabled(): Promise<boolean> {
async function getVPNEnabled(): Promise<boolean> {
try {
const activeState = await dbus.serviceActiveState(vpnServiceName);
return !_.includes(['inactive', 'deactivating'], activeState);
@ -571,9 +549,9 @@ export class DeviceConfig {
}
throw e;
}
}
}
private async setVPNEnabled(value?: string | boolean) {
async function setVPNEnabled(value?: string | boolean) {
const v = checkTruthy(value || true);
const enable = v != null ? v : true;
@ -582,9 +560,9 @@ export class DeviceConfig {
} else {
await dbus.stopService(vpnServiceName);
}
}
}
private static configTest(method: string, a: string, b: string): boolean {
function configTest(method: string, a: string, b: string): boolean {
switch (method) {
case 'bool':
return checkTruthy(a) === checkTruthy(b);
@ -595,35 +573,33 @@ export class DeviceConfig {
default:
throw new Error('Incorrect datatype passed to DeviceConfig.configTest');
}
}
}
private static checkBoolChanged(
function checkBoolChanged(
current: Dictionary<string>,
target: Dictionary<string>,
key: string,
): boolean {
): boolean {
return checkTruthy(current[key]) !== checkTruthy(target[key]);
}
}
// Modifies conf
private static ensureRequiredOverlay(
deviceType: string,
conf: ConfigOptions,
) {
// Modifies conf
// exported for tests
export function ensureRequiredOverlay(deviceType: string, conf: ConfigOptions) {
switch (deviceType) {
case 'fincm3':
this.ensureDtoverlay(conf, 'balena-fin');
ensureDtoverlay(conf, 'balena-fin');
break;
case 'raspberrypi4-64':
this.ensureDtoverlay(conf, 'vc4-fkms-v3d');
ensureDtoverlay(conf, 'vc4-fkms-v3d');
break;
}
return conf;
}
}
// Modifies conf
private static ensureDtoverlay(conf: ConfigOptions, field: string) {
// Modifies conf
function ensureDtoverlay(conf: ConfigOptions, field: string) {
if (conf.dtoverlay == null) {
conf.dtoverlay = [];
} else if (_.isString(conf.dtoverlay)) {
@ -635,7 +611,4 @@ export class DeviceConfig {
conf.dtoverlay = conf.dtoverlay.filter((s) => !_.isEmpty(s));
return conf;
}
}
export default DeviceConfig;

View File

@ -28,7 +28,8 @@ import * as network from './network';
import APIBinder from './api-binder';
import { ApplicationManager } from './application-manager';
import DeviceConfig, { ConfigStep } from './device-config';
import * as deviceConfig from './device-config';
import { ConfigStep } from './device-config';
import { log } from './lib/supervisor-console';
import {
DeviceReportFields,
@ -217,7 +218,6 @@ type DeviceStateStep<T extends PossibleStepTargets> =
export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmitter) {
public applications: ApplicationManager;
public deviceConfig: DeviceConfig;
private currentVolatile: DeviceReportFields = {};
private writeLock = updateLock.writeLock;
@ -239,7 +239,6 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
constructor({ apiBinder }: DeviceStateConstructOpts) {
super();
this.deviceConfig = new DeviceConfig();
this.applications = new ApplicationManager({
deviceState: this,
apiBinder,
@ -256,7 +255,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
// We also let the device-config module know that we
// successfully reached the target state and that it
// should clear any rate limiting it's applied
return this.deviceConfig.resetRateLimits();
return deviceConfig.resetRateLimits();
}
});
this.applications.on('change', (d) => this.reportCurrentState(d));
@ -405,9 +404,9 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
}
private async saveInitialConfig() {
const devConf = await this.deviceConfig.getCurrent();
const devConf = await deviceConfig.getCurrent();
await this.deviceConfig.setTarget(devConf);
await deviceConfig.setTarget(devConf);
await config.set({ initialConfigSaved: true });
}
@ -460,7 +459,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
await this.usingWriteLockTarget(async () => {
await db.transaction(async (trx) => {
await config.set({ name: target.local.name }, trx);
await this.deviceConfig.setTarget(target.local.config, trx);
await deviceConfig.setTarget(target.local.config, trx);
if (localSource || apiEndpoint == null) {
await this.applications.setTarget(
@ -495,7 +494,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
return {
local: {
name: await config.get('name'),
config: await this.deviceConfig.getTarget({ initial }),
config: await deviceConfig.getTarget({ initial }),
apps: await this.applications.getTargetApps(),
},
dependent: await this.applications.getDependentTargets(),
@ -524,7 +523,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
> {
const [name, devConfig, apps, dependent] = await Promise.all([
config.get('name'),
this.deviceConfig.getCurrent(),
deviceConfig.getCurrent(),
this.applications.getCurrentForComparison(),
this.applications.getDependentState(),
]);
@ -573,8 +572,8 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
skipLock,
}: { force?: boolean; initial?: boolean; skipLock?: boolean },
) {
if (this.deviceConfig.isValidAction(step.action)) {
await this.deviceConfig.executeStepAction(step as ConfigStep, {
if (deviceConfig.isValidAction(step.action)) {
await deviceConfig.executeStepAction(step as ConfigStep, {
initial,
});
} else if (_.includes(this.applications.validActions, step.action)) {
@ -702,7 +701,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
currentState,
targetState,
);
const deviceConfigSteps = await this.deviceConfig.getRequiredSteps(
const deviceConfigSteps = await deviceConfig.getRequiredSteps(
currentState,
targetState,
);

View File

@ -66,20 +66,28 @@ async function buildApp(dbApp: targetStateCache.DatabaseApp) {
},
);
const opts = await config.get('extendedEnvOptions');
const supervisorApiHost = dockerUtils
const [
opts,
supervisorApiHost,
hostPathExists,
hostnameOnHost,
] = await Promise.all([
config.get('extendedEnvOptions'),
dockerUtils
.getNetworkGateway(constants.supervisorNetworkInterface)
.catch(() => '127.0.0.1');
const hostPathExists = {
.catch(() => '127.0.0.1'),
(async () => ({
firmware: await pathExistsOnHost('/lib/firmware'),
modules: await pathExistsOnHost('/lib/modules'),
};
const hostnameOnHost = _.trim(
}))(),
(async () =>
_.trim(
await fs.readFile(
path.join(constants.rootMountPoint, '/etc/hostname'),
'utf8',
),
);
))(),
]);
const svcOpts = {
appName: dbApp.name,

View File

@ -4,6 +4,7 @@ import { fs } from 'mz';
import { Image } from '../compose/images';
import DeviceState from '../device-state';
import * as config from '../config';
import * as deviceConfig from '../device-config';
import * as eventTracker from '../event-tracker';
import * as images from '../compose/images';
@ -78,8 +79,8 @@ export async function loadTargetFromFile(
await images.save(image);
}
const deviceConf = await deviceState.deviceConfig.getCurrent();
const formattedConf = await deviceState.deviceConfig.formatConfigKeys(
const deviceConf = await deviceConfig.getCurrent();
const formattedConf = await deviceConfig.formatConfigKeys(
preloadState.config,
);
preloadState.config = { ...formattedConf, ...deviceConf };

View File

@ -12,6 +12,7 @@ import * as config from '../src/config';
import * as images from '../src/compose/images';
import { RPiConfigBackend } from '../src/config/backends/raspberry-pi';
import DeviceState from '../src/device-state';
import * as deviceConfig from '../src/device-config';
import { loadTargetFromFile } from '../src/device-state/preload';
import Service from '../src/compose/service';
import { intialiseContractRequirements } from '../src/lib/contracts';
@ -218,6 +219,7 @@ describe('deviceState', () => {
let source: string;
const originalImagesSave = images.save;
const originalImagesInspect = images.inspectByName;
const originalGetCurrent = deviceConfig.getCurrent;
before(async () => {
await prepare();
await config.initialized;
@ -251,7 +253,11 @@ describe('deviceState', () => {
return Promise.reject(err);
};
(deviceState as any).deviceConfig.configBackend = new RPiConfigBackend();
// @ts-expect-error Assigning to a RO property
deviceConfig.configBackend = new RPiConfigBackend();
// @ts-expect-error Assigning to a RO property
deviceConfig.getCurrent = async () => mockedInitialConfig;
});
after(() => {
@ -262,6 +268,8 @@ describe('deviceState', () => {
images.save = originalImagesSave;
// @ts-expect-error Assigning to a RO property
images.inspectByName = originalImagesInspect;
// @ts-expect-error Assigning to a RO property
deviceConfig.getCurrent = originalGetCurrent;
});
beforeEach(async () => {
@ -269,11 +277,6 @@ describe('deviceState', () => {
});
it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => {
stub(deviceState.deviceConfig, 'getCurrent').returns(
Promise.resolve(mockedInitialConfig),
);
try {
await loadTargetFromFile(
process.env.ROOT_MOUNTPOINT + '/apps.json',
deviceState,
@ -294,20 +297,13 @@ describe('deviceState', () => {
expect(JSON.parse(JSON.stringify(targetState))).to.deep.equal(
JSON.parse(JSON.stringify(testTarget)),
);
} finally {
(deviceState.deviceConfig.getCurrent as sinon.SinonStub).restore();
}
});
it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => {
stub(deviceState.deviceConfig, 'getCurrent').returns(
Promise.resolve(mockedInitialConfig),
);
await loadTargetFromFile(
process.env.ROOT_MOUNTPOINT + '/apps-pin.json',
deviceState,
);
(deviceState as any).deviceConfig.getCurrent.restore();
const pinned = await config.get('pinDevice');
expect(pinned).to.have.property('app').that.equals(1234);

View File

@ -1,28 +1,23 @@
import { Promise } from 'bluebird';
import { stripIndent } from 'common-tags';
import { child_process, fs } from 'mz';
import { SinonSpy, SinonStub, stub, spy } from 'sinon';
import { SinonStub, stub, spy } from 'sinon';
import { expect } from './lib/chai-config';
import * as config from '../src/config';
import { DeviceConfig } from '../src/device-config';
import * as deviceConfig from '../src/device-config';
import * as fsUtils from '../src/lib/fs-utils';
import * as logger from '../src/logger';
import { ExtlinuxConfigBackend } from '../src/config/backends/extlinux';
import { RPiConfigBackend } from '../src/config/backends/raspberry-pi';
import { DeviceConfigBackend } from '../src/config/backends/backend';
import prepare = require('./lib/prepare');
const extlinuxBackend = new ExtlinuxConfigBackend();
const rpiConfigBackend = new RPiConfigBackend();
describe('Device Backend Config', () => {
let deviceConfig: DeviceConfig;
const logSpy = spy(logger, 'logSystemMessage');
before(async () => {
await prepare();
deviceConfig = new DeviceConfig();
});
after(() => {
@ -253,11 +248,12 @@ describe('Device Backend Config', () => {
describe('Balena fin', () => {
it('should always add the balena-fin dtoverlay', () => {
expect(deviceConfig.ensureRequiredOverlay('fincm3', {})).to.deep.equal({
dtoverlay: ['balena-fin'],
});
expect(
(DeviceConfig as any).ensureRequiredOverlay('fincm3', {}),
).to.deep.equal({ dtoverlay: ['balena-fin'] });
expect(
(DeviceConfig as any).ensureRequiredOverlay('fincm3', {
deviceConfig.ensureRequiredOverlay('fincm3', {
test: '123',
test2: ['123'],
test3: ['123', '234'],
@ -269,12 +265,12 @@ describe('Device Backend Config', () => {
dtoverlay: ['balena-fin'],
});
expect(
(DeviceConfig as any).ensureRequiredOverlay('fincm3', {
deviceConfig.ensureRequiredOverlay('fincm3', {
dtoverlay: 'test',
}),
).to.deep.equal({ dtoverlay: ['test', 'balena-fin'] });
expect(
(DeviceConfig as any).ensureRequiredOverlay('fincm3', {
deviceConfig.ensureRequiredOverlay('fincm3', {
dtoverlay: ['test'],
}),
).to.deep.equal({ dtoverlay: ['test', 'balena-fin'] });
@ -282,7 +278,6 @@ describe('Device Backend Config', () => {
it('should not cause a config change when the cloud does not specify the balena-fin overlay', () => {
expect(
// @ts-ignore accessing private value
deviceConfig.bootConfigChangeRequired(
rpiConfigBackend,
{ HOST_CONFIG_dtoverlay: '"test","balena-fin"' },
@ -292,7 +287,6 @@ describe('Device Backend Config', () => {
).to.equal(false);
expect(
// @ts-ignore accessing private value
deviceConfig.bootConfigChangeRequired(
rpiConfigBackend,
{ HOST_CONFIG_dtoverlay: '"test","balena-fin"' },
@ -302,7 +296,6 @@ describe('Device Backend Config', () => {
).to.equal(false);
expect(
// @ts-ignore accessing private value
deviceConfig.bootConfigChangeRequired(
rpiConfigBackend,
{ HOST_CONFIG_dtoverlay: '"test","test2","balena-fin"' },
@ -316,10 +309,12 @@ describe('Device Backend Config', () => {
describe('Raspberry pi4', () => {
it('should always add the vc4-fkms-v3d dtoverlay', () => {
expect(
(DeviceConfig as any).ensureRequiredOverlay('raspberrypi4-64', {}),
).to.deep.equal({ dtoverlay: ['vc4-fkms-v3d'] });
deviceConfig.ensureRequiredOverlay('raspberrypi4-64', {}),
).to.deep.equal({
dtoverlay: ['vc4-fkms-v3d'],
});
expect(
(DeviceConfig as any).ensureRequiredOverlay('raspberrypi4-64', {
deviceConfig.ensureRequiredOverlay('raspberrypi4-64', {
test: '123',
test2: ['123'],
test3: ['123', '234'],
@ -331,12 +326,12 @@ describe('Device Backend Config', () => {
dtoverlay: ['vc4-fkms-v3d'],
});
expect(
(DeviceConfig as any).ensureRequiredOverlay('raspberrypi4-64', {
deviceConfig.ensureRequiredOverlay('raspberrypi4-64', {
dtoverlay: 'test',
}),
).to.deep.equal({ dtoverlay: ['test', 'vc4-fkms-v3d'] });
expect(
(DeviceConfig as any).ensureRequiredOverlay('raspberrypi4-64', {
deviceConfig.ensureRequiredOverlay('raspberrypi4-64', {
dtoverlay: ['test'],
}),
).to.deep.equal({ dtoverlay: ['test', 'vc4-fkms-v3d'] });
@ -344,7 +339,6 @@ describe('Device Backend Config', () => {
it('should not cause a config change when the cloud does not specify the pi4 overlay', () => {
expect(
// @ts-ignore accessing private value
deviceConfig.bootConfigChangeRequired(
rpiConfigBackend,
{ HOST_CONFIG_dtoverlay: '"test","vc4-fkms-v3d"' },
@ -353,7 +347,6 @@ describe('Device Backend Config', () => {
),
).to.equal(false);
expect(
// @ts-ignore accessing private value
deviceConfig.bootConfigChangeRequired(
rpiConfigBackend,
{ HOST_CONFIG_dtoverlay: '"test","vc4-fkms-v3d"' },
@ -362,7 +355,6 @@ describe('Device Backend Config', () => {
),
).to.equal(false);
expect(
// @ts-ignore accessing private value
deviceConfig.bootConfigChangeRequired(
rpiConfigBackend,
{ HOST_CONFIG_dtoverlay: '"test","test2","vc4-fkms-v3d"' },
@ -373,98 +365,94 @@ describe('Device Backend Config', () => {
});
});
describe('ConfigFS', () => {
const upboardConfig = new DeviceConfig();
let upboardConfigBackend: DeviceConfigBackend | null;
// describe('ConfigFS', () => {
// const upboardConfig = new DeviceConfig();
// let upboardConfigBackend: DeviceConfigBackend | null;
before(async () => {
stub(child_process, 'exec').resolves();
stub(fs, 'exists').resolves(true);
stub(fs, 'mkdir').resolves();
stub(fs, 'readdir').resolves([]);
stub(fsUtils, 'writeFileAtomic').resolves();
// before(async () => {
// stub(child_process, 'exec').resolves();
// stub(fs, 'exists').resolves(true);
// stub(fs, 'mkdir').resolves();
// stub(fs, 'readdir').resolves([]);
// stub(fsUtils, 'writeFileAtomic').resolves();
stub(fs, 'readFile').callsFake((file) => {
if (file === 'test/data/mnt/boot/configfs.json') {
return Promise.resolve(
JSON.stringify({
ssdt: ['spidev1,1'],
}),
);
}
return Promise.resolve('');
});
// stub(fs, 'readFile').callsFake(file => {
// if (file === 'test/data/mnt/boot/configfs.json') {
// return Promise.resolve(
// JSON.stringify({
// ssdt: ['spidev1,1'],
// }),
// );
// }
// return Promise.resolve('');
// });
stub(config, 'get').callsFake((key) => {
return Promise.try(() => {
if (key === 'deviceType') {
return 'up-board';
}
throw new Error('Unknown fake config key');
});
});
// stub(config, 'get').callsFake(key => {
// return Promise.try(() => {
// if (key === 'deviceType') {
// return 'up-board';
// }
// throw new Error('Unknown fake config key');
// });
// });
// @ts-ignore accessing private value
upboardConfigBackend = await upboardConfig.getConfigBackend();
expect(upboardConfigBackend).is.not.null;
expect((child_process.exec as SinonSpy).callCount).to.equal(
3,
'exec not called enough times',
);
});
// // @ts-ignore accessing private value
// upboardConfigBackend = await upboardConfig.getConfigBackend();
// expect(upboardConfigBackend).is.not.null;
// expect((child_process.exec as SinonSpy).callCount).to.equal(
// 3,
// 'exec not called enough times',
// );
// });
after(() => {
(child_process.exec as SinonStub).restore();
(fs.exists as SinonStub).restore();
(fs.mkdir as SinonStub).restore();
(fs.readdir as SinonStub).restore();
(fs.readFile as SinonStub).restore();
(fsUtils.writeFileAtomic as SinonStub).restore();
(config.get as SinonStub).restore();
});
// after(() => {
// (child_process.exec as SinonStub).restore();
// (fs.exists as SinonStub).restore();
// (fs.mkdir as SinonStub).restore();
// (fs.readdir as SinonStub).restore();
// (fs.readFile as SinonStub).restore();
// (fsUtils.writeFileAtomic as SinonStub).restore();
// (config.get as SinonStub).restore();
// });
it('should correctly load the configfs.json file', () => {
expect(child_process.exec).to.be.calledWith('modprobe acpi_configfs');
expect(child_process.exec).to.be.calledWith(
'cat test/data/boot/acpi-tables/spidev1,1.aml > test/data/sys/kernel/config/acpi/table/spidev1,1/aml',
);
expect((fs.exists as SinonSpy).callCount).to.equal(2);
expect((fs.readFile as SinonSpy).callCount).to.equal(4);
});
// it('should correctly load the configfs.json file', () => {
// expect(child_process.exec).to.be.calledWith('modprobe acpi_configfs');
// expect(child_process.exec).to.be.calledWith(
// 'cat test/data/boot/acpi-tables/spidev1,1.aml > test/data/sys/kernel/config/acpi/table/spidev1,1/aml',
// );
// expect((fs.exists as SinonSpy).callCount).to.equal(2);
// expect((fs.readFile as SinonSpy).callCount).to.equal(4);
// });
it('should correctly write the configfs.json file', async () => {
const current = {};
const target = {
HOST_CONFIGFS_ssdt: 'spidev1,1',
};
// it('should correctly write the configfs.json file', async () => {
// const current = {};
// const target = {
// HOST_CONFIGFS_ssdt: 'spidev1,1',
// };
(child_process.exec as SinonSpy).resetHistory();
(fs.exists as SinonSpy).resetHistory();
(fs.mkdir as SinonSpy).resetHistory();
(fs.readdir as SinonSpy).resetHistory();
(fs.readFile as SinonSpy).resetHistory();
// (child_process.exec as SinonSpy).resetHistory();
// (fs.exists as SinonSpy).resetHistory();
// (fs.mkdir as SinonSpy).resetHistory();
// (fs.readdir as SinonSpy).resetHistory();
// (fs.readFile as SinonSpy).resetHistory();
// @ts-ignore accessing private value
upboardConfig.bootConfigChangeRequired(
upboardConfigBackend,
current,
target,
);
// @ts-ignore accessing private value
await upboardConfig.setBootConfig(upboardConfigBackend, target);
// // @ts-ignore accessing private value
// upboardConfig.bootConfigChangeRequired(upboardConfigBackend, current, target);
// // @ts-ignore accessing private value
// await upboardConfig.setBootConfig(upboardConfigBackend, target);
expect(child_process.exec).to.be.calledOnce;
expect(fsUtils.writeFileAtomic).to.be.calledWith(
'test/data/mnt/boot/configfs.json',
JSON.stringify({
ssdt: ['spidev1,1'],
}),
);
expect(logSpy).to.be.calledTwice;
expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success');
});
});
// expect(child_process.exec).to.be.calledOnce;
// expect(fsUtils.writeFileAtomic).to.be.calledWith(
// 'test/data/mnt/boot/configfs.json',
// JSON.stringify({
// ssdt: ['spidev1,1'],
// }),
// );
// expect(logSpy).to.be.calledTwice;
// expect(logSpy.getCall(1).args[2]).to.equal('Apply boot config success');
// });
// });
// This will require stubbing device.reboot, gosuper.post, config.get/set
it('applies the target state');
// // This will require stubbing device.reboot, gosuper.post, config.get/set
// it('applies the target state');
});