Refactor update-locks implementation

The refactor simplifies the implementation and ensures that locks per
app can only be held by one supervisor task at the time.

Change-type: patch
This commit is contained in:
Felipe Lalanne 2024-11-19 19:01:25 -03:00
parent d8f54c05e7
commit 3c6e9dd209
No known key found for this signature in database
GPG Key ID: 03E696BFD472B26A
16 changed files with 965 additions and 1202 deletions

View File

@ -77,9 +77,6 @@ fi
# not a problem.
modprobe ip6_tables || true
export BASE_LOCK_DIR="/tmp/balena-supervisor/services"
export LOCKFILE_UID=65534
if [ "${LIVEPUSH}" = "1" ]; then
exec npx nodemon --watch src --watch typings --ignore tests -e js,ts,json \
--exec node -r ts-node/register/transpile-only src/app.ts

View File

@ -170,7 +170,7 @@ class AppImpl implements App {
if (services.size > 0) {
steps.push(
generateStep('takeLock', {
appId: parseInt(appId, 10),
appId,
services: Array.from(services),
force: state.force,
}),
@ -200,19 +200,14 @@ class AppImpl implements App {
}),
);
}
// Current & target should be the same appId, but one of either current
// or target may not have any services, so we need to check both
const allServices = this.services.concat(target.services);
if (
allServices.length > 0 &&
allServices.some((s) =>
state.locksTaken.isLocked(s.appId, s.serviceName),
)
) {
// If the app still has a lock, release it
if (state.lock != null) {
// Release locks for all services before settling state
steps.push(
generateStep('releaseLock', {
appId: target.appId,
lock: state.lock,
}),
);
}
@ -225,11 +220,7 @@ class AppImpl implements App {
): CompositionStep[] {
if (Object.keys(this.services).length > 0) {
// Take all locks before killing
if (
this.services.some(
(svc) => !state.locksTaken.isLocked(svc.appId, svc.serviceName),
)
) {
if (state.lock == null) {
return [
generateStep('takeLock', {
appId: this.appId,
@ -628,9 +619,7 @@ class AppImpl implements App {
appsToLock: AppsToLockMap;
} & UpdateState,
): CompositionStep[] {
const servicesLocked = this.services
.concat(context.targetApp.services)
.every((svc) => context.locksTaken.isLocked(svc.appId, svc.serviceName));
const servicesLocked = context.lock != null;
if (current?.status === 'Stopping') {
// There's a kill step happening already, emit a noop to ensure
// we stay alive while this happens

View File

@ -12,7 +12,7 @@ import * as contracts from '../lib/contracts';
import * as constants from '../lib/constants';
import log from '../lib/supervisor-console';
import { InternalInconsistencyError } from '../lib/errors';
import { getServicesLockedByAppId, LocksTakenMap } from '../lib/update-lock';
import type { Lock } from '../lib/update-lock';
import { checkTruthy } from '../lib/validation';
import { App } from './app';
@ -61,6 +61,13 @@ export function resetTimeSpentFetching(value: number = 0) {
timeSpentFetching = value;
}
interface LockRegistry {
[appId: string | number]: Lock;
}
// In memory registry of all app locks
const lockRegistry: LockRegistry = {};
const actionExecutors = getExecutors({
callbacks: {
fetchStart: () => {
@ -76,6 +83,12 @@ const actionExecutors = getExecutors({
reportCurrentState(state);
},
bestDeltaSource,
registerLock: (appId: string | number, lock: Lock) => {
lockRegistry[appId.toString()] = lock;
},
unregisterLock: (appId: string | number) => {
delete lockRegistry[appId.toString()];
},
},
});
@ -134,10 +147,21 @@ export async function getRequiredSteps(
downloading,
availableImages,
containerIdsByAppId,
locksTaken: await getServicesLockedByAppId(),
appLocks: lockRegistry,
});
}
interface InferNextOpts {
keepImages: boolean;
keepVolumes: boolean;
delta: boolean;
force: boolean;
downloading: UpdateState['downloading'];
availableImages: UpdateState['availableImages'];
containerIdsByAppId: { [appId: number]: UpdateState['containerIds'] };
appLocks: LockRegistry;
}
// Calculate the required steps from the current to the target state
export async function inferNextSteps(
currentApps: InstancedAppState,
@ -147,13 +171,11 @@ export async function inferNextSteps(
keepVolumes = false,
delta = true,
force = false,
downloading = [] as UpdateState['downloading'],
availableImages = [] as UpdateState['availableImages'],
containerIdsByAppId = {} as {
[appId: number]: UpdateState['containerIds'];
},
locksTaken = new LocksTakenMap(),
} = {},
downloading = [],
availableImages = [],
containerIdsByAppId = {},
appLocks = {},
}: Partial<InferNextOpts>,
) {
const currentAppIds = Object.keys(currentApps).map((i) => parseInt(i, 10));
const targetAppIds = Object.keys(targetApps).map((i) => parseInt(i, 10));
@ -215,8 +237,8 @@ export async function inferNextSteps(
availableImages,
containerIds: containerIdsByAppId[id],
downloading,
locksTaken,
force,
lock: appLocks[id],
},
targetApps[id],
),
@ -230,8 +252,8 @@ export async function inferNextSteps(
keepVolumes,
downloading,
containerIds: containerIdsByAppId[id],
locksTaken,
force,
lock: appLocks[id],
}),
);
}
@ -255,8 +277,8 @@ export async function inferNextSteps(
availableImages,
containerIds: containerIdsByAppId[id] ?? {},
downloading,
locksTaken,
force,
lock: appLocks[id],
},
targetApps[id],
),

View File

@ -5,9 +5,10 @@ import * as serviceManager from './service-manager';
import * as networkManager from './network-manager';
import * as volumeManager from './volume-manager';
import * as commitStore from './commit';
import * as updateLock from '../lib/update-lock';
import { Lockable } from '../lib/update-lock';
import type { DeviceLegacyReport } from '../types/state';
import type { CompositionStepAction, CompositionStepT } from './types';
import type { Lock } from '../lib/update-lock';
export type {
CompositionStep,
@ -27,6 +28,8 @@ interface CompositionCallbacks {
fetchTime: (time: number) => void;
stateReport: (state: DeviceLegacyReport) => void;
bestDeltaSource: (image: Image, available: Image[]) => string | null;
registerLock: (appId: string | number, lock: Lock) => void;
unregisterLock: (appId: string | number) => void;
}
export function generateStep<T extends CompositionStepAction>(
@ -141,10 +144,14 @@ export function getExecutors(app: { callbacks: CompositionCallbacks }) {
/* async noop */
},
takeLock: async (step) => {
await updateLock.takeLock(step.appId, step.services, step.force);
const lockable = Lockable.from(step.appId, step.services);
const lockOverride = await config.get('lockOverride');
const lock = await lockable.lock({ force: step.force || lockOverride });
app.callbacks.registerLock(step.appId, lock);
},
releaseLock: async (step) => {
await updateLock.releaseLock(step.appId);
app.callbacks.unregisterLock(step.appId);
await step.lock.unlock();
},
};

View File

@ -1,7 +1,7 @@
import type { Network } from './network';
import type { Volume } from './volume';
import type { Service } from './service';
import type { LocksTakenMap } from '../../lib/update-lock';
import type { Lock } from '../../lib/update-lock';
import type { Image } from './image';
import type { CompositionStep } from './composition-step';
@ -9,7 +9,7 @@ export interface UpdateState {
availableImages: Image[];
containerIds: Dictionary<string>;
downloading: string[];
locksTaken: LocksTakenMap;
lock: Lock | null;
force: boolean;
}

View File

@ -2,6 +2,7 @@ import type { Image } from './image';
import type { Service } from './service';
import type { Network } from './network';
import type { Volume } from './volume';
import type { Lock } from '../../lib/update-lock';
export interface CompositionStepArgs {
stop: {
@ -67,12 +68,13 @@ export interface CompositionStepArgs {
ensureSupervisorNetwork: object;
noop: object;
takeLock: {
appId: number;
appId: string | number;
services: string[];
force: boolean;
};
releaseLock: {
appId: number;
appId: string | number;
lock: Lock;
};
}

View File

@ -26,6 +26,7 @@ import {
NotFoundError,
BadRequestError,
} from '../lib/errors';
import { withLock } from '../lib/update-lock';
/**
* Run an array of healthchecks, outputting whether all passed or not
@ -233,39 +234,24 @@ const executeDeviceActionWithLock = async ({
targetService?: Service;
force: boolean;
}) => {
try {
if (currentService) {
const lockOverride = await config.get('lockOverride');
// Take lock for current service to be modified / stopped
const lockOverride = await config.get('lockOverride');
await withLock(
appId,
async () => {
// Execute action on service
await executeDeviceAction(
generateStep('takeLock', {
appId,
services: [currentService.serviceName],
force: force || lockOverride,
generateStep(action, {
current: currentService,
target: targetService,
wait: true,
}),
// 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 || lockOverride,
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,
}),
);
}
},
{ force: force || lockOverride },
);
};
/**

View File

@ -456,22 +456,26 @@ export async function shutdown({
const apps = await applicationManager.getCurrentApps();
const appIds = Object.keys(apps).map((strId) => parseInt(strId, 10));
// Try to create a lock for all the services before shutting down
return updateLock.lock(appIds, { force }, async () => {
let dbusAction;
switch (reboot) {
case true:
logger.logSystemMessage('Rebooting', {}, 'Reboot');
dbusAction = await dbus.reboot();
break;
case false:
logger.logSystemMessage('Shutting down', {}, 'Shutdown');
dbusAction = await dbus.shutdown();
break;
}
shuttingDown = true;
emitAsync('shutdown', undefined);
return dbusAction;
});
return updateLock.withLock(
appIds,
async () => {
let dbusAction;
switch (reboot) {
case true:
logger.logSystemMessage('Rebooting', {}, 'Reboot');
dbusAction = await dbus.reboot();
break;
case false:
logger.logSystemMessage('Shutting down', {}, 'Shutdown');
dbusAction = await dbus.shutdown();
break;
}
shuttingDown = true;
emitAsync('shutdown', undefined);
return dbusAction;
},
{ force },
);
}
// FIXME: this method should not be exported, all target state changes

View File

@ -103,25 +103,29 @@ export async function patch(
const { noProxy, ...targetConf } = conf.network.proxy;
// It's possible for appIds to be an empty array, but patch shouldn't fail
// as it's not dependent on there being any running user applications.
return updateLock.lock(appIds, { force }, async () => {
const proxyConf = await readProxy();
let currentConf: ProxyConfig | undefined = undefined;
if (proxyConf) {
delete proxyConf.noProxy;
currentConf = proxyConf;
}
return updateLock.withLock(
appIds,
async () => {
const proxyConf = await readProxy();
let currentConf: ProxyConfig | undefined = undefined;
if (proxyConf) {
delete proxyConf.noProxy;
currentConf = proxyConf;
}
// Merge current & target redsocks.conf
const patchedConf = patchProxy(
{
redsocks: currentConf,
},
{
redsocks: targetConf,
},
);
await setProxy(patchedConf, noProxy);
});
// Merge current & target redsocks.conf
const patchedConf = patchProxy(
{
redsocks: currentConf,
},
{
redsocks: targetConf,
},
);
await setProxy(patchedConf, noProxy);
},
{ force },
);
}
}

View File

@ -26,9 +26,12 @@ interface FindAllArgs {
recursive: boolean;
}
// Returns all current locks taken under a directory (default: /tmp)
// Optionally accepts filter function for only getting locks that match a condition.
// A file is counted as a lock by default if it ends with `.lock`.
/**
* Find all existing lockfiles under a given root directory (defaults to /mp)
*
* Optionally accepts filter function for only getting locks that match a condition.
* It will recursively look for all locks unless `recursive` is set to `false`
*/
export async function findAll({
root = '/tmp',
filter = (l) => l.path.endsWith('.lock'),
@ -96,6 +99,11 @@ export class LockfileExistsError implements ChildProcessError {
}
}
/**
* Lock the file provided as path
*
* Optionally accepts a user id to lock the path as
*/
export async function lock(path: string, uid: number = os.userInfo().uid) {
/**
* Set parent directory permissions to `drwxrwxrwt` (octal 1777), which are needed
@ -145,6 +153,9 @@ export async function lock(path: string, uid: number = os.userInfo().uid) {
}
}
/**
* Removes the lock indicated by the path
*/
export async function unlock(path: string): Promise<void> {
// Removing the lockfile releases the lock
await fs.unlink(path).catch((e) => {

View File

@ -1,32 +1,27 @@
import { promises as fs } from 'fs';
import path from 'path';
import { isRight } from 'fp-ts/lib/Either';
import { setTimeout } from 'timers/promises';
import type ReadWriteLock from 'rwlock';
import {
isENOENT,
UpdatesLockedError,
InternalInconsistencyError,
} from './errors';
import { isENOENT, UpdatesLockedError } from './errors';
import { pathOnRoot, pathExistsOnState } from './host-utils';
import { mkdirp } from './fs-utils';
import * as config from '../config';
import * as lockfile from './lockfile';
import { NumericIdentifier, StringIdentifier, DockerName } from '../types';
import { takeGlobalLockRW } from './process-lock';
import * as logger from '../logger';
import * as logTypes from './log-types';
import * as logger from '../logger';
const decodedUid = NumericIdentifier.decode(process.env.LOCKFILE_UID);
export const LOCKFILE_UID = isRight(decodedUid) ? decodedUid.right : 65534;
export const LOCKFILE_UID = 65534;
export const BASE_LOCK_DIR = '/tmp/balena-supervisor/services';
export const BASE_LOCK_DIR =
process.env.BASE_LOCK_DIR || '/tmp/balena-supervisor/services';
export function lockPath(appId: number, serviceName?: string): string {
export function lockPath(appId: string | number, serviceName?: string): string {
return path.join(BASE_LOCK_DIR, appId.toString(), serviceName ?? '');
}
function lockFilesOnHost(appId: number, serviceName: string): string[] {
function lockFilesOnHost(
appId: string | number,
serviceName: string,
): string[] {
return pathOnRoot(
...['updates.lock', 'resin-updates.lock'].map((filename) =>
path.join(lockPath(appId), serviceName, filename),
@ -40,308 +35,297 @@ function lockFilesOnHost(appId: number, serviceName: string): string[] {
* prevent reboot. If the Supervisor reboots while those services are still running,
* the device may become stuck in an invalid state during HUP.
*/
export function abortIfHUPInProgress({
export async function abortIfHUPInProgress({
force = false,
}: {
force: boolean | undefined;
force?: boolean;
}): Promise<boolean | never> {
return Promise.all(
const breadcrumbs = await Promise.all(
['rollback-health-breadcrumb', 'rollback-altboot-breadcrumb'].map(
(filename) => pathExistsOnState(filename),
),
).then((existsArray) => {
const anyExists = existsArray.some((e) => e);
if (anyExists && !force) {
throw new UpdatesLockedError('Waiting for Host OS update to finish');
}
return anyExists;
});
);
const hasHUPBreadcrumb = breadcrumbs.some((e) => e);
if (hasHUPBreadcrumb && !force) {
throw new UpdatesLockedError('Waiting for Host OS update to finish');
}
return hasHUPBreadcrumb;
}
/**
* Unlock all lockfiles of an appId | appUuid, then release resources.
* Meant for use in update-lock module only as as it assumes that a
* write lock has been acquired.
*/
async function dispose(
appIdentifier: string | number,
release: () => void,
): Promise<void> {
try {
const locks = await getLocksTaken(
pathOnRoot(`${BASE_LOCK_DIR}/${appIdentifier}`),
);
// Try to unlock all locks taken
await Promise.all(locks.map((l) => lockfile.unlock(l)));
} finally {
// Release final resource
release();
}
}
/**
* Composition step used by Supervisor compose module.
* Take all locks for an appId | appUuid, creating directories if they don't exist.
*/
export async function takeLock(
appId: number,
services: string[],
force: boolean = false,
) {
logger.logSystemEvent(logTypes.takeLock, {
appId,
services,
force,
});
const release = await takeGlobalLockRW(appId);
let lockOverride: boolean;
try {
lockOverride = await config.get('lockOverride');
} catch (err: any) {
throw new InternalInconsistencyError(
`Error getting lockOverride config value: ${err?.message ?? err}`,
);
}
try {
const actuallyLocked: string[] = [];
const locksTaken = await getServicesLockedByAppId();
// Filter out services that already have Supervisor-taken locks.
// This needs to be done after taking the appId write lock to avoid
// race conditions with locking.
const servicesWithoutLock = services.filter(
(svc) => !locksTaken.isLocked(appId, svc),
);
for (const service of servicesWithoutLock) {
await mkdirp(pathOnRoot(lockPath(appId, service)));
await lockService(appId, service, force || lockOverride);
actuallyLocked.push(service);
}
return actuallyLocked;
} catch (err) {
// If something errors while taking the lock, we should remove any
// lockfiles that may have been created so that all services return
// to unlocked status.
await dispose(appId, release);
// Re-throw error to be handled in caller
throw err;
} finally {
// If not already released from catch, released the RW process lock.
// If already released, this will not error.
release();
}
}
/**
* Composition step used by Supervisor compose module.
* Release all locks for an appId | appUuid.
*/
export async function releaseLock(appId: number) {
logger.logSystemEvent(logTypes.releaseLock, { appId });
const release = await takeGlobalLockRW(appId);
await dispose(appId, release);
}
/**
* Given a lockfile path `p`, return an array [appId, serviceName, filename] of that path.
* Paths are assumed to end in the format /:appId/:serviceName/(resin-)updates.lock.
*/
function getIdentifiersFromPath(p: string) {
const parts = p.split('/');
const filename = parts.pop();
if (filename?.match(/updates\.lock/) === null) {
return [];
}
const serviceName = parts.pop();
const appId = parts.pop();
return [appId, serviceName, filename];
}
type LockedEntity = { appId: number; services: string[] };
/**
* A map of locked services by appId.
* Exported for tests only; getServicesLockedByAppId is the public generator interface.
*/
export class LocksTakenMap extends Map<number, Set<string>> {
constructor(lockedEntities: LockedEntity[] = []) {
// Construct a Map<number, Set<string>> from user-friendly input args
super(
lockedEntities.map(({ appId, services }) => [appId, new Set(services)]),
);
}
// Add one or more locked services to an appId
public add(appId: number, services: string | string[]): void {
if (typeof services === 'string') {
services = [services];
}
if (this.has(appId)) {
const lockedSvcs = this.get(appId)!;
services.forEach((s) => lockedSvcs.add(s));
} else {
this.set(appId, new Set(services));
}
}
interface LockingOpts {
/**
* Delete existing user locks if any
*/
force: boolean;
/**
* @private Use this.getServices instead as there is no need to return
* a mutable reference to the internal Set data structure.
* If the locks are being held by another operation
* on the supervisor, this is the max time that the call
* will wait before throwing
*/
public get(appId: number): Set<string> | undefined {
return super.get(appId);
}
// Return an array copy of locked services under an appId
public getServices(appId: number): string[] {
return this.has(appId) ? Array.from(this.get(appId)!) : [];
}
// Return whether a service is locked under an appId
public isLocked(appId: number, service: string): boolean {
return this.has(appId) && this.get(appId)!.has(service);
}
maxWaitMs: number;
}
// A wrapper function for lockfile.getLocksTaken that filters for Supervisor-taken locks.
// Exported for tests only; getServicesLockedByAppId is the intended public interface.
export async function getLocksTaken(
rootDir: string = pathOnRoot(BASE_LOCK_DIR),
): Promise<string[]> {
return await lockfile.findAll({
root: rootDir,
filter: (l) => l.path.endsWith('updates.lock') && l.owner === LOCKFILE_UID,
async function takeGlobalLockOrFail(
appId: string,
maxWaitMs = 0, // 0 === wait forever
): Promise<ReadWriteLock.Release> {
let abort = false;
const lockingPromise = takeGlobalLockRW(appId).then((disposer) => {
// Even if the timer resolves first, takeGlobalLockRW
// will eventually resolve and in that case we need to call the disposer
// to avoid a deadlock
if (abort) {
disposer();
return () => {
/* noop */
};
} else {
return disposer;
}
});
}
/**
* Return a list of services that are locked by the Supervisor under each appId.
* Both `resin-updates.lock` and `updates.lock` should be present per
* [appId, serviceName] pair for a service to be considered locked.
*/
export async function getServicesLockedByAppId(): Promise<LocksTakenMap> {
const locksTaken = await getLocksTaken();
// Group locksTaken paths by appId & serviceName.
// filesTakenByAppId is of type Map<appId, Map<serviceName, Set<filename>>>
// and represents files taken under every [appId, serviceName] pair.
const filesTakenByAppId = new Map<number, Map<string, Set<string>>>();
for (const lockTakenPath of locksTaken) {
const [appId, serviceName, filename] =
getIdentifiersFromPath(lockTakenPath);
if (
!StringIdentifier.is(appId) ||
!DockerName.is(serviceName) ||
!filename?.match(/updates\.lock/)
) {
continue;
}
const numAppId = +appId;
if (!filesTakenByAppId.has(numAppId)) {
filesTakenByAppId.set(numAppId, new Map());
}
const servicesTaken = filesTakenByAppId.get(numAppId)!;
if (!servicesTaken.has(serviceName)) {
servicesTaken.set(serviceName, new Set());
}
servicesTaken.get(serviceName)!.add(filename);
const promises: Array<Promise<ReadWriteLock.Release | string>> = [
lockingPromise,
];
const ac = new AbortController();
const signal = ac.signal;
if (maxWaitMs > 0) {
promises.push(setTimeout(maxWaitMs, 'abort', { signal }));
}
// Construct a LocksTakenMap from filesTakenByAppId, which represents
// services locked by the Supervisor.
const servicesByAppId = new LocksTakenMap();
for (const [appId, servicesTaken] of filesTakenByAppId) {
for (const [serviceName, filenames] of servicesTaken) {
if (
filenames.has('resin-updates.lock') &&
filenames.has('updates.lock')
) {
servicesByAppId.add(appId, serviceName);
try {
const res = await Promise.race(promises);
if (res === 'abort') {
abort = true;
throw new UpdatesLockedError(
`Locks for app ${appId} are being held by another supervisor operation`,
);
}
return lockingPromise;
} finally {
// Clear the timeout
ac.abort();
}
}
export interface Lock {
unlock(): Promise<void>;
}
export interface Lockable {
lock(opts?: Partial<LockingOpts>): Promise<Lock>;
}
function newLockable(appId: string, services: string[]): Lockable {
async function unlockApp(locks: string[], release: () => void) {
try {
logger.logSystemEvent(logTypes.releaseLock, { appId });
await Promise.all(locks.map((l) => lockfile.unlock(l)));
} finally {
release();
}
}
async function lockApp({
force = false,
maxWaitMs = 0,
}: Partial<LockingOpts> = {}) {
// Log before taking the global lock to detect
// possible deadlocks
logger.logSystemEvent(logTypes.takeLock, {
appId,
services,
force,
});
// Try to take the global lock for the given appId
// this ensures the user app locks are only taken in a single
// place on the supervisor
const release: ReadWriteLock.Release = await takeGlobalLockOrFail(
appId,
maxWaitMs,
);
// This keeps a list of all locks currently being
// held by the lockable
let currentLocks: string[] = [];
try {
// Find all the locks already taken for the appId
// if this is not empty it probably means these locks are from
// a previous run of the supervisor
currentLocks = await lockfile.findAll({
root: pathOnRoot(lockPath(appId)),
filter: (l) =>
l.path.endsWith('updates.lock') && l.owner === LOCKFILE_UID,
});
// Group locks by service
const locksByService = services.map((service) => {
const existing = currentLocks.filter((lockFile) =>
lockFilesOnHost(appId, service).includes(lockFile),
);
return { service, existing };
}, {});
// Filter out services that already have Supervisor-taken locks.
// This needs to be done after taking the appId write lock to avoid
// race conditions with locking.
const servicesWithMissingLocks = locksByService.filter(
({ existing }) => existing.length < 2,
);
// For every service that has yet to be fully locked we need to
// take the locks
for (const { service, existing } of servicesWithMissingLocks) {
// Create the directory if it doesn't exist
await mkdirp(pathOnRoot(lockPath(appId, service)));
// We will only take those locks that have not yet been taken by
// the supervisor
const missingLocks = lockFilesOnHost(appId, service).filter(
(l) => !existing.includes(l),
);
for await (const file of missingLocks) {
try {
if (force) {
// If force: true we remove the lock first
await lockfile.unlock(file);
}
await lockfile.lock(file, LOCKFILE_UID);
// If locking was successful, we update the current locks
// list
currentLocks.push(file);
} catch (e) {
if (lockfile.LockfileExistsError.is(e)) {
// Throw more descriptive error
throw new UpdatesLockedError(
`Lockfile exists for { appId: ${appId}, service: ${service} }`,
);
}
// Otherwise just throw the error
throw e;
}
}
}
return {
unlock: async () => {
await unlockApp(currentLocks, release);
},
};
} catch (err) {
// If any error happens while taking locks, we need to unlock
// to avoid some locks being held by user apps and some by
// the supervisor
await unlockApp(currentLocks, release);
// Re-throw error to be handled in caller
throw err;
}
}
return servicesByAppId;
return { lock: lockApp };
}
export const Lockable = {
from(appId: string | number, services: string[]) {
// Convert appId to string so we always
// use the same value when taking the process lock
return newLockable(appId.toString(), services);
},
};
/**
* Try to take the locks for an application. If force is set, it will remove
* all existing lockfiles before performing the operation
* Call the given function after locks for the given apps
* have been taken.
*
* TODO: convert to native Promises and async/await. May require native implementation of Bluebird's dispose / using
* This is compatible with Lockable.lock() in the sense that only one locking
* operation is allowed at the time on the supervisor
*
* By default the call will wait 10 seconds for shared process locks before
* giving up
*/
export async function lock<T>(
appId: number | number[],
{ force = false }: { force: boolean },
export async function withLock<T>(
appIds: number | number[],
fn: () => Resolvable<T>,
{ force = false, maxWaitMs = 10 * 1000 }: Partial<LockingOpts> = {},
): Promise<T> {
const appIdsToLock = Array.isArray(appId) ? appId : [appId];
if (!appId || !appIdsToLock.length) {
appIds = Array.isArray(appIds) ? appIds : [appIds];
if (appIds.length === 0) {
return fn();
}
// Sort appIds so they are always locked in the same sequence
const sortedIds = appIdsToLock.sort();
// Always lock in the same order
appIds = appIds.sort();
let lockOverride: boolean;
const locks: Lock[] = [];
try {
lockOverride = await config.get('lockOverride');
} catch (err: any) {
throw new InternalInconsistencyError(
`Error getting lockOverride config value: ${err?.message ?? err}`,
);
}
const lockables: Lockable[] = [];
for (const appId of appIds) {
const appLockDir = pathOnRoot(lockPath(appId));
const services: string[] = [];
try {
// Read the contents of the app lockdir
const dirs = await fs.readdir(appLockDir);
const statResults = await Promise.allSettled(
dirs.map(async (service) => ({
service,
stat: await fs.lstat(path.join(appLockDir, service)),
})),
);
const releases = new Map<number, () => void>();
try {
for (const id of sortedIds) {
const lockDir = pathOnRoot(lockPath(id));
// Acquire write lock for appId
releases.set(id, await takeGlobalLockRW(id));
// Get list of service folders in lock directory
const serviceFolders = await fs.readdir(lockDir).catch((e) => {
for (const res of statResults) {
// Ignore rejected results
if (
res.status === 'fulfilled' &&
res.value.stat.isDirectory() &&
!res.value.stat.isSymbolicLink()
) {
services.push(res.value.service);
}
}
} catch (e) {
// If the directory does not exist, continue
if (isENOENT(e)) {
return [];
continue;
}
throw e;
});
// Attempt to create a lock for each service
for (const service of serviceFolders) {
await lockService(id, service, force || lockOverride);
}
lockables.push(Lockable.from(appId, services));
}
// Lock all apps at once to avoid adding up the wait times
const lockingResults = await Promise.allSettled(
lockables.map((l) => l.lock({ force, maxWaitMs })),
);
let err = null;
for (const res of lockingResults) {
if (res.status === 'fulfilled') {
locks.push(res.value);
} else {
err = res.reason;
}
}
// Resolve the function passed
// If there are any errors, then throw before calling the function
if (err != null) {
throw err;
}
// If we got here, then all locks were successfully acquired
// call the function now
return await fn();
} finally {
for (const [id, release] of releases.entries()) {
// Try to dispose all the locks
await dispose(id, release);
}
}
}
async function lockService(
appId: number,
service: string,
force: boolean = false,
): Promise<void> {
const serviceLockFiles = lockFilesOnHost(appId, service);
for await (const file of serviceLockFiles) {
try {
if (force) {
await lockfile.unlock(file);
}
await lockfile.lock(file, LOCKFILE_UID);
} catch (e) {
if (lockfile.LockfileExistsError.is(e)) {
// Throw more descriptive error
throw new UpdatesLockedError(
`Lockfile exists for { appId: ${appId}, service: ${service} }`,
);
}
// Otherwise just throw the error
throw e;
}
// Unlock all taken locks
await Promise.all(locks.map((l) => l.unlock()));
}
}

View File

@ -8,7 +8,6 @@ import { Network } from '~/src/compose/network';
import * as networkManager from '~/src/compose/network-manager';
import { Volume } from '~/src/compose/volume';
import * as config from '~/src/config';
import { LocksTakenMap } from '~/lib/update-lock';
import { createDockerImage } from '~/test-lib/docker-helper';
import {
createService,
@ -113,7 +112,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock lock taken to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
@ -225,7 +230,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock lock taken to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
@ -277,7 +288,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock lock taken to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
@ -410,7 +427,13 @@ describe('compose/application-manager', () => {
availableImages: c1.availableImages,
containerIdsByAppId: c1.containerIdsByAppId,
// Mock lock taken for `main` service which just needs metadata updated
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
// There should be two noop steps, one for target service which is still downloading,
@ -457,10 +480,13 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
// Mock locks taken for all services in either current or target state
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['old', 'main', 'new'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
// Service `old` is safe to kill after download for `new` has completed
@ -506,11 +532,14 @@ describe('compose/application-manager', () => {
// to avoid removeImage steps
availableImages: [],
containerIdsByAppId: c1.containerIdsByAppId,
// Mock locks for service to be updated via updateMetadata
// or kill to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['old', 'main', 'new'] },
]),
// Mock locks to avoid takeLock step
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
// Service `new` should be fetched
@ -583,10 +612,13 @@ describe('compose/application-manager', () => {
}),
],
containerIdsByAppId: c1.containerIdsByAppId,
// Mock lock taken for all services in target state
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['old', 'main', 'new'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
// Service `new` should be started
@ -629,9 +661,13 @@ describe('compose/application-manager', () => {
containerIdsByAppId: c1.containerIdsByAppId,
// Mock locks for service to be updated via updateMetadata
// or kill to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['old', 'main', 'new'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
// Service `new` should be fetched
@ -705,9 +741,13 @@ describe('compose/application-manager', () => {
],
containerIdsByAppId: c1.containerIdsByAppId,
// Mock lock taken for all services in target state
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'new'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
// Service `new` should be started
@ -805,9 +845,13 @@ describe('compose/application-manager', () => {
],
containerIdsByAppId,
// Mock locks taken for all services in target state
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('start', steps3, 2);
@ -877,7 +921,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock lock taken to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
@ -959,9 +1009,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'dep'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
@ -1028,9 +1082,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'dep'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
@ -1100,9 +1158,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'dep'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
@ -1146,10 +1208,18 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock lock already taken for the new and leftover services
locksTaken: new LocksTakenMap([
{ appId: 5, services: ['old-service'] },
{ appId: 1, services: ['main'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
'5': {
async unlock() {
/* noop */
},
},
},
},
);
@ -1665,7 +1735,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken to avoid takeLock step
locksTaken: new LocksTakenMap([{ appId: 2, services: ['main'] }]),
appLocks: {
'2': {
async unlock() {
/* noop */
},
},
},
},
);
@ -1747,10 +1823,18 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken to avoid takeLock step
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main'] },
{ appId: 2, services: ['main'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
'2': {
async unlock() {
/* noop */
},
},
},
},
);
@ -1816,9 +1900,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('kill', steps2, 2);
@ -1875,9 +1963,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('stop', steps2, 2);
@ -1934,9 +2026,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('start', steps2, 2);
@ -2100,9 +2196,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('kill', steps4, 2);
@ -2172,9 +2272,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('kill', steps2, 2);
@ -2231,7 +2335,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('kill', steps2);
@ -2254,7 +2364,13 @@ describe('compose/application-manager', () => {
availableImages: intermediateCurrent.availableImages,
containerIdsByAppId: intermediateCurrent.containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('removeNetwork', steps3);
@ -2320,7 +2436,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('kill', steps2, 1);
@ -2343,7 +2465,13 @@ describe('compose/application-manager', () => {
availableImages: intermediateCurrent.availableImages,
containerIdsByAppId: intermediateCurrent.containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('removeNetwork', steps3);
@ -2405,7 +2533,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('kill', steps2, 1);
@ -2424,7 +2558,13 @@ describe('compose/application-manager', () => {
availableImages: intermediateCurrent.availableImages,
containerIdsByAppId: intermediateCurrent.containerIdsByAppId,
// Mock locks taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
expectSteps('removeVolume', steps3);
@ -2456,9 +2596,13 @@ describe('compose/application-manager', () => {
downloading,
availableImages,
containerIdsByAppId,
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
},
);
const [releaseLockStep] = expectSteps('releaseLock', steps, 1, 1);
@ -2950,9 +3094,13 @@ describe('compose/application-manager', () => {
availableImages,
containerIdsByAppId,
// Mock locks taken for all services in target state
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two', 'three', 'four'] },
]),
appLocks: {
'1': {
async unlock() {
/* noop */
},
},
},
});
[startStep1, startStep2, startStep3, startStep4].forEach((step) => {

View File

@ -5,6 +5,7 @@ import Docker from 'dockerode';
import request from 'supertest';
import { setTimeout } from 'timers/promises';
import { testfs } from 'mocha-pod';
import { promises as fs } from 'fs';
import * as deviceState from '~/src/device-state';
import * as config from '~/src/config';
@ -378,7 +379,9 @@ describe('manages application lifecycle', () => {
await setTimeout(1000);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
await expect(
fs.readdir(pathOnRoot(updateLock.lockPath(1, 'server'))),
).to.eventually.deep.equal([]);
});
it('should restart an application when user locks are present if lockOverride is specified', async () => {
@ -423,7 +426,9 @@ describe('manages application lifecycle', () => {
await setTimeout(1000);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
await expect(
fs.readdir(pathOnRoot(updateLock.lockPath(1, 'server'))),
).to.eventually.deep.equal([]);
});
it('should restart service by removing and recreating corresponding container', async () => {
@ -519,7 +524,9 @@ describe('manages application lifecycle', () => {
await setTimeout(1000);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
await expect(
fs.readdir(pathOnRoot(updateLock.lockPath(1, 'server'))),
).to.eventually.deep.equal([]);
});
it('should restart service when user locks are present if lockOverride is specified', async () => {
@ -566,7 +573,9 @@ describe('manages application lifecycle', () => {
await setTimeout(1000);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
await expect(
fs.readdir(pathOnRoot(updateLock.lockPath(1, 'server'))),
).to.eventually.deep.equal([]);
});
it('should stop a running service', async () => {
@ -873,7 +882,9 @@ describe('manages application lifecycle', () => {
await setTimeout(500);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
await expect(
fs.readdir(pathOnRoot(updateLock.lockPath(1, 'server'))),
).to.eventually.deep.equal([]);
});
it('should restart an application when user locks are present if lockOverride is specified', async () => {
@ -918,7 +929,9 @@ describe('manages application lifecycle', () => {
await setTimeout(500);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
await expect(
fs.readdir(pathOnRoot(updateLock.lockPath(1, 'server'))),
).to.eventually.deep.equal([]);
});
it('should restart service by removing and recreating corresponding container', async () => {
@ -1026,7 +1039,9 @@ describe('manages application lifecycle', () => {
await setTimeout(500);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
await expect(
fs.readdir(pathOnRoot(updateLock.lockPath(1, 'server'))),
).to.eventually.deep.equal([]);
});
it('should restart service when user locks are present if lockOverride is specified', async () => {
@ -1073,7 +1088,9 @@ describe('manages application lifecycle', () => {
await setTimeout(500);
// User lock should be overridden
expect(await updateLock.getLocksTaken()).to.deep.equal([]);
await expect(
fs.readdir(pathOnRoot(updateLock.lockPath(1, 'server'))),
).to.eventually.deep.equal([]);
});
it('should stop a running service', async () => {

File diff suppressed because it is too large Load Diff

View File

@ -2,7 +2,7 @@ import { expect } from 'chai';
import type { Image } from '~/src/compose/images';
import { Network } from '~/src/compose/network';
import { Volume } from '~/src/compose/volume';
import { LocksTakenMap } from '~/lib/update-lock';
import type { Lock } from '~/lib/update-lock';
import {
createService,
@ -19,7 +19,13 @@ const defaultContext = {
availableImages: [] as Image[],
containerIds: {},
downloading: [] as string[],
locksTaken: new LocksTakenMap(),
lock: null,
};
const mockLock: Lock = {
async unlock() {
/* noop */
},
};
describe('compose/app', () => {
@ -190,7 +196,7 @@ describe('compose/app', () => {
...defaultContext,
availableImages,
// Mock lock already taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
lock: mockLock,
},
target,
);
@ -287,7 +293,7 @@ describe('compose/app', () => {
{
...contextWithImages,
// Mock locks already taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
intermediateTarget,
);
@ -299,7 +305,7 @@ describe('compose/app', () => {
{
...contextWithImages,
// Mock locks already taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
intermediateTarget,
);
@ -362,7 +368,7 @@ describe('compose/app', () => {
{
...contextWithImages,
// Mock locks already taken
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
target,
);
@ -525,7 +531,7 @@ describe('compose/app', () => {
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
lock: mockLock,
},
target,
);
@ -553,7 +559,7 @@ describe('compose/app', () => {
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
lock: mockLock,
},
target,
);
@ -678,7 +684,7 @@ describe('compose/app', () => {
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
lock: mockLock,
},
target,
);
@ -806,7 +812,7 @@ describe('compose/app', () => {
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
lock: mockLock,
},
target,
);
@ -879,9 +885,7 @@ describe('compose/app', () => {
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
lock: mockLock,
},
target,
);
@ -958,9 +962,7 @@ describe('compose/app', () => {
{
...defaultContext,
availableImages,
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two'] },
]),
lock: mockLock,
},
target,
);
@ -1108,9 +1110,7 @@ describe('compose/app', () => {
...defaultContext,
availableImages: [createImage({ serviceName: 'main' })],
// Mock locks already taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'aux'] },
]),
lock: mockLock,
},
target,
);
@ -1247,7 +1247,7 @@ describe('compose/app', () => {
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
target,
);
@ -1282,7 +1282,7 @@ describe('compose/app', () => {
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
target,
);
@ -1356,7 +1356,7 @@ describe('compose/app', () => {
const stepsToIntermediate = current.nextStepsForAppUpdate(
{
...contextWithImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
target,
);
@ -1376,7 +1376,7 @@ describe('compose/app', () => {
const stepsToTarget = intermediate.nextStepsForAppUpdate(
{
...contextWithImages,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
target,
);
@ -1435,9 +1435,7 @@ describe('compose/app', () => {
const contextWithImages = {
...defaultContext,
...{ availableImages },
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'dep'] },
]),
lock: mockLock,
};
// Only one start step and it should be that of the 'dep' service
@ -1555,7 +1553,7 @@ describe('compose/app', () => {
{
...contextWithImages,
// Mock locks taken before kill
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
target,
);
@ -1575,7 +1573,7 @@ describe('compose/app', () => {
...contextWithImages,
// Mock locks still taken after kill (releaseLock not
// yet inferred as state is not yet settled)
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
target,
);
@ -1703,7 +1701,7 @@ describe('compose/app', () => {
{
...contextWithImages,
// Mock locks taken from previous step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
target,
);
@ -1719,7 +1717,7 @@ describe('compose/app', () => {
{
...contextWithImages,
// Mock locks taken from previous step
locksTaken: new LocksTakenMap([{ appId: 1, services: ['main'] }]),
lock: mockLock,
},
target,
);
@ -1762,7 +1760,7 @@ describe('compose/app', () => {
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['test'] }]),
lock: mockLock,
},
target,
);
@ -1849,9 +1847,7 @@ describe('compose/app', () => {
{
...contextWithImages,
// Mock locks already taken
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['one', 'two', 'three'] },
]),
lock: mockLock,
},
target,
);
@ -2029,9 +2025,7 @@ describe('compose/app', () => {
serviceName: 'other',
}),
],
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['main', 'other'] },
]),
lock: mockLock,
},
target,
);
@ -2115,9 +2109,7 @@ describe('compose/app', () => {
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['server', 'client'] },
]),
lock: mockLock,
},
target,
);
@ -2128,7 +2120,7 @@ describe('compose/app', () => {
const steps2 = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([{ appId: 1, services: ['server'] }]),
lock: mockLock,
},
target,
);
@ -2173,10 +2165,7 @@ describe('compose/app', () => {
const steps = current.nextStepsForAppUpdate(
{
...defaultContext,
locksTaken: new LocksTakenMap([
{ appId: 1, services: ['server', 'client'] },
{ appId: 2, services: ['main'] },
]),
lock: mockLock,
},
target,
);

View File

@ -1,58 +0,0 @@
import { expect } from 'chai';
import * as path from 'path';
import * as updateLock from '~/lib/update-lock';
describe('lib/update-lock: unit tests', () => {
describe('lockPath', () => {
it('should return path prefix of service lockfiles on host', () => {
expect(updateLock.lockPath(123)).to.equal(
path.join(updateLock.BASE_LOCK_DIR, '123'),
);
expect(updateLock.lockPath(123, 'main')).to.equal(
path.join(updateLock.BASE_LOCK_DIR, '123', 'main'),
);
});
});
describe('LocksTakenMap', () => {
it('should be an instance of Map<number, Set<string>>', () => {
const map = new updateLock.LocksTakenMap();
expect(map).to.be.an.instanceof(Map);
});
it('should add services while ignoring duplicates', () => {
const map = new updateLock.LocksTakenMap();
map.add(123, 'main');
expect(map.getServices(123)).to.deep.include.members(['main']);
map.add(123, 'main');
expect(map.getServices(123)).to.deep.include.members(['main']);
map.add(123, ['main', 'aux']);
expect(map.getServices(123)).to.deep.include.members(['main', 'aux']);
});
it('should track any number of appIds', () => {
const map = new updateLock.LocksTakenMap();
map.add(123, 'main');
map.add(456, ['aux', 'dep']);
expect(map.getServices(123)).to.deep.include.members(['main']);
expect(map.getServices(456)).to.deep.include.members(['aux', 'dep']);
expect(map.size).to.equal(2);
});
it('should return empty array for non-existent appIds', () => {
const map = new updateLock.LocksTakenMap();
expect(map.getServices(123)).to.deep.equal([]);
});
it('should return whether a service is locked under an appId', () => {
const map = new updateLock.LocksTakenMap();
map.add(123, 'main');
expect(map.isLocked(123, 'main')).to.be.true;
expect(map.isLocked(123, 'aux')).to.be.false;
expect(map.isLocked(456, 'main')).to.be.false;
});
});
});