Split target state set/get into separate module

This removes the circular dependency between device state index and
preloading and cleans up the device state module code

Change-type: patch
This commit is contained in:
Felipe Lalanne 2024-05-28 12:40:21 -04:00 committed by Felipe Lalanne
parent fedeb518e5
commit 45d1f1f964
No known key found for this signature in database
GPG Key ID: 03E696BFD472B26A
3 changed files with 158 additions and 130 deletions

View File

@ -3,34 +3,28 @@ import { stripIndent } from 'common-tags';
import { EventEmitter } from 'events';
import _ from 'lodash';
import type StrictEventEmitter from 'strict-event-emitter-types';
import { isRight } from 'fp-ts/lib/Either';
import Reporter from 'io-ts-reporters';
import prettyMs from 'pretty-ms';
import * as config from '../config';
import * as db from '../db';
import * as logger from '../logger';
import * as globalEventBus from '../event-bus';
import * as network from '../network';
import * as deviceConfig from '../device-config';
import * as constants from '../lib/constants';
import * as dbus from '../lib/dbus';
import {
InternalInconsistencyError,
TargetStateError,
UpdatesLockedError,
} from '../lib/errors';
import { takeGlobalLockRW } from '../lib/process-lock';
import { InternalInconsistencyError, UpdatesLockedError } from '../lib/errors';
import * as updateLock from '../lib/update-lock';
import { takeGlobalLockRO, takeGlobalLockRW } from '../lib/process-lock';
import * as dbFormat from './db-format';
import { getGlobalApiKey } from '../lib/api-keys';
import * as sysInfo from '../lib/system-info';
import { log } from '../lib/supervisor-console';
import { loadTargetFromFile } from './preload';
import * as applicationManager from '../compose/application-manager';
import * as commitStore from '../compose/commit';
import type { InstancedDeviceState } from './target-state';
import * as TargetState from './target-state';
export { getTarget, setTarget } from './target-state';
import type {
DeviceLegacyState,
@ -38,38 +32,11 @@ import type {
DeviceReport,
AppState,
} from '../types';
import { TargetState } from '../types';
import type {
CompositionStepT,
CompositionStepAction,
} from '../compose/composition-steps';
import * as fsUtils from '../lib/fs-utils';
import { pathOnRoot } from '../lib/host-utils';
import { setTimeout } from 'timers/promises';
import type { InstancedAppState } from '../compose/types';
const TARGET_STATE_CONFIG_DUMP = pathOnRoot(
'/tmp/balena-supervisor/target-state-config',
);
interface InstancedDeviceState {
local: {
name: string;
config: Dictionary<string>;
apps: InstancedAppState;
};
}
function parseTargetState(state: unknown): TargetState {
const res = TargetState.decode(state);
if (isRight(res)) {
return res.right;
}
const errors = ['Invalid target state.'].concat(Reporter.report(res));
throw new TargetStateError(errors.join('\n'));
}
interface DeviceStateEvents {
error: Error;
@ -111,11 +78,9 @@ type DeviceStateStep<T extends PossibleStepTargets> =
let currentVolatile: DeviceReport = {};
let maxPollTime: number;
let intermediateTarget: InstancedDeviceState | null = null;
let applyBlocker: Nullable<Promise<void>>;
let cancelDelay: null | (() => void) = null;
let failedUpdates: number = 0;
let applyCancelled = false;
let lastApplyStart = process.hrtime();
let scheduledApply: { force?: boolean; delay?: number } | null = null;
@ -301,96 +266,14 @@ function emitAsync<T extends keyof DeviceStateEvents>(
return setImmediate(() => events.emit(ev as any, ...args));
}
const readLockTarget = () =>
takeGlobalLockRO('target').disposer((release) => release());
const writeLockTarget = () =>
takeGlobalLockRW('target').disposer((release) => release());
const inferStepsLock = () =>
takeGlobalLockRW('inferSteps').disposer((release) => release());
function usingReadLockTarget<T extends () => any, U extends ReturnType<T>>(
fn: T,
): Bluebird<UnwrappedPromise<U>> {
return Bluebird.using(readLockTarget, () => fn());
}
function usingWriteLockTarget<T extends () => any, U extends ReturnType<T>>(
fn: T,
): Bluebird<UnwrappedPromise<U>> {
return Bluebird.using(writeLockTarget, () => fn());
}
function usingInferStepsLock<T extends () => any, U extends ReturnType<T>>(
fn: T,
): Bluebird<UnwrappedPromise<U>> {
return Bluebird.using(inferStepsLock, () => fn());
}
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
// This means that we can attempt to apply the new state instantly
if (localSource == null) {
localSource = false;
}
failedUpdates = 0;
// This will throw if target state is invalid
target = parseTargetState(target);
globalEventBus.getInstance().emit('targetStateChanged', target);
const { uuid, apiEndpoint } = await config.getMany(['uuid', 'apiEndpoint']);
if (!uuid || !target[uuid]) {
throw new Error(
`Expected target state for local device with uuid '${uuid}'.`,
);
}
const localTarget = target[uuid];
await fsUtils.writeAndSyncFile(
TARGET_STATE_CONFIG_DUMP,
JSON.stringify(localTarget.config),
);
await usingWriteLockTarget(async () => {
await db.transaction(async (trx) => {
await config.set({ name: localTarget.name }, trx);
await deviceConfig.setTarget(localTarget.config, trx);
if (localSource || apiEndpoint == null || apiEndpoint === '') {
await applicationManager.setTarget(localTarget.apps, 'local', trx);
} else {
await applicationManager.setTarget(localTarget.apps, apiEndpoint, trx);
}
await config.set({ targetStateSet: true }, trx);
});
});
}
export function getTarget({
initial = false,
intermediate = false,
}: {
initial?: boolean;
intermediate?: boolean;
} = {}): Bluebird<InstancedDeviceState> {
return usingReadLockTarget(async () => {
if (intermediate) {
return intermediateTarget!;
}
return {
local: {
name: await config.get('name'),
config: await deviceConfig.getTarget({ initial }),
apps: await dbFormat.getApps(),
},
};
});
}
// This returns the current state of the device in (more or less)
// the same format as the target state. This method,
// getCurrent and getCurrentForComparison should probably get
@ -649,7 +532,7 @@ function applyError(
if (intermediate) {
throw err;
}
failedUpdates += 1;
TargetState.increaseFailedUpdates();
reportCurrentState({ update_failed: true });
if (scheduledApply != null) {
if (!(err instanceof UpdatesLockedError)) {
@ -660,7 +543,7 @@ function applyError(
}
} else {
const delay = Math.min(
Math.pow(2, failedUpdates) * constants.backoffIncrement,
Math.pow(2, TargetState.getFailedUpdates()) * constants.backoffIncrement,
maxPollTime,
);
// If there was an error then schedule another attempt briefly in the future.
@ -697,7 +580,7 @@ export const applyTarget = async ({
return usingInferStepsLock(async () => {
const [currentState, targetState] = await Promise.all([
getCurrentState(),
getTarget({ initial, intermediate }),
TargetState.getTarget({ initial, intermediate }),
]);
const deviceConfigSteps = await deviceConfig.getRequiredSteps(
currentState,
@ -744,7 +627,7 @@ export const applyTarget = async ({
if (!intermediate) {
log.debug('Finished applying target state');
applicationManager.resetTimeSpentFetching();
failedUpdates = 0;
TargetState.resetFailedUpdates();
lastSuccessfulUpdate = Date.now();
reportCurrentState({
update_failed: false,
@ -885,14 +768,14 @@ export async function applyIntermediateTarget(
) {
return pausingApply(async () => {
// TODO: Make sure we don't accidentally overwrite this
intermediateTarget = intermediate;
TargetState.setIntermediateTarget(intermediate);
applyInProgress = true;
return applyTarget({
intermediate: true,
force,
keepVolumes,
}).then(() => {
intermediateTarget = null;
TargetState.setIntermediateTarget(null);
applyInProgress = false;
});
});

View File

@ -4,7 +4,7 @@ import { promises as fs } from 'fs';
import type { Image } from '../compose/images';
import { imageFromService } from '../compose/images';
import { NumericIdentifier } from '../types';
import * as deviceState from '../device-state';
import { setTarget } from './target-state';
import * as config from '../config';
import * as deviceConfig from '../device-config';
import * as eventTracker from '../event-tracker';
@ -136,7 +136,7 @@ export async function loadTargetFromFile(appsPath: string): Promise<boolean> {
},
};
await deviceState.setTarget(localState);
await setTarget(localState);
await migrateAppsJson(appsPath);
log.success('Preloading complete');
if (preloadState.pinDevice) {

View File

@ -0,0 +1,145 @@
import Bluebird from 'bluebird';
import { isRight } from 'fp-ts/lib/Either';
import Reporter from 'io-ts-reporters';
import * as config from '../config';
import * as db from '../db';
import * as globalEventBus from '../event-bus';
import * as deviceConfig from '../device-config';
import { TargetStateError } from '../lib/errors';
import { takeGlobalLockRO, takeGlobalLockRW } from '../lib/process-lock';
import * as dbFormat from './db-format';
import * as applicationManager from '../compose/application-manager';
import { TargetState } from '../types';
import * as fsUtils from '../lib/fs-utils';
import { pathOnRoot } from '../lib/host-utils';
import type { InstancedAppState } from '../compose/types';
const TARGET_STATE_CONFIG_DUMP = pathOnRoot(
'/tmp/balena-supervisor/target-state-config',
);
export interface InstancedDeviceState {
local: {
name: string;
config: Dictionary<string>;
apps: InstancedAppState;
};
}
function parseTargetState(state: unknown): TargetState {
const res = TargetState.decode(state);
if (isRight(res)) {
return res.right;
}
const errors = ['Invalid target state.'].concat(Reporter.report(res));
throw new TargetStateError(errors.join('\n'));
}
// TODO: avoid singletons
let failedUpdates: number = 0;
let intermediateTarget: InstancedDeviceState | null = null;
const readLockTarget = () =>
takeGlobalLockRO('target').disposer((release) => release());
const writeLockTarget = () =>
takeGlobalLockRW('target').disposer((release) => release());
function usingReadLockTarget<T extends () => any, U extends ReturnType<T>>(
fn: T,
): Bluebird<UnwrappedPromise<U>> {
return Bluebird.using(readLockTarget, () => fn());
}
function usingWriteLockTarget<T extends () => any, U extends ReturnType<T>>(
fn: T,
): Bluebird<UnwrappedPromise<U>> {
return Bluebird.using(writeLockTarget, () => fn());
}
export function resetFailedUpdates() {
failedUpdates = 0;
}
export function increaseFailedUpdates() {
failedUpdates += 1;
}
export function getFailedUpdates() {
return failedUpdates;
}
export function setIntermediateTarget(tgt: InstancedDeviceState | null) {
intermediateTarget = tgt;
}
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
// This means that we can attempt to apply the new state instantly
if (localSource == null) {
localSource = false;
}
failedUpdates = 0;
// This will throw if target state is invalid
target = parseTargetState(target);
globalEventBus.getInstance().emit('targetStateChanged', target);
const { uuid, apiEndpoint } = await config.getMany(['uuid', 'apiEndpoint']);
if (!uuid || !target[uuid]) {
throw new Error(
`Expected target state for local device with uuid '${uuid}'.`,
);
}
const localTarget = target[uuid];
await fsUtils.writeAndSyncFile(
TARGET_STATE_CONFIG_DUMP,
JSON.stringify(localTarget.config),
);
await usingWriteLockTarget(async () => {
await db.transaction(async (trx) => {
await config.set({ name: localTarget.name }, trx);
await deviceConfig.setTarget(localTarget.config, trx);
if (localSource || apiEndpoint == null || apiEndpoint === '') {
await applicationManager.setTarget(localTarget.apps, 'local', trx);
} else {
await applicationManager.setTarget(localTarget.apps, apiEndpoint, trx);
}
await config.set({ targetStateSet: true }, trx);
});
});
}
export function getTarget({
initial = false,
intermediate = false,
}: {
initial?: boolean;
intermediate?: boolean;
} = {}): Bluebird<InstancedDeviceState> {
return usingReadLockTarget(async () => {
if (intermediate) {
return intermediateTarget!;
}
return {
local: {
name: await config.get('name'),
config: await deviceConfig.getTarget({ initial }),
apps: await dbFormat.getApps(),
},
};
});
}