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:
Anso
2026-07-24 15:57:18 -04:00
committed by GitHub
parent e33eda3c38
commit 17a8dc8a94
19 changed files with 1092 additions and 286 deletions
+79 -8
View File
@@ -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');
+39
View File
@@ -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 {