Refactor current state report to patch v3 state

This change makes the `api-binder/report` module more agnostic
to internal device state implementation details, moving necessary
healthchecks and data filtering to getCurrentForReport in device-state.

This also adds generic functions to perform comparison between current
state reports.
This commit is contained in:
Felipe Lalanne 2021-09-09 19:30:35 +00:00
parent 25e9ab4786
commit 381abeadb9
5 changed files with 389 additions and 146 deletions

View File

@ -1,5 +1,5 @@
import * as _ from 'lodash';
import * as url from 'url';
import * as _ from 'lodash';
import { CoreOptions } from 'request';
import * as constants from '../lib/constants';
@ -7,79 +7,41 @@ import { withBackoff, OnFailureInfo } from '../lib/backoff';
import { log } from '../lib/supervisor-console';
import { InternalInconsistencyError, StatusError } from '../lib/errors';
import { getRequestInstance } from '../lib/request';
import * as sysInfo from '../lib/system-info';
import { DeviceLegacyState } from '../types';
import { DeviceState } from '../types';
import * as config from '../config';
import { SchemaTypeKey, SchemaReturn } from '../config/schema-type';
import * as eventTracker from '../event-tracker';
import * as deviceState from '../device-state';
const INTERNAL_STATE_KEYS = [
'update_pending',
'update_downloaded',
'update_failed',
];
import { shallowDiff, prune, empty } from '../lib/json';
export let stateReportErrors = 0;
const lastReportedState: DeviceLegacyState = {
local: {},
dependent: {},
};
let lastReport: DeviceState = {};
let reportPending = false;
export let stateReportErrors = 0;
type CurrentStateReportConf = {
type StateReportOpts = {
[key in keyof Pick<
config.ConfigMap<SchemaTypeKey>,
| 'uuid'
| 'apiEndpoint'
| 'apiTimeout'
| 'deviceApiKey'
| 'deviceId'
| 'localMode'
| 'appUpdatePollInterval'
| 'hardwareMetrics'
'apiEndpoint' | 'apiTimeout' | 'deviceApiKey' | 'appUpdatePollInterval'
>]: SchemaReturn<key>;
};
type StateReport = {
stateDiff: DeviceLegacyState;
conf: Omit<CurrentStateReportConf, 'deviceId' | 'hardwareMetrics'>;
};
type StateReport = { body: Partial<DeviceState>; opts: StateReportOpts };
/**
* Returns an object that contains only status fields relevant for the local mode.
* It basically removes information about applications state.
*/
const stripDeviceStateInLocalMode = (
state: DeviceLegacyState,
): DeviceLegacyState => {
return {
local: _.cloneDeep(
_.omit(state.local, 'apps', 'is_on__commit', 'logs_channel'),
),
};
};
async function report({ stateDiff, conf }: StateReport): Promise<boolean> {
let body = stateDiff;
const { apiEndpoint, apiTimeout, deviceApiKey, localMode, uuid } = conf;
if (localMode) {
body = stripDeviceStateInLocalMode(stateDiff);
}
if (_.isEmpty(body.local)) {
// Nothing to send.
async function report({ body, opts }: StateReport) {
const { apiEndpoint, apiTimeout, deviceApiKey } = opts;
if (empty(body)) {
return false;
}
if (conf.uuid == null || conf.apiEndpoint == null) {
if (!apiEndpoint) {
throw new InternalInconsistencyError(
'No uuid or apiEndpoint provided to CurrentState.report',
'No apiEndpoint available for patching current state',
);
}
const endpoint = url.resolve(apiEndpoint, `/device/v2/${uuid}/state`);
const endpoint = url.resolve(apiEndpoint, `/device/v3/state`);
const request = await getRequestInstance();
const params: CoreOptions = {
@ -101,68 +63,36 @@ async function report({ stateDiff, conf }: StateReport): Promise<boolean> {
headers['retry-after'] ? parseInt(headers['retry-after'], 10) : undefined,
);
}
// State was reported
return true;
}
function newStateDiff(stateForReport: DeviceLegacyState): DeviceLegacyState {
const lastReportedLocal = lastReportedState.local;
const lastReportedDependent = lastReportedState.dependent;
if (lastReportedLocal == null || lastReportedDependent == null) {
throw new InternalInconsistencyError(
`No local or dependent component of lastReportedLocal in CurrentState.getStateDiff: ${JSON.stringify(
lastReportedState,
)}`,
);
}
const diff = {
local: _.omitBy(
stateForReport.local,
(val, key: keyof NonNullable<DeviceLegacyState['local']>) =>
INTERNAL_STATE_KEYS.includes(key) ||
_.isEqual(lastReportedLocal[key], val) ||
!sysInfo.isSignificantChange(
key,
lastReportedLocal[key] as number,
val as number,
),
),
dependent: _.omitBy(
stateForReport.dependent,
(val, key: keyof DeviceLegacyState['dependent']) =>
INTERNAL_STATE_KEYS.includes(key) ||
_.isEqual(lastReportedDependent[key], val),
),
};
return _.omitBy(diff, _.isEmpty);
}
async function reportCurrentState(conf: CurrentStateReportConf) {
async function reportCurrentState(opts: StateReportOpts) {
// Ensure no other report starts
reportPending = true;
// Wrap the report with fetching of state so report always has the latest state diff
const getStateAndReport = async () => {
// Get state to report
const stateToReport = await generateStateForReport();
// Get diff from last reported state
const stateDiff = newStateDiff(stateToReport);
const currentState = await deviceState.getCurrentForReport(lastReport);
// Depth 2 is the apps level
const stateDiff = prune(shallowDiff(lastReport, currentState, 2));
// Report diff
if (await report({ stateDiff, conf })) {
// Update lastReportedState
_.assign(lastReportedState.local, stateDiff.local);
_.assign(lastReportedState.dependent, stateDiff.dependent);
if (await report({ body: stateDiff, opts })) {
// Update lastReportedState if the report succeeds
lastReport = currentState;
// Log that we successfully reported the current state
log.info('Reported current state to the cloud');
}
};
// Create a report that will backoff on errors
const reportWithBackoff = withBackoff(getStateAndReport, {
maxDelay: conf.appUpdatePollInterval,
maxDelay: opts.appUpdatePollInterval,
minDelay: 15000,
onFailure: handleRetry,
});
// Run in try block to avoid throwing any exceptions
try {
await reportWithBackoff();
@ -196,49 +126,14 @@ function handleRetry(retryInfo: OnFailureInfo) {
);
}
async function generateStateForReport() {
const { hardwareMetrics } = await config.getMany(['hardwareMetrics']);
const currentDeviceState = await deviceState.getLegacyState();
// If hardwareMetrics is false, send null patch for system metrics to cloud API
const info = {
...(hardwareMetrics
? await sysInfo.getSystemMetrics()
: {
cpu_usage: null,
memory_usage: null,
memory_total: null,
storage_usage: null,
storage_total: null,
storage_block_device: null,
cpu_temp: null,
cpu_id: null,
}),
...(await sysInfo.getSystemChecks()),
};
return {
local: {
...currentDeviceState.local,
...info,
},
dependent: currentDeviceState.dependent,
};
}
export async function startReporting() {
// Get configs needed to make a report
const reportConfigs = (await config.getMany([
'uuid',
'apiEndpoint',
'apiTimeout',
'deviceApiKey',
'deviceId',
'localMode',
'appUpdatePollInterval',
'hardwareMetrics',
])) as CurrentStateReportConf;
])) as StateReportOpts;
// Throttle reportCurrentState so we don't query device or hit API excessively
const throttledReport = _.throttle(
reportCurrentState,

View File

@ -37,14 +37,15 @@ import * as deviceConfig from './device-config';
import { ConfigStep } from './device-config';
import { log } from './lib/supervisor-console';
import {
DeviceLegacyReport,
DeviceLegacyState,
InstancedDeviceState,
TargetState,
DeviceState,
DeviceReport,
} from './types';
import * as dbFormat from './device-state/db-format';
import * as apiKeys from './lib/api-keys';
import * as sysInfo from './lib/system-info';
const disallowedHostConfigPatchFields = ['local_ip', 'local_port'];
@ -248,7 +249,7 @@ type DeviceStateStep<T extends PossibleStepTargets> =
| CompositionStepT<T extends CompositionStepAction ? T : never>
| ConfigStep;
let currentVolatile: DeviceLegacyReport = {};
let currentVolatile: DeviceReport = {};
const writeLock = updateLock.writeLock;
const readLock = updateLock.readLock;
let maxPollTime: number;
@ -355,7 +356,7 @@ export async function initNetworkChecks({
});
log.debug('Starting periodic check for IP addresses');
await network.startIPAddressUpdate()(async (addresses) => {
network.startIPAddressUpdate()(async (addresses) => {
const macAddress = await config.get('macAddress');
reportCurrentState({
ip_address: addresses.join(' '),
@ -570,21 +571,75 @@ export async function getLegacyState(): Promise<DeviceLegacyState> {
return theState as DeviceLegacyState;
}
async function getSysInfo(
lastInfo: Partial<sysInfo.SystemInfo>,
): Promise<sysInfo.SystemInfo> {
// If hardwareMetrics is false, send null patch for system metrics to cloud API
const currentInfo = {
...((await config.get('hardwareMetrics'))
? await sysInfo.getSystemMetrics()
: {
cpu_usage: null,
memory_usage: null,
memory_total: null,
storage_usage: null,
storage_total: null,
storage_block_device: null,
cpu_temp: null,
cpu_id: null,
}),
...(await sysInfo.getSystemChecks()),
};
return Object.assign(
{} as sysInfo.SystemInfo,
...Object.keys(currentInfo).map((key: keyof sysInfo.SystemInfo) => ({
[key]: sysInfo.isSignificantChange(
key,
lastInfo[key] as number,
currentInfo[key] as number,
)
? (currentInfo[key] as number)
: (lastInfo[key] as number),
})),
);
}
// Return current state in a way that the API understands
export async function getCurrentForReport(): Promise<DeviceState> {
export async function getCurrentForReport(
lastReport = {} as DeviceState,
): Promise<DeviceState> {
const apps = await applicationManager.getState();
const { name, uuid } = await config.getMany(['name', 'uuid']);
const { name, uuid, localMode } = await config.getMany([
'name',
'uuid',
'localMode',
]);
if (!uuid) {
throw new InternalInconsistencyError('No uuid found for local device');
}
const omitFromReport = [
'update_pending',
'update_downloaded',
'update_failed',
...(localMode ? ['apps', 'logs_channel'] : []),
];
const systemInfo = await getSysInfo(lastReport[uuid] ?? {});
return {
[uuid]: {
name,
apps,
},
[uuid]: _.omitBy(
{
...currentVolatile,
...systemInfo,
name,
apps,
},
(__, key) => omitFromReport.includes(key),
),
};
}
@ -607,7 +662,7 @@ export async function getCurrentState(): Promise<InstancedDeviceState> {
};
}
export function reportCurrentState(newState: DeviceLegacyReport = {}) {
export function reportCurrentState(newState: DeviceReport = {}) {
if (newState == null) {
newState = {};
}

134
src/lib/json.ts Normal file
View File

@ -0,0 +1,134 @@
function isObject(value: unknown): value is object {
return typeof value === 'object' && value !== null;
}
/**
* Calculates deep equality between javascript
* objects
*/
export function equals<T>(value: T, other: T): boolean {
if (isObject(value) && isObject(other)) {
const [vProps, oProps] = [value, other].map(
(a) => Object.getOwnPropertyNames(a) as Array<keyof T>,
);
if (vProps.length !== oProps.length) {
// If the property lists are different lengths we don't need
// to check any further
return false;
}
// Otherwise this comparison will catch it. This works even
// for arrays as getOwnPropertyNames returns the list of indexes
// for each array
return vProps.every((key) => equals(value[key], other[key]));
}
return value === other;
}
/**
* Returns true if the the object equals `{}` or is an empty
* array
*/
export function empty<T>(value: T): boolean {
return (Array.isArray(value) && value.length === 0) || equals(value, {});
}
/**
* Calculate the difference between the dst object and src object
* and return both the object and whether there are any changes
*/
function diffcmp<T>(src: T, tgt: T, depth = Infinity): [Partial<T>, boolean] {
if (!isObject(src) || !isObject(tgt)) {
// Always returns tgt in this case, but let the caller
// know if there have been any changes
return [tgt, src !== tgt];
}
// Compare arrays when reporting differences
if (Array.isArray(src) || Array.isArray(tgt) || depth === 0) {
return [tgt, !equals(src, tgt)];
}
const r = (Object.getOwnPropertyNames(tgt) as Array<keyof T>)
.map((key) => {
const [delta, changed] = diffcmp(src[key], tgt[key], depth - 1);
return changed ? { [key]: delta } : {};
})
.concat(
(Object.getOwnPropertyNames(src) as Array<keyof T>).map((key) => {
const [delta, changed] = diffcmp(src[key], tgt[key], depth - 1);
return changed ? { [key]: delta } : {};
}),
)
.reduce((res, delta) => ({ ...res, ...delta }), {} as Partial<T>);
return [r, Object.keys(r).length > 0];
}
/**
* Calculate the difference between the target object and the source object.
*
* This considers both additive and substractive differences. If both the source
* and target elements are arrays, it returns the value of the target array
* (no array comparison)
* e.g.
* ```
* // Returns `{b:2}`
* diff({a:1}, {a: 1, b:2})
*
* // Returns `{b: undefined}`
* diff({a:1, b:2}, {a: 1})
* ```
*/
export function diff<T>(src: T, tgt: T): Partial<T> {
const [res] = diffcmp(src, tgt);
return res;
}
/**
* Calulate the difference between the target object and the source
* object up to the given depth.
*
* If depth is 0, it compares using `equals` and return the target if they are
* different
*
* shallowDiff(src,tgt, Infinity) return the same result as diff(src, tgt)
*/
export function shallowDiff<T>(src: T, tgt: T, depth = 1): Partial<T> {
const [res] = diffcmp(src, tgt, depth);
return res;
}
/**
* Removes empty branches from the json object
*
* e.g.
* ```
* prune({a: 1, b: {}})
* // Returns `{a: 1}`
* prune({a: 1, b: {}})
*
* // Returns `{a: 1, b: {c:1}}`
* prune({a: 1, b: {c: 1, d: {}}})
* ```
*/
export function prune<T>(obj: T): Partial<T> {
if (!isObject(obj) || Array.isArray(obj)) {
return obj;
}
return (Object.getOwnPropertyNames(obj) as Array<keyof T>)
.map((key) => {
const prunedChild = prune(obj[key]);
if (
isObject(obj[key]) &&
!Array.isArray(obj) &&
equals(prunedChild, {})
) {
return {};
}
return { [key]: prunedChild };
})
.reduce((res, delta) => ({ ...res, ...delta }), {} as Partial<T>);
}

View File

@ -29,7 +29,7 @@ export type DeviceLegacyReport = Partial<{
update_failed: boolean;
update_pending: boolean;
update_downloaded: boolean;
logs_channel: null;
logs_channel: string | null;
mac_address: string | null;
}>;
@ -85,12 +85,12 @@ export type DeviceReport = {
os_variant?: string | null; // TODO: Should these purely come from the os app?
supervisor_version?: string; // TODO: Should this purely come from the supervisor app?
provisioning_progress?: number | null; // TODO: should this be reported as part of the os app?
provisioning_state?: string | null; // TODO: should this be reported as part of the os app?
provisioning_state?: string; // TODO: should this be reported as part of the os app?
ip_address?: string;
mac_address?: string | null;
api_port?: number; // TODO: should this be reported as part of the supervisor app?
api_secret?: string | null; // TODO: should this be reported as part of the supervisor app?
logs_channel?: string; // TODO: should this be reported as part of the supervisor app? or should it not be reported anymore at all?
logs_channel?: string | null; // TODO: should this be reported as part of the supervisor app? or should it not be reported anymore at all?
memory_usage?: number;
memory_total?: number;
storage_block_device?: string;
@ -100,7 +100,7 @@ export type DeviceReport = {
cpu_usage?: number;
cpu_id?: string;
is_undervolted?: boolean;
// TODO: these are ignored by the API but are used by supervisor local API, remove?
// TODO: these are ignored by the API but are used by supervisor local API
update_failed?: boolean;
update_pending?: boolean;
update_downloaded?: boolean;

159
test/src/lib/json.spec.ts Normal file
View File

@ -0,0 +1,159 @@
import { expect } from 'chai';
import { equals, diff, prune, shallowDiff } from '../../../src/lib/json';
describe('JSON utils', () => {
describe('equals', () => {
it('should compare non-objects', () => {
expect(equals(0, 1)).to.be.false;
expect(equals(1111, 'a' as any)).to.be.false;
expect(equals(1111, 2222)).to.be.false;
expect(equals('aaa', 'bbb')).to.be.false;
expect(equals('aaa', 'aaa')).to.be.true;
expect(equals(null, null)).to.be.true;
expect(equals(null, undefined)).to.be.false;
expect(equals([], [])).to.be.true;
expect(equals([1, 2, 3], [1, 2, 3])).to.be.true;
expect(equals([1, 2, 3], [1, 2])).to.be.false;
expect(equals([], []), 'empty arrays').to.be.true;
});
it('should compare objects recursively', () => {
expect(equals({}, {}), 'empty objects').to.be.true;
expect(equals({ a: 1 }, { a: 1 }), 'single level objects').to.be.true;
expect(equals({ a: 1 }, { a: 2 }), 'differing value single level objects')
.to.be.false;
expect(equals({ a: 1 }, { b: 1 }), 'differing keys single level objects');
expect(
equals({ a: 1 }, { b: 1, c: 2 }),
'differing keys single level objects',
).to.be.false;
expect(equals({ a: { b: 1 } }, { a: { b: 1 } }), 'multiple level objects')
.to.be.true;
expect(
equals({ a: { b: 1 } }, { a: { b: 1, c: 2 } }),
'extra keys in multiple level objects',
).to.be.false;
expect(
equals({ a: { b: 1 }, c: 2 }, { a: { b: 1 } }),
'source object with extra keys',
).to.be.false;
expect(
equals({ a: { b: 1 } }, { a: { b: 1 }, c: 2 }),
'other object with extra keys',
).to.be.false;
expect(
equals({ a: { b: 1 }, c: 2 }, { a: { b: 1 }, c: 2 }),
'multiple level objects with extra keys',
).to.be.true;
expect(
equals({ a: { b: 1 }, d: 2 }, { a: { b: 1 }, c: 2 }),
'multiple level objects with same number of keys',
).to.be.false;
});
});
describe('diff', () => {
it('when comparing non-objects or arrays, always returns the target value', () => {
expect(diff(1, 2)).to.equal(2);
expect(diff(1, 'a' as any)).to.equal('a');
expect(diff(1.1, 2)).to.equal(2);
expect(diff('aaa', 'bbb')).to.equal('bbb');
expect(diff({}, 'bbb' as any)).to.equal('bbb');
expect(diff([1, 2, 3], [3, 4, 5])).to.deep.equal([3, 4, 5]);
});
it('when comparing objects, calculates differences recursively', () => {
// Reports all changes
expect(diff({ a: 1 }, { b: 1 })).to.deep.equal({ a: undefined, b: 1 });
// Only reports array changes if arrays are different
expect(diff({ a: [1, 2] }, { a: [1, 2] })).to.deep.equal({});
// Multiple key comparisons
expect(diff({ a: 1, b: 1 }, { b: 2 })).to.deep.equal({
a: undefined,
b: 2,
});
// Multiple target keys
expect(diff({ a: 1 }, { b: 2, c: 1 })).to.deep.equal({
a: undefined,
b: 2,
c: 1,
});
// Removing a branch
expect(diff({ a: 1, b: { c: 1 } }, { a: 1 })).to.deep.equal({
b: undefined,
});
// If the arrays are different, return target array
expect(diff({ a: [1, 2] }, { a: [2, 3] })).to.deep.equal({ a: [2, 3] });
// Value to object
expect(diff({ a: 1 }, { a: { c: 1 }, b: 2 })).to.deep.equal({
a: { c: 1 },
b: 2,
});
// Changes in nested object
expect(diff({ a: { c: 1 } }, { a: { c: 1, d: 2 }, b: 3 })).to.deep.equal({
a: { d: 2 },
b: 3,
});
// Multiple level nested objects with value removal
expect(
diff({ a: { c: { f: 1, g: 2 } } }, { a: { c: { f: 2 }, d: 2 }, b: 3 }),
).to.deep.equal({
a: { c: { f: 2, g: undefined }, d: 2 },
b: 3,
});
});
});
describe('shallowDiff', () => {
it('compares objects only to the given depth', () => {
expect(shallowDiff({ a: 1 }, { a: 1 }, 0)).to.deep.equal({ a: 1 });
expect(shallowDiff({ a: 1 }, { a: 1 }, 1)).to.deep.equal({});
expect(shallowDiff({ a: 1 }, { a: 1 }, 2)).to.deep.equal({});
expect(shallowDiff({ a: 1, b: 1 }, { a: 1 }, 1)).to.deep.equal({
b: undefined,
});
expect(
shallowDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }, 1),
).to.deep.equal({});
expect(
shallowDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2, d: 3 } }, 1),
).to.deep.equal({ b: { c: 2, d: 3 } });
expect(
shallowDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2, d: 3 } }, 2),
).to.deep.equal({ b: { d: 3 } });
});
});
describe('prune', () => {
it('does not remove empty arrays or other "empty values"', () => {
expect(prune([])).to.deep.equal([]);
expect(prune([0])).to.deep.equal([0]);
expect(prune(0)).to.deep.equal(0);
expect(prune({ a: 0 })).to.deep.equal({ a: 0 });
expect(prune({ a: [] })).to.deep.equal({ a: [] });
expect(prune({ a: [], b: 0 })).to.deep.equal({ a: [], b: 0 });
});
it('removes empty branches from a json object', () => {
expect(prune({})).to.deep.equal({});
expect(prune({ a: {} })).to.deep.equal({});
expect(prune({ a: { b: {} } })).to.deep.equal({});
expect(prune({ a: 1, b: {} })).to.deep.equal({ a: 1 });
expect(prune({ a: 1, b: {}, c: { d: 2, e: {} } })).to.deep.equal({
a: 1,
c: { d: 2 },
});
expect(prune({ a: 1, b: {}, c: { d: 2, e: [] } })).to.deep.equal({
a: 1,
c: { d: 2, e: [] },
});
});
});
});