mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-08 16:35:59 +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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user