Modify update lock module to use new lockfile binary and library

Also uninstall lockfile NPM package as we're no longer using it

Signed-off-by: Christina Wang <christina@balena.io>
This commit is contained in:
Christina Wang 2022-03-07 16:56:11 -08:00
parent 51e63ea22b
commit e9738b5f78
7 changed files with 241 additions and 265 deletions

15
package-lock.json generated
View File

@ -616,12 +616,6 @@
"integrity": "sha512-KZfv4ea6bEbdQhfwpxtDuTPO2mHAAXMQqPOZyS4MgNyCymKoLHp0FVzzYq3H2zCeIotN4h1453TahLCCm8rf2w==", "integrity": "sha512-KZfv4ea6bEbdQhfwpxtDuTPO2mHAAXMQqPOZyS4MgNyCymKoLHp0FVzzYq3H2zCeIotN4h1453TahLCCm8rf2w==",
"dev": true "dev": true
}, },
"@types/lockfile": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@types/lockfile/-/lockfile-1.0.1.tgz",
"integrity": "sha512-65WZedEm4AnOsBDdsapJJG42MhROu3n4aSSiu87JXF/pSdlubxZxp3S1yz3kTfkJ2KBPud4CpjoHVAptOm9Zmw==",
"dev": true
},
"@types/lodash": { "@types/lodash": {
"version": "4.14.159", "version": "4.14.159",
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.159.tgz", "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.14.159.tgz",
@ -6607,15 +6601,6 @@
"p-locate": "^4.1.0" "p-locate": "^4.1.0"
} }
}, },
"lockfile": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/lockfile/-/lockfile-1.0.4.tgz",
"integrity": "sha512-cvbTwETRfsFh4nHsL1eGWapU1XFi5Ot9E85sWAwia7Y7EgB7vfqcZhTKZ+l7hCGxSPoushMv5GKhT5PdLv03WA==",
"dev": true,
"requires": {
"signal-exit": "^3.0.2"
}
},
"lodash": { "lodash": {
"version": "4.17.21", "version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",

View File

@ -54,7 +54,6 @@
"@types/dockerode": "^2.5.34", "@types/dockerode": "^2.5.34",
"@types/event-stream": "^3.3.34", "@types/event-stream": "^3.3.34",
"@types/express": "^4.17.3", "@types/express": "^4.17.3",
"@types/lockfile": "^1.0.1",
"@types/lodash": "^4.14.159", "@types/lodash": "^4.14.159",
"@types/memoizee": "^0.4.4", "@types/memoizee": "^0.4.4",
"@types/mocha": "^8.2.2", "@types/mocha": "^8.2.2",
@ -100,7 +99,6 @@
"knex": "^0.20.13", "knex": "^0.20.13",
"lint-staged": "^10.2.11", "lint-staged": "^10.2.11",
"livepush": "^3.5.1", "livepush": "^3.5.1",
"lockfile": "^1.0.4",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"memoizee": "^0.4.14", "memoizee": "^0.4.14",
"mixpanel": "^0.10.3", "mixpanel": "^0.10.3",

View File

@ -107,9 +107,11 @@ export async function lock(path: string, uid = LOCKFILE_UID) {
} }
} }
export async function unlock(path: string) { export async function unlock(path: string): Promise<void> {
// Removing the updates.lock file releases the lock // Removing the updates.lock file releases the lock
return await unlinkAll(path); await unlinkAll(path);
// Remove lockfile's in-memory tracking of a file
delete locksTaken[path];
} }
export function unlockSync(path: string) { export function unlockSync(path: string) {

View File

@ -1,5 +1,4 @@
import * as Bluebird from 'bluebird'; import * as Bluebird from 'bluebird';
import * as lockFileLib from 'lockfile';
import * as _ from 'lodash'; import * as _ from 'lodash';
import { promises as fs } from 'fs'; import { promises as fs } from 'fs';
import * as path from 'path'; import * as path from 'path';
@ -8,30 +7,15 @@ import * as Lock from 'rwlock';
import * as constants from './constants'; import * as constants from './constants';
import { import {
ENOENT, ENOENT,
EEXIST,
UpdatesLockedError, UpdatesLockedError,
InternalInconsistencyError, InternalInconsistencyError,
} from './errors'; } from './errors';
import { getPathOnHost, pathExistsOnHost } from './fs-utils'; import { getPathOnHost, pathExistsOnHost } from './fs-utils';
import * as config from '../config'; import * as config from '../config';
import * as lockfile from './lockfile';
type asyncLockFile = typeof lockFileLib & {
unlockAsync(path: string): Bluebird<void>;
lockAsync(path: string): Bluebird<void>;
};
const lockFile = Bluebird.promisifyAll(lockFileLib) as asyncLockFile;
export type LockCallback = (
appId: number,
opts: { force: boolean },
fn: () => PromiseLike<void>,
) => Bluebird<void>;
export function lockPath(appId: number, serviceName?: string): string { export function lockPath(appId: number, serviceName?: string): string {
return path.join( return path.join(lockfile.BASE_LOCK_DIR, appId.toString(), serviceName ?? '');
'/tmp/balena-supervisor/services',
appId.toString(),
serviceName ?? '',
);
} }
function lockFilesOnHost(appId: number, serviceName: string): string[] { function lockFilesOnHost(appId: number, serviceName: string): string[] {
@ -69,19 +53,6 @@ export function abortIfHUPInProgress({
}); });
} }
const locksTaken: { [lockName: string]: boolean } = {};
// Try to clean up any existing locks when the program exits
process.on('exit', () => {
for (const lockName of _.keys(locksTaken)) {
try {
lockFile.unlockSync(lockName);
} catch (e) {
// Ignore unlocking errors
}
}
});
type LockFn = (key: string | number) => Bluebird<() => void>; type LockFn = (key: string | number) => Bluebird<() => void>;
const locker = new Lock(); const locker = new Lock();
export const writeLock: LockFn = Bluebird.promisify(locker.async.writeLock, { export const writeLock: LockFn = Bluebird.promisify(locker.async.writeLock, {
@ -92,54 +63,32 @@ export const readLock: LockFn = Bluebird.promisify(locker.async.readLock, {
}); });
function dispose(release: () => void): Bluebird<void> { function dispose(release: () => void): Bluebird<void> {
return Bluebird.map(_.keys(locksTaken), (lockName) => { return Bluebird.map(lockfile.getLocksTaken(), (lockName) => {
delete locksTaken[lockName]; return lockfile.unlock(lockName);
return lockFile.unlockAsync(lockName);
}) })
.finally(release) .finally(release)
.return(); .return();
} }
const lockExistsErrHandler = (err: Error, release: () => void) => {
let errMsg = err.message;
if (EEXIST(err)) {
// Extract appId|appUuid and serviceName from lockfile path for log message
// appId: [0-9]{7}, appUuid: [0-9a-w]{32}, short appUuid: [0-9a-w]{7}
const pathMatch = err.message.match(
/\/([0-9]{7}|[0-9a-w]{32}|[0-9a-w]{7})\/(.*)\/(?:resin-)?updates.lock/,
);
if (pathMatch && pathMatch.length === 3) {
errMsg = `Lockfile exists for ${JSON.stringify({
serviceName: pathMatch[2],
[/^[0-9]{7}$/.test(pathMatch[1]) ? 'appId' : 'appUuid']: pathMatch[1],
})}`;
}
}
return dispose(release).throw(new UpdatesLockedError(errMsg));
};
/** /**
* Try to take the locks for an application. If force is set, it will remove * Try to take the locks for an application. If force is set, it will remove
* all existing lockfiles before performing the operation * all existing lockfiles before performing the operation
* *
* TODO: convert to native Promises. 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 * 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. * without an option to skip.
*/ */
export function lock<T extends unknown>( export function lock<T extends unknown>(
appId: number | null, appId: number,
{ force = false, skipLock = false }: { force: boolean; skipLock?: boolean }, { force = false, skipLock = false }: { force: boolean; skipLock?: boolean },
fn: () => Resolvable<T>, fn: () => Resolvable<T>,
): Bluebird<T> { ): Bluebird<T> {
if (skipLock) { if (skipLock || appId == null) {
return Bluebird.resolve(fn()); return Bluebird.resolve(fn());
} }
const takeTheLock = () => { const takeTheLock = () => {
if (appId == null) {
return;
}
return config return config
.get('lockOverride') .get('lockOverride')
.then((lockOverride) => { .then((lockOverride) => {
@ -152,20 +101,36 @@ export function lock<T extends unknown>(
return Bluebird.mapSeries( return Bluebird.mapSeries(
lockFilesOnHost(appId, serviceName), lockFilesOnHost(appId, serviceName),
(tmpLockName) => { (tmpLockName) => {
return Bluebird.try(() => { return (
if (force || lockOverride) { Bluebird.try(() => {
return lockFile.unlockAsync(tmpLockName); if (force || lockOverride) {
} return lockfile.unlock(tmpLockName);
}) }
.then(() => lockFile.lockAsync(tmpLockName))
.then(() => {
locksTaken[tmpLockName] = true;
}) })
.catchReturn(ENOENT, undefined); .then(() => {
return lockfile.lock(tmpLockName);
})
// If lockfile exists, throw a user-friendly error.
// Otherwise throw the error as-is.
// This will interrupt the call to Bluebird.using, so
// dispose needs to be called even though it's referenced
// by .disposer later.
.catch((error) => {
return dispose(release).throw(
lockfile.LockfileExistsError.is(error)
? new UpdatesLockedError(
`Lockfile exists for ${JSON.stringify({
serviceName,
appId,
})}`,
)
: (error as Error),
);
})
);
}, },
); );
}) });
.catch((err) => lockExistsErrHandler(err, release));
}) })
.disposer(dispose); .disposer(dispose);
}) })
@ -177,9 +142,6 @@ export function lock<T extends unknown>(
}; };
const disposer = takeTheLock(); const disposer = takeTheLock();
if (disposer) {
return Bluebird.using(disposer, fn as () => PromiseLike<T>); return Bluebird.using(disposer, fn as () => PromiseLike<T>);
} else {
return Bluebird.resolve(fn());
}
} }

