Refine update locking interface

* Remove Supervisor lockfile cleanup SIGTERM listener
* Modify lockfile.getLocksTaken to read files from the filesystem
* Remove in-memory tracking of locks taken in favor of filesystem
* Require both `(resin-)updates.lock` to be locked with `nobody` UID
  for service to count as locked by the Supervisor

Signed-off-by: Christina Ying Wang <christina@balena.io>
This commit is contained in:
Christina Ying Wang 2024-03-13 00:10:08 -07:00
parent 10f294cf8e
commit fb1bd33ab6
13 changed files with 329 additions and 171 deletions

View File

@ -80,10 +80,6 @@ modprobe ip6_tables || true
export BASE_LOCK_DIR="/tmp/balena-supervisor/services"
export LOCKFILE_UID=65534
# Cleanup leftover Supervisor-created lockfiles from any previous processes.
# Supervisor-created lockfiles have a UID of 65534.
find "${ROOT_MOUNTPOINT}${BASE_LOCK_DIR}" -type f -user "${LOCKFILE_UID}" -name "*updates.lock" -delete || true
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

@ -17,7 +17,7 @@ import {
ContractViolationError,
InternalInconsistencyError,
} from '../lib/errors';
import { getServicesLockedByAppId } from '../lib/update-lock';
import { getServicesLockedByAppId, LocksTakenMap } from '../lib/update-lock';
import { checkTruthy } from '../lib/validation';
import App from './app';
@ -150,7 +150,7 @@ export async function getRequiredSteps(
downloading,
availableImages,
containerIdsByAppId,
locksTaken: getServicesLockedByAppId(),
locksTaken: await getServicesLockedByAppId(),
});
}
@ -168,7 +168,7 @@ export async function inferNextSteps(
containerIdsByAppId = {} as {
[appId: number]: UpdateState['containerIds'];
},
locksTaken = getServicesLockedByAppId(),
locksTaken = new LocksTakenMap(),
} = {},
) {
const currentAppIds = Object.keys(currentApps).map((i) => parseInt(i, 10));

View File

@ -56,7 +56,7 @@ export async function get<T extends SchemaTypeKey>(
): Promise<SchemaReturn<T>> {
const $db = trx || db.models;
if (Object.prototype.hasOwnProperty.call(Schema.schema, key)) {
if (Object.hasOwn(Schema.schema, key)) {
const schemaKey = key as Schema.SchemaKey;
return getSchema(schemaKey, $db).then((value) => {
@ -82,7 +82,7 @@ export async function get<T extends SchemaTypeKey>(
// the type system happy
return checkValueDecode(decoded, key, value) && decoded.right;
});
} else if (Object.prototype.hasOwnProperty.call(FnSchema.fnSchema, key)) {
} else if (Object.hasOwn(FnSchema.fnSchema, key)) {
const fnKey = key as FnSchema.FnSchemaKey;
// Cast the promise as something that produces an unknown, and this means that
// we can validate the output of the function as well, ensuring that the type matches
@ -269,7 +269,7 @@ function validateConfigMap<T extends SchemaTypeKey>(
// throw if any value fails verification
return _.mapValues(configMap, (value, key) => {
if (
!Object.prototype.hasOwnProperty.call(Schema.schema, key) ||
!Object.hasOwn(Schema.schema, key) ||
!Schema.schema[key as Schema.SchemaKey].mutable
) {
throw new Error(

View File

@ -12,8 +12,8 @@ import * as imageManager from '../compose/images';
import {
AppsJsonParseError,
EISDIR,
ENOENT,
isEISDIR,
isENOENT,
InternalInconsistencyError,
} from '../lib/errors';
import log from '../lib/supervisor-console';
@ -163,7 +163,7 @@ export async function loadTargetFromFile(appsPath: string): Promise<boolean> {
// It can be an empty path because if the file does not exist
// on host, the docker daemon creates an empty directory when
// the bind mount is added
if (ENOENT(e) || EISDIR(e)) {
if (isENOENT(e) || isEISDIR(e)) {
log.debug('No apps.json file present, skipping preload');
} else {
log.debug(e.message);

View File

@ -6,7 +6,7 @@ import path from 'path';
import * as config from './config';
import * as applicationManager from './compose/application-manager';
import * as dbus from './lib/dbus';
import { ENOENT } from './lib/errors';
import { isENOENT } from './lib/errors';
import { mkdirp, unlinkAll } from './lib/fs-utils';
import {
writeToBoot,
@ -66,8 +66,8 @@ async function readProxy(): Promise<ProxyConfig | undefined> {
let redsocksConf: string;
try {
redsocksConf = await readFromBoot(redsocksConfPath, 'utf-8');
} catch (e: any) {
if (!ENOENT(e)) {
} catch (e: unknown) {
if (!isENOENT(e)) {
throw e;
}
return;
@ -97,8 +97,8 @@ async function readProxy(): Promise<ProxyConfig | undefined> {
if (noProxy.length) {
conf.noProxy = noProxy;
}
} catch (e: any) {
if (!ENOENT(e)) {
} catch (e: unknown) {
if (!isENOENT(e)) {
throw e;
}
}

View File

@ -40,23 +40,27 @@ export class BadRequestError extends StatusError {
export const isBadRequestError = (e: unknown): e is BadRequestError =>
isStatusError(e) && e.statusCode === 400;
export class DeviceNotFoundError extends TypedError {}
interface CodedSysError extends Error {
code?: string;
}
export class DeviceNotFoundError extends TypedError {}
const isCodedSysError = (e: unknown): e is CodedSysError =>
// See https://mdn.io/hasOwn
e != null && e instanceof Error && Object.hasOwn(e, 'code');
export function ENOENT(err: CodedSysError): boolean {
return err.code === 'ENOENT';
}
export const isENOENT = (e: unknown): e is CodedSysError =>
isCodedSysError(e) && e.code === 'ENOENT';
export function EEXIST(err: CodedSysError): boolean {
return err.code === 'EEXIST';
}
export const isEEXIST = (e: unknown): e is CodedSysError =>
isCodedSysError(e) && e.code === 'EEXIST';
export function EISDIR(err: CodedSysError): boolean {
return err.code === 'EISDIR';
}
export const isEISDIR = (e: unknown): e is CodedSysError =>
isCodedSysError(e) && e.code === 'EISDIR';
export const isEPERM = (e: unknown): e is CodedSysError =>
isCodedSysError(e) && e.code === 'EPERM';
export function UnitNotLoadedError(err: string[]): boolean {
return endsWith(err[0], 'not loaded.');

View File

@ -3,6 +3,7 @@ import path from 'path';
import { exec as execSync } from 'child_process';
import { promisify } from 'util';
import { uptime } from 'os';
import { isENOENT } from './errors';
export const exec = promisify(execSync);
@ -76,7 +77,7 @@ export const touch = (file: string, time = new Date()) =>
fs.utimes(file, time, time).catch((e) =>
// only create the file if it doesn't exist,
// if some other error happens is probably better to not touch it
e.code === 'ENOENT'
isENOENT(e)
? fs
.open(file, 'w')
.then((fd) => fd.close())

View File

@ -1,34 +1,44 @@
import { promises as fs, unlinkSync, rmdirSync } from 'fs';
import { promises as fs } from 'fs';
import type { Stats, Dirent } from 'fs';
import os from 'os';
import { dirname } from 'path';
import { exec } from './fs-utils';
import { isENOENT, isEISDIR, isEPERM } from './errors';
// Equivalent to `drwxrwxrwt`
const STICKY_WRITE_PERMISSIONS = 0o1777;
/**
* Internal lockfile manager to track files in memory
*/
// Track locksTaken, so that the proper locks can be cleaned up on process exit
const locksTaken: { [lockName: string]: boolean } = {};
// Returns all current locks taken, as they've been stored in-memory.
// Returns all current locks taken under a directory (default: /tmp)
// Optionally accepts filter function for only getting locks that match a condition.
export const getLocksTaken = (
lockFilter: (path: string) => boolean = () => true,
): string[] => Object.keys(locksTaken).filter(lockFilter);
// Try to clean up any existing locks when the process exits
process.on('exit', () => {
for (const lockName of getLocksTaken()) {
try {
unlockSync(lockName);
} catch (e) {
// Ignore unlocking errors
// A file is counted as a lock by default if it ends with `.lock`.
export const getLocksTaken = async (
rootDir: string = '/tmp',
lockFilter: (path: string, stat: Stats) => boolean = (p) =>
p.endsWith('.lock'),
): Promise<string[]> => {
const locksTaken: string[] = [];
let filesOrDirs: Dirent[] = [];
try {
filesOrDirs = await fs.readdir(rootDir, { withFileTypes: true });
} catch (err) {
// If lockfile directory doesn't exist, no locks are taken
if (isENOENT(err)) {
return locksTaken;
}
}
});
for (const fileOrDir of filesOrDirs) {
const lockPath = `${rootDir}/${fileOrDir.name}`;
// A lock is taken if it's a file or directory within rootDir that passes filter fn
if (lockFilter(lockPath, await fs.stat(lockPath))) {
locksTaken.push(lockPath);
// Otherwise, if non-lock directory, seek locks recursively within directory
} else if (fileOrDir.isDirectory()) {
locksTaken.push(...(await getLocksTaken(lockPath, lockFilter)));
}
}
return locksTaken;
};
interface ChildProcessError {
code: number;
@ -77,8 +87,6 @@ export async function lock(path: string, uid: number = os.userInfo().uid) {
try {
// Lock the file using binary
await exec(`lockfile -r 0 ${path}`, { uid });
// Store a lock in memory as taken
locksTaken[path] = true;
} catch (error) {
// Code 73 refers to EX_CANTCREAT (73) in sysexits.h, or:
// A (user specified) output file cannot be created.
@ -110,7 +118,7 @@ export async function unlock(path: string): Promise<void> {
// Removing the lockfile releases the lock
await fs.unlink(path).catch((e) => {
// if the error is EPERM|EISDIR, the file is a directory
if (e.code === 'EPERM' || e.code === 'EISDIR') {
if (isEPERM(e) || isEISDIR(e)) {
return fs.rmdir(path).catch(() => {
// if the directory is not empty or something else
// happens, ignore
@ -119,17 +127,4 @@ export async function unlock(path: string): Promise<void> {
// If the file does not exist or some other error
// happens, then ignore the error
});
// Remove lockfile's in-memory tracking of a file
delete locksTaken[path];
}
export function unlockSync(path: string) {
try {
return unlinkSync(path);
} catch (e: any) {
if (e.code === 'EPERM' || e.code === 'EISDIR') {
return rmdirSync(path);
}
throw e;
}
}

View File

@ -1,9 +1,10 @@
import { promises as fs } from 'fs';
import path from 'path';
import type { Stats } from 'fs';
import { isRight } from 'fp-ts/lib/Either';
import {
ENOENT,
isENOENT,
UpdatesLockedError,
InternalInconsistencyError,
} from './errors';
@ -65,10 +66,10 @@ async function dispose(
appIdentifier: string | number,
release: () => void,
): Promise<void> {
const locks = lockfile.getLocksTaken((p: string) =>
p.includes(`${BASE_LOCK_DIR}/${appIdentifier}`),
);
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 {
@ -89,11 +90,12 @@ export async function takeLock(
const release = await takeGlobalLockRW(appId);
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) => !getServicesLockedByAppId().isLocked(appId, svc),
(svc) => !locksTaken.isLocked(appId, svc),
);
for (const service of servicesWithoutLock) {
await mkdirp(pathOnRoot(lockPath(appId, service)));
@ -116,17 +118,18 @@ export async function releaseLock(appId: number) {
}
/**
* Given a lockfile path `p`, return a tuple [appId, serviceName] of that path.
* 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('/');
if (parts.pop()?.match(/updates\.lock/) === null) {
const filename = parts.pop();
if (filename?.match(/updates\.lock/) === null) {
return [];
}
const serviceName = parts.pop();
const appId = parts.pop();
return [appId, serviceName];
return [appId, serviceName, filename];
}
type LockedEntity = { appId: number; services: string[] };
@ -175,19 +178,62 @@ export class LocksTakenMap extends Map<number, Set<string>> {
}
}
// 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.getLocksTaken(
rootDir,
(p: string, s: Stats) =>
p.endsWith('updates.lock') && s.uid === LOCKFILE_UID,
);
}
/**
* 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 function getServicesLockedByAppId(): LocksTakenMap {
const locksTaken = lockfile.getLocksTaken();
const servicesByAppId = new LocksTakenMap();
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] = getIdentifiersFromPath(lockTakenPath);
if (!StringIdentifier.is(appId) || !DockerName.is(serviceName)) {
const [appId, serviceName, filename] =
getIdentifiersFromPath(lockTakenPath);
if (
!StringIdentifier.is(appId) ||
!DockerName.is(serviceName) ||
!filename?.match(/updates\.lock/)
) {
continue;
}
const numAppId = +appId;
servicesByAppId.add(numAppId, serviceName);
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);
}
// 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);
}
}
}
return servicesByAppId;
}
@ -228,7 +274,7 @@ export async function lock<T>(
releases.set(id, await takeGlobalLockRW(id));
// Get list of service folders in lock directory
const serviceFolders = await fs.readdir(lockDir).catch((e) => {
if (ENOENT(e)) {
if (isENOENT(e)) {
return [];
}
throw e;

View File

@ -5,7 +5,7 @@ import os from 'os';
import url from 'url';
import * as constants from './lib/constants';
import { EEXIST } from './lib/errors';
import { isEEXIST } from './lib/errors';
import { checkFalsey } from './lib/validation';
import blink = require('./lib/blink');
@ -71,7 +71,7 @@ export const startConnectivityCheck = _.once(
try {
await fs.mkdir(constants.vpnStatusPath);
} catch (err: any) {
if (EEXIST(err)) {
if (isEEXIST(err)) {
log.debug('VPN status path exists.');
} else {
throw err;

View File

@ -1,5 +1,5 @@
import { expect } from 'chai';
import { promises as fs, mkdirSync } from 'fs';
import { promises as fs } from 'fs';
import type { TestFs } from 'mocha-pod';
import { testfs } from 'mocha-pod';
import * as os from 'os';
@ -148,54 +148,82 @@ describe('lib/lockfile', () => {
await expect(fs.access(lock)).to.be.rejected;
});
it('should synchronously unlock a lockfile', () => {
const lock = path.join(lockdir, 'other.lock');
it('should get locks taken with default args', async () => {
// Set up lock dirs
await fs.mkdir(`${lockdir}/1/main`, { recursive: true });
await fs.mkdir(`${lockdir}/2/aux`, { recursive: true });
lockfile.unlockSync(lock);
// Take some locks
const locks = [
`${lockdir}/updates.lock`,
`${lockdir}/two.lock`,
`${lockdir}/1/main/updates.lock`,
`${lockdir}/1/main/resin-updates.lock`,
`${lockdir}/2/aux/updates.lock`,
`${lockdir}/2/aux/resin-updates.lock`,
];
await Promise.all(locks.map((lock) => lockfile.lock(lock)));
// Verify lockfile does not exist
return expect(fs.access(lock)).to.be.rejected;
// Assert all locks are listed as taken
expect(await lockfile.getLocksTaken(lockdir)).to.have.members(
locks.concat([`${lockdir}/other.lock`]),
);
// Clean up locks
await fs.rm(`${lockdir}`, { recursive: true });
});
it('should synchronously unlock a lockfile dir', () => {
const lock = path.join(lockdir, 'update.lock');
it('should get locks taken with a custom filter', async () => {
// Set up lock dirs
await fs.mkdir(`${lockdir}/1`, { recursive: true });
await fs.mkdir(`${lockdir}/services/main`, { recursive: true });
await fs.mkdir(`${lockdir}/services/aux`, { recursive: true });
mkdirSync(lock, { recursive: true });
lockfile.unlockSync(lock);
// Verify lockfile does not exist
return expect(fs.access(lock)).to.be.rejected;
});
it('should try to clean up existing locks on process exit', async () => {
// Create lockfiles
const lockOne = path.join(lockdir, 'updates.lock');
const lockTwo = path.join(lockdir, 'two.lock');
await expect(lockfile.lock(lockOne)).to.not.be.rejected;
await expect(lockfile.lock(lockTwo, NOBODY_UID)).to.not.be.rejected;
// @ts-expect-error simulate process exit event
process.emit('exit');
// Verify lockfile removal regardless of appId / appUuid
await expect(fs.access(lockOne)).to.be.rejected;
await expect(fs.access(lockTwo)).to.be.rejected;
});
it('allows to list locks taken according to a filter function', async () => {
// Create multiple lockfiles
const lockOne = path.join(lockdir, 'updates.lock');
const lockTwo = path.join(lockdir, 'two.lock');
await expect(lockfile.lock(lockOne)).to.not.be.rejected;
await expect(lockfile.lock(lockTwo, NOBODY_UID)).to.not.be.rejected;
// Take some locks...
// - with a specific UID
await lockfile.lock(`${lockdir}/updates.lock`, NOBODY_UID);
// - as a directory
await fs.mkdir(`${lockdir}/1/updates.lock`);
// - as a directory with a specific UID
await fs.mkdir(`${lockdir}/1/resin-updates.lock`);
await fs.chown(`${lockdir}/1/resin-updates.lock`, NOBODY_UID, NOBODY_UID);
// - under a different root dir from default
await lockfile.lock(`${lockdir}/services/main/updates.lock`);
await lockfile.lock(`${lockdir}/services/aux/resin-updates.lock`);
// Assert appropriate locks are listed as taken...
// - with a specific UID
expect(
lockfile.getLocksTaken((filepath) => filepath.includes('lockdir')),
).to.have.members([lockOne, lockTwo]);
await lockfile.getLocksTaken(
lockdir,
(p, stats) => p.endsWith('.lock') && stats.uid === NOBODY_UID,
),
).to.have.members([
`${lockdir}/updates.lock`,
`${lockdir}/1/resin-updates.lock`,
`${lockdir}/other.lock`,
]);
// - as a directory
expect(
lockfile.getLocksTaken((filepath) => filepath.includes('two')),
).to.have.members([lockTwo]);
expect(lockfile.getLocksTaken()).to.have.members([lockOne, lockTwo]);
await lockfile.getLocksTaken(
lockdir,
(p, stats) => p.endsWith('.lock') && stats.isDirectory(),
),
).to.have.members([
`${lockdir}/1/updates.lock`,
`${lockdir}/1/resin-updates.lock`,
]);
// - under a different root dir from default
expect(
await lockfile.getLocksTaken(`${lockdir}/services`, (p) =>
p.endsWith('.lock'),
),
).to.have.members([
`${lockdir}/services/main/updates.lock`,
`${lockdir}/services/aux/resin-updates.lock`,
]);
// Clean up locks
await fs.rm(`${lockdir}`, { recursive: true });
});
});

View File

@ -70,13 +70,16 @@ describe('lib/update-lock', () => {
const takeLocks = () =>
Promise.all(
supportedLockfiles.map((lf) =>
lockfile.lock(path.join(lockdir(testAppId, testServiceName), lf)),
lockfile.lock(
path.join(lockdir(testAppId, testServiceName), lf),
updateLock.LOCKFILE_UID,
),
),
);
const releaseLocks = async () => {
await Promise.all(
lockfile.getLocksTaken().map((lock) => lockfile.unlock(lock)),
(await updateLock.getLocksTaken()).map((lock) => lockfile.unlock(lock)),
);
// Remove any other lockfiles created for the testAppId
@ -150,8 +153,8 @@ describe('lib/update-lock', () => {
)
.catch((err) => expect(err).to.be.instanceOf(UpdatesLockedError));
// Since the lock-taking failed, there should be no locks to dispose of
expect(lockfile.getLocksTaken()).to.have.length(0);
// Since the lock-taking with `nobody` uid failed, there should be no locks to dispose of
expect(await updateLock.getLocksTaken()).to.have.length(0);
// Restore the locks that were taken at the beginning of the test
await releaseLocks();
@ -286,23 +289,96 @@ describe('lib/update-lock', () => {
});
});
describe('getLocksTaken', () => {
const lockdir = pathOnRoot(updateLock.BASE_LOCK_DIR);
before(async () => {
await testfs({
[lockdir]: {},
}).enable();
// TODO: enable mocha-pod to work with empty directories
await fs.mkdir(`${lockdir}/123/main`, { recursive: true });
await fs.mkdir(`${lockdir}/123/aux`, { recursive: true });
await fs.mkdir(`${lockdir}/123/invalid`, { recursive: true });
});
after(async () => {
await fs.rm(`${lockdir}/123`, { recursive: true });
await testfs.restore();
});
it('resolves with all locks taken with the Supervisor lockfile UID', async () => {
// Set up valid lockfiles including some directories
await Promise.all(
['resin-updates.lock', 'updates.lock'].map((lf) => {
const p = `${lockdir}/123/main/${lf}`;
return fs
.mkdir(p)
.then(() =>
fs.chown(p, updateLock.LOCKFILE_UID, updateLock.LOCKFILE_UID),
);
}),
);
await Promise.all([
lockfile.lock(
`${lockdir}/123/aux/updates.lock`,
updateLock.LOCKFILE_UID,
),
lockfile.lock(
`${lockdir}/123/aux/resin-updates.lock`,
updateLock.LOCKFILE_UID,
),
]);
// Set up invalid lockfiles with root UID
await Promise.all(
['resin-updates.lock', 'updates.lock'].map((lf) =>
lockfile.lock(`${lockdir}/123/invalid/${lf}`),
),
);
const locksTaken = await updateLock.getLocksTaken();
expect(locksTaken).to.have.length(4);
expect(locksTaken).to.deep.include.members([
`${lockdir}/123/aux/resin-updates.lock`,
`${lockdir}/123/aux/updates.lock`,
`${lockdir}/123/main/resin-updates.lock`,
`${lockdir}/123/main/updates.lock`,
]);
expect(locksTaken).to.not.deep.include.members([
`${lockdir}/123/invalid/resin-updates.lock`,
`${lockdir}/123/invalid/updates.lock`,
]);
});
});
describe('getServicesLockedByAppId', () => {
const validPaths = [
'/tmp/123/one/updates.lock',
'/tmp/123/two/updates.lock',
'/tmp/123/three/updates.lock',
'/tmp/balena-supervisor/services/456/server/updates.lock',
'/tmp/balena-supervisor/services/456/client/updates.lock',
'/tmp/balena-supervisor/services/789/main/resin-updates.lock',
const lockdir = pathOnRoot(updateLock.BASE_LOCK_DIR);
const validDirs = [
`${lockdir}/123/one`,
`${lockdir}/123/two`,
`${lockdir}/123/three`,
`${lockdir}/456/server`,
`${lockdir}/456/client`,
`${lockdir}/789/main`,
];
const validPaths = ['resin-updates.lock', 'updates.lock']
.map((lf) => validDirs.map((d) => path.join(d, lf)))
.flat();
const invalidPaths = [
'/tmp/balena-supervisor/services/456/updates.lock',
'/tmp/balena-supervisor/services/server/updates.lock',
'/tmp/test/updates.lock',
// No appId
`${lockdir}/456/updates.lock`,
// No service
`${lockdir}/server/updates.lock`,
// No appId or service
`${lockdir}/test/updates.lock`,
// One of (resin-)updates.lock is missing
`${lockdir}/123/one/resin-updates.lock`,
`${lockdir}/123/two/updates.lock`,
];
let tFs: TestFs.Enabled;
beforeEach(async () => {
tFs = await testfs({ '/tmp': {} }).enable();
tFs = await testfs({
[lockdir]: {},
}).enable();
// TODO: mocha-pod should support empty directories
await Promise.all(
validPaths
@ -325,7 +401,7 @@ describe('lib/update-lock', () => {
validPaths.map((p) => lockfile.lock(p, updateLock.LOCKFILE_UID)),
);
const locksTakenMap = updateLock.getServicesLockedByAppId();
const locksTakenMap = await updateLock.getServicesLockedByAppId();
expect([...locksTakenMap.keys()]).to.deep.include.members([
123, 456, 789,
]);
@ -348,9 +424,17 @@ describe('lib/update-lock', () => {
it('should ignore invalid lockfile locations', async () => {
// Set up lockfiles
await Promise.all(invalidPaths.map((p) => lockfile.lock(p)));
expect(updateLock.getServicesLockedByAppId().size).to.equal(0);
await Promise.all(
invalidPaths.map((p) => lockfile.lock(p, updateLock.LOCKFILE_UID)),
);
// Take another lock with an invalid UID but with everything else
// (appId, service, both lockfiles present) correct
await Promise.all(
['resin-updates.lock', 'updates.lock'].map((lf) =>
lockfile.lock(path.join(`${lockdir}/789/main`, lf)),
),
);
expect((await updateLock.getServicesLockedByAppId()).size).to.equal(0);
// Cleanup lockfiles
await Promise.all(invalidPaths.map((p) => lockfile.unlock(p)));
@ -396,10 +480,10 @@ describe('lib/update-lock', () => {
// Take locks for appId 1
await updateLock.takeLock(1, ['server', 'client']);
// Locks should have been taken
expect(lockfile.getLocksTaken()).to.deep.include.members(
expect(await updateLock.getLocksTaken()).to.deep.include.members(
serviceLockPaths[1],
);
expect(lockfile.getLocksTaken()).to.have.length(4);
expect(await updateLock.getLocksTaken()).to.have.length(4);
expect(
await fs.readdir(path.join(lockdir, '1', 'server')),
).to.include.members(['updates.lock', 'resin-updates.lock']);
@ -409,11 +493,11 @@ describe('lib/update-lock', () => {
// Take locks for appId 2
await updateLock.takeLock(2, ['main']);
// Locks should have been taken for appid 1 & 2
expect(lockfile.getLocksTaken()).to.deep.include.members([
expect(await updateLock.getLocksTaken()).to.deep.include.members([
...serviceLockPaths[1],
...serviceLockPaths[2],
]);
expect(lockfile.getLocksTaken()).to.have.length(6);
expect(await updateLock.getLocksTaken()).to.have.length(6);
expect(
await fs.readdir(path.join(lockdir, '2', 'main')),
).to.have.length(2);
@ -429,7 +513,7 @@ describe('lib/update-lock', () => {
// Take locks for app with nonexistent service directories
await updateLock.takeLock(3, ['api']);
// Locks should have been taken
expect(lockfile.getLocksTaken()).to.deep.include(
expect(await updateLock.getLocksTaken()).to.deep.include(
path.join(lockdir, '3', 'api', 'updates.lock'),
path.join(lockdir, '3', 'api', 'resin-updates.lock'),
);
@ -450,21 +534,21 @@ describe('lib/update-lock', () => {
it('should not take lock for services where Supervisor-taken lock already exists', async () => {
// Take locks for one service of appId 1
await lockfile.lock(serviceLockPaths[1][0]);
await lockfile.lock(serviceLockPaths[1][1]);
await lockfile.lock(serviceLockPaths[1][0], updateLock.LOCKFILE_UID);
await lockfile.lock(serviceLockPaths[1][1], updateLock.LOCKFILE_UID);
// Sanity check that locks are taken & tracked by Supervisor
expect(lockfile.getLocksTaken()).to.deep.include(
expect(await updateLock.getLocksTaken()).to.deep.include(
serviceLockPaths[1][0],
serviceLockPaths[1][1],
);
expect(lockfile.getLocksTaken()).to.have.length(2);
expect(await updateLock.getLocksTaken()).to.have.length(2);
// Take locks using takeLock, should only lock service which doesn't
// already have locks
await expect(
updateLock.takeLock(1, ['server', 'client']),
).to.eventually.deep.include.members(['client']);
// Check that locks are taken
expect(lockfile.getLocksTaken()).to.deep.include.members(
expect(await updateLock.getLocksTaken()).to.deep.include.members(
serviceLockPaths[1],
);
// Clean up lockfiles
@ -483,7 +567,7 @@ describe('lib/update-lock', () => {
updateLock.takeLock(1, ['server', 'client']),
).to.eventually.be.rejectedWith(UpdatesLockedError);
// No Supervisor locks should have been taken
expect(lockfile.getLocksTaken()).to.have.length(0);
expect(await updateLock.getLocksTaken()).to.have.length(0);
// Clean up user-created lockfiles
for (const lockPath of serviceLockPaths[1]) {
await fs.rm(lockPath);
@ -493,10 +577,10 @@ describe('lib/update-lock', () => {
updateLock.takeLock(1, ['server', 'client']),
).to.eventually.not.be.rejectedWith(UpdatesLockedError);
// Check that locks are taken
expect(lockfile.getLocksTaken()).to.deep.include.members(
expect(await updateLock.getLocksTaken()).to.deep.include.members(
serviceLockPaths[1],
);
expect(lockfile.getLocksTaken()).to.have.length(4);
expect(await updateLock.getLocksTaken()).to.have.length(4);
// Clean up lockfiles
for (const lockPath of serviceLockPaths[1]) {
await lockfile.unlock(lockPath);
@ -510,13 +594,13 @@ describe('lib/update-lock', () => {
const takeLockPromise = updateLock.takeLock(1, ['server', 'client']);
// Locks should have not been taken even after waiting
await setTimeout(500);
expect(lockfile.getLocksTaken()).to.have.length(0);
expect(await updateLock.getLocksTaken()).to.have.length(0);
// Release the write lock
release();
// Locks should be taken
await takeLockPromise;
// Locks should have been taken
expect(lockfile.getLocksTaken()).to.deep.include.members(
expect(await updateLock.getLocksTaken()).to.deep.include.members(
serviceLockPaths[1],
);
});
@ -545,38 +629,42 @@ describe('lib/update-lock', () => {
it('releases locks for an appId', async () => {
// Lock services for appId 1
for (const lockPath of serviceLockPaths[1]) {
await lockfile.lock(lockPath);
await lockfile.lock(lockPath, updateLock.LOCKFILE_UID);
}
// Sanity check that locks are taken & tracked by Supervisor
expect(lockfile.getLocksTaken()).to.deep.include.members(
expect(await updateLock.getLocksTaken()).to.deep.include.members(
serviceLockPaths[1],
);
// Release locks for appId 1
await updateLock.releaseLock(1);
// Locks should have been released
expect(lockfile.getLocksTaken()).to.have.length(0);
expect(await updateLock.getLocksTaken()).to.have.length(0);
// Double check that the lockfiles are removed
expect(await fs.readdir(`${lockdir}/1/server`)).to.have.length(0);
expect(await fs.readdir(`${lockdir}/1/client`)).to.have.length(0);
});
it('does not error if there are no locks to release', async () => {
expect(lockfile.getLocksTaken()).to.have.length(0);
expect(await updateLock.getLocksTaken()).to.have.length(0);
// Should not error
await updateLock.releaseLock(1);
expect(lockfile.getLocksTaken()).to.have.length(0);
expect(await updateLock.getLocksTaken()).to.have.length(0);
});
it('ignores locks outside of appId scope', async () => {
const lockPath = `${lockdir}/2/main/updates.lock`;
// Lock services outside of appId scope
await lockfile.lock(lockPath);
await lockfile.lock(lockPath, updateLock.LOCKFILE_UID);
// Sanity check that locks are taken & tracked by Supervisor
expect(lockfile.getLocksTaken()).to.deep.include.members([lockPath]);
expect(await updateLock.getLocksTaken()).to.deep.include.members([
lockPath,
]);
// Release locks for appId 1
await updateLock.releaseLock(1);
// Locks for appId 2 should not have been released
expect(lockfile.getLocksTaken()).to.deep.include.members([lockPath]);
expect(await updateLock.getLocksTaken()).to.deep.include.members([
lockPath,
]);
// Double check that the lockfile is still there
expect(await fs.readdir(`${lockdir}/2/main`)).to.have.length(1);
// Clean up the lockfile
@ -586,10 +674,10 @@ describe('lib/update-lock', () => {
it('waits to release locks until resource write lock is taken', async () => {
// Lock services for appId 1
for (const lockPath of serviceLockPaths[1]) {
await lockfile.lock(lockPath);
await lockfile.lock(lockPath, updateLock.LOCKFILE_UID);
}
// Sanity check that locks are taken & tracked by Supervisor
expect(lockfile.getLocksTaken()).to.deep.include.members(
expect(await updateLock.getLocksTaken()).to.deep.include.members(
serviceLockPaths[1],
);
// Take the write lock for appId 1
@ -598,7 +686,7 @@ describe('lib/update-lock', () => {
const releaseLockPromise = updateLock.releaseLock(1);
// Locks should have not been released even after waiting
await setTimeout(500);
expect(lockfile.getLocksTaken()).to.deep.include.members(
expect(await updateLock.getLocksTaken()).to.deep.include.members(
serviceLockPaths[1],
);
// Release the write lock
@ -606,7 +694,7 @@ describe('lib/update-lock', () => {
// Release locks for appId 1 should resolve
await releaseLockPromise;
// Locks should have been released
expect(lockfile.getLocksTaken()).to.have.length(0);
expect(await updateLock.getLocksTaken()).to.have.length(0);
});
});
});

View File

@ -74,7 +74,7 @@ export function registerOverride<
}
export function restoreOverride<T extends DockerodeFunction>(name: T) {
if (Object.prototype.hasOwnProperty.call(overrides, name)) {
if (Object.hasOwn(overrides, name)) {
delete overrides[name];
}
}