Files changed: - src/security/crypto-utils.js: 16 calls → log tagged 'crypto' - src/security/docker-security.js: 15 calls → log tagged 'security' - src/managers/port-lock-manager.js: 16 calls → log tagged 'portlock' - src/docker/self-updater.js: 10 calls → log tagged 'updater' - src/security/event-workers.js: 5 calls → log tagged 'events' - src/security/keychain-manager.js: 4 calls → log tagged 'keychain' Fixed 2 bugs found during sweep: - self-updater.js:161 — arrow expression body had trailing semicolon (SyntaxError) - port-lock-manager.js:137 — log.error referenced 'port' var out of scope (ReferenceError) 1539/1539 Jest tests pass. All ESLint warnings pre-existing (0 new).
238 lines
6.4 KiB
JavaScript
238 lines
6.4 KiB
JavaScript
/**
|
|
* Port Lock Manager
|
|
* Provides atomic port allocation using file-based locks to prevent race conditions
|
|
* during concurrent container deployments
|
|
*/
|
|
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
const lockfile = require('proper-lockfile');
|
|
const platformPaths = require('../../platform-paths');
|
|
const { log } = require('../utils/logging');
|
|
|
|
const LOCK_DIR = process.env.PORT_LOCK_DIR || path.join(platformPaths.dataDir, '.port-locks');
|
|
const LOCK_TIMEOUT = 120000; // 2 minutes
|
|
const LOCK_STALE_THRESHOLD = 120000; // 2 minutes
|
|
const LOCK_RETRY_OPTIONS = {
|
|
retries: {
|
|
retries: 10,
|
|
minTimeout: 100,
|
|
maxTimeout: 1000,
|
|
randomize: true
|
|
},
|
|
stale: LOCK_STALE_THRESHOLD,
|
|
realpath: false
|
|
};
|
|
|
|
class PortLockManager {
|
|
constructor() {
|
|
this.activeLocks = new Map(); // Map of lockId -> { ports: [], release: fn }
|
|
this.ensureLockDirectory();
|
|
}
|
|
|
|
/**
|
|
* Ensure lock directory exists
|
|
*/
|
|
ensureLockDirectory() {
|
|
if (!fs.existsSync(LOCK_DIR)) {
|
|
fs.mkdirSync(LOCK_DIR, { recursive: true });
|
|
log.info('portlock', 'Created lock directory', { dir: LOCK_DIR });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get lock file path for a port
|
|
*/
|
|
getLockFilePath(port) {
|
|
return path.join(LOCK_DIR, `port-${port}.lock`);
|
|
}
|
|
|
|
/**
|
|
* Acquire locks for multiple ports atomically
|
|
* Ports are sorted to prevent deadlocks
|
|
* @param {string[]} ports - Array of port numbers as strings
|
|
* @returns {Promise<string>} Lock ID for releasing locks later
|
|
*/
|
|
async acquirePorts(ports) {
|
|
if (!Array.isArray(ports) || ports.length === 0) {
|
|
throw new Error('Ports must be a non-empty array');
|
|
}
|
|
|
|
const lockId = `lock-${Date.now()}-${Math.random().toString(36).substring(7)}`;
|
|
const sortedPorts = [...new Set(ports)].sort((a, b) => parseInt(a) - parseInt(b));
|
|
const acquiredLocks = [];
|
|
const releaseFunctions = [];
|
|
|
|
try {
|
|
log.info('portlock', 'Acquiring locks', { ports: sortedPorts });
|
|
|
|
// Acquire locks in sorted order to prevent deadlocks
|
|
for (const port of sortedPorts) {
|
|
const lockFilePath = this.getLockFilePath(port);
|
|
|
|
// Create lock file if it doesn't exist
|
|
if (!fs.existsSync(lockFilePath)) {
|
|
fs.writeFileSync(lockFilePath, JSON.stringify({
|
|
created: new Date().toISOString(),
|
|
port
|
|
}));
|
|
}
|
|
|
|
// Acquire lock with retry
|
|
const release = await lockfile.lock(lockFilePath, LOCK_RETRY_OPTIONS);
|
|
|
|
acquiredLocks.push(port);
|
|
releaseFunctions.push(release);
|
|
|
|
log.info('portlock', 'Locked port', { port });
|
|
}
|
|
|
|
// Store lock information
|
|
this.activeLocks.set(lockId, {
|
|
ports: sortedPorts,
|
|
releases: releaseFunctions,
|
|
timestamp: Date.now()
|
|
});
|
|
|
|
log.info('portlock', 'Acquired all locks', { lockId });
|
|
return lockId;
|
|
|
|
} catch (error) {
|
|
// Release any locks we managed to acquire
|
|
log.error('portlock', error, { operation: 'acquire', lockId });
|
|
|
|
for (const release of releaseFunctions) {
|
|
try {
|
|
await release();
|
|
} catch (releaseError) {
|
|
log.error('portlock', releaseError, { operation: 'releaseCleanup', lockId });
|
|
}
|
|
}
|
|
|
|
throw new Error(`Failed to acquire port locks: ${error.message}`);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Release locks for a lock ID
|
|
* @param {string} lockId - Lock ID returned from acquirePorts
|
|
*/
|
|
async releasePorts(lockId) {
|
|
const lockInfo = this.activeLocks.get(lockId);
|
|
|
|
if (!lockInfo) {
|
|
log.warn('portlock', 'Lock ID not found', { lockId });
|
|
return;
|
|
}
|
|
|
|
log.info('portlock', 'Releasing locks', { lockId, ports: lockInfo.ports });
|
|
|
|
const errors = [];
|
|
|
|
for (const release of lockInfo.releases) {
|
|
try {
|
|
await release();
|
|
} catch (error) {
|
|
errors.push(error.message);
|
|
log.error('portlock', error, { operation: 'release', lockId });
|
|
}
|
|
}
|
|
|
|
this.activeLocks.delete(lockId);
|
|
|
|
if (errors.length > 0) {
|
|
log.warn('portlock', 'Released locks with errors', { lockId, errorCount: errors.length });
|
|
} else {
|
|
log.info('portlock', 'Released all locks', { lockId });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Clean up stale lock files
|
|
* Removes locks older than LOCK_STALE_THRESHOLD
|
|
*/
|
|
async cleanupStaleLocks() {
|
|
log.info('portlock', 'Cleaning up stale locks');
|
|
|
|
this.ensureLockDirectory();
|
|
|
|
let cleaned = 0;
|
|
let errors = 0;
|
|
|
|
try {
|
|
const files = fs.readdirSync(LOCK_DIR);
|
|
|
|
for (const file of files) {
|
|
if (!file.endsWith('.lock')) continue;
|
|
|
|
const lockFilePath = path.join(LOCK_DIR, file);
|
|
|
|
try {
|
|
// Check if lock is stale using proper-lockfile's built-in check
|
|
const isLocked = await lockfile.check(lockFilePath, { realpath: false, stale: LOCK_STALE_THRESHOLD });
|
|
|
|
if (!isLocked) {
|
|
// Lock is stale or not locked, safe to remove
|
|
fs.unlinkSync(lockFilePath);
|
|
cleaned++;
|
|
log.info('portlock', 'Removed stale lock', { file });
|
|
}
|
|
} catch (error) {
|
|
// File might not exist or might have been removed by another process
|
|
if (error.code !== 'ENOENT') {
|
|
errors++;
|
|
log.warn('portlock', 'Error checking lock', { file, error: error.message });
|
|
}
|
|
}
|
|
}
|
|
|
|
log.info('portlock', 'Cleanup complete', { cleaned, errors });
|
|
} catch (error) {
|
|
log.error('portlock', error, { operation: 'cleanup' });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Get current lock status
|
|
*/
|
|
getStatus() {
|
|
const activeLocks = Array.from(this.activeLocks.entries()).map(([lockId, info]) => ({
|
|
lockId,
|
|
ports: info.ports,
|
|
age: Date.now() - info.timestamp,
|
|
timestamp: new Date(info.timestamp).toISOString()
|
|
}));
|
|
|
|
return {
|
|
activeLocks: activeLocks.length,
|
|
locks: activeLocks,
|
|
lockDirectory: LOCK_DIR
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Check if a port is currently locked
|
|
* @param {string} port - Port number as string
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async isPortLocked(port) {
|
|
const lockFilePath = this.getLockFilePath(port);
|
|
|
|
if (!fs.existsSync(lockFilePath)) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
return await lockfile.check(lockFilePath, { realpath: false, stale: LOCK_STALE_THRESHOLD });
|
|
} catch (error) {
|
|
// If we can't check, assume it's not locked
|
|
return false;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Singleton instance
|
|
const portLockManager = new PortLockManager();
|
|
|
|
module.exports = portLockManager;
|