mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-09 10:21:03 +00:00
fix(stacks): serialize concurrent lifecycle operations per stack (#1182)
* 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).
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Tracks in-flight stack lifecycle operations (deploy, down, restart, stop,
|
||||
* start, update) per (nodeId, stackName). A second request to the same stack
|
||||
* while the first is still running returns 409 instead of racing the first.
|
||||
*
|
||||
* State is intentionally process-local: a Sencho restart clears all locks,
|
||||
* which matches the lifecycle of any in-flight `docker compose` child process.
|
||||
*/
|
||||
|
||||
export type StackOpAction = 'deploy' | 'down' | 'restart' | 'stop' | 'start' | 'update';
|
||||
|
||||
export interface StackOpLock {
|
||||
action: StackOpAction;
|
||||
startedAt: number;
|
||||
user: string;
|
||||
}
|
||||
|
||||
interface AcquireSuccess {
|
||||
acquired: true;
|
||||
}
|
||||
|
||||
interface AcquireConflict {
|
||||
acquired: false;
|
||||
existing: StackOpLock;
|
||||
}
|
||||
|
||||
export type AcquireResult = AcquireSuccess | AcquireConflict;
|
||||
|
||||
export class StackOpLockService {
|
||||
private static instance: StackOpLockService;
|
||||
private readonly locks = new Map<string, StackOpLock>();
|
||||
|
||||
public static getInstance(): StackOpLockService {
|
||||
if (!this.instance) this.instance = new StackOpLockService();
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
public static resetForTests(): void {
|
||||
this.instance = new StackOpLockService();
|
||||
}
|
||||
|
||||
private key(nodeId: number, stackName: string): string {
|
||||
return `${nodeId}:${stackName}`;
|
||||
}
|
||||
|
||||
public tryAcquire(
|
||||
nodeId: number,
|
||||
stackName: string,
|
||||
action: StackOpAction,
|
||||
user: string,
|
||||
): AcquireResult {
|
||||
const k = this.key(nodeId, stackName);
|
||||
const existing = this.locks.get(k);
|
||||
if (existing) return { acquired: false, existing };
|
||||
this.locks.set(k, { action, startedAt: Date.now(), user });
|
||||
return { acquired: true };
|
||||
}
|
||||
|
||||
public release(nodeId: number, stackName: string): void {
|
||||
this.locks.delete(this.key(nodeId, stackName));
|
||||
}
|
||||
|
||||
public get(nodeId: number, stackName: string): StackOpLock | undefined {
|
||||
return this.locks.get(this.key(nodeId, stackName));
|
||||
}
|
||||
|
||||
public size(): number {
|
||||
return this.locks.size;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user