mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 19:57:37 +00:00
60ecd574b3
* fix(stacks): serialize concurrent lifecycle operations per stack
Two simultaneous POSTs to /api/stacks/:name/{deploy,down,restart,stop,
start,update} could race against the same compose project, doubling
notifications, doubling post-deploy scans, and corrupting the
atomic-deploy backup snapshot. Each lifecycle route now acquires a
per-(nodeId, stackName) in-process lock; the second caller gets 409
with {code: 'stack_op_in_progress', inProgress: {action, startedAt,
user}} and the frontend surfaces a "X is already deploying" toast.
The lock is process-local on purpose: it shares a lifetime with the
docker compose child process. A Sencho restart clears all locks, which
matches the truth that an in-flight compose op is gone too.
The existing policy-block 409 is shape-distinguishable (has policy /
violations) and continues to work; the frontend checks the new code
discriminator first before falling through to policy handling.
* chore(stacks): validate action enum in 409 parser; cover start collision
The frontend parseStackOpInProgress used to cast the parsed action
directly to StackOpAction. A backend bug or spoofed payload returning
action='wibble' would slip through. Validate against the known enum
set before returning the parsed info.
Adds an integration test for the deploy-blocks-while-start-in-flight
case so all six lifecycle verbs have collision coverage (the existing
suite covered deploy/down/restart/stop/update; start was indirect).
97 lines
3.5 KiB
TypeScript
97 lines
3.5 KiB
TypeScript
/**
|
|
* Unit tests for StackOpLockService.
|
|
*
|
|
* The service is the in-memory mutex behind the 409 fast-fail for concurrent
|
|
* stack lifecycle operations. Each test resets the singleton via
|
|
* `resetForTests()` so state doesn't leak between cases.
|
|
*/
|
|
import { describe, it, expect, beforeEach } from 'vitest';
|
|
import { StackOpLockService } from '../services/StackOpLockService';
|
|
|
|
beforeEach(() => {
|
|
StackOpLockService.resetForTests();
|
|
});
|
|
|
|
describe('StackOpLockService', () => {
|
|
it('returns a singleton instance', () => {
|
|
const a = StackOpLockService.getInstance();
|
|
const b = StackOpLockService.getInstance();
|
|
expect(a).toBe(b);
|
|
});
|
|
|
|
it('acquires a lock when the slot is empty', () => {
|
|
const svc = StackOpLockService.getInstance();
|
|
const result = svc.tryAcquire(1, 'web', 'deploy', 'admin');
|
|
expect(result.acquired).toBe(true);
|
|
expect(svc.size()).toBe(1);
|
|
});
|
|
|
|
it('returns acquired=false with the existing lock when already held', () => {
|
|
const svc = StackOpLockService.getInstance();
|
|
svc.tryAcquire(1, 'web', 'deploy', 'admin');
|
|
const result = svc.tryAcquire(1, 'web', 'restart', 'bob');
|
|
expect(result.acquired).toBe(false);
|
|
if (!result.acquired) {
|
|
expect(result.existing.action).toBe('deploy');
|
|
expect(result.existing.user).toBe('admin');
|
|
expect(typeof result.existing.startedAt).toBe('number');
|
|
}
|
|
});
|
|
|
|
it('different stacks on the same node lock independently', () => {
|
|
const svc = StackOpLockService.getInstance();
|
|
expect(svc.tryAcquire(1, 'web', 'deploy', 'admin').acquired).toBe(true);
|
|
expect(svc.tryAcquire(1, 'api', 'deploy', 'admin').acquired).toBe(true);
|
|
expect(svc.size()).toBe(2);
|
|
});
|
|
|
|
it('same stack name on different nodes locks independently', () => {
|
|
const svc = StackOpLockService.getInstance();
|
|
expect(svc.tryAcquire(1, 'web', 'deploy', 'admin').acquired).toBe(true);
|
|
expect(svc.tryAcquire(2, 'web', 'deploy', 'admin').acquired).toBe(true);
|
|
expect(svc.size()).toBe(2);
|
|
});
|
|
|
|
it('release frees the slot so the next caller acquires', () => {
|
|
const svc = StackOpLockService.getInstance();
|
|
svc.tryAcquire(1, 'web', 'deploy', 'admin');
|
|
svc.release(1, 'web');
|
|
expect(svc.size()).toBe(0);
|
|
expect(svc.tryAcquire(1, 'web', 'restart', 'bob').acquired).toBe(true);
|
|
});
|
|
|
|
it('release on an unheld key is a no-op', () => {
|
|
const svc = StackOpLockService.getInstance();
|
|
expect(() => svc.release(1, 'nope')).not.toThrow();
|
|
expect(svc.size()).toBe(0);
|
|
});
|
|
|
|
it('get returns the lock contents or undefined', () => {
|
|
const svc = StackOpLockService.getInstance();
|
|
expect(svc.get(1, 'web')).toBeUndefined();
|
|
svc.tryAcquire(1, 'web', 'update', 'eve');
|
|
const lock = svc.get(1, 'web');
|
|
expect(lock?.action).toBe('update');
|
|
expect(lock?.user).toBe('eve');
|
|
});
|
|
|
|
it('resetForTests clears all state and returns a fresh instance', () => {
|
|
const before = StackOpLockService.getInstance();
|
|
before.tryAcquire(1, 'web', 'deploy', 'admin');
|
|
StackOpLockService.resetForTests();
|
|
const after = StackOpLockService.getInstance();
|
|
expect(after).not.toBe(before);
|
|
expect(after.size()).toBe(0);
|
|
});
|
|
|
|
it('records the lock startedAt as a recent timestamp', () => {
|
|
const svc = StackOpLockService.getInstance();
|
|
const t0 = Date.now();
|
|
svc.tryAcquire(1, 'web', 'deploy', 'admin');
|
|
const lock = svc.get(1, 'web');
|
|
expect(lock).toBeDefined();
|
|
expect(lock!.startedAt).toBeGreaterThanOrEqual(t0);
|
|
expect(lock!.startedAt).toBeLessThanOrEqual(Date.now());
|
|
});
|
|
});
|