View File

@ -8,10 +8,8 @@ import { normaliseLegacyDatabase } from './lib/legacy';
import * as osRelease from './lib/os-release'; import * as osRelease from './lib/os-release';
import * as logger from './logger'; import * as logger from './logger';
import SupervisorAPI from './supervisor-api'; import SupervisorAPI from './supervisor-api';
import log from './lib/supervisor-console'; import log from './lib/supervisor-console';
import version = require('./lib/supervisor-version'); import version = require('./lib/supervisor-version');
import * as avahi from './lib/avahi'; import * as avahi from './lib/avahi';
import * as firewall from './lib/firewall'; import * as firewall from './lib/firewall';
import logMonitor from './logging/monitor'; import logMonitor from './logging/monitor';

View File

@ -6,7 +6,6 @@ import mock = require('mock-fs');
import * as lockfile from '../../../src/lib/lockfile'; import * as lockfile from '../../../src/lib/lockfile';
import * as fsUtils from '../../../src/lib/fs-utils'; import * as fsUtils from '../../../src/lib/fs-utils';
import { ChildProcessError } from '../../../src/lib/errors';
describe('lib/lockfile', () => { describe('lib/lockfile', () => {
const lockPath = `${lockfile.BASE_LOCK_DIR}/1234567/updates.lock`; const lockPath = `${lockfile.BASE_LOCK_DIR}/1234567/updates.lock`;
@ -70,8 +69,14 @@ describe('lib/lockfile', () => {
}); });
}); });
afterEach(() => { afterEach(async () => {
execStub.restore(); execStub.restore();
// Even though mock-fs is restored, this is needed to delete any in-memory storage of locks
for (const lock of lockfile.getLocksTaken()) {
await lockfile.unlock(lock);
}
mock.restore(); mock.restore();
}); });
@ -109,15 +114,14 @@ describe('lib/lockfile', () => {
// no errors, but we want it to throw an error just for this unit test // no errors, but we want it to throw an error just for this unit test
execStub.restore(); execStub.restore();
const childProcessError = new Error() as ChildProcessError; const childProcessError = new lockfile.LockfileExistsError(
childProcessError.code = 73; '/tmp/test/path',
childProcessError.stderr = 'lockfile: Test error'; );
execStub = stub(fsUtils, 'exec').throws(childProcessError); execStub = stub(fsUtils, 'exec').throws(childProcessError);
try { try {
await lockfile.lock(lockPath); await lockfile.lock(lockPath);
expect.fail('lockfile.lock should have thrown an error'); expect.fail('lockfile.lock should throw an error');
} catch (err) { } catch (err) {
expect(err).to.exist; expect(err).to.exist;
} }
@ -164,4 +168,18 @@ describe('lib/lockfile', () => {
expect.fail((err as Error)?.message ?? err); expect.fail((err as Error)?.message ?? err);
}); });
}); });
it('should try to clean up existing locks on process exit', async () => {
// Mock directory with sticky + write permissions
mockDir(STICKY_WRITE_PERMISSIONS.unix, { createLock: false });
// Lock file, which stores lock path in memory
await lockfile.lock(lockPath);
// @ts-ignore
process.emit('exit');
// Verify lockfile removal
await checkLockDirFiles(lockPath, { shouldExist: false });
});
}); });

