mirror of
https://github.com/balena-os/balena-supervisor.git
synced 2025-06-06 01:21:39 +00:00
device-state: Convert to a singleton
Change-type: patch Signed-off-by: Rich Bayliss <rich@balena.io> Signed-off-by: Cameron Diver <cameron@balena.io>
This commit is contained in:
parent
d50f7791e1
commit
e3864915bc
@ -23,7 +23,7 @@ import * as request from './lib/request';
|
|||||||
|
|
||||||
import log from './lib/supervisor-console';
|
import log from './lib/supervisor-console';
|
||||||
|
|
||||||
import DeviceState from './device-state';
|
import * as deviceState from './device-state';
|
||||||
import * as globalEventBus from './event-bus';
|
import * as globalEventBus from './event-bus';
|
||||||
import * as TargetState from './device-state/target-state';
|
import * as TargetState from './device-state/target-state';
|
||||||
import * as logger from './logger';
|
import * as logger from './logger';
|
||||||
@ -53,7 +53,6 @@ interface DeviceTag {
|
|||||||
value: string;
|
value: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export let deviceState: DeviceState;
|
|
||||||
const lastReportedState: DeviceStatus = {
|
const lastReportedState: DeviceStatus = {
|
||||||
local: {},
|
local: {},
|
||||||
dependent: {},
|
dependent: {},
|
||||||
@ -66,10 +65,6 @@ let reportPending = false;
|
|||||||
export let stateReportErrors = 0;
|
export let stateReportErrors = 0;
|
||||||
let readyForUpdates = false;
|
let readyForUpdates = false;
|
||||||
|
|
||||||
export function setDeviceState(newState: DeviceState) {
|
|
||||||
deviceState = newState;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function healthcheck() {
|
export async function healthcheck() {
|
||||||
const {
|
const {
|
||||||
appUpdatePollInterval,
|
appUpdatePollInterval,
|
||||||
@ -179,7 +174,7 @@ export async function start() {
|
|||||||
// must wait for the provisioning because we need a
|
// must wait for the provisioning because we need a
|
||||||
// target state on which to apply the backup
|
// target state on which to apply the backup
|
||||||
globalEventBus.getInstance().once('targetStateChanged', async (state) => {
|
globalEventBus.getInstance().once('targetStateChanged', async (state) => {
|
||||||
await loadBackupFromMigration(deviceState, state, bootstrapRetryDelay);
|
await loadBackupFromMigration(state, bootstrapRetryDelay);
|
||||||
});
|
});
|
||||||
|
|
||||||
readyForUpdates = true;
|
readyForUpdates = true;
|
||||||
@ -718,6 +713,7 @@ export let balenaApi: PinejsClientRequest | null = null;
|
|||||||
export const initialized = (async () => {
|
export const initialized = (async () => {
|
||||||
await config.initialized;
|
await config.initialized;
|
||||||
await eventTracker.initialized;
|
await eventTracker.initialized;
|
||||||
|
await deviceState.initialized;
|
||||||
|
|
||||||
const { unmanaged, apiEndpoint, currentApiKey } = await config.getMany([
|
const { unmanaged, apiEndpoint, currentApiKey } = await config.getMany([
|
||||||
'unmanaged',
|
'unmanaged',
|
||||||
|
8
src/application-manager.d.ts
vendored
8
src/application-manager.d.ts
vendored
@ -7,9 +7,9 @@ import { ServiceAction } from './device-api/common';
|
|||||||
import { DeviceStatus, InstancedAppState } from './types/state';
|
import { DeviceStatus, InstancedAppState } from './types/state';
|
||||||
|
|
||||||
import type { Image } from './compose/images';
|
import type { Image } from './compose/images';
|
||||||
import DeviceState from './device-state';
|
import * as deviceState from './device-state';
|
||||||
|
import * as apiBinder from './api-binder';
|
||||||
|
|
||||||
import { APIBinder } from './api-binder';
|
|
||||||
import * as config from './config';
|
import * as config from './config';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@ -41,8 +41,6 @@ class ApplicationManager extends EventEmitter {
|
|||||||
// TODO: When the module which is/declares these fields is converted to
|
// TODO: When the module which is/declares these fields is converted to
|
||||||
// typecript, type the following
|
// typecript, type the following
|
||||||
public _lockingIfNecessary: any;
|
public _lockingIfNecessary: any;
|
||||||
public deviceState: DeviceState;
|
|
||||||
public apiBinder: APIBinder;
|
|
||||||
|
|
||||||
public proxyvisor: any;
|
public proxyvisor: any;
|
||||||
public timeSpentFetching: number;
|
public timeSpentFetching: number;
|
||||||
@ -52,7 +50,7 @@ class ApplicationManager extends EventEmitter {
|
|||||||
|
|
||||||
public router: Router;
|
public router: Router;
|
||||||
|
|
||||||
public constructor({ deviceState: DeviceState, apiBinder: APIBinder });
|
public constructor();
|
||||||
|
|
||||||
public init(): Promise<void>;
|
public init(): Promise<void>;
|
||||||
|
|
||||||
|
@ -34,6 +34,9 @@ import { createV1Api } from './device-api/v1';
|
|||||||
import { createV2Api } from './device-api/v2';
|
import { createV2Api } from './device-api/v2';
|
||||||
import { serviceAction } from './device-api/common';
|
import { serviceAction } from './device-api/common';
|
||||||
|
|
||||||
|
import * as deviceState from './device-state';
|
||||||
|
import * as apiBinder from './api-binder';
|
||||||
|
|
||||||
import * as db from './db';
|
import * as db from './db';
|
||||||
|
|
||||||
// TODO: move this to an Image class?
|
// TODO: move this to an Image class?
|
||||||
@ -70,7 +73,7 @@ const createApplicationManagerRouter = function (applications) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export class ApplicationManager extends EventEmitter {
|
export class ApplicationManager extends EventEmitter {
|
||||||
constructor({ deviceState, apiBinder }) {
|
constructor() {
|
||||||
super();
|
super();
|
||||||
|
|
||||||
this.serviceAction = serviceAction;
|
this.serviceAction = serviceAction;
|
||||||
@ -123,7 +126,6 @@ export class ApplicationManager extends EventEmitter {
|
|||||||
};
|
};
|
||||||
|
|
||||||
this.reportCurrentState = this.reportCurrentState.bind(this);
|
this.reportCurrentState = this.reportCurrentState.bind(this);
|
||||||
this.init = this.init.bind(this);
|
|
||||||
this.getStatus = this.getStatus.bind(this);
|
this.getStatus = this.getStatus.bind(this);
|
||||||
this.getDependentState = this.getDependentState.bind(this);
|
this.getDependentState = this.getDependentState.bind(this);
|
||||||
this.getCurrentForComparison = this.getCurrentForComparison.bind(this);
|
this.getCurrentForComparison = this.getCurrentForComparison.bind(this);
|
||||||
|
@ -3,6 +3,8 @@ import { NextFunction, Request, Response, Router } from 'express';
|
|||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
import { ApplicationManager } from '../application-manager';
|
import { ApplicationManager } from '../application-manager';
|
||||||
|
import * as deviceState from '../device-state';
|
||||||
|
import * as apiBinder from '../api-binder';
|
||||||
import { Service } from '../compose/service';
|
import { Service } from '../compose/service';
|
||||||
import Volume from '../compose/volume';
|
import Volume from '../compose/volume';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
@ -25,7 +27,7 @@ import { isVPNActive } from '../network';
|
|||||||
import { doPurge, doRestart, safeStateClone, serviceAction } from './common';
|
import { doPurge, doRestart, safeStateClone, serviceAction } from './common';
|
||||||
|
|
||||||
export function createV2Api(router: Router, applications: ApplicationManager) {
|
export function createV2Api(router: Router, applications: ApplicationManager) {
|
||||||
const { _lockingIfNecessary, deviceState } = applications;
|
const { _lockingIfNecessary } = applications;
|
||||||
|
|
||||||
const handleServiceAction = (
|
const handleServiceAction = (
|
||||||
req: Request,
|
req: Request,
|
||||||
@ -394,7 +396,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
|
|||||||
router.get('/v2/state/status', async (_req, res) => {
|
router.get('/v2/state/status', async (_req, res) => {
|
||||||
const currentRelease = await config.get('currentCommit');
|
const currentRelease = await config.get('currentCommit');
|
||||||
|
|
||||||
const pending = applications.deviceState.applyInProgress;
|
const pending = deviceState.isApplyInProgress();
|
||||||
const containerStates = (await serviceManager.getAll()).map((svc) =>
|
const containerStates = (await serviceManager.getAll()).map((svc) =>
|
||||||
_.pick(
|
_.pick(
|
||||||
svc,
|
svc,
|
||||||
@ -452,7 +454,7 @@ export function createV2Api(router: Router, applications: ApplicationManager) {
|
|||||||
|
|
||||||
router.get('/v2/device/tags', async (_req, res) => {
|
router.get('/v2/device/tags', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const tags = await applications.apiBinder.fetchDeviceTags();
|
const tags = await apiBinder.fetchDeviceTags();
|
||||||
return res.json({
|
return res.json({
|
||||||
status: 'success',
|
status: 'success',
|
||||||
tags,
|
tags,
|
||||||
|
@ -26,7 +26,6 @@ import * as updateLock from './lib/update-lock';
|
|||||||
import * as validation from './lib/validation';
|
import * as validation from './lib/validation';
|
||||||
import * as network from './network';
|
import * as network from './network';
|
||||||
|
|
||||||
import * as APIBinder from './api-binder';
|
|
||||||
import { ApplicationManager } from './application-manager';
|
import { ApplicationManager } from './application-manager';
|
||||||
import * as deviceConfig from './device-config';
|
import * as deviceConfig from './device-config';
|
||||||
import { ConfigStep } from './device-config';
|
import { ConfigStep } from './device-config';
|
||||||
@ -88,8 +87,8 @@ function validateState(state: any): asserts state is TargetState {
|
|||||||
|
|
||||||
// TODO (refactor): This shouldn't be here, and instead should be part of the other
|
// TODO (refactor): This shouldn't be here, and instead should be part of the other
|
||||||
// device api stuff in ./device-api
|
// device api stuff in ./device-api
|
||||||
function createDeviceStateRouter(deviceState: DeviceState) {
|
function createDeviceStateRouter() {
|
||||||
const router = express.Router();
|
router = express.Router();
|
||||||
router.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));
|
router.use(bodyParser.urlencoded({ limit: '10mb', extended: true }));
|
||||||
router.use(bodyParser.json({ limit: '10mb' }));
|
router.use(bodyParser.json({ limit: '10mb' }));
|
||||||
|
|
||||||
@ -101,10 +100,7 @@ function createDeviceStateRouter(deviceState: DeviceState) {
|
|||||||
const override = await config.get('lockOverride');
|
const override = await config.get('lockOverride');
|
||||||
const force = validation.checkTruthy(req.body.force) || override;
|
const force = validation.checkTruthy(req.body.force) || override;
|
||||||
try {
|
try {
|
||||||
const response = await deviceState.executeStepAction(
|
const response = await executeStepAction({ action }, { force });
|
||||||
{ action },
|
|
||||||
{ force },
|
|
||||||
);
|
|
||||||
res.status(202).json(response);
|
res.status(202).json(response);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
const status = e instanceof UpdatesLockedError ? 423 : 500;
|
const status = e instanceof UpdatesLockedError ? 423 : 500;
|
||||||
@ -139,7 +135,7 @@ function createDeviceStateRouter(deviceState: DeviceState) {
|
|||||||
|
|
||||||
router.get('/v1/device', async (_req, res) => {
|
router.get('/v1/device', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
const state = await deviceState.getStatus();
|
const state = await getStatus();
|
||||||
const stateToSend = _.pick(state.local, [
|
const stateToSend = _.pick(state.local, [
|
||||||
'api_port',
|
'api_port',
|
||||||
'ip_address',
|
'ip_address',
|
||||||
@ -173,14 +169,10 @@ function createDeviceStateRouter(deviceState: DeviceState) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
router.use(deviceState.applications.router);
|
router.use(applications.router);
|
||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface DeviceStateConstructOpts {
|
|
||||||
apiBinder: typeof APIBinder;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DeviceStateEvents {
|
interface DeviceStateEvents {
|
||||||
error: Error;
|
error: Error;
|
||||||
change: void;
|
change: void;
|
||||||
@ -203,6 +195,15 @@ type DeviceStateEventEmitter = StrictEventEmitter<
|
|||||||
EventEmitter,
|
EventEmitter,
|
||||||
DeviceStateEvents
|
DeviceStateEvents
|
||||||
>;
|
>;
|
||||||
|
const events = new EventEmitter() as DeviceStateEventEmitter;
|
||||||
|
export const on: typeof events['on'] = events.on.bind(events);
|
||||||
|
export const once: typeof events['once'] = events.once.bind(events);
|
||||||
|
export const removeListener: typeof events['removeListener'] = events.removeListener.bind(
|
||||||
|
events,
|
||||||
|
);
|
||||||
|
export const removeAllListeners: typeof events['removeAllListeners'] = events.removeAllListeners.bind(
|
||||||
|
events,
|
||||||
|
);
|
||||||
|
|
||||||
type DeviceStateStepTarget = 'reboot' | 'shutdown' | 'noop';
|
type DeviceStateStepTarget = 'reboot' | 'shutdown' | 'noop';
|
||||||
|
|
||||||
@ -216,36 +217,32 @@ type DeviceStateStep<T extends PossibleStepTargets> =
|
|||||||
| CompositionStep<T extends CompositionStepAction ? T : never>
|
| CompositionStep<T extends CompositionStepAction ? T : never>
|
||||||
| ConfigStep;
|
| ConfigStep;
|
||||||
|
|
||||||
export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmitter) {
|
// export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmitter) {
|
||||||
public applications: ApplicationManager;
|
export const applications = new ApplicationManager();
|
||||||
|
|
||||||
private currentVolatile: DeviceReportFields = {};
|
let currentVolatile: DeviceReportFields = {};
|
||||||
private writeLock = updateLock.writeLock;
|
const writeLock = updateLock.writeLock;
|
||||||
private readLock = updateLock.readLock;
|
const readLock = updateLock.readLock;
|
||||||
private cancelDelay: null | (() => void) = null;
|
let maxPollTime: number;
|
||||||
private maxPollTime: number;
|
let intermediateTarget: TargetState | null = null;
|
||||||
private intermediateTarget: TargetState | null = null;
|
let applyBlocker: Nullable<Promise<void>>;
|
||||||
private applyBlocker: Nullable<Promise<void>>;
|
let cancelDelay: null | (() => void) = null;
|
||||||
|
|
||||||
public lastSuccessfulUpdate: number | null = null;
|
let failedUpdates: number = 0;
|
||||||
public failedUpdates: number = 0;
|
let applyCancelled = false;
|
||||||
public applyInProgress = false;
|
let lastApplyStart = process.hrtime();
|
||||||
public applyCancelled = false;
|
let scheduledApply: { force?: boolean; delay?: number } | null = null;
|
||||||
public lastApplyStart = process.hrtime();
|
let shuttingDown = false;
|
||||||
public scheduledApply: { force?: boolean; delay?: number } | null = null;
|
|
||||||
public shuttingDown = false;
|
|
||||||
public connected: boolean;
|
|
||||||
public router: express.Router;
|
|
||||||
|
|
||||||
constructor({ apiBinder }: DeviceStateConstructOpts) {
|
let applyInProgress = false;
|
||||||
super();
|
export let connected: boolean;
|
||||||
this.applications = new ApplicationManager({
|
export let lastSuccessfulUpdate: number | null = null;
|
||||||
deviceState: this,
|
|
||||||
apiBinder,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.on('error', (err) => log.error('deviceState error: ', err));
|
export let router: express.Router;
|
||||||
this.on('apply-target-state-end', function (err) {
|
createDeviceStateRouter();
|
||||||
|
|
||||||
|
events.on('error', (err) => log.error('deviceState error: ', err));
|
||||||
|
events.on('apply-target-state-end', function (err) {
|
||||||
if (err != null) {
|
if (err != null) {
|
||||||
if (!(err instanceof UpdatesLockedError)) {
|
if (!(err instanceof UpdatesLockedError)) {
|
||||||
return log.error('Device state apply error', err);
|
return log.error('Device state apply error', err);
|
||||||
@ -258,11 +255,29 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
return deviceConfig.resetRateLimits();
|
return deviceConfig.resetRateLimits();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
this.applications.on('change', (d) => this.reportCurrentState(d));
|
applications.on('change', (d) => reportCurrentState(d));
|
||||||
this.router = createDeviceStateRouter(this);
|
|
||||||
|
export const initialized = (async () => {
|
||||||
|
await config.initialized;
|
||||||
|
|
||||||
|
config.on('change', (changedConfig) => {
|
||||||
|
if (changedConfig.loggingEnabled != null) {
|
||||||
|
logger.enable(changedConfig.loggingEnabled);
|
||||||
|
}
|
||||||
|
if (changedConfig.apiSecret != null) {
|
||||||
|
reportCurrentState({ api_secret: changedConfig.apiSecret });
|
||||||
|
}
|
||||||
|
if (changedConfig.appUpdatePollInterval != null) {
|
||||||
|
maxPollTime = changedConfig.appUpdatePollInterval;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
export function isApplyInProgress() {
|
||||||
|
return applyInProgress;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async healthcheck() {
|
export async function healthcheck() {
|
||||||
const unmanaged = await config.get('unmanaged');
|
const unmanaged = await config.get('unmanaged');
|
||||||
|
|
||||||
// Don't have to perform checks for unmanaged
|
// Don't have to perform checks for unmanaged
|
||||||
@ -270,44 +285,66 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
const cycleTime = process.hrtime(this.lastApplyStart);
|
const cycleTime = process.hrtime(lastApplyStart);
|
||||||
const cycleTimeMs = cycleTime[0] * 1000 + cycleTime[1] / 1e6;
|
const cycleTimeMs = cycleTime[0] * 1000 + cycleTime[1] / 1e6;
|
||||||
const cycleTimeWithinInterval =
|
const cycleTimeWithinInterval =
|
||||||
cycleTimeMs - this.applications.timeSpentFetching < 2 * this.maxPollTime;
|
cycleTimeMs - applications.timeSpentFetching < 2 * maxPollTime;
|
||||||
|
|
||||||
// Check if target is healthy
|
// Check if target is healthy
|
||||||
const applyTargetHealthy =
|
const applyTargetHealthy =
|
||||||
!this.applyInProgress ||
|
!applyInProgress ||
|
||||||
this.applications.fetchesInProgress > 0 ||
|
applications.fetchesInProgress > 0 ||
|
||||||
cycleTimeWithinInterval;
|
cycleTimeWithinInterval;
|
||||||
|
|
||||||
if (!applyTargetHealthy) {
|
if (!applyTargetHealthy) {
|
||||||
log.info(
|
log.info(
|
||||||
stripIndent`
|
stripIndent`
|
||||||
Healthcheck failure - Atleast ONE of the following conditions must be true:
|
Healthcheck failure - Atleast ONE of the following conditions must be true:
|
||||||
- No applyInProgress ? ${!(this.applyInProgress === true)}
|
- No applyInProgress ? ${!(applyInProgress === true)}
|
||||||
- fetchesInProgress ? ${this.applications.fetchesInProgress > 0}
|
- fetchesInProgress ? ${applications.fetchesInProgress > 0}
|
||||||
- cycleTimeWithinInterval ? ${cycleTimeWithinInterval}`,
|
- cycleTimeWithinInterval ? ${cycleTimeWithinInterval}`,
|
||||||
);
|
);
|
||||||
return false;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// All tests pass!
|
// All tests pass!
|
||||||
return true;
|
return applyTargetHealthy;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async init() {
|
export async function initNetworkChecks({
|
||||||
config.on('change', (changedConfig) => {
|
apiEndpoint,
|
||||||
if (changedConfig.loggingEnabled != null) {
|
connectivityCheckEnabled,
|
||||||
logger.enable(changedConfig.loggingEnabled);
|
}: {
|
||||||
}
|
apiEndpoint: config.ConfigType<'apiEndpoint'>;
|
||||||
if (changedConfig.apiSecret != null) {
|
connectivityCheckEnabled: config.ConfigType<'connectivityCheckEnabled'>;
|
||||||
this.reportCurrentState({ api_secret: changedConfig.apiSecret });
|
}) {
|
||||||
}
|
network.startConnectivityCheck(apiEndpoint, connectivityCheckEnabled, (c) => {
|
||||||
if (changedConfig.appUpdatePollInterval != null) {
|
connected = c;
|
||||||
this.maxPollTime = changedConfig.appUpdatePollInterval;
|
});
|
||||||
|
config.on('change', function (changedConfig) {
|
||||||
|
if (changedConfig.connectivityCheckEnabled != null) {
|
||||||
|
network.enableConnectivityCheck(changedConfig.connectivityCheckEnabled);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
log.debug('Starting periodic check for IP addresses');
|
||||||
|
|
||||||
|
await network.startIPAddressUpdate()(async (addresses) => {
|
||||||
|
const macAddress = await config.get('macAddress');
|
||||||
|
reportCurrentState({
|
||||||
|
ip_address: addresses.join(' '),
|
||||||
|
mac_address: macAddress,
|
||||||
|
});
|
||||||
|
}, constants.ipAddressUpdateInterval);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveInitialConfig() {
|
||||||
|
const devConf = await deviceConfig.getCurrent();
|
||||||
|
|
||||||
|
await deviceConfig.setTarget(devConf);
|
||||||
|
await config.set({ initialConfigSaved: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function loadInitialState() {
|
||||||
|
await applications.init();
|
||||||
|
|
||||||
const conf = await config.getMany([
|
const conf = await config.getMany([
|
||||||
'initialConfigSaved',
|
'initialConfigSaved',
|
||||||
@ -325,17 +362,16 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
'unmanaged',
|
'unmanaged',
|
||||||
'appUpdatePollInterval',
|
'appUpdatePollInterval',
|
||||||
]);
|
]);
|
||||||
this.maxPollTime = conf.appUpdatePollInterval;
|
maxPollTime = conf.appUpdatePollInterval;
|
||||||
|
|
||||||
|
initNetworkChecks(conf);
|
||||||
|
|
||||||
await this.applications.init();
|
|
||||||
if (!conf.initialConfigSaved) {
|
if (!conf.initialConfigSaved) {
|
||||||
await this.saveInitialConfig();
|
await saveInitialConfig();
|
||||||
}
|
}
|
||||||
|
|
||||||
this.initNetworkChecks(conf);
|
|
||||||
|
|
||||||
log.info('Reporting initial state, supervisor version and API info');
|
log.info('Reporting initial state, supervisor version and API info');
|
||||||
await this.reportCurrentState({
|
reportCurrentState({
|
||||||
api_port: conf.listenPort,
|
api_port: conf.listenPort,
|
||||||
api_secret: conf.apiSecret,
|
api_secret: conf.apiSecret,
|
||||||
os_version: conf.osVersion,
|
os_version: conf.osVersion,
|
||||||
@ -351,10 +387,10 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
update_downloaded: false,
|
update_downloaded: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
const targetApps = await this.applications.getTargetApps();
|
const targetApps = await applications.getTargetApps();
|
||||||
if (!conf.provisioned || (_.isEmpty(targetApps) && !conf.targetStateSet)) {
|
if (!conf.provisioned || (_.isEmpty(targetApps) && !conf.targetStateSet)) {
|
||||||
try {
|
try {
|
||||||
await loadTargetFromFile(null, this);
|
await loadTargetFromFile(null);
|
||||||
} finally {
|
} finally {
|
||||||
await config.set({ targetStateSet: true });
|
await config.set({ targetStateSet: true });
|
||||||
}
|
}
|
||||||
@ -368,87 +404,51 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
await config.set({ targetStateSet: true });
|
await config.set({ targetStateSet: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
await this.triggerApplyTarget({ initial: true });
|
triggerApplyTarget({ initial: true });
|
||||||
}
|
|
||||||
|
|
||||||
public async initNetworkChecks({
|
|
||||||
apiEndpoint,
|
|
||||||
connectivityCheckEnabled,
|
|
||||||
}: {
|
|
||||||
apiEndpoint: config.ConfigType<'apiEndpoint'>;
|
|
||||||
connectivityCheckEnabled: config.ConfigType<'connectivityCheckEnabled'>;
|
|
||||||
}) {
|
|
||||||
network.startConnectivityCheck(
|
|
||||||
apiEndpoint,
|
|
||||||
connectivityCheckEnabled,
|
|
||||||
(connected) => {
|
|
||||||
return (this.connected = connected);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
config.on('change', function (changedConfig) {
|
|
||||||
if (changedConfig.connectivityCheckEnabled != null) {
|
|
||||||
return network.enableConnectivityCheck(
|
|
||||||
changedConfig.connectivityCheckEnabled,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
log.debug('Starting periodic check for IP addresses');
|
|
||||||
|
|
||||||
await network.startIPAddressUpdate()(async (addresses) => {
|
|
||||||
const macAddress = await config.get('macAddress');
|
|
||||||
await this.reportCurrentState({
|
|
||||||
ip_address: addresses.join(' '),
|
|
||||||
mac_address: macAddress,
|
|
||||||
});
|
|
||||||
}, constants.ipAddressUpdateInterval);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async saveInitialConfig() {
|
|
||||||
const devConf = await deviceConfig.getCurrent();
|
|
||||||
|
|
||||||
await deviceConfig.setTarget(devConf);
|
|
||||||
await config.set({ initialConfigSaved: true });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// We keep compatibility with the StrictEventEmitter types
|
// We keep compatibility with the StrictEventEmitter types
|
||||||
// from the outside, but within this function all hells
|
// from the outside, but within this function all hells
|
||||||
// breaks loose due to the liberal any casting
|
// breaks loose due to the liberal any casting
|
||||||
private emitAsync<T extends keyof DeviceStateEvents>(
|
function emitAsync<T extends keyof DeviceStateEvents>(
|
||||||
ev: T,
|
ev: T,
|
||||||
...args: DeviceStateEvents[T] extends (...args: any) => void
|
...args: DeviceStateEvents[T] extends (...args: any) => void
|
||||||
? Parameters<DeviceStateEvents[T]>
|
? Parameters<DeviceStateEvents[T]>
|
||||||
: Array<DeviceStateEvents[T]>
|
: Array<DeviceStateEvents[T]>
|
||||||
) {
|
) {
|
||||||
if (_.isArray(args)) {
|
if (_.isArray(args)) {
|
||||||
return setImmediate(() => this.emit(ev as any, ...args));
|
return setImmediate(() => events.emit(ev as any, ...args));
|
||||||
} else {
|
} else {
|
||||||
return setImmediate(() => this.emit(ev as any, args));
|
return setImmediate(() => events.emit(ev as any, args));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private readLockTarget = () =>
|
const readLockTarget = () =>
|
||||||
this.readLock('target').disposer((release) => release());
|
readLock('target').disposer((release) => release());
|
||||||
private writeLockTarget = () =>
|
const writeLockTarget = () =>
|
||||||
this.writeLock('target').disposer((release) => release());
|
writeLock('target').disposer((release) => release());
|
||||||
private inferStepsLock = () =>
|
const inferStepsLock = () =>
|
||||||
this.writeLock('inferSteps').disposer((release) => release());
|
writeLock('inferSteps').disposer((release) => release());
|
||||||
private usingReadLockTarget(fn: () => any) {
|
function usingReadLockTarget(fn: () => any) {
|
||||||
return Bluebird.using(this.readLockTarget, () => fn());
|
return Bluebird.using(readLockTarget, () => fn());
|
||||||
}
|
}
|
||||||
private usingWriteLockTarget(fn: () => any) {
|
function usingWriteLockTarget(fn: () => any) {
|
||||||
return Bluebird.using(this.writeLockTarget, () => fn());
|
return Bluebird.using(writeLockTarget, () => fn());
|
||||||
}
|
}
|
||||||
private usingInferStepsLock(fn: () => any) {
|
function usingInferStepsLock(fn: () => any) {
|
||||||
return Bluebird.using(this.inferStepsLock, () => fn());
|
return Bluebird.using(inferStepsLock, () => fn());
|
||||||
}
|
}
|
||||||
|
|
||||||
public async setTarget(target: TargetState, localSource?: boolean) {
|
export async function setTarget(target: TargetState, localSource?: boolean) {
|
||||||
|
await db.initialized;
|
||||||
|
await config.initialized;
|
||||||
|
|
||||||
// When we get a new target state, clear any built up apply errors
|
// When we get a new target state, clear any built up apply errors
|
||||||
// This means that we can attempt to apply the new state instantly
|
// This means that we can attempt to apply the new state instantly
|
||||||
if (localSource == null) {
|
if (localSource == null) {
|
||||||
localSource = false;
|
localSource = false;
|
||||||
}
|
}
|
||||||
this.failedUpdates = 0;
|
failedUpdates = 0;
|
||||||
|
|
||||||
validateState(target);
|
validateState(target);
|
||||||
|
|
||||||
@ -456,20 +456,20 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
|
|
||||||
const apiEndpoint = await config.get('apiEndpoint');
|
const apiEndpoint = await config.get('apiEndpoint');
|
||||||
|
|
||||||
await this.usingWriteLockTarget(async () => {
|
await usingWriteLockTarget(async () => {
|
||||||
await db.transaction(async (trx) => {
|
await db.transaction(async (trx) => {
|
||||||
await config.set({ name: target.local.name }, trx);
|
await config.set({ name: target.local.name }, trx);
|
||||||
await deviceConfig.setTarget(target.local.config, trx);
|
await deviceConfig.setTarget(target.local.config, trx);
|
||||||
|
|
||||||
if (localSource || apiEndpoint == null) {
|
if (localSource || apiEndpoint == null) {
|
||||||
await this.applications.setTarget(
|
await applications.setTarget(
|
||||||
target.local.apps,
|
target.local.apps,
|
||||||
target.dependent,
|
target.dependent,
|
||||||
'local',
|
'local',
|
||||||
trx,
|
trx,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
await this.applications.setTarget(
|
await applications.setTarget(
|
||||||
target.local.apps,
|
target.local.apps,
|
||||||
target.dependent,
|
target.dependent,
|
||||||
apiEndpoint,
|
apiEndpoint,
|
||||||
@ -480,52 +480,52 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
public getTarget({
|
export function getTarget({
|
||||||
initial = false,
|
initial = false,
|
||||||
intermediate = false,
|
intermediate = false,
|
||||||
}: { initial?: boolean; intermediate?: boolean } = {}): Bluebird<
|
}: { initial?: boolean; intermediate?: boolean } = {}): Bluebird<
|
||||||
InstancedDeviceState
|
InstancedDeviceState
|
||||||
> {
|
> {
|
||||||
return this.usingReadLockTarget(async () => {
|
return usingReadLockTarget(async () => {
|
||||||
if (intermediate) {
|
if (intermediate) {
|
||||||
return this.intermediateTarget;
|
return intermediateTarget;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
local: {
|
local: {
|
||||||
name: await config.get('name'),
|
name: await config.get('name'),
|
||||||
config: await deviceConfig.getTarget({ initial }),
|
config: await deviceConfig.getTarget({ initial }),
|
||||||
apps: await this.applications.getTargetApps(),
|
apps: await applications.getTargetApps(),
|
||||||
},
|
},
|
||||||
dependent: await this.applications.getDependentTargets(),
|
dependent: await applications.getDependentTargets(),
|
||||||
};
|
};
|
||||||
}) as Bluebird<InstancedDeviceState>;
|
}) as Bluebird<InstancedDeviceState>;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getStatus(): Promise<DeviceStatus> {
|
export async function getStatus(): Promise<DeviceStatus> {
|
||||||
const appsStatus = await this.applications.getStatus();
|
const appsStatus = await applications.getStatus();
|
||||||
const theState: DeepPartial<DeviceStatus> = {
|
const theState: DeepPartial<DeviceStatus> = {
|
||||||
local: {},
|
local: {},
|
||||||
dependent: {},
|
dependent: {},
|
||||||
};
|
};
|
||||||
theState.local = { ...theState.local, ...this.currentVolatile };
|
theState.local = { ...theState.local, ...currentVolatile };
|
||||||
theState.local.apps = appsStatus.local;
|
theState.local!.apps = appsStatus.local;
|
||||||
theState.dependent!.apps = appsStatus.dependent;
|
theState.dependent!.apps = appsStatus.dependent;
|
||||||
if (appsStatus.commit && !this.applyInProgress) {
|
if (appsStatus.commit && !applyInProgress) {
|
||||||
theState.local.is_on__commit = appsStatus.commit;
|
theState.local!.is_on__commit = appsStatus.commit;
|
||||||
}
|
}
|
||||||
|
|
||||||
return theState as DeviceStatus;
|
return theState as DeviceStatus;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getCurrentForComparison(): Promise<
|
export async function getCurrentForComparison(): Promise<
|
||||||
DeviceStatus & { local: { name: string } }
|
DeviceStatus & { local: { name: string } }
|
||||||
> {
|
> {
|
||||||
const [name, devConfig, apps, dependent] = await Promise.all([
|
const [name, devConfig, apps, dependent] = await Promise.all([
|
||||||
config.get('name'),
|
config.get('name'),
|
||||||
deviceConfig.getCurrent(),
|
deviceConfig.getCurrent(),
|
||||||
this.applications.getCurrentForComparison(),
|
applications.getCurrentForComparison(),
|
||||||
this.applications.getDependentState(),
|
applications.getDependentState(),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
local: {
|
local: {
|
||||||
@ -538,33 +538,33 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
public reportCurrentState(newState: DeviceReportFields = {}) {
|
export function reportCurrentState(newState: DeviceReportFields = {}) {
|
||||||
if (newState == null) {
|
if (newState == null) {
|
||||||
newState = {};
|
newState = {};
|
||||||
}
|
}
|
||||||
this.currentVolatile = { ...this.currentVolatile, ...newState };
|
currentVolatile = { ...currentVolatile, ...newState };
|
||||||
return this.emitAsync('change', undefined);
|
emitAsync('change', undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async reboot(force?: boolean, skipLock?: boolean) {
|
export async function reboot(force?: boolean, skipLock?: boolean) {
|
||||||
await this.applications.stopAll({ force, skipLock });
|
await applications.stopAll({ force, skipLock });
|
||||||
logger.logSystemMessage('Rebooting', {}, 'Reboot');
|
logger.logSystemMessage('Rebooting', {}, 'Reboot');
|
||||||
const reboot = await dbus.reboot();
|
const $reboot = await dbus.reboot();
|
||||||
this.shuttingDown = true;
|
shuttingDown = true;
|
||||||
this.emitAsync('shutdown', undefined);
|
emitAsync('shutdown', undefined);
|
||||||
return reboot;
|
return await $reboot;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async shutdown(force?: boolean, skipLock?: boolean) {
|
export async function shutdown(force?: boolean, skipLock?: boolean) {
|
||||||
await this.applications.stopAll({ force, skipLock });
|
await applications.stopAll({ force, skipLock });
|
||||||
logger.logSystemMessage('Shutting down', {}, 'Shutdown');
|
logger.logSystemMessage('Shutting down', {}, 'Shutdown');
|
||||||
const shutdown = await dbus.shutdown();
|
const $shutdown = await dbus.shutdown();
|
||||||
this.shuttingDown = true;
|
shuttingDown = true;
|
||||||
this.emitAsync('shutdown', undefined);
|
emitAsync('shutdown', undefined);
|
||||||
return shutdown;
|
return $shutdown;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async executeStepAction<T extends PossibleStepTargets>(
|
export async function executeStepAction<T extends PossibleStepTargets>(
|
||||||
step: DeviceStateStep<T>,
|
step: DeviceStateStep<T>,
|
||||||
{
|
{
|
||||||
force,
|
force,
|
||||||
@ -576,8 +576,8 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
await deviceConfig.executeStepAction(step as ConfigStep, {
|
await deviceConfig.executeStepAction(step as ConfigStep, {
|
||||||
initial,
|
initial,
|
||||||
});
|
});
|
||||||
} else if (_.includes(this.applications.validActions, step.action)) {
|
} else if (_.includes(applications.validActions, step.action)) {
|
||||||
return this.applications.executeStepAction(step as any, {
|
return applications.executeStepAction(step as any, {
|
||||||
force,
|
force,
|
||||||
skipLock,
|
skipLock,
|
||||||
});
|
});
|
||||||
@ -588,13 +588,13 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
// and if they do, we wouldn't know about it until after
|
// and if they do, we wouldn't know about it until after
|
||||||
// the response has been sent back to the API. Just return
|
// the response has been sent back to the API. Just return
|
||||||
// "OK" for this and the below action
|
// "OK" for this and the below action
|
||||||
await this.reboot(force, skipLock);
|
await reboot(force, skipLock);
|
||||||
return {
|
return {
|
||||||
Data: 'OK',
|
Data: 'OK',
|
||||||
Error: null,
|
Error: null,
|
||||||
};
|
};
|
||||||
case 'shutdown':
|
case 'shutdown':
|
||||||
await this.shutdown(force, skipLock);
|
await shutdown(force, skipLock);
|
||||||
return {
|
return {
|
||||||
Data: 'OK',
|
Data: 'OK',
|
||||||
Error: null,
|
Error: null,
|
||||||
@ -607,7 +607,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async applyStep<T extends PossibleStepTargets>(
|
export async function applyStep<T extends PossibleStepTargets>(
|
||||||
step: DeviceStateStep<T>,
|
step: DeviceStateStep<T>,
|
||||||
{
|
{
|
||||||
force,
|
force,
|
||||||
@ -619,23 +619,23 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
skipLock?: boolean;
|
skipLock?: boolean;
|
||||||
},
|
},
|
||||||
) {
|
) {
|
||||||
if (this.shuttingDown) {
|
if (shuttingDown) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const stepResult = await this.executeStepAction(step, {
|
const stepResult = await executeStepAction(step, {
|
||||||
force,
|
force,
|
||||||
initial,
|
initial,
|
||||||
skipLock,
|
skipLock,
|
||||||
});
|
});
|
||||||
this.emitAsync('step-completed', null, step, stepResult || undefined);
|
emitAsync('step-completed', null, step, stepResult || undefined);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.emitAsync('step-error', e, step);
|
emitAsync('step-error', e, step);
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyError(
|
function applyError(
|
||||||
err: Error,
|
err: Error,
|
||||||
{
|
{
|
||||||
force,
|
force,
|
||||||
@ -643,14 +643,14 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
intermediate,
|
intermediate,
|
||||||
}: { force?: boolean; initial?: boolean; intermediate?: boolean },
|
}: { force?: boolean; initial?: boolean; intermediate?: boolean },
|
||||||
) {
|
) {
|
||||||
this.emitAsync('apply-target-state-error', err);
|
emitAsync('apply-target-state-error', err);
|
||||||
this.emitAsync('apply-target-state-end', err);
|
emitAsync('apply-target-state-end', err);
|
||||||
if (intermediate) {
|
if (intermediate) {
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
this.failedUpdates += 1;
|
failedUpdates += 1;
|
||||||
this.reportCurrentState({ update_failed: true });
|
reportCurrentState({ update_failed: true });
|
||||||
if (this.scheduledApply != null) {
|
if (scheduledApply != null) {
|
||||||
if (!(err instanceof UpdatesLockedError)) {
|
if (!(err instanceof UpdatesLockedError)) {
|
||||||
log.error(
|
log.error(
|
||||||
"Updating failed, but there's another update scheduled immediately: ",
|
"Updating failed, but there's another update scheduled immediately: ",
|
||||||
@ -659,8 +659,8 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const delay = Math.min(
|
const delay = Math.min(
|
||||||
Math.pow(2, this.failedUpdates) * constants.backoffIncrement,
|
Math.pow(2, failedUpdates) * constants.backoffIncrement,
|
||||||
this.maxPollTime,
|
maxPollTime,
|
||||||
);
|
);
|
||||||
// If there was an error then schedule another attempt briefly in the future.
|
// If there was an error then schedule another attempt briefly in the future.
|
||||||
if (err instanceof UpdatesLockedError) {
|
if (err instanceof UpdatesLockedError) {
|
||||||
@ -675,29 +675,30 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
err,
|
err,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return this.triggerApplyTarget({ force, delay, initial });
|
return triggerApplyTarget({ force, delay, initial });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public async applyTarget({
|
// We define this function this way so we can mock it in the tests
|
||||||
|
export const applyTarget = async ({
|
||||||
force = false,
|
force = false,
|
||||||
initial = false,
|
initial = false,
|
||||||
intermediate = false,
|
intermediate = false,
|
||||||
skipLock = false,
|
skipLock = false,
|
||||||
nextDelay = 200,
|
nextDelay = 200,
|
||||||
retryCount = 0,
|
retryCount = 0,
|
||||||
} = {}) {
|
} = {}) => {
|
||||||
if (!intermediate) {
|
if (!intermediate) {
|
||||||
await this.applyBlocker;
|
await applyBlocker;
|
||||||
}
|
}
|
||||||
await this.applications.localModeSwitchCompletion();
|
await applications.localModeSwitchCompletion();
|
||||||
|
|
||||||
return this.usingInferStepsLock(async () => {
|
return usingInferStepsLock(async () => {
|
||||||
const [currentState, targetState] = await Promise.all([
|
const [currentState, targetState] = await Promise.all([
|
||||||
this.getCurrentForComparison(),
|
getCurrentForComparison(),
|
||||||
this.getTarget({ initial, intermediate }),
|
getTarget({ initial, intermediate }),
|
||||||
]);
|
]);
|
||||||
const extraState = await this.applications.getExtraStateForComparison(
|
const extraState = await applications.getExtraStateForComparison(
|
||||||
currentState,
|
currentState,
|
||||||
targetState,
|
targetState,
|
||||||
);
|
);
|
||||||
@ -717,7 +718,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
backoff = false;
|
backoff = false;
|
||||||
steps = deviceConfigSteps;
|
steps = deviceConfigSteps;
|
||||||
} else {
|
} else {
|
||||||
const appSteps = await this.applications.getRequiredSteps(
|
const appSteps = await applications.getRequiredSteps(
|
||||||
currentState,
|
currentState,
|
||||||
targetState,
|
targetState,
|
||||||
extraState,
|
extraState,
|
||||||
@ -738,13 +739,13 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (_.isEmpty(steps)) {
|
if (_.isEmpty(steps)) {
|
||||||
this.emitAsync('apply-target-state-end', null);
|
emitAsync('apply-target-state-end', null);
|
||||||
if (!intermediate) {
|
if (!intermediate) {
|
||||||
log.debug('Finished applying target state');
|
log.debug('Finished applying target state');
|
||||||
this.applications.timeSpentFetching = 0;
|
applications.timeSpentFetching = 0;
|
||||||
this.failedUpdates = 0;
|
failedUpdates = 0;
|
||||||
this.lastSuccessfulUpdate = Date.now();
|
lastSuccessfulUpdate = Date.now();
|
||||||
this.reportCurrentState({
|
reportCurrentState({
|
||||||
update_failed: false,
|
update_failed: false,
|
||||||
update_pending: false,
|
update_pending: false,
|
||||||
update_downloaded: false,
|
update_downloaded: false,
|
||||||
@ -754,7 +755,7 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!intermediate) {
|
if (!intermediate) {
|
||||||
this.reportCurrentState({ update_pending: true });
|
reportCurrentState({ update_pending: true });
|
||||||
}
|
}
|
||||||
if (_.every(steps, (step) => step.action === 'noop')) {
|
if (_.every(steps, (step) => step.action === 'noop')) {
|
||||||
if (backoff) {
|
if (backoff) {
|
||||||
@ -768,11 +769,11 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
steps.map((s) => this.applyStep(s, { force, initial, skipLock })),
|
steps.map((s) => applyStep(s, { force, initial, skipLock })),
|
||||||
);
|
);
|
||||||
|
|
||||||
await Bluebird.delay(nextDelay);
|
await Bluebird.delay(nextDelay);
|
||||||
await this.applyTarget({
|
await applyTarget({
|
||||||
force,
|
force,
|
||||||
initial,
|
initial,
|
||||||
intermediate,
|
intermediate,
|
||||||
@ -793,19 +794,19 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}).catch((err) => {
|
}).catch((err) => {
|
||||||
return this.applyError(err, { force, initial, intermediate });
|
return applyError(err, { force, initial, intermediate });
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
|
|
||||||
public pausingApply(fn: () => any) {
|
export function pausingApply(fn: () => any) {
|
||||||
const lock = () => {
|
const lock = () => {
|
||||||
return this.writeLock('pause').disposer((release) => release());
|
return writeLock('pause').disposer((release) => release());
|
||||||
};
|
};
|
||||||
// TODO: This function is a bit of a mess
|
// TODO: This function is a bit of a mess
|
||||||
const pause = () => {
|
const pause = () => {
|
||||||
return Bluebird.try(() => {
|
return Bluebird.try(() => {
|
||||||
let res;
|
let res;
|
||||||
this.applyBlocker = new Promise((resolve) => {
|
applyBlocker = new Promise((resolve) => {
|
||||||
res = resolve;
|
res = resolve;
|
||||||
});
|
});
|
||||||
return res;
|
return res;
|
||||||
@ -815,78 +816,73 @@ export class DeviceState extends (EventEmitter as new () => DeviceStateEventEmit
|
|||||||
return Bluebird.using(lock(), () => Bluebird.using(pause(), () => fn()));
|
return Bluebird.using(lock(), () => Bluebird.using(pause(), () => fn()));
|
||||||
}
|
}
|
||||||
|
|
||||||
public triggerApplyTarget({
|
export function triggerApplyTarget({
|
||||||
force = false,
|
force = false,
|
||||||
delay = 0,
|
delay = 0,
|
||||||
initial = false,
|
initial = false,
|
||||||
isFromApi = false,
|
isFromApi = false,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
if (this.applyInProgress) {
|
if (applyInProgress) {
|
||||||
if (this.scheduledApply == null || (isFromApi && this.cancelDelay)) {
|
if (scheduledApply == null || (isFromApi && cancelDelay)) {
|
||||||
this.scheduledApply = { force, delay };
|
scheduledApply = { force, delay };
|
||||||
if (isFromApi) {
|
if (isFromApi) {
|
||||||
// Cancel promise delay if call came from api to
|
// Cancel promise delay if call came from api to
|
||||||
// prevent waiting due to backoff (and if we've
|
// prevent waiting due to backoff (and if we've
|
||||||
// previously setup a delay)
|
// previously setup a delay)
|
||||||
this.cancelDelay?.();
|
cancelDelay?.();
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If a delay has been set it's because we need to hold off before applying again,
|
// If a delay has been set it's because we need to hold off before applying again,
|
||||||
// so we need to respect the maximum delay that has
|
// so we need to respect the maximum delay that has
|
||||||
// been passed
|
// been passed
|
||||||
if (!this.scheduledApply.delay) {
|
if (!scheduledApply.delay) {
|
||||||
throw new InternalInconsistencyError(
|
throw new InternalInconsistencyError(
|
||||||
'No delay specified in scheduledApply',
|
'No delay specified in scheduledApply',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
this.scheduledApply.delay = Math.max(delay, this.scheduledApply.delay);
|
scheduledApply.delay = Math.max(delay, scheduledApply.delay);
|
||||||
if (!this.scheduledApply.force) {
|
if (!scheduledApply.force) {
|
||||||
this.scheduledApply.force = force;
|
scheduledApply.force = force;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.applyCancelled = false;
|
applyCancelled = false;
|
||||||
this.applyInProgress = true;
|
applyInProgress = true;
|
||||||
new Bluebird((resolve, reject) => {
|
new Bluebird((resolve, reject) => {
|
||||||
setTimeout(resolve, delay);
|
setTimeout(resolve, delay);
|
||||||
this.cancelDelay = reject;
|
cancelDelay = reject;
|
||||||
})
|
})
|
||||||
.catch(() => {
|
.catch(() => {
|
||||||
this.applyCancelled = true;
|
applyCancelled = true;
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
this.cancelDelay = null;
|
cancelDelay = null;
|
||||||
if (this.applyCancelled) {
|
if (applyCancelled) {
|
||||||
log.info('Skipping applyTarget because of a cancellation');
|
log.info('Skipping applyTarget because of a cancellation');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
this.lastApplyStart = process.hrtime();
|
lastApplyStart = process.hrtime();
|
||||||
log.info('Applying target state');
|
log.info('Applying target state');
|
||||||
return this.applyTarget({ force, initial });
|
return applyTarget({ force, initial });
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
this.applyInProgress = false;
|
applyInProgress = false;
|
||||||
this.reportCurrentState();
|
reportCurrentState();
|
||||||
if (this.scheduledApply != null) {
|
if (scheduledApply != null) {
|
||||||
this.triggerApplyTarget(this.scheduledApply);
|
triggerApplyTarget(scheduledApply);
|
||||||
this.scheduledApply = null;
|
scheduledApply = null;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
public applyIntermediateTarget(
|
export function applyIntermediateTarget(
|
||||||
intermediateTarget: TargetState,
|
intermediate: TargetState,
|
||||||
{ force = false, skipLock = false } = {},
|
{ force = false, skipLock = false } = {},
|
||||||
) {
|
) {
|
||||||
this.intermediateTarget = _.cloneDeep(intermediateTarget);
|
intermediateTarget = _.cloneDeep(intermediate);
|
||||||
return this.applyTarget({ intermediate: true, force, skipLock }).then(
|
return applyTarget({ intermediate: true, force, skipLock }).then(() => {
|
||||||
() => {
|
intermediateTarget = null;
|
||||||
this.intermediateTarget = null;
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export default DeviceState;
|
|
||||||
|
@ -7,7 +7,7 @@ import { DeviceStatus } from '../types/state';
|
|||||||
import { getRequestInstance } from '../lib/request';
|
import { getRequestInstance } from '../lib/request';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import * as eventTracker from '../event-tracker';
|
import * as eventTracker from '../event-tracker';
|
||||||
import DeviceState from '../device-state';
|
import * as deviceState from '../device-state';
|
||||||
import { CoreOptions } from 'request';
|
import { CoreOptions } from 'request';
|
||||||
import * as url from 'url';
|
import * as url from 'url';
|
||||||
|
|
||||||
@ -22,7 +22,6 @@ const INTERNAL_STATE_KEYS = [
|
|||||||
'update_failed',
|
'update_failed',
|
||||||
];
|
];
|
||||||
|
|
||||||
let deviceState: DeviceState;
|
|
||||||
export let stateReportErrors = 0;
|
export let stateReportErrors = 0;
|
||||||
const lastReportedState: DeviceStatus = {
|
const lastReportedState: DeviceStatus = {
|
||||||
local: {},
|
local: {},
|
||||||
@ -243,9 +242,7 @@ const reportCurrentState = (): null => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// TODO: Remove the passing in of deviceState once it's a singleton
|
export const startReporting = () => {
|
||||||
export const startReporting = ($deviceState: typeof deviceState) => {
|
|
||||||
deviceState = $deviceState;
|
|
||||||
deviceState.on('change', () => {
|
deviceState.on('change', () => {
|
||||||
if (!reportPending) {
|
if (!reportPending) {
|
||||||
// A latency of 100ms should be acceptable and
|
// A latency of 100ms should be acceptable and
|
||||||
|
@ -2,7 +2,7 @@ import * as _ from 'lodash';
|
|||||||
import { fs } from 'mz';
|
import { fs } from 'mz';
|
||||||
|
|
||||||
import { Image } from '../compose/images';
|
import { Image } from '../compose/images';
|
||||||
import DeviceState from '../device-state';
|
import * as deviceState from '../device-state';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import * as deviceConfig from '../device-config';
|
import * as deviceConfig from '../device-config';
|
||||||
import * as eventTracker from '../event-tracker';
|
import * as eventTracker from '../event-tracker';
|
||||||
@ -17,7 +17,6 @@ import { AppsJsonFormat } from '../types/state';
|
|||||||
|
|
||||||
export async function loadTargetFromFile(
|
export async function loadTargetFromFile(
|
||||||
appsPath: Nullable<string>,
|
appsPath: Nullable<string>,
|
||||||
deviceState: DeviceState,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
log.info('Attempting to load any preloaded applications');
|
log.info('Attempting to load any preloaded applications');
|
||||||
if (!appsPath) {
|
if (!appsPath) {
|
||||||
@ -88,7 +87,6 @@ export async function loadTargetFromFile(
|
|||||||
};
|
};
|
||||||
|
|
||||||
await deviceState.setTarget(localState);
|
await deviceState.setTarget(localState);
|
||||||
|
|
||||||
log.success('Preloading complete');
|
log.success('Preloading complete');
|
||||||
if (preloadState.pinDevice) {
|
if (preloadState.pinDevice) {
|
||||||
// Multi-app warning!
|
// Multi-app warning!
|
||||||
|
@ -3,20 +3,24 @@ import * as _ from 'lodash';
|
|||||||
import * as mkdirp from 'mkdirp';
|
import * as mkdirp from 'mkdirp';
|
||||||
import { child_process, fs } from 'mz';
|
import { child_process, fs } from 'mz';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { PinejsClientRequest } from 'pinejs-client-request';
|
|
||||||
import * as rimraf from 'rimraf';
|
import * as rimraf from 'rimraf';
|
||||||
|
|
||||||
const mkdirpAsync = Bluebird.promisify(mkdirp);
|
const mkdirpAsync = Bluebird.promisify(mkdirp);
|
||||||
const rimrafAsync = Bluebird.promisify(rimraf);
|
const rimrafAsync = Bluebird.promisify(rimraf);
|
||||||
|
|
||||||
import { ApplicationManager } from '../application-manager';
|
import * as apiBinder from '../api-binder';
|
||||||
import * as config from '../config';
|
import * as config from '../config';
|
||||||
import * as db from '../db';
|
import * as db from '../db';
|
||||||
import * as volumeManager from '../compose/volume-manager';
|
import * as volumeManager from '../compose/volume-manager';
|
||||||
import * as serviceManager from '../compose/service-manager';
|
import * as serviceManager from '../compose/service-manager';
|
||||||
import DeviceState from '../device-state';
|
import * as deviceState from '../device-state';
|
||||||
import * as constants from '../lib/constants';
|
import * as constants from '../lib/constants';
|
||||||
import { BackupError, DatabaseParseError, NotFoundError } from '../lib/errors';
|
import {
|
||||||
|
BackupError,
|
||||||
|
DatabaseParseError,
|
||||||
|
NotFoundError,
|
||||||
|
InternalInconsistencyError,
|
||||||
|
} from '../lib/errors';
|
||||||
import { docker } from '../lib/docker-utils';
|
import { docker } from '../lib/docker-utils';
|
||||||
import { pathExistsOnHost } from '../lib/fs-utils';
|
import { pathExistsOnHost } from '../lib/fs-utils';
|
||||||
import { log } from '../lib/supervisor-console';
|
import { log } from '../lib/supervisor-console';
|
||||||
@ -108,10 +112,16 @@ export function convertLegacyAppsJson(appsArray: any[]): AppsJsonFormat {
|
|||||||
return { apps, config: deviceConfig } as AppsJsonFormat;
|
return { apps, config: deviceConfig } as AppsJsonFormat;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function normaliseLegacyDatabase(
|
export async function normaliseLegacyDatabase() {
|
||||||
application: ApplicationManager,
|
await apiBinder.initialized;
|
||||||
balenaApi: PinejsClientRequest,
|
await deviceState.initialized;
|
||||||
) {
|
|
||||||
|
if (apiBinder.balenaApi == null) {
|
||||||
|
throw new InternalInconsistencyError(
|
||||||
|
'API binder is not initialized correctly',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// When legacy apps are present, we kill their containers and migrate their /data to a named volume
|
// When legacy apps are present, we kill their containers and migrate their /data to a named volume
|
||||||
log.info('Migrating ids for legacy app...');
|
log.info('Migrating ids for legacy app...');
|
||||||
|
|
||||||
@ -147,7 +157,7 @@ export async function normaliseLegacyDatabase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.debug(`Getting release ${app.commit} for app ${app.appId} from API`);
|
log.debug(`Getting release ${app.commit} for app ${app.appId} from API`);
|
||||||
const releases = await balenaApi.get({
|
const releases = await apiBinder.balenaApi.get({
|
||||||
resource: 'release',
|
resource: 'release',
|
||||||
options: {
|
options: {
|
||||||
$filter: {
|
$filter: {
|
||||||
@ -248,7 +258,7 @@ export async function normaliseLegacyDatabase(
|
|||||||
await serviceManager.killAllLegacy();
|
await serviceManager.killAllLegacy();
|
||||||
log.debug('Migrating legacy app volumes');
|
log.debug('Migrating legacy app volumes');
|
||||||
|
|
||||||
const targetApps = await application.getTargetApps();
|
const targetApps = await deviceState.applications.getTargetApps();
|
||||||
|
|
||||||
for (const appId of _.keys(targetApps)) {
|
for (const appId of _.keys(targetApps)) {
|
||||||
await volumeManager.createFromLegacy(parseInt(appId, 10));
|
await volumeManager.createFromLegacy(parseInt(appId, 10));
|
||||||
@ -260,7 +270,6 @@ export async function normaliseLegacyDatabase(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadBackupFromMigration(
|
export async function loadBackupFromMigration(
|
||||||
deviceState: DeviceState,
|
|
||||||
targetState: TargetState,
|
targetState: TargetState,
|
||||||
retryDelay: number,
|
retryDelay: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
@ -340,6 +349,6 @@ export async function loadBackupFromMigration(
|
|||||||
log.error(`Error restoring migration backup, retrying: ${err}`);
|
log.error(`Error restoring migration backup, retrying: ${err}`);
|
||||||
|
|
||||||
await Bluebird.delay(retryDelay);
|
await Bluebird.delay(retryDelay);
|
||||||
return loadBackupFromMigration(deviceState, targetState, retryDelay);
|
return loadBackupFromMigration(targetState, retryDelay);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import * as apiBinder from './api-binder';
|
import * as apiBinder from './api-binder';
|
||||||
import * as db from './db';
|
import * as db from './db';
|
||||||
import * as config from './config';
|
import * as config from './config';
|
||||||
import DeviceState from './device-state';
|
import * as deviceState from './device-state';
|
||||||
import * as eventTracker from './event-tracker';
|
import * as eventTracker from './event-tracker';
|
||||||
import { intialiseContractRequirements } from './lib/contracts';
|
import { intialiseContractRequirements } from './lib/contracts';
|
||||||
import { normaliseLegacyDatabase } from './lib/migration';
|
import { normaliseLegacyDatabase } from './lib/migration';
|
||||||
@ -31,26 +31,18 @@ const startupConfigFields: config.ConfigKey[] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
export class Supervisor {
|
export class Supervisor {
|
||||||
private deviceState: DeviceState;
|
|
||||||
private api: SupervisorAPI;
|
private api: SupervisorAPI;
|
||||||
|
|
||||||
public constructor() {
|
public constructor() {
|
||||||
this.deviceState = new DeviceState({
|
|
||||||
apiBinder,
|
|
||||||
});
|
|
||||||
|
|
||||||
// workaround the circular dependency
|
|
||||||
apiBinder.setDeviceState(this.deviceState);
|
|
||||||
|
|
||||||
// FIXME: rearchitect proxyvisor to avoid this circular dependency
|
// FIXME: rearchitect proxyvisor to avoid this circular dependency
|
||||||
// by storing current state and having the APIBinder query and report it / provision devices
|
// by storing current state and having the APIBinder query and report it / provision devices
|
||||||
this.deviceState.applications.proxyvisor.bindToAPI(apiBinder);
|
deviceState.applications.proxyvisor.bindToAPI(apiBinder);
|
||||||
|
|
||||||
this.api = new SupervisorAPI({
|
this.api = new SupervisorAPI({
|
||||||
routers: [apiBinder.router, this.deviceState.router],
|
routers: [apiBinder.router, deviceState.router],
|
||||||
healthchecks: [
|
healthchecks: [
|
||||||
apiBinder.healthcheck,
|
apiBinder.healthcheck,
|
||||||
this.deviceState.healthcheck.bind(this.deviceState),
|
deviceState.healthcheck,
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -79,20 +71,19 @@ export class Supervisor {
|
|||||||
log.debug('Starting api binder');
|
log.debug('Starting api binder');
|
||||||
await apiBinder.initialized;
|
await apiBinder.initialized;
|
||||||
|
|
||||||
|
await deviceState.initialized;
|
||||||
|
|
||||||
logger.logSystemMessage('Supervisor starting', {}, 'Supervisor start');
|
logger.logSystemMessage('Supervisor starting', {}, 'Supervisor start');
|
||||||
if (conf.legacyAppsPresent && apiBinder.balenaApi != null) {
|
if (conf.legacyAppsPresent && apiBinder.balenaApi != null) {
|
||||||
log.info('Legacy app detected, running migration');
|
log.info('Legacy app detected, running migration');
|
||||||
await normaliseLegacyDatabase(
|
await normaliseLegacyDatabase();
|
||||||
this.deviceState.applications,
|
|
||||||
apiBinder.balenaApi,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.deviceState.init();
|
await deviceState.loadInitialState();
|
||||||
|
|
||||||
log.info('Starting API server');
|
log.info('Starting API server');
|
||||||
this.api.listen(conf.listenPort, conf.apiTimeout);
|
this.api.listen(conf.listenPort, conf.apiTimeout);
|
||||||
this.deviceState.on('shutdown', () => this.api.stop());
|
deviceState.on('shutdown', () => this.api.stop());
|
||||||
|
|
||||||
await apiBinder.start();
|
await apiBinder.start();
|
||||||
}
|
}
|
||||||
|
@ -6,6 +6,7 @@ process.env.DATABASE_PATH_2 = './test/data/database2.sqlite';
|
|||||||
process.env.DATABASE_PATH_3 = './test/data/database3.sqlite';
|
process.env.DATABASE_PATH_3 = './test/data/database3.sqlite';
|
||||||
process.env.LED_FILE = './test/data/led_file';
|
process.env.LED_FILE = './test/data/led_file';
|
||||||
|
|
||||||
|
import './lib/mocked-dockerode';
|
||||||
import './lib/mocked-iptables';
|
import './lib/mocked-iptables';
|
||||||
import './lib/mocked-event-tracker';
|
import './lib/mocked-event-tracker';
|
||||||
|
|
||||||
|
@ -122,6 +122,11 @@ describe('Config', () => {
|
|||||||
expect(osVariant).to.be.undefined;
|
expect(osVariant).to.be.undefined;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('reads and exposes MAC addresses', async () => {
|
||||||
|
const macAddress = await conf.get('macAddress');
|
||||||
|
expect(macAddress).to.have.length.greaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
describe('Function config providers', () => {
|
describe('Function config providers', () => {
|
||||||
it('should throw if a non-mutable function provider is set', () => {
|
it('should throw if a non-mutable function provider is set', () => {
|
||||||
expect(conf.set({ version: 'some-version' })).to.be.rejected;
|
expect(conf.set({ version: 'some-version' })).to.be.rejected;
|
||||||
|
@ -1,17 +1,14 @@
|
|||||||
import * as Bluebird from 'bluebird';
|
|
||||||
import { stripIndent } from 'common-tags';
|
|
||||||
import * as _ from 'lodash';
|
import * as _ from 'lodash';
|
||||||
import { SinonSpy, SinonStub, spy, stub } from 'sinon';
|
import { stub } from 'sinon';
|
||||||
|
|
||||||
import chai = require('./lib/chai-config');
|
import chai = require('./lib/chai-config');
|
||||||
import { StatusCodeError } from '../src/lib/errors';
|
import { StatusCodeError } from '../src/lib/errors';
|
||||||
import prepare = require('./lib/prepare');
|
import prepare = require('./lib/prepare');
|
||||||
import Log from '../src/lib/supervisor-console';
|
|
||||||
import * as dockerUtils from '../src/lib/docker-utils';
|
import * as dockerUtils from '../src/lib/docker-utils';
|
||||||
import * as config from '../src/config';
|
import * as config from '../src/config';
|
||||||
import * as images from '../src/compose/images';
|
import * as images from '../src/compose/images';
|
||||||
import { ConfigTxt } from '../src/config/backends/config-txt';
|
import { ConfigTxt } from '../src/config/backends/config-txt';
|
||||||
import DeviceState from '../src/device-state';
|
import * as deviceState from '../src/device-state';
|
||||||
import * as deviceConfig from '../src/device-config';
|
import * as deviceConfig from '../src/device-config';
|
||||||
import { loadTargetFromFile } from '../src/device-state/preload';
|
import { loadTargetFromFile } from '../src/device-state/preload';
|
||||||
import Service from '../src/compose/service';
|
import Service from '../src/compose/service';
|
||||||
@ -215,14 +212,16 @@ const testTargetInvalid = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('deviceState', () => {
|
describe('deviceState', () => {
|
||||||
let deviceState: DeviceState;
|
|
||||||
let source: string;
|
let source: string;
|
||||||
const originalImagesSave = images.save;
|
const originalImagesSave = images.save;
|
||||||
const originalImagesInspect = images.inspectByName;
|
const originalImagesInspect = images.inspectByName;
|
||||||
const originalGetCurrent = deviceConfig.getCurrent;
|
const originalGetCurrent = deviceConfig.getCurrent;
|
||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
await prepare();
|
await prepare();
|
||||||
await config.initialized;
|
await config.initialized;
|
||||||
|
await deviceState.initialized;
|
||||||
|
|
||||||
source = await config.get('apiEndpoint');
|
source = await config.get('apiEndpoint');
|
||||||
|
|
||||||
stub(Service as any, 'extendEnvVars').callsFake((env) => {
|
stub(Service as any, 'extendEnvVars').callsFake((env) => {
|
||||||
@ -235,10 +234,6 @@ describe('deviceState', () => {
|
|||||||
deviceType: 'intel-nuc',
|
deviceType: 'intel-nuc',
|
||||||
});
|
});
|
||||||
|
|
||||||
deviceState = new DeviceState({
|
|
||||||
apiBinder: null as any,
|
|
||||||
});
|
|
||||||
|
|
||||||
stub(dockerUtils, 'getNetworkGateway').returns(
|
stub(dockerUtils, 'getNetworkGateway').returns(
|
||||||
Promise.resolve('172.17.0.1'),
|
Promise.resolve('172.17.0.1'),
|
||||||
);
|
);
|
||||||
@ -277,10 +272,7 @@ describe('deviceState', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => {
|
it('loads a target state from an apps.json file and saves it as target state, then returns it', async () => {
|
||||||
await loadTargetFromFile(
|
await loadTargetFromFile(process.env.ROOT_MOUNTPOINT + '/apps.json');
|
||||||
process.env.ROOT_MOUNTPOINT + '/apps.json',
|
|
||||||
deviceState,
|
|
||||||
);
|
|
||||||
const targetState = await deviceState.getTarget();
|
const targetState = await deviceState.getTarget();
|
||||||
|
|
||||||
const testTarget = _.cloneDeep(testTarget1);
|
const testTarget = _.cloneDeep(testTarget1);
|
||||||
@ -300,19 +292,16 @@ describe('deviceState', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => {
|
it('stores info for pinning a device after loading an apps.json with a pinDevice field', async () => {
|
||||||
await loadTargetFromFile(
|
await loadTargetFromFile(process.env.ROOT_MOUNTPOINT + '/apps-pin.json');
|
||||||
process.env.ROOT_MOUNTPOINT + '/apps-pin.json',
|
|
||||||
deviceState,
|
|
||||||
);
|
|
||||||
|
|
||||||
const pinned = await config.get('pinDevice');
|
const pinned = await config.get('pinDevice');
|
||||||
expect(pinned).to.have.property('app').that.equals(1234);
|
expect(pinned).to.have.property('app').that.equals(1234);
|
||||||
expect(pinned).to.have.property('commit').that.equals('abcdef');
|
expect(pinned).to.have.property('commit').that.equals('abcdef');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('emits a change event when a new state is reported', () => {
|
it('emits a change event when a new state is reported', (done) => {
|
||||||
|
deviceState.once('change', done);
|
||||||
deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any);
|
deviceState.reportCurrentState({ someStateDiff: 'someValue' } as any);
|
||||||
return (expect as any)(deviceState).to.emit('change');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('returns the current state');
|
it('returns the current state');
|
||||||
@ -347,105 +336,30 @@ describe('deviceState', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('allows triggering applying the target state', (done) => {
|
it('allows triggering applying the target state', (done) => {
|
||||||
stub(deviceState as any, 'applyTarget').returns(Promise.resolve());
|
const applyTargetStub = stub(deviceState, 'applyTarget').returns(
|
||||||
|
Promise.resolve(),
|
||||||
|
);
|
||||||
|
|
||||||
deviceState.triggerApplyTarget({ force: true });
|
deviceState.triggerApplyTarget({ force: true });
|
||||||
expect((deviceState as any).applyTarget).to.not.be.called;
|
expect(applyTargetStub).to.not.be.called;
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
expect((deviceState as any).applyTarget).to.be.calledWith({
|
expect(applyTargetStub).to.be.calledWith({
|
||||||
force: true,
|
force: true,
|
||||||
initial: false,
|
initial: false,
|
||||||
});
|
});
|
||||||
(deviceState as any).applyTarget.restore();
|
applyTargetStub.restore();
|
||||||
done();
|
done();
|
||||||
}, 5);
|
}, 1000);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('cancels current promise applying the target state', (done) => {
|
// TODO: There is no easy way to test this behaviour with the current
|
||||||
(deviceState as any).scheduledApply = { force: false, delay: 100 };
|
// interface of device-state. We should really think about the device-state
|
||||||
(deviceState as any).applyInProgress = true;
|
// interface to allow this flexibility (and to avoid having to change module
|
||||||
(deviceState as any).applyCancelled = false;
|
// internal variables)
|
||||||
|
it.skip('cancels current promise applying the target state');
|
||||||
|
|
||||||
new Bluebird((resolve, reject) => {
|
it.skip('applies the target state for device config');
|
||||||
setTimeout(resolve, 100000);
|
|
||||||
(deviceState as any).cancelDelay = reject;
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
(deviceState as any).applyCancelled = true;
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
expect((deviceState as any).scheduledApply).to.deep.equal({
|
|
||||||
force: true,
|
|
||||||
delay: 0,
|
|
||||||
});
|
|
||||||
expect((deviceState as any).applyCancelled).to.be.true;
|
|
||||||
done();
|
|
||||||
});
|
|
||||||
|
|
||||||
deviceState.triggerApplyTarget({ force: true, isFromApi: true });
|
it.skip('applies the target state for applications');
|
||||||
});
|
|
||||||
|
|
||||||
it('applies the target state for device config');
|
|
||||||
|
|
||||||
it('applies the target state for applications');
|
|
||||||
|
|
||||||
describe('healthchecks', () => {
|
|
||||||
let configStub: SinonStub;
|
|
||||||
let infoLobSpy: SinonSpy;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
// This configStub will be modified in each test case so we can
|
|
||||||
// create the exact conditions we want to for testing healthchecks
|
|
||||||
configStub = stub(config, 'get');
|
|
||||||
infoLobSpy = spy(Log, 'info');
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
configStub.restore();
|
|
||||||
infoLobSpy.restore();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes with correct conditions', async () => {
|
|
||||||
// Setup passing condition
|
|
||||||
const previousValue = deviceState.applyInProgress;
|
|
||||||
deviceState.applyInProgress = false;
|
|
||||||
expect(await deviceState.healthcheck()).to.equal(true);
|
|
||||||
// Restore value
|
|
||||||
deviceState.applyInProgress = previousValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('passes if unmanaged is true and exit early', async () => {
|
|
||||||
// Setup failing conditions
|
|
||||||
const previousValue = deviceState.applyInProgress;
|
|
||||||
deviceState.applyInProgress = true;
|
|
||||||
// Verify this causes healthcheck to fail
|
|
||||||
expect(await deviceState.healthcheck()).to.equal(false);
|
|
||||||
// Do it again but set unmanaged to true
|
|
||||||
configStub.resolves({
|
|
||||||
unmanaged: true,
|
|
||||||
});
|
|
||||||
expect(await deviceState.healthcheck()).to.equal(true);
|
|
||||||
// Restore value
|
|
||||||
deviceState.applyInProgress = previousValue;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('fails when applyTargetHealthy is false', async () => {
|
|
||||||
// Copy previous values to restore later
|
|
||||||
const previousValue = deviceState.applyInProgress;
|
|
||||||
// Setup failing conditions
|
|
||||||
deviceState.applyInProgress = true;
|
|
||||||
expect(await deviceState.healthcheck()).to.equal(false);
|
|
||||||
expect(Log.info).to.be.calledOnce;
|
|
||||||
expect((Log.info as SinonSpy).lastCall?.lastArg).to.equal(
|
|
||||||
stripIndent`
|
|
||||||
Healthcheck failure - At least ONE of the following conditions must be true:
|
|
||||||
- No applyInProgress ? false
|
|
||||||
- fetchesInProgress ? false
|
|
||||||
- cycleTimeWithinInterval ? false`,
|
|
||||||
);
|
|
||||||
// Restore value
|
|
||||||
deviceState.applyInProgress = previousValue;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
@ -5,7 +5,7 @@ import { SinonSpy, SinonStub, spy, stub } from 'sinon';
|
|||||||
|
|
||||||
import prepare = require('./lib/prepare');
|
import prepare = require('./lib/prepare');
|
||||||
import * as config from '../src/config';
|
import * as config from '../src/config';
|
||||||
import DeviceState from '../src/device-state';
|
import * as deviceState from '../src/device-state';
|
||||||
import Log from '../src/lib/supervisor-console';
|
import Log from '../src/lib/supervisor-console';
|
||||||
import chai = require('./lib/chai-config');
|
import chai = require('./lib/chai-config');
|
||||||
import balenaAPI = require('./lib/mocked-balena-api');
|
import balenaAPI = require('./lib/mocked-balena-api');
|
||||||
@ -47,11 +47,8 @@ const initModels = async (obj: Dictionary<any>, filename: string) => {
|
|||||||
await ApiBinder.initialized;
|
await ApiBinder.initialized;
|
||||||
obj.apiBinder = ApiBinder;
|
obj.apiBinder = ApiBinder;
|
||||||
|
|
||||||
obj.deviceState = new DeviceState({
|
await deviceState.initialized;
|
||||||
apiBinder: obj.apiBinder,
|
obj.deviceState = deviceState;
|
||||||
});
|
|
||||||
|
|
||||||
obj.apiBinder.setDeviceState(obj.deviceState);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockProvisioningOpts = {
|
const mockProvisioningOpts = {
|
||||||
@ -462,12 +459,12 @@ describe('ApiBinder', () => {
|
|||||||
// Copy previous values to restore later
|
// Copy previous values to restore later
|
||||||
const previousStateReportErrors = components.apiBinder.stateReportErrors;
|
const previousStateReportErrors = components.apiBinder.stateReportErrors;
|
||||||
const previousDeviceStateConnected =
|
const previousDeviceStateConnected =
|
||||||
components.apiBinder.deviceState.connected;
|
// @ts-ignore
|
||||||
|
components.deviceState.connected;
|
||||||
|
|
||||||
// Set additional conditions not in configStub to cause a fail
|
// Set additional conditions not in configStub to cause a fail
|
||||||
// @ts-expect-error
|
components.apiBinder.stateReportErrors = 4;
|
||||||
CurrentState.stateReportErrors = 4;
|
components.deviceState.connected = true;
|
||||||
components.apiBinder.deviceState.connected = true;
|
|
||||||
|
|
||||||
expect(await components.apiBinder.healthcheck()).to.equal(false);
|
expect(await components.apiBinder.healthcheck()).to.equal(false);
|
||||||
|
|
||||||
@ -481,9 +478,8 @@ describe('ApiBinder', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
// Restore previous values
|
// Restore previous values
|
||||||
// @ts-expect-error
|
components.apiBinder.stateReportErrors = previousStateReportErrors;
|
||||||
CurrentState.stateReportErrors = previousStateReportErrors;
|
components.deviceState.connected = previousDeviceStateConnected;
|
||||||
components.apiBinder.deviceState.connected = previousDeviceStateConnected;
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -6,7 +6,7 @@ import Network from '../src/compose/network';
|
|||||||
|
|
||||||
import Service from '../src/compose/service';
|
import Service from '../src/compose/service';
|
||||||
import Volume from '../src/compose/volume';
|
import Volume from '../src/compose/volume';
|
||||||
import DeviceState from '../src/device-state';
|
import * as deviceState from '../src/device-state';
|
||||||
import * as dockerUtils from '../src/lib/docker-utils';
|
import * as dockerUtils from '../src/lib/docker-utils';
|
||||||
import * as images from '../src/compose/images';
|
import * as images from '../src/compose/images';
|
||||||
|
|
||||||
@ -67,10 +67,9 @@ describe('ApplicationManager', function () {
|
|||||||
const originalInspectByName = images.inspectByName;
|
const originalInspectByName = images.inspectByName;
|
||||||
before(async function () {
|
before(async function () {
|
||||||
await prepare();
|
await prepare();
|
||||||
this.deviceState = new DeviceState({
|
await deviceState.initialized;
|
||||||
apiBinder: null as any,
|
|
||||||
});
|
this.applications = deviceState.applications;
|
||||||
this.applications = this.deviceState.applications;
|
|
||||||
|
|
||||||
// @ts-expect-error assigning to a RO property
|
// @ts-expect-error assigning to a RO property
|
||||||
images.inspectByName = () =>
|
images.inspectByName = () =>
|
||||||
|
@ -1,35 +1,26 @@
|
|||||||
import { SinonStub, stub } from 'sinon';
|
import { SinonStub, stub } from 'sinon';
|
||||||
|
import { expect } from './lib/chai-config';
|
||||||
|
import * as _ from 'lodash';
|
||||||
|
|
||||||
import * as APIBinder from '../src/api-binder';
|
import * as apiBinder from '../src/api-binder';
|
||||||
import { ApplicationManager } from '../src/application-manager';
|
import { ApplicationManager } from '../src/application-manager';
|
||||||
import DeviceState from '../src/device-state';
|
import * as deviceState from '../src/device-state';
|
||||||
import * as constants from '../src/lib/constants';
|
import * as constants from '../src/lib/constants';
|
||||||
import { docker } from '../src/lib/docker-utils';
|
import { docker } from '../src/lib/docker-utils';
|
||||||
import { Supervisor } from '../src/supervisor';
|
import { Supervisor } from '../src/supervisor';
|
||||||
import { expect } from './lib/chai-config';
|
|
||||||
import _ = require('lodash');
|
|
||||||
|
|
||||||
describe('Startup', () => {
|
describe('Startup', () => {
|
||||||
let reportCurrentStateStub: SinonStub;
|
|
||||||
let startStub: SinonStub;
|
let startStub: SinonStub;
|
||||||
let vpnStatusPathStub: SinonStub;
|
let vpnStatusPathStub: SinonStub;
|
||||||
let appManagerStub: SinonStub;
|
let appManagerStub: SinonStub;
|
||||||
let deviceStateStub: SinonStub;
|
let deviceStateStub: SinonStub;
|
||||||
let dockerStub: SinonStub;
|
let dockerStub: SinonStub;
|
||||||
|
|
||||||
before(() => {
|
before(async () => {
|
||||||
reportCurrentStateStub = stub(
|
startStub = stub(apiBinder as any, 'start').resolves();
|
||||||
DeviceState.prototype as any,
|
deviceStateStub = stub(deviceState, 'applyTarget').resolves();
|
||||||
'reportCurrentState',
|
appManagerStub = stub(ApplicationManager.prototype, 'init').resolves();
|
||||||
).resolves();
|
|
||||||
startStub = stub(APIBinder as any, 'start').returns(Promise.resolve());
|
|
||||||
appManagerStub = stub(ApplicationManager.prototype, 'init').returns(
|
|
||||||
Promise.resolve(),
|
|
||||||
);
|
|
||||||
vpnStatusPathStub = stub(constants, 'vpnStatusPath').returns('');
|
vpnStatusPathStub = stub(constants, 'vpnStatusPath').returns('');
|
||||||
deviceStateStub = stub(DeviceState.prototype as any, 'applyTarget').returns(
|
|
||||||
Promise.resolve(),
|
|
||||||
);
|
|
||||||
dockerStub = stub(docker, 'listContainers').returns(Promise.resolve([]));
|
dockerStub = stub(docker, 'listContainers').returns(Promise.resolve([]));
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -39,12 +30,12 @@ describe('Startup', () => {
|
|||||||
vpnStatusPathStub.restore();
|
vpnStatusPathStub.restore();
|
||||||
deviceStateStub.restore();
|
deviceStateStub.restore();
|
||||||
dockerStub.restore();
|
dockerStub.restore();
|
||||||
reportCurrentStateStub.restore();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should startup correctly', async () => {
|
it('should startup correctly', async () => {
|
||||||
const supervisor = new Supervisor();
|
const supervisor = new Supervisor();
|
||||||
expect(await supervisor.init()).to.not.throw;
|
await supervisor.init();
|
||||||
|
|
||||||
// Cast as any to access private properties
|
// Cast as any to access private properties
|
||||||
const anySupervisor = supervisor as any;
|
const anySupervisor = supervisor as any;
|
||||||
expect(anySupervisor.db).to.not.be.null;
|
expect(anySupervisor.db).to.not.be.null;
|
||||||
@ -52,20 +43,5 @@ describe('Startup', () => {
|
|||||||
expect(anySupervisor.logger).to.not.be.null;
|
expect(anySupervisor.logger).to.not.be.null;
|
||||||
expect(anySupervisor.deviceState).to.not.be.null;
|
expect(anySupervisor.deviceState).to.not.be.null;
|
||||||
expect(anySupervisor.apiBinder).to.not.be.null;
|
expect(anySupervisor.apiBinder).to.not.be.null;
|
||||||
|
|
||||||
let macAddresses: string[] = [];
|
|
||||||
reportCurrentStateStub.getCalls().forEach((call) => {
|
|
||||||
const m: string = call.args[0]['mac_address'];
|
|
||||||
if (!m) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
macAddresses = _.union(macAddresses, m.split(' '));
|
|
||||||
});
|
|
||||||
|
|
||||||
const allMacAddresses = macAddresses.join(' ');
|
|
||||||
|
|
||||||
expect(allMacAddresses).to.have.length.greaterThan(0);
|
|
||||||
expect(allMacAddresses).to.not.contain('NO:');
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -3,7 +3,7 @@ import { spy, stub, SinonStub } from 'sinon';
|
|||||||
import * as supertest from 'supertest';
|
import * as supertest from 'supertest';
|
||||||
|
|
||||||
import * as apiBinder from '../src/api-binder';
|
import * as apiBinder from '../src/api-binder';
|
||||||
import DeviceState from '../src/device-state';
|
import * as deviceState from '../src/device-state';
|
||||||
import Log from '../src/lib/supervisor-console';
|
import Log from '../src/lib/supervisor-console';
|
||||||
import * as images from '../src/compose/images';
|
import * as images from '../src/compose/images';
|
||||||
import SupervisorAPI from '../src/supervisor-api';
|
import SupervisorAPI from '../src/supervisor-api';
|
||||||
@ -25,11 +25,12 @@ describe('SupervisorAPI', () => {
|
|||||||
|
|
||||||
before(async () => {
|
before(async () => {
|
||||||
await apiBinder.initialized;
|
await apiBinder.initialized;
|
||||||
|
await deviceState.initialized;
|
||||||
|
|
||||||
// Stub health checks so we can modify them whenever needed
|
// Stub health checks so we can modify them whenever needed
|
||||||
healthCheckStubs = [
|
healthCheckStubs = [
|
||||||
stub(apiBinder, 'healthcheck'),
|
stub(apiBinder, 'healthcheck'),
|
||||||
stub(DeviceState.prototype, 'healthcheck'),
|
stub(deviceState, 'healthcheck'),
|
||||||
];
|
];
|
||||||
// The mockedAPI contains stubs that might create unexpected results
|
// The mockedAPI contains stubs that might create unexpected results
|
||||||
// See the module to know what has been stubbed
|
// See the module to know what has been stubbed
|
||||||
|
@ -10,8 +10,8 @@ import * as config from '../../src/config';
|
|||||||
import * as db from '../../src/db';
|
import * as db from '../../src/db';
|
||||||
import { createV1Api } from '../../src/device-api/v1';
|
import { createV1Api } from '../../src/device-api/v1';
|
||||||
import { createV2Api } from '../../src/device-api/v2';
|
import { createV2Api } from '../../src/device-api/v2';
|
||||||
import * as APIBinder from '../../src/api-binder';
|
import * as apiBinder from '../../src/api-binder';
|
||||||
import DeviceState from '../../src/device-state';
|
import * as deviceState from '../../src/device-state';
|
||||||
import SupervisorAPI from '../../src/supervisor-api';
|
import SupervisorAPI from '../../src/supervisor-api';
|
||||||
|
|
||||||
const DB_PATH = './test/data/supervisor-api.sqlite';
|
const DB_PATH = './test/data/supervisor-api.sqlite';
|
||||||
@ -67,14 +67,12 @@ const STUBBED_VALUES = {
|
|||||||
|
|
||||||
async function create(): Promise<SupervisorAPI> {
|
async function create(): Promise<SupervisorAPI> {
|
||||||
// Get SupervisorAPI construct options
|
// Get SupervisorAPI construct options
|
||||||
const { deviceState, apiBinder } = await createAPIOpts();
|
await createAPIOpts();
|
||||||
|
|
||||||
// Stub functions
|
// Stub functions
|
||||||
setupStubs();
|
setupStubs();
|
||||||
// Create ApplicationManager
|
// Create ApplicationManager
|
||||||
const appManager = new ApplicationManager({
|
const appManager = new ApplicationManager();
|
||||||
deviceState,
|
|
||||||
apiBinder: null,
|
|
||||||
});
|
|
||||||
// Create SupervisorAPI
|
// Create SupervisorAPI
|
||||||
const api = new SupervisorAPI({
|
const api = new SupervisorAPI({
|
||||||
routers: [deviceState.router, buildRoutes(appManager)],
|
routers: [deviceState.router, buildRoutes(appManager)],
|
||||||
@ -101,19 +99,12 @@ async function cleanUp(): Promise<void> {
|
|||||||
return restoreStubs();
|
return restoreStubs();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function createAPIOpts(): Promise<SupervisorAPIOpts> {
|
async function createAPIOpts(): Promise<void> {
|
||||||
await db.initialized;
|
await db.initialized;
|
||||||
|
await deviceState.initialized;
|
||||||
|
|
||||||
// Initialize and set values for mocked Config
|
// Initialize and set values for mocked Config
|
||||||
await initConfig();
|
await initConfig();
|
||||||
// Create deviceState
|
|
||||||
const deviceState = new DeviceState({
|
|
||||||
apiBinder: null as any,
|
|
||||||
});
|
|
||||||
const apiBinder = APIBinder;
|
|
||||||
return {
|
|
||||||
deviceState,
|
|
||||||
apiBinder,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function initConfig(): Promise<void> {
|
async function initConfig(): Promise<void> {
|
||||||
@ -168,9 +159,4 @@ function restoreStubs() {
|
|||||||
serviceManager.getAllByAppId = originalSvcGetAppId;
|
serviceManager.getAllByAppId = originalSvcGetAppId;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SupervisorAPIOpts {
|
|
||||||
deviceState: DeviceState;
|
|
||||||
apiBinder: typeof APIBinder;
|
|
||||||
}
|
|
||||||
|
|
||||||
export = { create, cleanUp, STUBBED_VALUES };
|
export = { create, cleanUp, STUBBED_VALUES };
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
|
process.env.DOCKER_HOST = 'unix:///your/dockerode/mocks/are/not/working';
|
||||||
|
|
||||||
import * as dockerode from 'dockerode';
|
import * as dockerode from 'dockerode';
|
||||||
|
|
||||||
export interface TestData {
|
export interface TestData {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user