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
@@ -23,6 +23,10 @@ import { StackOpLockService, stackOpSkipMessage } from './StackOpLockService';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import {
BLUEPRINT_MARKER_FILENAME,
parseBlueprintMarker,
} from '../helpers/blueprintMarker';
/**
* Directory that may contain recovery override files for a tombstone sweep.
@@ -46,13 +50,30 @@ export interface DeleteDeployedStackInput {
stackName: string;
pruneVolumes: boolean;
actor: string;
/** When set, deletion requires an on-disk .blueprint.json matching this blueprint ID. */
requireBlueprintId?: number;
/** When true, skip acquiring a new lock (caller already holds delete via continuation). */
continuationIntentId?: string;
}
export type DeleteDeployedStackResult =
| { ok: true }
| { ok: false; code: 'lock_conflict' | 'fs_failed' | 'tombstone_failed' | 'db_failed'; error: string; existingAction?: string };
| { ok: true; status: 'deleted' | 'already_absent' }
| {
ok: false;
code: 'lock_conflict' | 'fs_failed' | 'tombstone_failed' | 'db_failed' | 'name_conflict' | 'failed';
error: string;
existingAction?: string;
};
type DirProbe = { kind: 'absent' } | { kind: 'present' } | { kind: 'error'; error: string };
type MarkerProbe =
| { kind: 'match' }
| { kind: 'name_conflict'; error: string }
| { kind: 'failed'; error: string };
function blueprintMarkerMismatchError(stackName: string): string {
return `Stack "${stackName}" exists without a matching blueprint marker; refusing to withdraw.`;
}
function collectArtifactsFromGenerations(
generations: Array<{ override_path: string | null; services_json: string }>,
@@ -100,6 +121,51 @@ function parseJsonStringArray(raw: string): string[] {
}
}
async function probeStackDirectory(nodeId: number, stackName: string): Promise<DirProbe> {
// Canonical js/path-injection barrier inline with the stat sink.
const baseResolved = path.resolve(FileSystemService.getInstance(nodeId).getBaseDir());
const safePath = path.resolve(baseResolved, stackName);
if (!safePath.startsWith(baseResolved + path.sep)) {
return { kind: 'error', error: 'Invalid stack path' };
}
try {
const stat = await fs.stat(safePath);
return stat.isDirectory() ? { kind: 'present' } : { kind: 'absent' };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') return { kind: 'absent' };
return { kind: 'error', error: getErrorMessage(error, 'Failed to access stack directory') };
}
}
async function probeBlueprintMarkerOwnership(
nodeId: number,
stackName: string,
requireBlueprintId: number,
): Promise<MarkerProbe> {
// Canonical js/path-injection barrier inline with the read sink.
const baseResolved = path.resolve(NodeRegistry.getInstance().getComposeDir(nodeId));
const safePath = path.resolve(baseResolved, stackName, BLUEPRINT_MARKER_FILENAME);
if (!safePath.startsWith(baseResolved + path.sep)) {
return { kind: 'failed', error: 'Invalid stack path for blueprint marker' };
}
try {
const content = await fs.readFile(safePath, 'utf-8');
const marker = parseBlueprintMarker(content);
if (!marker || marker.blueprintId !== requireBlueprintId) {
return { kind: 'name_conflict', error: blueprintMarkerMismatchError(stackName) };
}
return { kind: 'match' };
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === 'ENOENT') {
return { kind: 'name_conflict', error: blueprintMarkerMismatchError(stackName) };
}
return { kind: 'failed', error: getErrorMessage(error, 'Failed to read blueprint marker') };
}
}
export class DeployedStackDeletionService {
private static instance: DeployedStackDeletionService;
@@ -164,6 +230,42 @@ export class DeployedStackDeletionService {
const { nodeId, stackName, pruneVolumes } = input;
const db = DatabaseService.getInstance();
// Continuation loads ownership from the persisted intent; first call uses input.
let requiredBlueprintId: number | null =
typeof input.requireBlueprintId === 'number' ? input.requireBlueprintId : null;
if (existingIntentId) {
const existing = db.getDeletionIntentById(existingIntentId);
if (!existing || existing.status !== 'prepared') {
return { ok: false, code: 'tombstone_failed', error: 'Deletion intent is not prepared' };
}
if (existing.required_blueprint_id != null) {
requiredBlueprintId = existing.required_blueprint_id;
}
}
let skipPhysical = false;
if (requiredBlueprintId != null) {
const dirProbe = await probeStackDirectory(nodeId, stackName);
if (dirProbe.kind === 'error') {
return { ok: false, code: 'failed', error: dirProbe.error };
}
if (dirProbe.kind === 'absent') {
skipPhysical = true;
} else {
const ownership = await probeBlueprintMarkerOwnership(nodeId, stackName, requiredBlueprintId);
if (ownership.kind === 'failed') {
return { ok: false, code: 'failed', error: ownership.error };
}
if (ownership.kind === 'name_conflict') {
if (existingIntentId) {
db.updateCleanupPendingStatus(existingIntentId, 'cancelled');
}
return { ok: false, code: 'name_conflict', error: ownership.error };
}
}
}
let intentId = existingIntentId;
if (!intentId) {
const { tags, overridePaths } = collectArtifacts(nodeId, stackName);
@@ -177,6 +279,7 @@ export class DeployedStackDeletionService {
rollback_tags_json: JSON.stringify(tags),
override_paths_json: JSON.stringify(overridePaths),
prune_volumes_requested: pruneVolumes ? 1 : 0,
required_blueprint_id: requiredBlueprintId,
created_at: now,
updated_at: now,
};
@@ -197,38 +300,53 @@ export class DeployedStackDeletionService {
return { ok: false, code: 'tombstone_failed', error: 'Deletion intent is not prepared' };
}
try {
await ComposeService.getInstance(nodeId).downStack(stackName);
} catch (downErr) {
console.warn(
'[DeployedStackDeletion] Compose down failed or no-op for %s:',
sanitizeForLog(stackName),
downErr,
);
}
if (intent.prune_volumes_requested === 1) {
if (!skipPhysical) {
try {
await DockerController.getInstance(nodeId).pruneManagedOnly('volumes', [stackName]);
} catch (pruneErr) {
await ComposeService.getInstance(nodeId).downStack(stackName);
} catch (downErr) {
console.warn(
'[DeployedStackDeletion] Volume prune failed for %s, continuing delete:',
'[DeployedStackDeletion] Compose down failed or no-op for %s:',
sanitizeForLog(stackName),
pruneErr,
downErr,
);
}
if (intent.prune_volumes_requested === 1) {
try {
await DockerController.getInstance(nodeId).pruneManagedOnly('volumes', [stackName]);
} catch (pruneErr) {
console.warn(
'[DeployedStackDeletion] Volume prune failed for %s, continuing delete:',
sanitizeForLog(stackName),
pruneErr,
);
}
}
try {
await FileSystemService.getInstance(nodeId).deleteStack(stackName);
} catch (fsErr) {
db.updateCleanupPendingStatus(intentId, 'cancelled');
return {
ok: false,
code: 'fs_failed',
error: getErrorMessage(fsErr, 'Failed to remove stack files'),
};
}
}
try {
await FileSystemService.getInstance(nodeId).deleteStack(stackName);
} catch (fsErr) {
db.updateCleanupPendingStatus(intentId, 'cancelled');
return {
ok: false,
code: 'fs_failed',
error: getErrorMessage(fsErr, 'Failed to remove stack files'),
};
}
const finalized = await this.finalizeLogicalDeletion(input, intentId);
if (!finalized.ok) return finalized;
return { ok: true, status: skipPhysical ? 'already_absent' : 'deleted' };
}
/** Ready transaction, secondary DB/RBAC cleanup, mesh opt-out, sweep, invalidate. */
private async finalizeLogicalDeletion(
input: DeleteDeployedStackInput,
intentId: string,
): Promise<DeleteDeployedStackResult> {
const { nodeId, stackName } = input;
const db = DatabaseService.getInstance();
if (!db.commitStackDeletionReadyTransaction(intentId, nodeId, stackName)) {
return {
@@ -282,7 +400,7 @@ export class DeployedStackDeletionService {
stackName,
ts: Date.now(),
});
return { ok: true };
return { ok: true, status: 'deleted' };
}
/**
@@ -497,6 +615,31 @@ export class DeployedStackDeletionService {
continue;
}
if (intent.required_blueprint_id != null) {
const ownership = await probeBlueprintMarkerOwnership(
nodeId,
stackName,
intent.required_blueprint_id,
);
if (ownership.kind === 'name_conflict') {
db.updateCleanupPendingStatus(intent.id, 'cancelled');
console.warn(
'[DeployedStackDeletion] Startup cancelled blueprint deletion for %s: %s',
sanitizeForLog(stackName),
sanitizeForLog(ownership.error),
);
continue;
}
if (ownership.kind === 'failed') {
console.warn(
'[DeployedStackDeletion] Startup ownership probe failed for %s (leaving prepared): %s',
sanitizeForLog(stackName),
sanitizeForLog(ownership.error),
);
continue;
}
}
const result = await this.deleteDeployedStack({
nodeId,
stackName,