View File

@ -1,48 +1,40 @@
import { expect } from 'chai'; import { expect } from 'chai';
import { SinonSpy, SinonStub, spy, stub } from 'sinon'; import { SinonSpy, SinonStub, spy, stub } from 'sinon';
import * as path from 'path'; import * as path from 'path';
import * as Bluebird from 'bluebird'; import { promises as fs } from 'fs';
import rewire = require('rewire');
import mockFs = require('mock-fs'); import mockFs = require('mock-fs');
import * as updateLock from '../../../src/lib/update-lock';
import * as constants from '../../../src/lib/constants'; import * as constants from '../../../src/lib/constants';
import { UpdatesLockedError } from '../../../src/lib/errors'; import { UpdatesLockedError } from '../../../src/lib/errors';
import * as config from '../../../src/config';
import * as lockfile from '../../../src/lib/lockfile';
import * as fsUtils from '../../../src/lib/fs-utils';
describe('lib/update-lock', () => { describe('lib/update-lock', () => {
const updateLock = rewire('../../../src/lib/update-lock'); const appId = 1234567;
const breadcrumbFiles = [ const serviceName = 'test';
'rollback-health-breadcrumb',
'rollback-altboot-breadcrumb',
];
const mockBreadcrumbs = (breadcrumb?: string) => {
mockFs({
[path.join(
constants.rootMountPoint,
constants.stateMountPoint,
breadcrumb ? breadcrumb : '',
)]: '',
});
};
const mockLockDir = ({ const mockLockDir = ({
appId,
service,
createLockfile = true, createLockfile = true,
}: { }: {
appId: number;
service: string;
createLockfile?: boolean; createLockfile?: boolean;
}) => { }) => {
const lockDirFiles: any = {};
if (createLockfile) {
lockDirFiles['updates.lock'] = mockFs.file({
uid: lockfile.LOCKFILE_UID,
});
lockDirFiles['resin-updates.lock'] = mockFs.file({
uid: lockfile.LOCKFILE_UID,
});
}
mockFs({ mockFs({
[path.join( [path.join(
constants.rootMountPoint, constants.rootMountPoint,
updateLock.lockPath(appId), updateLock.lockPath(appId),
service, serviceName,
)]: { )]: lockDirFiles,
[createLockfile ? 'updates.lock' : 'ignore-this.lock']: '',
},
}); });
}; };
@ -57,29 +49,33 @@ describe('lib/update-lock', () => {
constants.rootMountPoint = process.env.ROOT_MOUNTPOINT; constants.rootMountPoint = process.env.ROOT_MOUNTPOINT;
}); });
describe('Lockfile path methods', () => { describe('lockPath', () => {
const testAppId = 1234567;
const testService = 'test';
it('should return path prefix of service lockfiles on host', () => { it('should return path prefix of service lockfiles on host', () => {
expect(updateLock.lockPath(testAppId)).to.equal( expect(updateLock.lockPath(appId)).to.equal(
`/tmp/balena-supervisor/services/${testAppId}`, `/tmp/balena-supervisor/services/${appId}`,
); );
expect(updateLock.lockPath(testAppId, testService)).to.equal( expect(updateLock.lockPath(appId, serviceName)).to.equal(
`/tmp/balena-supervisor/services/${testAppId}/${testService}`, `/tmp/balena-supervisor/services/${appId}/${serviceName}`,
); );
}); });
it('should return the complete paths of (non-)legacy lockfiles on host', () => {
const lockFilesOnHost = updateLock.__get__('lockFilesOnHost');
expect(lockFilesOnHost(testAppId, testService)).to.deep.equal([
`${constants.rootMountPoint}/tmp/balena-supervisor/services/${testAppId}/${testService}/updates.lock`,
`${constants.rootMountPoint}/tmp/balena-supervisor/services/${testAppId}/${testService}/resin-updates.lock`,
]);
});
}); });
describe('abortIfHUPInProgress', () => { describe('abortIfHUPInProgress', () => {
const breadcrumbFiles = [
'rollback-health-breadcrumb',
'rollback-altboot-breadcrumb',
];
const mockBreadcrumbs = (breadcrumb?: string) => {
mockFs({
[path.join(
constants.rootMountPoint,
constants.stateMountPoint,
breadcrumb ? breadcrumb : '',
)]: '',
});
};
afterEach(() => mockFs.restore()); afterEach(() => mockFs.restore());
it('should throw if any breadcrumbs exist on host', async () => { it('should throw if any breadcrumbs exist on host', async () => {
@ -109,152 +105,169 @@ describe('lib/update-lock', () => {
}); });
describe('Lock/dispose functionality', () => { describe('Lock/dispose functionality', () => {
const lockFile = updateLock.__get__('lockFile'); const getLockParentDir = (): string =>
const locksTaken = updateLock.__get__('locksTaken'); `${constants.rootMountPoint}${updateLock.lockPath(appId, serviceName)}`;
const dispose = updateLock.__get__('dispose');
const lockExistsErrHandler = updateLock.__get__('lockExistsErrHandler');
const releaseFn = stub(); const expectLocks = async (exists: boolean = true) => {
const testLockPaths = ['/tmp/test/1', '/tmp/test/2']; expect(await fs.readdir(getLockParentDir())).to.deep.equal(
exists ? ['resin-updates.lock', 'updates.lock'] : [],
);
};
let unlockSyncStub: SinonStub; let unlockSpy: SinonSpy;
let unlockAsyncSpy: SinonSpy; let lockSpy: SinonSpy;
let lockAsyncSpy: SinonSpy; let execStub: SinonStub;
let configGetStub: SinonStub;
beforeEach(() => { beforeEach(() => {
unlockSpy = spy(lockfile, 'unlock');
lockSpy = spy(lockfile, 'lock');
// lockfile.lock calls exec to interface with the lockfile binary,
// so mock it here as we don't have access to the binary in the test env
// @ts-ignore // @ts-ignore
unlockSyncStub = stub(lockFile, 'unlockSync').callsFake((lockPath) => { execStub = stub(fsUtils, 'exec').callsFake(async (command, opts) => {
// Throw error on process.exit for one of the two lockpaths // Sanity check for the command call
if (lockPath === testLockPaths[1]) { expect(command.trim().startsWith('lockfile')).to.be.true;
throw new Error(
'handled unlockSync error which should not crash test process', // Remove any `lockfile` command options to leave just the command and the target filepath
); const [, targetPath] = command
} .replace(/-v|-nnn|-r\s+\d+|-l\s+\d+|-s\s+\d+|-!|-ml|-mu/g, '')
.split(/\s+/);
// Emulate the lockfile binary exec call
await fsUtils.touch(targetPath);
await fs.chown(targetPath, opts!.uid!, 0);
}); });
unlockAsyncSpy = spy(lockFile, 'unlockAsync');
lockAsyncSpy = spy(lockFile, 'lockAsync'); // config.get is called in updateLock.lock to get `lockOverride` value,
// so mock it here to definitively avoid any side effects
configGetStub = stub(config, 'get').resolves(false);
}); });
afterEach(() => { afterEach(async () => {
for (const key of Object.keys(locksTaken)) { unlockSpy.restore();
delete locksTaken[key]; lockSpy.restore();
} execStub.restore();
unlockSyncStub.restore();
unlockAsyncSpy.restore();
lockAsyncSpy.restore();
});
it('should try to clean up existing locks on process exit', () => { configGetStub.restore();
testLockPaths.forEach((p) => (locksTaken[p] = true));
// @ts-ignore // Even though mock-fs is restored, this is needed to delete any in-memory storage of locks
process.emit('exit'); for (const lock of lockfile.getLocksTaken()) {
testLockPaths.forEach((p) => { await lockfile.unlock(lock);
expect(unlockSyncStub).to.have.been.calledWith(p);
});
});
it('should dispose of locks', async () => {
for (const lock of testLockPaths) {
locksTaken[lock] = true;
} }
await dispose(releaseFn); mockFs.restore();
expect(locksTaken).to.deep.equal({});
expect(releaseFn).to.have.been.called;
testLockPaths.forEach((p) => {
expect(unlockAsyncSpy).to.have.been.calledWith(p);
});
}); });
describe('lockExistsErrHandler', () => { it('should take the lock, run the function, then dispose of locks', async () => {
it('should handle EEXIST', async () => { // Set up fake filesystem for lockfiles
const appIdentifiers = [ mockLockDir({ createLockfile: false });
{ id: '1234567', service: 'test1', type: 'appId' },
{ await expect(
id: 'c89a7cb83d974518479591ffaf7c2417', updateLock.lock(appId, { force: false }, async () => {
service: 'test2', // At this point the locks should be taken and not removed
type: 'appUuid', // until this function has been resolved
await expectLocks(true);
return Promise.resolve();
}),
).to.eventually.be.fulfilled;
// Both `updates.lock` and `resin-updates.lock` should have been taken
expect(lockSpy.args).to.have.length(2);
// Everything that was locked should have been unlocked
expect(lockSpy.args).to.deep.equal(unlockSpy.args);
});
it('should throw UpdatesLockedError if lockfile exists', async () => {
// Set up fake filesystem for lockfiles
mockLockDir({ createLockfile: true });
const lockPath = `${getLockParentDir()}/updates.lock`;
execStub.throws(new lockfile.LockfileExistsError(lockPath));
try {
await updateLock.lock(appId, { force: false }, async () => {
await expectLocks(false);
return Promise.resolve();
});
expect.fail('updateLock.lock should throw an UpdatesLockedError');
} catch (err) {
expect(err).to.be.instanceOf(UpdatesLockedError);
}
// Should only have attempted to take `updates.lock`
expect(lockSpy.args.flat()).to.deep.equal([lockPath]);
// Since the lock-taking failed, there should be no locks to dispose of
expect(lockfile.getLocksTaken()).to.have.length(0);
// Since nothing was locked, nothing should be unlocked
expect(unlockSpy.args).to.have.length(0);
});
it('should dispose of taken locks on any other errors', async () => {
// Set up fake filesystem for lockfiles
mockLockDir({ createLockfile: false });
try {
await updateLock.lock(
appId,
{ force: false },
// At this point 2 lockfiles have been written, so this is testing
// that even if the function rejects, lockfiles will be disposed of
async () => {
await expectLocks();
return Promise.reject(new Error('Test error'));
}, },
{ id: 'c89a7cb', service: 'test3', type: 'appUuid' }, );
]; } catch {
for (const { id, service, type } of appIdentifiers) { /* noop */
// Handle legacy & nonlegacy lockfile names // This just catches the 'Test error' above
for (const lockfile of ['updates.lock', 'resin-updates.lock']) { }
const error = {
code: 'EEXIST',
message: `EEXIST: open "/tmp/balena-supervisor/services/${id}/${service}/${lockfile}"`,
};
await expect(lockExistsErrHandler(error, releaseFn))
.to.eventually.be.rejectedWith(
`Lockfile exists for ${JSON.stringify({
serviceName: service,
[type]: id,
})}`,
)
.and.be.an.instanceOf(UpdatesLockedError);
}
}
});
it('should handle any other errors', async () => { // Both `updates.lock` and `resin-updates.lock` should have been taken
await expect(lockExistsErrHandler(new Error('Test error'), releaseFn)) expect(lockSpy.args).to.have.length(2);
.to.eventually.be.rejectedWith('Test error')
.and.be.an.instanceOf(UpdatesLockedError); // Everything that was locked should have been unlocked
}); expect(lockSpy.args).to.deep.equal(unlockSpy.args);
}); });
describe('lock', () => { it('resolves input function without locking when appId is null', async () => {
let bluebirdUsing: SinonSpy; mockLockDir({ createLockfile: true });
let bluebirdResolve: SinonSpy;
const lockParamFn = stub().resolves();
beforeEach(() => { await expect(
bluebirdUsing = spy(Bluebird, 'using'); updateLock.lock(null as any, { force: false }, stub().resolves()),
bluebirdResolve = spy(Bluebird, 'resolve'); ).to.be.fulfilled;
});
afterEach(() => { // Since appId is null, updateLock.lock should just run the function, so
bluebirdUsing.restore(); // there should be no interfacing with the lockfile module
bluebirdResolve.restore(); expect(unlockSpy).to.not.have.been.called;
expect(lockSpy).to.not.have.been.called;
});
mockFs.restore(); it('unlocks lockfile to resolve function if force option specified', async () => {
}); mockLockDir({ createLockfile: true });
it('resolves input function without dispose pattern when appId is null', async () => { await expect(updateLock.lock(1234567, { force: true }, stub().resolves()))
mockLockDir({ appId: 1234567, service: 'test', createLockfile: true }); .to.be.fulfilled;
await expect(updateLock.lock(null, { force: false }, lockParamFn)).to.be
.fulfilled;
expect(bluebirdResolve).to.have.been.called;
});
it('resolves input function without dispose pattern when no lockfiles exist', async () => { expect(unlockSpy).to.have.been.called;
mockLockDir({ appId: 1234567, service: 'test', createLockfile: false }); expect(lockSpy).to.have.been.called;
await expect(updateLock.lock(1234567, { force: false }, lockParamFn)).to });
.be.fulfilled;
expect(bluebirdResolve).to.have.been.called;
});
it('uses dispose pattern if lockfile present and throws error', async () => { it('unlocks lockfile to resolve function if lockOverride option specified', async () => {
mockLockDir({ appId: 1234567, service: 'test' }); configGetStub.resolves(true);
await expect(updateLock.lock(1234567, { force: false }, lockParamFn)) mockLockDir({ createLockfile: true });
.to.eventually.be.rejectedWith(
'Lockfile exists for {"serviceName":"test","appId":"1234567"}',
)
.and.be.an.instanceOf(UpdatesLockedError);
expect(lockAsyncSpy).to.have.been.called;
expect(bluebirdUsing).to.have.been.called;
});
it('unlocks lockfile to resolve function if force option specified', async () => { await expect(
mockLockDir({ appId: 1234567, service: 'test' }); updateLock.lock(1234567, { force: false }, stub().resolves()),
await expect(updateLock.lock(1234567, { force: true }, lockParamFn)).to ).to.be.fulfilled;
.be.fulfilled;
expect(unlockAsyncSpy).to.have.been.called; expect(unlockSpy).to.have.been.called;
expect(lockAsyncSpy).to.have.been.called; expect(lockSpy).to.have.been.called;
expect(bluebirdUsing).to.have.been.called;
});
}); });
}); });
}); });