Simplify lock interface to prep for adding takeLock to state funnel

This commit changes a few things:

* Pass `force` to `takeLock` step directly. This allows us to remove
the `lockFn` used by app manager's action executors, setting takeLock
as the main interface to interact with the update lock module. Note
that this commit by itself will not pass tests, as no update locking
occurs where it once did. This will be amended in the next commit.

* Remove locking functions from doRestart & doPurge, as this is
the only area where skipLock is required.

* Remove `skipLock` interface, as it's redundant with the functionality
of `force`. The only time `skipLock` is true is in doRestart/doPurge,
as those API methods are already run within a lock function. We removed
the lock function which removes the need for skipLock, and in the next
commit we'll add locking as a composition step to replace the
functionality removed here.

* Remove some methods not in use, such as app manager's `stopAll`.

Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
Christina Ying Wang 2024-03-05 23:44:31 -08:00
parent 2f728ee43e
commit cf8d8cedd7
9 changed files with 480 additions and 222 deletions

View File

@ -45,6 +45,7 @@ export interface UpdateState {
containerIds: Dictionary<string>; containerIds: Dictionary<string>;
downloading: string[]; downloading: string[];
locksTaken: LocksTakenMap; locksTaken: LocksTakenMap;
force: boolean;
} }
interface ChangingPair<T> { interface ChangingPair<T> {
@ -111,8 +112,15 @@ export class App {
// Check to see if we need to polyfill in some "new" data for legacy services // Check to see if we need to polyfill in some "new" data for legacy services
this.migrateLegacy(target); this.migrateLegacy(target);
// Check for changes in the volumes. We don't remove any volumes until we remove an let steps: CompositionStep[] = [];
// entire app
// Any services which have died get a remove step
for (const service of this.services) {
if (service.status === 'Dead') {
steps.push(generateStep('remove', { current: service }));
}
}
const volumeChanges = this.compareComponents( const volumeChanges = this.compareComponents(
this.volumes, this.volumes,
target.volumes, target.volumes,
@ -124,15 +132,6 @@ export class App {
true, true,
); );
let steps: CompositionStep[] = [];
// Any services which have died get a remove step
for (const service of this.services) {
if (service.status === 'Dead') {
steps.push(generateStep('remove', { current: service }));
}
}
const { removePairs, installPairs, updatePairs } = this.compareServices( const { removePairs, installPairs, updatePairs } = this.compareServices(
this.services, this.services,
target.services, target.services,
@ -571,7 +570,12 @@ export class App {
current.isEqualConfig(target, context.containerIds) current.isEqualConfig(target, context.containerIds)
) { ) {
// Update service metadata or start/stop a service // Update service metadata or start/stop a service
return this.generateContainerStep(current, target, context.locksTaken); return this.generateContainerStep(
current,
target,
context.locksTaken,
context.force,
);
} }
let strategy: string; let strategy: string;
@ -664,6 +668,7 @@ export class App {
current: Service, current: Service,
target: Service, target: Service,
locksTaken: LocksTakenMap, locksTaken: LocksTakenMap,
force: boolean,
) { ) {
// Update container metadata if service release has changed // Update container metadata if service release has changed
if (current.commit !== target.commit) { if (current.commit !== target.commit) {
@ -679,6 +684,7 @@ export class App {
return generateStep('takeLock', { return generateStep('takeLock', {
appId: target.appId, appId: target.appId,
services: [target.serviceName], services: [target.serviceName],
force,
}); });
} else if (target.config.running !== current.config.running) { } else if (target.config.running !== current.config.running) {
if (target.config.running) { if (target.config.running) {

View File

@ -17,7 +17,7 @@ import {
ContractViolationError, ContractViolationError,
InternalInconsistencyError, InternalInconsistencyError,
} from '../lib/errors'; } from '../lib/errors';
import { getServicesLockedByAppId, lock } from '../lib/update-lock'; import { getServicesLockedByAppId } from '../lib/update-lock';
import { checkTruthy } from '../lib/validation'; import { checkTruthy } from '../lib/validation';
import App from './app'; import App from './app';
@ -64,7 +64,6 @@ export function resetTimeSpentFetching(value: number = 0) {
} }
const actionExecutors = getExecutors({ const actionExecutors = getExecutors({
lockFn: lock,
callbacks: { callbacks: {
fetchStart: () => { fetchStart: () => {
fetchesInProgress += 1; fetchesInProgress += 1;
@ -120,6 +119,7 @@ export async function getRequiredSteps(
targetApps: InstancedAppState, targetApps: InstancedAppState,
keepImages?: boolean, keepImages?: boolean,
keepVolumes?: boolean, keepVolumes?: boolean,
force: boolean = false,
): Promise<CompositionStep[]> { ): Promise<CompositionStep[]> {
// get some required data // get some required data
const [downloading, availableImages, { localMode, delta }] = const [downloading, availableImages, { localMode, delta }] =
@ -146,6 +146,7 @@ export async function getRequiredSteps(
// Volumes are not removed when stopping an app when going to local mode // Volumes are not removed when stopping an app when going to local mode
keepVolumes, keepVolumes,
delta, delta,
force,
downloading, downloading,
availableImages, availableImages,
containerIdsByAppId, containerIdsByAppId,
@ -161,6 +162,7 @@ export async function inferNextSteps(
keepImages = false, keepImages = false,
keepVolumes = false, keepVolumes = false,
delta = true, delta = true,
force = false,
downloading = [] as UpdateState['downloading'], downloading = [] as UpdateState['downloading'],
availableImages = [] as UpdateState['availableImages'], availableImages = [] as UpdateState['availableImages'],
containerIdsByAppId = {} as { containerIdsByAppId = {} as {
@ -219,6 +221,7 @@ export async function inferNextSteps(
containerIds: containerIdsByAppId[id], containerIds: containerIdsByAppId[id],
downloading, downloading,
locksTaken, locksTaken,
force,
}, },
targetApps[id], targetApps[id],
), ),
@ -233,6 +236,7 @@ export async function inferNextSteps(
downloading, downloading,
containerIds: containerIdsByAppId[id], containerIds: containerIdsByAppId[id],
locksTaken, locksTaken,
force,
}), }),
); );
} }
@ -257,6 +261,7 @@ export async function inferNextSteps(
containerIds: containerIdsByAppId[id] ?? {}, containerIds: containerIdsByAppId[id] ?? {},
downloading, downloading,
locksTaken, locksTaken,
force,
}, },
targetApps[id], targetApps[id],
), ),
@ -301,17 +306,6 @@ export async function inferNextSteps(
return steps; return steps;
} }
export async function stopAll({ force = false, skipLock = false } = {}) {
const services = await serviceManager.getAll();
await Promise.all(
services.map(async (s) => {
return lock(s.appId, { force, skipLock }, async () => {
await serviceManager.kill(s, { removeContainer: false, wait: true });
});
}),
);
}
// The following two function may look pretty odd, but after the move to uuids, // The following two function may look pretty odd, but after the move to uuids,
// there's a chance that the current running apps don't have a uuid set. We // there's a chance that the current running apps don't have a uuid set. We
// still need to be able to work on these and perform various state changes. To // still need to be able to work on these and perform various state changes. To
@ -501,7 +495,7 @@ function killServicesUsingApi(current: InstancedAppState): CompositionStep[] {
// intermediate targets to perform changes // intermediate targets to perform changes
export async function executeStep( export async function executeStep(
step: CompositionStep, step: CompositionStep,
{ force = false, skipLock = false } = {}, { force = false } = {},
): Promise<void> { ): Promise<void> {
if (!validActions.includes(step.action)) { if (!validActions.includes(step.action)) {
return Promise.reject( return Promise.reject(
@ -515,7 +509,6 @@ export async function executeStep(
await actionExecutors[step.action]({ await actionExecutors[step.action]({
...step, ...step,
force, force,
skipLock,
} as any); } as any);
} }

View File

@ -1,5 +1,3 @@
import _ from 'lodash';
import * as config from '../config'; import * as config from '../config';
import type { Image } from './images'; import type { Image } from './images';
import * as images from './images'; import * as images from './images';
@ -13,33 +11,22 @@ import * as commitStore from './commit';
import * as updateLock from '../lib/update-lock'; import * as updateLock from '../lib/update-lock';
import type { DeviceLegacyReport } from '../types/state'; import type { DeviceLegacyReport } from '../types/state';
interface BaseCompositionStepArgs {
force?: boolean;
skipLock?: boolean;
}
// FIXME: Most of the steps take the
// BaseCompositionStepArgs, but some also take an options
// structure which includes some of the same fields. It
// would be nice to remove the need for this
interface CompositionStepArgs { interface CompositionStepArgs {
stop: { stop: {
current: Service; current: Service;
options?: { options?: {
skipLock?: boolean;
wait?: boolean; wait?: boolean;
}; };
} & BaseCompositionStepArgs; };
kill: { kill: {
current: Service; current: Service;
options?: { options?: {
skipLock?: boolean;
wait?: boolean; wait?: boolean;
}; };
} & BaseCompositionStepArgs; };
remove: { remove: {
current: Service; current: Service;
} & BaseCompositionStepArgs; };
updateMetadata: { updateMetadata: {
current: Service; current: Service;
target: Service; target: Service;
@ -47,13 +34,10 @@ interface CompositionStepArgs {
restart: { restart: {
current: Service; current: Service;
target: Service; target: Service;
options?: { };
skipLock?: boolean;
};
} & BaseCompositionStepArgs;
start: { start: {
target: Service; target: Service;
} & BaseCompositionStepArgs; };
updateCommit: { updateCommit: {
target: string; target: string;
appId: number; appId: number;
@ -62,10 +46,9 @@ interface CompositionStepArgs {
current: Service; current: Service;
target: Service; target: Service;
options?: { options?: {
skipLock?: boolean;
timeout?: number; timeout?: number;
}; };
} & BaseCompositionStepArgs; };
fetch: { fetch: {
image: Image; image: Image;
serviceName: string; serviceName: string;
@ -94,6 +77,7 @@ interface CompositionStepArgs {
takeLock: { takeLock: {
appId: number; appId: number;
services: string[]; services: string[];
force: boolean;
}; };
releaseLock: { releaseLock: {
appId: number; appId: number;
@ -119,13 +103,6 @@ export function generateStep<T extends CompositionStepAction>(
type Executors<T extends CompositionStepAction> = { type Executors<T extends CompositionStepAction> = {
[key in T]: (step: CompositionStepT<key>) => Promise<unknown>; [key in T]: (step: CompositionStepT<key>) => Promise<unknown>;
}; };
type LockingFn = (
// TODO: Once the entire codebase is typescript, change
// this to number
app: number | number[] | null,
args: BaseCompositionStepArgs,
fn: () => Promise<unknown>,
) => Promise<unknown>;
interface CompositionCallbacks { interface CompositionCallbacks {
// TODO: Once the entire codebase is typescript, change // TODO: Once the entire codebase is typescript, change
@ -137,38 +114,20 @@ interface CompositionCallbacks {
bestDeltaSource: (image: Image, available: Image[]) => string | null; bestDeltaSource: (image: Image, available: Image[]) => string | null;
} }
export function getExecutors(app: { export function getExecutors(app: { callbacks: CompositionCallbacks }) {
lockFn: LockingFn;
callbacks: CompositionCallbacks;
}) {
const executors: Executors<CompositionStepAction> = { const executors: Executors<CompositionStepAction> = {
stop: (step) => { stop: async (step) => {
return app.lockFn( // Should always be preceded by a takeLock step,
step.current.appId, // so the call is executed assuming that the lock is taken.
{ await serviceManager.kill(step.current, {
force: step.force, removeContainer: false,
skipLock: step.skipLock || _.get(step, ['options', 'skipLock']), wait: step.options?.wait || false,
}, });
async () => {
const wait = _.get(step, ['options', 'wait'], false);
await serviceManager.kill(step.current, {
removeContainer: false,
wait,
});
},
);
}, },
kill: (step) => { kill: async (step) => {
return app.lockFn( // Should always be preceded by a takeLock step,
step.current.appId, // so the call is executed assuming that the lock is taken.
{ await serviceManager.kill(step.current);
force: step.force,
skipLock: step.skipLock || _.get(step, ['options', 'skipLock']),
},
async () => {
await serviceManager.kill(step.current);
},
);
}, },
remove: async (step) => { remove: async (step) => {
// Only called for dead containers, so no need to // Only called for dead containers, so no need to
@ -180,18 +139,11 @@ export function getExecutors(app: {
// so the call is executed assuming that the lock is taken. // so the call is executed assuming that the lock is taken.
await serviceManager.updateMetadata(step.current, step.target); await serviceManager.updateMetadata(step.current, step.target);
}, },
restart: (step) => { restart: async (step) => {
return app.lockFn( // Should always be preceded by a takeLock step,
step.current.appId, // so the call is executed assuming that the lock is taken.
{ await serviceManager.kill(step.current, { wait: true });
force: step.force, await serviceManager.start(step.target);
skipLock: step.skipLock || _.get(step, ['options', 'skipLock']),
},
async () => {
await serviceManager.kill(step.current, { wait: true });
await serviceManager.start(step.target);
},
);
}, },
start: async (step) => { start: async (step) => {
await serviceManager.start(step.target); await serviceManager.start(step.target);
@ -199,17 +151,10 @@ export function getExecutors(app: {
updateCommit: async (step) => { updateCommit: async (step) => {
await commitStore.upsertCommitForApp(step.appId, step.target); await commitStore.upsertCommitForApp(step.appId, step.target);
}, },
handover: (step) => { handover: async (step) => {
return app.lockFn( // Should always be preceded by a takeLock step,
step.current.appId, // so the call is executed assuming that the lock is taken.
{ await serviceManager.handover(step.current, step.target);
force: step.force,
skipLock: step.skipLock || _.get(step, ['options', 'skipLock']),
},
async () => {
await serviceManager.handover(step.current, step.target);
},
);
}, },
fetch: async (step) => { fetch: async (step) => {
const startTime = process.hrtime(); const startTime = process.hrtime();
@ -271,7 +216,7 @@ export function getExecutors(app: {
/* async noop */ /* async noop */
}, },
takeLock: async (step) => { takeLock: async (step) => {
await updateLock.takeLock(step.appId, step.services); await updateLock.takeLock(step.appId, step.services, step.force);
}, },
releaseLock: async (step) => { releaseLock: async (step) => {
await updateLock.releaseLock(step.appId); await updateLock.releaseLock(step.appId);

View File

@ -11,11 +11,11 @@ import * as applicationManager from '../compose/application-manager';
import type { CompositionStepAction } from '../compose/composition-steps'; import type { CompositionStepAction } from '../compose/composition-steps';
import { generateStep } from '../compose/composition-steps'; import { generateStep } from '../compose/composition-steps';
import * as commitStore from '../compose/commit'; import * as commitStore from '../compose/commit';
import type Service from '../compose/service';
import { getApp } from '../device-state/db-format'; import { getApp } from '../device-state/db-format';
import * as TargetState from '../device-state/target-state'; import * as TargetState from '../device-state/target-state';
import log from '../lib/supervisor-console'; import log from '../lib/supervisor-console';
import blink = require('../lib/blink'); import blink = require('../lib/blink');
import { lock } from '../lib/update-lock';
import * as constants from '../lib/constants'; import * as constants from '../lib/constants';
import { import {
InternalInconsistencyError, InternalInconsistencyError,
@ -88,32 +88,29 @@ export const regenerateKey = async (oldKey: string) => {
export const doRestart = async (appId: number, force: boolean = false) => { export const doRestart = async (appId: number, force: boolean = false) => {
await deviceState.initialized(); await deviceState.initialized();
return await lock(appId, { force }, async () => { const currentState = await deviceState.getCurrentState();
const currentState = await deviceState.getCurrentState(); if (currentState.local.apps?.[appId] == null) {
if (currentState.local.apps?.[appId] == null) { throw new InternalInconsistencyError(
throw new InternalInconsistencyError( `Application with ID ${appId} is not in the current state`,
`Application with ID ${appId} is not in the current state`, );
); }
}
const app = currentState.local.apps[appId];
const services = app.services;
app.services = [];
return deviceState const app = currentState.local.apps[appId];
.applyIntermediateTarget(currentState, { const services = app.services;
skipLock: true,
}) try {
.then(() => { // Set target so that services get deleted
app.services = services; app.services = [];
return deviceState.applyIntermediateTarget(currentState, { await deviceState.applyIntermediateTarget(currentState, { force });
skipLock: true, // Restore services
keepVolumes: false, app.services = services;
}); return deviceState.applyIntermediateTarget(currentState, {
}) keepVolumes: false,
.finally(() => { force,
deviceState.triggerApplyTarget(); });
}); } finally {
}); deviceState.triggerApplyTarget();
}
}; };
/** /**
@ -131,47 +128,37 @@ export const doPurge = async (appId: number, force: boolean = false) => {
'Purge data', 'Purge data',
); );
return await lock(appId, { force }, async () => { const currentState = await deviceState.getCurrentState();
const currentState = await deviceState.getCurrentState(); if (currentState.local.apps?.[appId] == null) {
if (currentState.local.apps?.[appId] == null) { throw new InternalInconsistencyError(
throw new InternalInconsistencyError( `Application with ID ${appId} is not in the current state`,
`Application with ID ${appId} is not in the current state`, );
); }
} // Save & delete the app from the current state
const app = currentState.local.apps[appId];
delete currentState.local.apps[appId];
const app = currentState.local.apps[appId]; try {
// Purposely tell the apply function to delete volumes so
// Delete the app from the current state // they can get deleted even in local mode
delete currentState.local.apps[appId]; await deviceState.applyIntermediateTarget(currentState, {
keepVolumes: false,
return deviceState force,
.applyIntermediateTarget(currentState, {
skipLock: true,
// Purposely tell the apply function to delete volumes so they can get
// deleted even in local mode
keepVolumes: false,
})
.then(() => {
currentState.local.apps[appId] = app;
return deviceState.applyIntermediateTarget(currentState, {
skipLock: true,
});
})
.finally(() => {
deviceState.triggerApplyTarget();
});
})
.then(() =>
logger.logSystemMessage('Purged data', { appId }, 'Purge data success'),
)
.catch((err) => {
logger.logSystemMessage(
`Error purging data: ${err}`,
{ appId, error: err },
'Purge data error',
);
throw err;
}); });
// Restore user app after purge
currentState.local.apps[appId] = app;
await deviceState.applyIntermediateTarget(currentState);
logger.logSystemMessage('Purged data', { appId }, 'Purge data success');
} catch (err: any) {
logger.logSystemMessage(
`Error purging data: ${err}`,
{ appId, error: err?.message ?? err },
'Purge data error',
);
throw err;
} finally {
deviceState.triggerApplyTarget();
}
}; };
type ClientError = BadRequestError | NotFoundError; type ClientError = BadRequestError | NotFoundError;
@ -224,6 +211,57 @@ export const executeDeviceAction = async (
}); });
}; };
/**
* Used internally by executeServiceAction to handle locks
* around execution of a service action.
*/
const executeDeviceActionWithLock = async ({
action,
appId,
currentService,
targetService,
force = false,
}: {
action: CompositionStepAction;
appId: number;
currentService?: Service;
targetService?: Service;
force: boolean;
}) => {
try {
if (currentService) {
// Take lock for current service to be modified / stopped
await executeDeviceAction(
generateStep('takeLock', {
appId,
services: [currentService.serviceName],
force,
}),
// FIXME: deviceState.executeStepAction only accepts force as a separate arg
// instead of reading force from the step object, so we have to pass it twice
force,
);
}
// Execute action on service
await executeDeviceAction(
generateStep(action, {
current: currentService,
target: targetService,
wait: true,
}),
force,
);
} finally {
// Release lock regardless of action success to prevent leftover lockfile
await executeDeviceAction(
generateStep('releaseLock', {
appId,
}),
);
}
};
/** /**
* Executes a composition step action on a service. * Executes a composition step action on a service.
* isLegacy indicates that the action is being called from a legacy (v1) endpoint, * isLegacy indicates that the action is being called from a legacy (v1) endpoint,
@ -279,15 +317,23 @@ export const executeServiceAction = async ({
throw new NotFoundError(messages.targetServiceNotFound); throw new NotFoundError(messages.targetServiceNotFound);
} }
// Execute action on service // A single service start action doesn't require locks
return await executeDeviceAction( if (action === 'start') {
generateStep(action, { // Execute action on service
current: currentService, await executeDeviceAction(
target: targetService, generateStep(action, {
wait: true, target: targetService,
}), }),
force, );
); } else {
await executeDeviceActionWithLock({
action,
appId,
currentService,
targetService,
force,
});
}
}; };
/** /**

View File

@ -574,11 +574,7 @@ export async function shutdown({
// should happen via intermediate targets // should happen via intermediate targets
export async function executeStepAction( export async function executeStepAction(
step: DeviceStateStep<PossibleStepTargets>, step: DeviceStateStep<PossibleStepTargets>,
{ { force, initial }: { force?: boolean; initial?: boolean },
force,
initial,
skipLock,
}: { force?: boolean; initial?: boolean; skipLock?: boolean },
) { ) {
if (deviceConfig.isValidAction(step.action)) { if (deviceConfig.isValidAction(step.action)) {
await deviceConfig.executeStepAction(step as deviceConfig.ConfigStep, { await deviceConfig.executeStepAction(step as deviceConfig.ConfigStep, {
@ -587,7 +583,6 @@ export async function executeStepAction(
} else if (applicationManager.validActions.includes(step.action)) { } else if (applicationManager.validActions.includes(step.action)) {
return applicationManager.executeStep(step as any, { return applicationManager.executeStep(step as any, {
force, force,
skipLock,
}); });
} else { } else {
switch (step.action) { switch (step.action) {
@ -613,11 +608,9 @@ export async function applyStep(
{ {
force, force,
initial, initial,
skipLock,
}: { }: {
force?: boolean; force?: boolean;
initial?: boolean; initial?: boolean;
skipLock?: boolean;
}, },
) { ) {
if (shuttingDown) { if (shuttingDown) {
@ -627,7 +620,6 @@ export async function applyStep(
await executeStepAction(step, { await executeStepAction(step, {
force, force,
initial, initial,
skipLock,
}); });
emitAsync('step-completed', null, step); emitAsync('step-completed', null, step);
} catch (e: any) { } catch (e: any) {
@ -685,7 +677,6 @@ export const applyTarget = async ({
force = false, force = false,
initial = false, initial = false,
intermediate = false, intermediate = false,
skipLock = false,
nextDelay = 200, nextDelay = 200,
retryCount = 0, retryCount = 0,
keepVolumes = undefined as boolean | undefined, keepVolumes = undefined as boolean | undefined,
@ -724,6 +715,7 @@ export const applyTarget = async ({
// the value // the value
intermediate || undefined, intermediate || undefined,
keepVolumes, keepVolumes,
force,
); );
if (_.isEmpty(appSteps)) { if (_.isEmpty(appSteps)) {
@ -769,16 +761,13 @@ export const applyTarget = async ({
} }
try { try {
await Promise.all( await Promise.all(steps.map((s) => applyStep(s, { force, initial })));
steps.map((s) => applyStep(s, { force, initial, skipLock })),
);
await setTimeout(nextDelay); await setTimeout(nextDelay);
await applyTarget({ await applyTarget({
force, force,
initial, initial,
intermediate, intermediate,
skipLock,
nextDelay, nextDelay,
retryCount, retryCount,
keepVolumes, keepVolumes,
@ -884,11 +873,7 @@ export function triggerApplyTarget({
export async function applyIntermediateTarget( export async function applyIntermediateTarget(
intermediate: InstancedDeviceState, intermediate: InstancedDeviceState,
{ { force = false, keepVolumes = undefined as boolean | undefined } = {},
force = false,
skipLock = false,
keepVolumes = undefined as boolean | undefined,
} = {},
) { ) {
return pausingApply(async () => { return pausingApply(async () => {
// TODO: Make sure we don't accidentally overwrite this // TODO: Make sure we don't accidentally overwrite this
@ -897,7 +882,6 @@ export async function applyIntermediateTarget(
return applyTarget({ return applyTarget({
intermediate: true, intermediate: true,
force, force,
skipLock,
keepVolumes, keepVolumes,
}).then(() => { }).then(() => {
intermediateTarget = null; intermediateTarget = null;

View File

@ -81,7 +81,11 @@ async function dispose(
* Composition step used by Supervisor compose module. * Composition step used by Supervisor compose module.
* Take all locks for an appId | appUuid, creating directories if they don't exist. * Take all locks for an appId | appUuid, creating directories if they don't exist.
*/ */
export async function takeLock(appId: number, services: string[]) { export async function takeLock(
appId: number,
services: string[],
force: boolean = false,
) {
const release = await takeGlobalLockRW(appId); const release = await takeGlobalLockRW(appId);
try { try {
const actuallyLocked: string[] = []; const actuallyLocked: string[] = [];
@ -93,7 +97,7 @@ export async function takeLock(appId: number, services: string[]) {
); );
for (const service of servicesWithoutLock) { for (const service of servicesWithoutLock) {
await mkdirp(pathOnRoot(lockPath(appId, service))); await mkdirp(pathOnRoot(lockPath(appId, service)));
await lockService(appId, service); await lockService(appId, service, force);
actuallyLocked.push(service); actuallyLocked.push(service);
} }
return actuallyLocked; return actuallyLocked;
@ -193,17 +197,14 @@ export function getServicesLockedByAppId(): LocksTakenMap {
* all existing lockfiles before performing the operation * all existing lockfiles before performing the operation
* *
* TODO: convert to native Promises and async/await. May require native implementation of Bluebird's dispose / using * TODO: convert to native Promises and async/await. May require native implementation of Bluebird's dispose / using
*
* TODO: Remove skipLock as it's not a good interface. If lock is called it should try to take the lock
* without an option to skip.
*/ */
export async function lock<T>( export async function lock<T>(
appId: number | number[], appId: number | number[],
{ force = false, skipLock = false }: { force: boolean; skipLock?: boolean }, { force = false }: { force: boolean },
fn: () => Resolvable<T>, fn: () => Resolvable<T>,
): Promise<T> { ): Promise<T> {
const appIdsToLock = Array.isArray(appId) ? appId : [appId]; const appIdsToLock = Array.isArray(appId) ? appId : [appId];
if (skipLock || !appId || !appIdsToLock.length) { if (!appId || !appIdsToLock.length) {
return fn(); return fn();
} }

View File

@ -996,7 +996,7 @@ describe('compose/application-manager', () => {
}, },
); );
// A start step should happen for the depended service first // A start step should happen for the dependant service first
expect(startStep).to.have.property('action').that.equals('start'); expect(startStep).to.have.property('action').that.equals('start');
expect(startStep) expect(startStep)
.to.have.property('target') .to.have.property('target')

View File

@ -4,6 +4,7 @@ import { stub } from 'sinon';
import Docker from 'dockerode'; import Docker from 'dockerode';
import request from 'supertest'; import request from 'supertest';
import { setTimeout } from 'timers/promises'; import { setTimeout } from 'timers/promises';
import { testfs } from 'mocha-pod';
import * as deviceState from '~/src/device-state'; import * as deviceState from '~/src/device-state';
import * as config from '~/src/config'; import * as config from '~/src/config';
@ -11,10 +12,12 @@ import * as hostConfig from '~/src/host-config';
import * as deviceApi from '~/src/device-api'; import * as deviceApi from '~/src/device-api';
import * as actions from '~/src/device-api/actions'; import * as actions from '~/src/device-api/actions';
import * as TargetState from '~/src/device-state/target-state'; import * as TargetState from '~/src/device-state/target-state';
import * as updateLock from '~/lib/update-lock';
import { pathOnRoot } from '~/lib/host-utils';
import { exec } from '~/lib/fs-utils';
import * as lockfile from '~/lib/lockfile';
import { cleanupDocker } from '~/test-lib/docker-helper'; import { cleanupDocker } from '~/test-lib/docker-helper';
import { exec } from '~/src/lib/fs-utils';
export async function dbusSend( export async function dbusSend(
dest: string, dest: string,
path: string, path: string,
@ -79,6 +82,7 @@ describe('manages application lifecycle', () => {
const BALENA_SUPERVISOR_ADDRESS = const BALENA_SUPERVISOR_ADDRESS =
process.env.BALENA_SUPERVISOR_ADDRESS || 'http://balena-supervisor:48484'; process.env.BALENA_SUPERVISOR_ADDRESS || 'http://balena-supervisor:48484';
const APP_ID = 1; const APP_ID = 1;
const lockdir = pathOnRoot(updateLock.BASE_LOCK_DIR);
const docker = new Docker(); const docker = new Docker();
const getSupervisorTarget = async () => const getSupervisorTarget = async () =>
@ -218,6 +222,11 @@ describe('manages application lifecycle', () => {
ctns.every(({ State }) => !startedAt.includes(State.StartedAt)); ctns.every(({ State }) => !startedAt.includes(State.StartedAt));
}; };
const mockFs = testfs(
{ [`${lockdir}/${APP_ID}`]: {} },
{ cleanup: [`${lockdir}/${APP_ID}/**/*.lock`] },
);
before(async () => { before(async () => {
// Images are ignored in local mode so we need to pull the base image // Images are ignored in local mode so we need to pull the base image
await docker.pull(BASE_IMAGE); await docker.pull(BASE_IMAGE);
@ -251,10 +260,16 @@ describe('manages application lifecycle', () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await mockFs.enable();
// Create a single-container application in local mode // Create a single-container application in local mode
await setSupervisorTarget(targetState); await setSupervisorTarget(targetState);
}); });
afterEach(async () => {
await mockFs.restore();
});
// Make sure the app is running and correct before testing more assertions // Make sure the app is running and correct before testing more assertions
it('should setup a single container app (sanity check)', async () => { it('should setup a single container app (sanity check)', async () => {
containers = await waitForSetup(targetState); containers = await waitForSetup(targetState);
@ -292,6 +307,69 @@ describe('manages application lifecycle', () => {
); );
}); });
it('should not restart an application when user locks are present', async () => {
containers = await waitForSetup(targetState);
// Create a lock
await lockfile.lock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
await request(BALENA_SUPERVISOR_ADDRESS)
.post(`/v1/restart`)
.set('Content-Type', 'application/json')
.send(JSON.stringify({ appId: APP_ID }))
.expect(423);
// Containers should not have been restarted
const containersAfterRestart = await waitForSetup(targetState);
expect(
containersAfterRestart.map((ctn) => ctn.State.StartedAt),
).to.deep.include.members(containers.map((ctn) => ctn.State.StartedAt));
// Remove the lock
await lockfile.unlock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
});
it('should restart an application when user locks are present if force is specified', async () => {
containers = await waitForSetup(targetState);
const isRestartSuccessful = startTimesChanged(
containers.map((ctn) => ctn.State.StartedAt),
);
// Create a lock
await lockfile.lock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
await request(BALENA_SUPERVISOR_ADDRESS)
.post(`/v1/restart`)
.set('Content-Type', 'application/json')
.send(JSON.stringify({ appId: APP_ID, force: true }));
const restartedContainers = await waitForSetup(
targetState,
isRestartSuccessful,
);
// Technically the wait function above should already verify that the two
// containers have been restarted, but verify explcitly with an assertion
expect(isRestartSuccessful(restartedContainers)).to.be.true;
// Containers should have different Ids since they're recreated
expect(restartedContainers.map(({ Id }) => Id)).to.not.have.members(
containers.map((ctn) => ctn.Id),
);
// Wait briefly for state to settle which includes releasing locks
await setTimeout(1000);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
});
it('should restart service by removing and recreating corresponding container', async () => { it('should restart service by removing and recreating corresponding container', async () => {
containers = await waitForSetup(targetState); containers = await waitForSetup(targetState);
const isRestartSuccessful = startTimesChanged( const isRestartSuccessful = startTimesChanged(
@ -321,6 +399,73 @@ describe('manages application lifecycle', () => {
); );
}); });
// Since restart-service follows the same code paths as start|stop-service,
// these lock test cases should be sufficient to cover all three service actions.
it('should not restart service when user locks are present', async () => {
containers = await waitForSetup(targetState);
// Create a lock
await lockfile.lock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
await request(BALENA_SUPERVISOR_ADDRESS)
.post('/v2/applications/1/restart-service')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ serviceName: serviceNames[0] }))
.expect(423);
// Containers should not have been restarted
const containersAfterRestart = await waitForSetup(targetState);
expect(
containersAfterRestart.map((ctn) => ctn.State.StartedAt),
).to.deep.include.members(containers.map((ctn) => ctn.State.StartedAt));
// Remove the lock
await lockfile.unlock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
});
it('should restart service when user locks are present if force is specified', async () => {
containers = await waitForSetup(targetState);
const isRestartSuccessful = startTimesChanged(
containers
.filter((ctn) => ctn.Name.includes(serviceNames[0]))
.map((ctn) => ctn.State.StartedAt),
);
// Create a lock
await lockfile.lock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
await request(BALENA_SUPERVISOR_ADDRESS)
.post('/v2/applications/1/restart-service')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ serviceName: serviceNames[0], force: true }));
const restartedContainers = await waitForSetup(
targetState,
isRestartSuccessful,
);
// Technically the wait function above should already verify that the two
// containers have been restarted, but verify explcitly with an assertion
expect(isRestartSuccessful(restartedContainers)).to.be.true;
// Containers should have different Ids since they're recreated
expect(restartedContainers.map(({ Id }) => Id)).to.not.have.members(
containers.map((ctn) => ctn.Id),
);
// Wait briefly for state to settle which includes releasing locks
await setTimeout(1000);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
});
it('should stop a running service', async () => { it('should stop a running service', async () => {
containers = await waitForSetup(targetState); containers = await waitForSetup(targetState);
@ -520,10 +665,15 @@ describe('manages application lifecycle', () => {
}); });
beforeEach(async () => { beforeEach(async () => {
await mockFs.enable();
// Create a multi-container application in local mode // Create a multi-container application in local mode
await setSupervisorTarget(targetState); await setSupervisorTarget(targetState);
}); });
afterEach(async () => {
await mockFs.restore();
});
// Make sure the app is running and correct before testing more assertions // Make sure the app is running and correct before testing more assertions
it('should setup a multi-container app (sanity check)', async () => { it('should setup a multi-container app (sanity check)', async () => {
containers = await waitForSetup(targetState); containers = await waitForSetup(targetState);
@ -560,6 +710,69 @@ describe('manages application lifecycle', () => {
); );
}); });
it('should not restart an application when user locks are present', async () => {
containers = await waitForSetup(targetState);
// Create a lock
await lockfile.lock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
await request(BALENA_SUPERVISOR_ADDRESS)
.post(`/v1/restart`)
.set('Content-Type', 'application/json')
.send(JSON.stringify({ appId: APP_ID }))
.expect(423);
// Containers should not have been restarted
const containersAfterRestart = await waitForSetup(targetState);
expect(
containersAfterRestart.map((ctn) => ctn.State.StartedAt),
).to.deep.include.members(containers.map((ctn) => ctn.State.StartedAt));
// Remove the lock
await lockfile.unlock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
});
it('should restart an application when user locks are present if force is specified', async () => {
containers = await waitForSetup(targetState);
const isRestartSuccessful = startTimesChanged(
containers.map((ctn) => ctn.State.StartedAt),
);
// Create a lock
await lockfile.lock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
await request(BALENA_SUPERVISOR_ADDRESS)
.post(`/v1/restart`)
.set('Content-Type', 'application/json')
.send(JSON.stringify({ appId: APP_ID, force: true }));
const restartedContainers = await waitForSetup(
targetState,
isRestartSuccessful,
);
// Technically the wait function above should already verify that the two
// containers have been restarted, but verify explcitly with an assertion
expect(isRestartSuccessful(restartedContainers)).to.be.true;
// Containers should have different Ids since they're recreated
expect(restartedContainers.map(({ Id }) => Id)).to.not.have.members(
containers.map((ctn) => ctn.Id),
);
// Wait briefly for state to settle which includes releasing locks
await setTimeout(500);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
});
it('should restart service by removing and recreating corresponding container', async () => { it('should restart service by removing and recreating corresponding container', async () => {
containers = await waitForSetup(targetState); containers = await waitForSetup(targetState);
const serviceName = serviceNames[0]; const serviceName = serviceNames[0];
@ -601,6 +814,73 @@ describe('manages application lifecycle', () => {
expect(sharedIds.length).to.equal(1); expect(sharedIds.length).to.equal(1);
}); });
// Since restart-service follows the same code paths as start|stop-service,
// these lock test cases should be sufficient to cover all three service actions.
it('should not restart service when user locks are present', async () => {
containers = await waitForSetup(targetState);
// Create a lock
await lockfile.lock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
await request(BALENA_SUPERVISOR_ADDRESS)
.post('/v2/applications/1/restart-service')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ serviceName: serviceNames[0] }))
.expect(423);
// Containers should not have been restarted
const containersAfterRestart = await waitForSetup(targetState);
expect(
containersAfterRestart.map((ctn) => ctn.State.StartedAt),
).to.deep.include.members(containers.map((ctn) => ctn.State.StartedAt));
// Remove the lock
await lockfile.unlock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
});
it('should restart service when user locks are present if force is specified', async () => {
containers = await waitForSetup(targetState);
const isRestartSuccessful = startTimesChanged(
containers
.filter((ctn) => ctn.Name.includes(serviceNames[0]))
.map((ctn) => ctn.State.StartedAt),
);
// Create a lock
await lockfile.lock(
`${lockdir}/${APP_ID}/${serviceNames[0]}/updates.lock`,
);
await request(BALENA_SUPERVISOR_ADDRESS)
.post('/v2/applications/1/restart-service')
.set('Content-Type', 'application/json')
.send(JSON.stringify({ serviceName: serviceNames[0], force: true }));
const restartedContainers = await waitForSetup(
targetState,
isRestartSuccessful,
);
// Technically the wait function above should already verify that the two
// containers have been restarted, but verify explcitly with an assertion
expect(isRestartSuccessful(restartedContainers)).to.be.true;
// Containers should have different Ids since they're recreated
expect(restartedContainers.map(({ Id }) => Id)).to.not.have.members(
containers.map((ctn) => ctn.Id),
);
// Wait briefly for state to settle which includes releasing locks
await setTimeout(500);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
});
it('should stop a running service', async () => { it('should stop a running service', async () => {
containers = await waitForSetup(targetState); containers = await waitForSetup(targetState);

View File

@ -15,6 +15,7 @@ import {
const defaultContext = { const defaultContext = {
keepVolumes: false, keepVolumes: false,
force: false,
availableImages: [] as Image[], availableImages: [] as Image[],
containerIds: {}, containerIds: {},
downloading: [] as string[], downloading: [] as string[],
@ -151,6 +152,7 @@ describe('compose/app', () => {
}), }),
], ],
volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')], volumes: [Volume.fromComposeObject('test-volume', 1, 'deadbeef')],
networks: [DEFAULT_NETWORK],
}); });
const target = createApp({ const target = createApp({
services: [ services: [
@ -164,6 +166,7 @@ describe('compose/app', () => {
labels: { test: 'test' }, labels: { test: 'test' },
}), }),
], ],
networks: [DEFAULT_NETWORK],
isTarget: true, isTarget: true,
}); });
@ -453,9 +456,10 @@ describe('compose/app', () => {
const steps = current.nextStepsForAppUpdate(defaultContext, target); const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [removeNetworkStep] = expectSteps('kill', steps); const [killStep] = expectSteps('kill', steps);
console.log(killStep);
expect(removeNetworkStep).to.have.property('current').that.deep.includes({ expect(killStep).to.have.property('current').that.deep.includes({
serviceName: 'test', serviceName: 'test',
}); });
}); });
@ -576,7 +580,7 @@ describe('compose/app', () => {
services: [ services: [
await createService({ await createService({
serviceName: 'test', serviceName: 'test',
composition: { networks: ['test-network'] }, composition: { networks: { 'test-network': {} } },
}), }),
], ],
networks: [Network.fromComposeObject('test-network', 1, 'appuuid', {})], networks: [Network.fromComposeObject('test-network', 1, 'appuuid', {})],
@ -977,7 +981,6 @@ describe('compose/app', () => {
// Take lock before updating metadata // Take lock before updating metadata
const steps = current.nextStepsForAppUpdate(defaultContext, target); const steps = current.nextStepsForAppUpdate(defaultContext, target);
const [takeLockStep] = expectSteps('takeLock', steps); const [takeLockStep] = expectSteps('takeLock', steps);
expect(takeLockStep).to.have.property('appId').that.equals(1);
expect(takeLockStep) expect(takeLockStep)
.to.have.property('services') .to.have.property('services')
.that.deep.equals(['main']); .that.deep.equals(['main']);
@ -1080,7 +1083,7 @@ describe('compose/app', () => {
.to.have.property('current') .to.have.property('current')
.that.deep.includes({ serviceName: 'main' }); .that.deep.includes({ serviceName: 'main' });
// assume the intermediate step has already removed the app // Assume the intermediate step has already removed the app
const intermediate = createApp({ const intermediate = createApp({
services: [], services: [],
// Default network was already created // Default network was already created
@ -1092,7 +1095,6 @@ describe('compose/app', () => {
contextWithImages, contextWithImages,
target, target,
); );
const [startStep] = expectSteps('start', stepsToTarget); const [startStep] = expectSteps('start', stepsToTarget);
expect(startStep) expect(startStep)
.to.have.property('target') .to.have.property('target')
@ -1173,7 +1175,6 @@ describe('compose/app', () => {
{ ...contextWithImages, ...{ containerIds: { dep: 'dep-id' } } }, { ...contextWithImages, ...{ containerIds: { dep: 'dep-id' } } },
target, target,
); );
const [startMainStep] = expectSteps('start', stepsToTarget); const [startMainStep] = expectSteps('start', stepsToTarget);
expect(startMainStep) expect(startMainStep)
.to.have.property('target') .to.have.property('target')
@ -1470,6 +1471,7 @@ describe('compose/app', () => {
commit: 'old-release', commit: 'old-release',
}), }),
], ],
networks: [DEFAULT_NETWORK],
}); });
const target = createApp({ const target = createApp({
services: [ services: [
@ -1479,6 +1481,7 @@ describe('compose/app', () => {
commit: 'new-release', commit: 'new-release',
}), }),
], ],
networks: [DEFAULT_NETWORK],
isTarget: true, isTarget: true,
}); });