mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-25 09:46:47 +00:00
fix(blueprints): fail closed on marker ownership for apply and withdraw (#1694)
* fix(blueprints): fail closed on marker ownership for apply and withdraw Require a matching .blueprint.json under the stack lock, persist required_blueprint_id on deletion intents, remove the legacy remote apply fallback, and protect the marker in the file explorer. * fix(blueprints): add CodeQL path barriers on ownership probes Use the canonical resolve-and-startsWith sanitizer inline at the marker and stack-directory fs sinks so js/path-injection clears. * fix(blueprints): block delete on failed withdraw and defer marker write Refuse Blueprint DELETE when pre-delete withdraw does not complete, and write .blueprint.json only after a successful deploy so failed applies cannot orphan stacks or claim an unapplied revision. * test(blueprints): align lock-order assert with deferred marker write Update the per-stack lock ordering expectations to compose, cleanup, deploy, then marker after the partial-apply fix. * fix(deps): bump postcss past GHSA-r28c-9q8g-f849 for npm audit Raise the Vitest/Vite transitive postcss to 8.5.23 so Backend CI audit --audit-level=high passes.
This commit is contained in:
@@ -7,7 +7,9 @@ import {
|
||||
type BlueprintSelector,
|
||||
type DriftMode,
|
||||
} from '../services/DatabaseService';
|
||||
import { BlueprintService } from '../services/BlueprintService';
|
||||
import { BlueprintService, BlueprintNameConflictError, BlueprintOwnershipProbeError } from '../services/BlueprintService';
|
||||
import { DeployedStackDeletionService } from '../services/DeployedStackDeletionService';
|
||||
import { refuseIfSelfStack } from '../helpers/selfStackGuard';
|
||||
import {
|
||||
BlueprintReconciler,
|
||||
messageForConfirmedOutcomes,
|
||||
@@ -317,12 +319,9 @@ blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<voi
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Best-effort cleanup before delete: withdraw exactly the rows a stateful delete would
|
||||
// block, i.e. stacks Sencho deployed and still owns (last_deployed_at set, and neither a
|
||||
// name_conflict nor an already-withdrawn row). Never run the withdraw primitive for a
|
||||
// never-deployed or unmanaged row: withdrawFromNode proceeds on a missing marker and would
|
||||
// down/delete a same-name stack Sencho does not own. The blueprint-delete cascade removes
|
||||
// the rows the loop skips.
|
||||
// Withdraw owned deployments before delete. Fail closed: if any withdraw does not
|
||||
// complete as withdrawn, keep the blueprint (and its deployment rows) so the operator
|
||||
// can retry. Never orphan a live stack by deleting the only control-plane record.
|
||||
const nodes = DatabaseService.getInstance().getNodes();
|
||||
const deployments = DatabaseService.getInstance().listDeployments(id);
|
||||
for (const dep of deployments) {
|
||||
@@ -330,9 +329,24 @@ blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<voi
|
||||
const node = nodes.find(n => n.id === dep.node_id);
|
||||
if (!node) continue;
|
||||
try {
|
||||
await BlueprintService.getInstance().withdrawFromNode(blueprint, node);
|
||||
const outcome = await BlueprintService.getInstance().withdrawFromNode(blueprint, node);
|
||||
if (outcome.status !== 'withdrawn') {
|
||||
res.status(409).json({
|
||||
error: `Cannot delete blueprint: withdraw on node "${node.name}" ended as ${outcome.status}. Resolve that deployment, then retry.`,
|
||||
code: 'withdraw_failed_blocking_delete',
|
||||
nodeId: node.id,
|
||||
withdrawStatus: outcome.status,
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[Blueprints] Pre-delete withdraw failed for blueprint ${id} on node ${node.id}:`, err);
|
||||
res.status(409).json({
|
||||
error: `Cannot delete blueprint: withdraw on node "${node.name}" failed. Resolve that deployment, then retry.`,
|
||||
code: 'withdraw_failed_blocking_delete',
|
||||
nodeId: node.id,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
DatabaseService.getInstance().deleteBlueprint(id);
|
||||
@@ -384,11 +398,68 @@ blueprintsRouter.post('/apply-local', async (req: Request, res: Response): Promi
|
||||
}
|
||||
res.json({ deployed: true });
|
||||
} catch (error) {
|
||||
if (error instanceof BlueprintNameConflictError) {
|
||||
res.status(409).json({ error: error.message, code: 'name_conflict' });
|
||||
return;
|
||||
}
|
||||
if (error instanceof BlueprintOwnershipProbeError) {
|
||||
console.error('[Blueprints] apply-local ownership probe failed:', sanitizeForLog(error.message));
|
||||
res.status(500).json({ error: error.message });
|
||||
return;
|
||||
}
|
||||
console.error('[Blueprints] apply-local error:', sanitizeForLog(getErrorMessage(error, 'apply failed')));
|
||||
res.status(500).json({ error: getErrorMessage(error, 'Blueprint apply failed') });
|
||||
}
|
||||
});
|
||||
|
||||
// Node-to-node atomic blueprint withdraw. Ownership is validated under the
|
||||
// delete lock on this node. Requires stack:delete and refuses Sencho's own stack.
|
||||
blueprintsRouter.post('/withdraw-local', async (req: Request, res: Response): Promise<void> => {
|
||||
const body = (req.body ?? {}) as { stackName?: unknown; blueprintId?: unknown };
|
||||
if (typeof body.stackName !== 'string' || !isValidStackName(body.stackName)) {
|
||||
res.status(400).json({ error: 'Invalid stack name' });
|
||||
return;
|
||||
}
|
||||
if (typeof body.blueprintId !== 'number' || !Number.isInteger(body.blueprintId) || body.blueprintId <= 0) {
|
||||
res.status(400).json({ error: 'blueprintId must be a positive integer' });
|
||||
return;
|
||||
}
|
||||
if (!requirePermission(req, res, 'stack:delete', 'stack', body.stackName)) return;
|
||||
if (await refuseIfSelfStack(req, res, body.stackName)) return;
|
||||
|
||||
try {
|
||||
const result = await DeployedStackDeletionService.getInstance().deleteDeployedStack({
|
||||
nodeId: req.nodeId,
|
||||
stackName: body.stackName,
|
||||
pruneVolumes: false,
|
||||
actor: req.user?.username ?? 'system:blueprint',
|
||||
requireBlueprintId: body.blueprintId,
|
||||
});
|
||||
if (result.ok) {
|
||||
res.json({
|
||||
status: result.status === 'already_absent' ? 'already_absent' : 'withdrawn',
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (result.code === 'name_conflict') {
|
||||
res.status(409).json({ error: result.error, code: 'name_conflict' });
|
||||
return;
|
||||
}
|
||||
if (result.code === 'lock_conflict') {
|
||||
res.status(409).json({
|
||||
error: result.error,
|
||||
code: 'stack_op_in_progress',
|
||||
inProgress: { action: result.existingAction },
|
||||
});
|
||||
return;
|
||||
}
|
||||
res.status(500).json({ error: result.error });
|
||||
} catch (error) {
|
||||
console.error('[Blueprints] withdraw-local error:', sanitizeForLog(getErrorMessage(error, 'withdraw failed')));
|
||||
res.status(500).json({ error: getErrorMessage(error, 'Blueprint withdraw failed') });
|
||||
}
|
||||
});
|
||||
|
||||
blueprintsRouter.post('/:id/apply', async (req: Request, res: Response): Promise<void> => {
|
||||
if (!requireAdmin(req, res)) return;
|
||||
const id = parseIntParam(req, res, 'id');
|
||||
|
||||
@@ -135,6 +135,40 @@ function releaseStackOpLock(req: Request, stackName: string): void {
|
||||
StackOpLockService.getInstance().release(req.nodeId, stackName);
|
||||
}
|
||||
|
||||
/** Root compose + blueprint marker on the stack-source root must not change while a lifecycle op holds the stack lock. */
|
||||
const STACK_OP_LOCKED_ROOT_TRUST_FILES = new Set([
|
||||
'compose.yaml',
|
||||
'compose.yml',
|
||||
'docker-compose.yaml',
|
||||
'docker-compose.yml',
|
||||
'.blueprint.json',
|
||||
]);
|
||||
|
||||
function rejectIfStackOpBlocksRootTrustFileWrite(
|
||||
req: Request,
|
||||
res: Response,
|
||||
stackName: string,
|
||||
relPath: string,
|
||||
root: StackFileRoot,
|
||||
): boolean {
|
||||
if (root.kind !== 'stack-source') return false;
|
||||
if (relPath.includes('/')) return false;
|
||||
const base = relPath.toLowerCase();
|
||||
if (!STACK_OP_LOCKED_ROOT_TRUST_FILES.has(base)) return false;
|
||||
const existing = StackOpLockService.getInstance().get(req.nodeId, stackName);
|
||||
if (!existing) return false;
|
||||
res.status(409).json({
|
||||
error: `${stackName} is busy: another operation (${existing.action}) is already in progress`,
|
||||
code: 'stack_op_in_progress',
|
||||
inProgress: {
|
||||
action: existing.action,
|
||||
startedAt: existing.startedAt,
|
||||
user: existing.user,
|
||||
},
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
function stackFileEtag(mtimeMs: number): string {
|
||||
return `W/"${Math.floor(mtimeMs)}"`;
|
||||
}
|
||||
@@ -2884,6 +2918,10 @@ stacksRouter.post(
|
||||
return res.status(400).json({ error: 'Invalid filename' });
|
||||
}
|
||||
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
|
||||
if (rejectIfStackOpBlocksRootTrustFileWrite(req, res, stackName, targetRelPath, root)) {
|
||||
await cleanupUploadTemp(req);
|
||||
return;
|
||||
}
|
||||
const overwrite = String(req.query.overwrite) === '1';
|
||||
// The multer wrapper stashed the route-entry timestamp on the request so
|
||||
// the success path and the rejection paths share one window. Fall back to
|
||||
@@ -2987,6 +3025,7 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response
|
||||
const expectedVersion = req.header('if-match') || undefined;
|
||||
const root = await resolveRootForOp(req, res, stackName, 'write');
|
||||
if (!root) return;
|
||||
if (rejectIfStackOpBlocksRootTrustFileWrite(req, res, stackName, relPath, root)) return;
|
||||
const startedAt = Date.now();
|
||||
logFileDiag('write start', { stackName, relPath, nodeId: req.nodeId, bytes: Buffer.byteLength(content, 'utf-8'), hasIfMatch: expectedVersion !== undefined, rootKind: root.kind });
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user