mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-09-03 06:07:58 +00:00
fix(blueprints): fail closed on marker ownership for apply and withdraw
Require a matching .blueprint.json before withdraw or in-lock apply overwrite, treat already-absent stacks as withdrawn, and block compose/marker file writes while a stack lifecycle lock is held so confirmed applies cannot deploy editor races or hijack unmanaged directories. Co-authored-by: Anso <dev@anso.codes>
This commit is contained in:
@@ -104,6 +104,10 @@ describe('Blueprint compose apply (real filesystem)', () => {
|
|||||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yml'), 'services:\n a:\n image: a\n');
|
await fsPromises.writeFile(path.join(stackDir, 'compose.yml'), 'services:\n a:\n image: a\n');
|
||||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yaml'), 'services:\n b:\n image: b\n');
|
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yaml'), 'services:\n b:\n image: b\n');
|
||||||
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yml'), 'services:\n c:\n image: c\n');
|
await fsPromises.writeFile(path.join(stackDir, 'docker-compose.yml'), 'services:\n c:\n image: c\n');
|
||||||
|
await fsPromises.writeFile(
|
||||||
|
path.join(stackDir, '.blueprint.json'),
|
||||||
|
JSON.stringify({ blueprintId: 2, revision: 2, lastApplied: 1 }, null, 2),
|
||||||
|
);
|
||||||
|
|
||||||
const composeContent = 'services:\n app:\n image: redis:7\n';
|
const composeContent = 'services:\n app:\n image: redis:7\n';
|
||||||
const markerContent = JSON.stringify({ blueprintId: 2, revision: 3, lastApplied: Date.now() }, null, 2);
|
const markerContent = JSON.stringify({ blueprintId: 2, revision: 3, lastApplied: Date.now() }, null, 2);
|
||||||
@@ -133,6 +137,32 @@ describe('Blueprint compose apply (real filesystem)', () => {
|
|||||||
await expectMissing(path.join(stackDir, 'docker-compose.yml'));
|
await expectMissing(path.join(stackDir, 'docker-compose.yml'));
|
||||||
expect(deploySpy).toHaveBeenCalledTimes(1);
|
expect(deploySpy).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses to overwrite an existing unmanaged stack directory inside the lock', async () => {
|
||||||
|
const { BlueprintNameConflictError } = await import('../services/BlueprintService');
|
||||||
|
const nodeId = seedLocalNode();
|
||||||
|
const stackName = `bp-hijack-${counter}`;
|
||||||
|
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||||
|
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||||
|
const original = 'services:\n mine:\n image: nginx:alpine\n';
|
||||||
|
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), original);
|
||||||
|
|
||||||
|
const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue(undefined);
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
BlueprintService.getInstance().applyLocalUnderLock(
|
||||||
|
nodeId,
|
||||||
|
stackName,
|
||||||
|
'services:\n bp:\n image: redis:7\n',
|
||||||
|
JSON.stringify({ blueprintId: 99, revision: 1, lastApplied: Date.now() }, null, 2),
|
||||||
|
'/api/blueprints/test/apply',
|
||||||
|
),
|
||||||
|
).rejects.toBeInstanceOf(BlueprintNameConflictError);
|
||||||
|
|
||||||
|
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe(original);
|
||||||
|
await expectMissing(path.join(stackDir, '.blueprint.json'));
|
||||||
|
expect(deploySpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('FileSystemService.removeAlternateRootComposeFiles', () => {
|
describe('FileSystemService.removeAlternateRootComposeFiles', () => {
|
||||||
|
|||||||
@@ -111,9 +111,9 @@ describe('Blueprint route edge cases', () => {
|
|||||||
|
|
||||||
describe('Blueprint delete guard', () => {
|
describe('Blueprint delete guard', () => {
|
||||||
// Rows with no stack of ours on the node must not block delete, AND the route must not run the
|
// Rows with no stack of ours on the node must not block delete, AND the route must not run the
|
||||||
// withdraw primitive for them: withdrawFromNode proceeds on a missing marker and would
|
// withdraw primitive for them. withdrawFromNode now refuses a missing marker on a present
|
||||||
// down/delete a same-name stack Sencho never owned. A name_conflict is exactly that unmanaged
|
// stack, but skipping these rows still avoids a useless conflict path. A name_conflict is
|
||||||
// stack, so it is excluded even though it carries a last_deployed_at timestamp.
|
// exactly that unmanaged stack, so it is excluded even though it may carry last_deployed_at.
|
||||||
it.each([
|
it.each([
|
||||||
{ label: 'never-deployed pending review', status: 'pending_state_review' as const, last_deployed_at: null },
|
{ label: 'never-deployed pending review', status: 'pending_state_review' as const, last_deployed_at: null },
|
||||||
{ label: 'first-deploy failure', status: 'failed' as const, last_deployed_at: null },
|
{ label: 'first-deploy failure', status: 'failed' as const, last_deployed_at: null },
|
||||||
@@ -271,6 +271,41 @@ describe('BlueprintService marker edge cases', () => {
|
|||||||
expect(dep).toBeDefined();
|
expect(dep).toBeDefined();
|
||||||
expect(dep?.status).toBe('name_conflict');
|
expect(dep?.status).toBe('name_conflict');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses to withdraw when the marker is missing but the local stack directory still exists', async () => {
|
||||||
|
const fs = await import('fs');
|
||||||
|
const path = await import('path');
|
||||||
|
const composeDir = process.env.COMPOSE_DIR!;
|
||||||
|
const localNode = DatabaseService.getInstance().getNodes()[0];
|
||||||
|
DatabaseService.getInstance().getDb()
|
||||||
|
.prepare('UPDATE nodes SET compose_dir = ? WHERE id = ?')
|
||||||
|
.run(composeDir, localNode.id);
|
||||||
|
const refreshed = DatabaseService.getInstance().getNode(localNode.id)!;
|
||||||
|
const bp = seedBlueprint([refreshed.id]);
|
||||||
|
const bpObj = DatabaseService.getInstance().getBlueprint(bp.id)!;
|
||||||
|
DatabaseService.getInstance().upsertDeployment({
|
||||||
|
blueprint_id: bp.id,
|
||||||
|
node_id: refreshed.id,
|
||||||
|
status: 'active',
|
||||||
|
applied_revision: bpObj.revision,
|
||||||
|
last_deployed_at: Date.now(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const stackDir = path.join(composeDir, bpObj.name);
|
||||||
|
fs.mkdirSync(stackDir, { recursive: true });
|
||||||
|
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
|
||||||
|
|
||||||
|
vi.spyOn(BlueprintService.getInstance(), 'readMarker').mockResolvedValue(null);
|
||||||
|
const { DeployedStackDeletionService } = await import('../services/DeployedStackDeletionService');
|
||||||
|
const deleteSpy = vi.spyOn(DeployedStackDeletionService.getInstance(), 'deleteDeployedStack');
|
||||||
|
|
||||||
|
const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, refreshed);
|
||||||
|
|
||||||
|
expect(result.status).toBe('name_conflict');
|
||||||
|
expect(deleteSpy).not.toHaveBeenCalled();
|
||||||
|
expect(DatabaseService.getInstance().getDeployment(bp.id, refreshed.id)?.status).toBe('name_conflict');
|
||||||
|
expect(fs.existsSync(path.join(stackDir, 'compose.yaml'))).toBe(true);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('BlueprintService developer-mode diagnostics', () => {
|
describe('BlueprintService developer-mode diagnostics', () => {
|
||||||
|
|||||||
@@ -326,6 +326,7 @@ describe('POST /api/blueprints/:id/apply confirm binding', () => {
|
|||||||
const conflict = await BlueprintService.getInstance().hasNameConflict(
|
const conflict = await BlueprintService.getInstance().hasNameConflict(
|
||||||
created.body.name as string,
|
created.body.name as string,
|
||||||
DatabaseService.getInstance().getNode(node.id)!,
|
DatabaseService.getInstance().getNode(node.id)!,
|
||||||
|
created.body.id as number,
|
||||||
);
|
);
|
||||||
expect(conflict).toBe(true);
|
expect(conflict).toBe(true);
|
||||||
|
|
||||||
|
|||||||
@@ -204,7 +204,15 @@ describe('BlueprintService remote deploy', () => {
|
|||||||
applied_revision: bpObj.revision,
|
applied_revision: bpObj.revision,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.spyOn(axios, 'get').mockResolvedValue({ status: 404, data: {} }); // readMarker → null → proceed
|
const marker = {
|
||||||
|
blueprintId: bp.id,
|
||||||
|
revision: bpObj.revision,
|
||||||
|
lastApplied: Date.now(),
|
||||||
|
};
|
||||||
|
vi.spyOn(axios, 'get').mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
data: { content: JSON.stringify(marker) },
|
||||||
|
});
|
||||||
vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: {} }); // remote down (best-effort)
|
vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: {} }); // remote down (best-effort)
|
||||||
const delSpy = vi.spyOn(axios, 'delete').mockResolvedValue({ status: 200, data: {} });
|
const delSpy = vi.spyOn(axios, 'delete').mockResolvedValue({ status: 200, data: {} });
|
||||||
|
|
||||||
@@ -215,6 +223,56 @@ describe('BlueprintService remote deploy', () => {
|
|||||||
expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)).toBeUndefined();
|
expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('refuses remote withdraw when the marker is missing but the stack still exists', async () => {
|
||||||
|
const node = seedRemoteNode();
|
||||||
|
const bp = seedBlueprint([node.id]);
|
||||||
|
const nodeObj = DatabaseService.getInstance().getNode(node.id)!;
|
||||||
|
const bpObj = DatabaseService.getInstance().getBlueprint(bp.id)!;
|
||||||
|
DatabaseService.getInstance().upsertDeployment({
|
||||||
|
blueprint_id: bp.id,
|
||||||
|
node_id: node.id,
|
||||||
|
status: 'active',
|
||||||
|
applied_revision: bpObj.revision,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Marker GET 404, then stack list shows the same-name stack still present.
|
||||||
|
vi.spyOn(axios, 'get')
|
||||||
|
.mockResolvedValueOnce({ status: 404, data: {} })
|
||||||
|
.mockResolvedValueOnce({ status: 200, data: [{ name: bpObj.name }] });
|
||||||
|
const delSpy = vi.spyOn(axios, 'delete');
|
||||||
|
|
||||||
|
const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj);
|
||||||
|
|
||||||
|
expect(result.status).toBe('name_conflict');
|
||||||
|
expect(delSpy).not.toHaveBeenCalled();
|
||||||
|
expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)?.status).toBe('name_conflict');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears a remote deployment row when the stack is already absent (no marker)', async () => {
|
||||||
|
const node = seedRemoteNode();
|
||||||
|
const bp = seedBlueprint([node.id]);
|
||||||
|
const nodeObj = DatabaseService.getInstance().getNode(node.id)!;
|
||||||
|
const bpObj = DatabaseService.getInstance().getBlueprint(bp.id)!;
|
||||||
|
DatabaseService.getInstance().upsertDeployment({
|
||||||
|
blueprint_id: bp.id,
|
||||||
|
node_id: node.id,
|
||||||
|
status: 'active',
|
||||||
|
applied_revision: bpObj.revision,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Marker missing and stack list empty → already gone.
|
||||||
|
vi.spyOn(axios, 'get')
|
||||||
|
.mockResolvedValueOnce({ status: 404, data: {} })
|
||||||
|
.mockResolvedValueOnce({ status: 200, data: [] });
|
||||||
|
const delSpy = vi.spyOn(axios, 'delete');
|
||||||
|
|
||||||
|
const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj);
|
||||||
|
|
||||||
|
expect(result.status).toBe('withdrawn');
|
||||||
|
expect(delSpy).not.toHaveBeenCalled();
|
||||||
|
expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it('does not clear hub role assignments when withdrawing a remote deployment', async () => {
|
it('does not clear hub role assignments when withdrawing a remote deployment', async () => {
|
||||||
const bcrypt = await import('bcrypt');
|
const bcrypt = await import('bcrypt');
|
||||||
const db = DatabaseService.getInstance();
|
const db = DatabaseService.getInstance();
|
||||||
@@ -237,7 +295,15 @@ describe('BlueprintService remote deploy', () => {
|
|||||||
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name,
|
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name,
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.spyOn(axios, 'get').mockResolvedValue({ status: 404, data: {} });
|
const marker = {
|
||||||
|
blueprintId: bp.id,
|
||||||
|
revision: bpObj.revision,
|
||||||
|
lastApplied: Date.now(),
|
||||||
|
};
|
||||||
|
vi.spyOn(axios, 'get').mockResolvedValue({
|
||||||
|
status: 200,
|
||||||
|
data: { content: JSON.stringify(marker) },
|
||||||
|
});
|
||||||
vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: {} });
|
vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: {} });
|
||||||
const delSpy = vi.spyOn(axios, 'delete').mockResolvedValue({ status: 200, data: {} });
|
const delSpy = vi.spyOn(axios, 'delete').mockResolvedValue({ status: 200, data: {} });
|
||||||
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource');
|
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource');
|
||||||
|
|||||||
@@ -456,6 +456,12 @@ describe('BlueprintService per-stack lock', () => {
|
|||||||
const nodeId = seedNode();
|
const nodeId = seedNode();
|
||||||
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] });
|
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] });
|
||||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||||
|
// Matching marker required before the delete lock is attempted.
|
||||||
|
vi.spyOn(BlueprintService.getInstance(), 'readMarker').mockResolvedValue({
|
||||||
|
blueprintId: bp.id,
|
||||||
|
revision: bp.revision,
|
||||||
|
lastApplied: Date.now(),
|
||||||
|
});
|
||||||
// A manual operation holds the lock; the withdraw must not race it.
|
// A manual operation holds the lock; the withdraw must not race it.
|
||||||
StackOpLockService.getInstance().tryAcquire(nodeId, bp.name, 'update', 'admin');
|
StackOpLockService.getInstance().tryAcquire(nodeId, bp.name, 'update', 'admin');
|
||||||
|
|
||||||
@@ -499,7 +505,11 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
|
|||||||
user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId),
|
user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId),
|
||||||
});
|
});
|
||||||
|
|
||||||
vi.spyOn(BlueprintService.getInstance(), 'readMarker').mockResolvedValue(null);
|
vi.spyOn(BlueprintService.getInstance(), 'readMarker').mockResolvedValue({
|
||||||
|
blueprintId: bp.id,
|
||||||
|
revision: bp.revision,
|
||||||
|
lastApplied: Date.now(),
|
||||||
|
});
|
||||||
const { ComposeService } = await import('../services/ComposeService');
|
const { ComposeService } = await import('../services/ComposeService');
|
||||||
const { FileSystemService } = await import('../services/FileSystemService');
|
const { FileSystemService } = await import('../services/FileSystemService');
|
||||||
vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
|
vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
type BlueprintSelector,
|
type BlueprintSelector,
|
||||||
type DriftMode,
|
type DriftMode,
|
||||||
} from '../services/DatabaseService';
|
} from '../services/DatabaseService';
|
||||||
import { BlueprintService } from '../services/BlueprintService';
|
import { BlueprintService, BlueprintNameConflictError } from '../services/BlueprintService';
|
||||||
import {
|
import {
|
||||||
BlueprintReconciler,
|
BlueprintReconciler,
|
||||||
messageForConfirmedOutcomes,
|
messageForConfirmedOutcomes,
|
||||||
@@ -320,8 +320,9 @@ blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<voi
|
|||||||
// Best-effort cleanup before delete: withdraw exactly the rows a stateful delete would
|
// 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
|
// 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
|
// 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
|
// never-deployed or unmanaged row: withdrawFromNode requires a matching marker (or an
|
||||||
// down/delete a same-name stack Sencho does not own. The blueprint-delete cascade removes
|
// already-absent stack) and refuses missing/foreign markers, so skipping those rows keeps
|
||||||
|
// delete from attempting a no-op conflict path. The blueprint-delete cascade removes
|
||||||
// the rows the loop skips.
|
// the rows the loop skips.
|
||||||
const nodes = DatabaseService.getInstance().getNodes();
|
const nodes = DatabaseService.getInstance().getNodes();
|
||||||
const deployments = DatabaseService.getInstance().listDeployments(id);
|
const deployments = DatabaseService.getInstance().listDeployments(id);
|
||||||
@@ -384,6 +385,10 @@ blueprintsRouter.post('/apply-local', async (req: Request, res: Response): Promi
|
|||||||
}
|
}
|
||||||
res.json({ deployed: true });
|
res.json({ deployed: true });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (error instanceof BlueprintNameConflictError) {
|
||||||
|
res.status(409).json({ error: error.message, code: 'name_conflict' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
console.error('[Blueprints] apply-local error:', sanitizeForLog(getErrorMessage(error, 'apply failed')));
|
console.error('[Blueprints] apply-local error:', sanitizeForLog(getErrorMessage(error, 'apply failed')));
|
||||||
res.status(500).json({ error: getErrorMessage(error, 'Blueprint apply failed') });
|
res.status(500).json({ error: getErrorMessage(error, 'Blueprint apply failed') });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -135,6 +135,39 @@ function releaseStackOpLock(req: Request, stackName: string): void {
|
|||||||
StackOpLockService.getInstance().release(req.nodeId, stackName);
|
StackOpLockService.getInstance().release(req.nodeId, stackName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Root compose + blueprint marker must not change while a lifecycle op holds the stack lock. */
|
||||||
|
const STACK_OP_LOCKED_ROOT_FILES = new Set([
|
||||||
|
'compose.yaml',
|
||||||
|
'compose.yml',
|
||||||
|
'docker-compose.yaml',
|
||||||
|
'docker-compose.yml',
|
||||||
|
'.blueprint.json',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function rejectIfStackOpBlocksFileWrite(
|
||||||
|
req: Request,
|
||||||
|
res: Response,
|
||||||
|
stackName: string,
|
||||||
|
relPath: string,
|
||||||
|
): boolean {
|
||||||
|
// Only stack-root trust files matter for compose discovery / marker ownership.
|
||||||
|
if (relPath.includes('/')) return false;
|
||||||
|
const base = relPath.toLowerCase();
|
||||||
|
if (!STACK_OP_LOCKED_ROOT_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 {
|
function stackFileEtag(mtimeMs: number): string {
|
||||||
return `W/"${Math.floor(mtimeMs)}"`;
|
return `W/"${Math.floor(mtimeMs)}"`;
|
||||||
}
|
}
|
||||||
@@ -2884,6 +2917,10 @@ stacksRouter.post(
|
|||||||
return res.status(400).json({ error: 'Invalid filename' });
|
return res.status(400).json({ error: 'Invalid filename' });
|
||||||
}
|
}
|
||||||
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
|
const targetRelPath = relPath ? `${relPath}/${originalName}` : originalName;
|
||||||
|
if (rejectIfStackOpBlocksFileWrite(req, res, stackName, targetRelPath)) {
|
||||||
|
await cleanupUploadTemp(req);
|
||||||
|
return;
|
||||||
|
}
|
||||||
const overwrite = String(req.query.overwrite) === '1';
|
const overwrite = String(req.query.overwrite) === '1';
|
||||||
// The multer wrapper stashed the route-entry timestamp on the request so
|
// 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
|
// the success path and the rejection paths share one window. Fall back to
|
||||||
@@ -2980,6 +3017,7 @@ stacksRouter.put('/:stackName/files/content', async (req: Request, res: Response
|
|||||||
if (!isValidRelativeStackPath(relPath)) {
|
if (!isValidRelativeStackPath(relPath)) {
|
||||||
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
return res.status(400).json({ error: 'Invalid path', code: 'INVALID_PATH' });
|
||||||
}
|
}
|
||||||
|
if (rejectIfStackOpBlocksFileWrite(req, res, stackName, relPath)) return;
|
||||||
const { content } = req.body as { content?: unknown };
|
const { content } = req.body as { content?: unknown };
|
||||||
if (typeof content !== 'string') {
|
if (typeof content !== 'string') {
|
||||||
return res.status(400).json({ error: '"content" must be a string' });
|
return res.status(400).json({ error: '"content" must be a string' });
|
||||||
|
|||||||
@@ -52,6 +52,15 @@ export interface DeployOutcome {
|
|||||||
error?: string;
|
error?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Thrown when apply refuses to overwrite a directory it does not own. */
|
||||||
|
export class BlueprintNameConflictError extends Error {
|
||||||
|
readonly code = 'name_conflict' as const;
|
||||||
|
constructor(message: string) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'BlueprintNameConflictError';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* BlueprintService is the orchestration layer between the reconciler and the
|
* BlueprintService is the orchestration layer between the reconciler and the
|
||||||
* concrete deploy/withdraw primitives. It owns:
|
* concrete deploy/withdraw primitives. It owns:
|
||||||
@@ -154,11 +163,11 @@ export class BlueprintService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns true when a stack directory by this name exists on the target
|
* Returns true when a stack directory by this name exists on the target
|
||||||
* node but does not carry our marker file. The reconciler must not
|
* node but does not carry a marker for this blueprint. The reconciler must
|
||||||
* deploy in that case: there is a real user-authored stack with the
|
* not deploy in that case: there is a real user-authored stack (or another
|
||||||
* same name and we must not overwrite it.
|
* blueprint's stack) with the same name and we must not overwrite it.
|
||||||
*/
|
*/
|
||||||
async hasNameConflict(blueprintName: string, node: Node): Promise<boolean> {
|
async hasNameConflict(blueprintName: string, node: Node, blueprintId: number): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
if (node.type === 'local') {
|
if (node.type === 'local') {
|
||||||
const baseDir = NodeRegistry.getInstance().getComposeDir(node.id);
|
const baseDir = NodeRegistry.getInstance().getComposeDir(node.id);
|
||||||
@@ -170,13 +179,8 @@ export class BlueprintService {
|
|||||||
} catch {
|
} catch {
|
||||||
return false; // directory doesn't exist → no conflict
|
return false; // directory doesn't exist → no conflict
|
||||||
}
|
}
|
||||||
const markerPath = path.join(stackDir, MARKER_FILENAME);
|
const marker = await this.readLocalMarkerFromDisk(node.id, blueprintName);
|
||||||
try {
|
return marker == null || marker.blueprintId !== blueprintId;
|
||||||
await fsPromises.stat(markerPath);
|
|
||||||
return false; // marker present → ours
|
|
||||||
} catch {
|
|
||||||
return true; // directory exists but no marker → conflict
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||||
if (!target) return false;
|
if (!target) return false;
|
||||||
@@ -192,12 +196,54 @@ export class BlueprintService {
|
|||||||
const exists = stacks.some(s => s?.name === blueprintName);
|
const exists = stacks.some(s => s?.name === blueprintName);
|
||||||
if (!exists) return false;
|
if (!exists) return false;
|
||||||
const marker = await this.readMarker(blueprintName, node);
|
const marker = await this.readMarker(blueprintName, node);
|
||||||
return marker == null;
|
return marker == null || marker.blueprintId !== blueprintId;
|
||||||
} catch {
|
} catch {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Read and parse a local on-disk marker without going through the remote HTTP path. */
|
||||||
|
private async readLocalMarkerFromDisk(nodeId: number, stackName: string): Promise<BlueprintMarker | null> {
|
||||||
|
try {
|
||||||
|
const baseDir = NodeRegistry.getInstance().getComposeDir(nodeId);
|
||||||
|
const markerPath = path.resolve(baseDir, stackName, MARKER_FILENAME);
|
||||||
|
if (!markerPath.startsWith(path.resolve(baseDir))) return null;
|
||||||
|
const content = await fsPromises.readFile(markerPath, 'utf-8');
|
||||||
|
return BlueprintService.parseMarker(content);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Probe whether a stack directory/name still exists. Used by withdraw to
|
||||||
|
* distinguish "already gone" (safe to clear the deployment row) from
|
||||||
|
* "present without our marker" (must refuse).
|
||||||
|
*/
|
||||||
|
private async probeStackExistence(
|
||||||
|
blueprintName: string,
|
||||||
|
node: Node,
|
||||||
|
): Promise<'present' | 'absent' | 'unknown'> {
|
||||||
|
try {
|
||||||
|
if (node.type === 'local') {
|
||||||
|
return (await this.stackDirExists(node.id, blueprintName)) ? 'present' : 'absent';
|
||||||
|
}
|
||||||
|
const target = NodeRegistry.getInstance().getProxyTarget(node.id);
|
||||||
|
if (!target) return 'unknown';
|
||||||
|
const baseUrl = target.apiUrl.replace(/\/$/, '');
|
||||||
|
const listRes = await axios.get(`${baseUrl}/api/stacks`, {
|
||||||
|
headers: this.remoteHeaders(target.apiToken),
|
||||||
|
timeout: REMOTE_HTTP_TIMEOUT_MS,
|
||||||
|
validateStatus: () => true,
|
||||||
|
});
|
||||||
|
if (listRes.status !== 200) return 'unknown';
|
||||||
|
const stacks = Array.isArray(listRes.data) ? listRes.data as Array<{ name?: string }> : [];
|
||||||
|
return stacks.some(s => s?.name === blueprintName) ? 'present' : 'absent';
|
||||||
|
} catch {
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deploy this blueprint to the given target node. Caller must have already
|
* Deploy this blueprint to the given target node. Caller must have already
|
||||||
* resolved that the target should receive this blueprint (selector match
|
* resolved that the target should receive this blueprint (selector match
|
||||||
@@ -222,7 +268,7 @@ export class BlueprintService {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
this.setStatus(blueprint.id, node.id, 'deploying');
|
this.setStatus(blueprint.id, node.id, 'deploying');
|
||||||
if (await this.hasNameConflict(blueprint.name, node)) {
|
if (await this.hasNameConflict(blueprint.name, node, blueprint.id)) {
|
||||||
this.setStatus(blueprint.id, node.id, 'name_conflict', {
|
this.setStatus(blueprint.id, node.id, 'name_conflict', {
|
||||||
last_error: `A stack named "${blueprint.name}" already exists on this node and is not managed by Sencho.`,
|
last_error: `A stack named "${blueprint.name}" already exists on this node and is not managed by Sencho.`,
|
||||||
});
|
});
|
||||||
@@ -249,6 +295,14 @@ export class BlueprintService {
|
|||||||
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
||||||
return { status: 'active' };
|
return { status: 'active' };
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
if (err instanceof BlueprintNameConflictError) {
|
||||||
|
this.setStatus(blueprint.id, node.id, 'name_conflict', {
|
||||||
|
last_error: err.message,
|
||||||
|
});
|
||||||
|
console.warn('[BlueprintService] deploy name conflict blueprint=%s node=%s durationMs=%s',
|
||||||
|
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
||||||
|
return { status: 'name_conflict', error: 'name_conflict' };
|
||||||
|
}
|
||||||
const message = BlueprintService.formatError(err);
|
const message = BlueprintService.formatError(err);
|
||||||
this.setStatus(blueprint.id, node.id, 'failed', { last_error: message });
|
this.setStatus(blueprint.id, node.id, 'failed', { last_error: message });
|
||||||
console.error('[BlueprintService] deploy failed blueprint=%s node=%s durationMs=%s error=%s',
|
console.error('[BlueprintService] deploy failed blueprint=%s node=%s durationMs=%s error=%s',
|
||||||
@@ -280,11 +334,31 @@ export class BlueprintService {
|
|||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
this.setStatus(blueprint.id, node.id, 'withdrawing');
|
this.setStatus(blueprint.id, node.id, 'withdrawing');
|
||||||
// Refuse to withdraw a directory we do not own
|
// Marker is the trust root: only withdraw when it matches this blueprint.
|
||||||
|
// Missing/unreadable marker on a still-present stack is refuse (not delete).
|
||||||
|
// Missing stack entirely is treated as already withdrawn.
|
||||||
const marker = await this.readMarker(blueprint.name, node);
|
const marker = await this.readMarker(blueprint.name, node);
|
||||||
if (marker && marker.blueprintId !== blueprint.id) {
|
if (!marker || marker.blueprintId !== blueprint.id) {
|
||||||
|
if (marker && marker.blueprintId !== blueprint.id) {
|
||||||
|
this.setStatus(blueprint.id, node.id, 'name_conflict', {
|
||||||
|
last_error: `Marker on this node points to a different blueprint (id=${marker.blueprintId}); refusing to withdraw.`,
|
||||||
|
});
|
||||||
|
return { status: 'name_conflict' };
|
||||||
|
}
|
||||||
|
const existence = await this.probeStackExistence(blueprint.name, node);
|
||||||
|
if (existence === 'absent') {
|
||||||
|
DatabaseService.getInstance().deleteDeployment(blueprint.id, node.id);
|
||||||
|
console.info('[BlueprintService] withdraw noop (stack already absent) blueprint=%s node=%s durationMs=%s',
|
||||||
|
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
||||||
|
return { status: 'withdrawn' };
|
||||||
|
}
|
||||||
|
if (existence === 'unknown') {
|
||||||
|
const message = 'Could not verify blueprint marker before withdraw; refusing to delete.';
|
||||||
|
this.setStatus(blueprint.id, node.id, 'failed', { last_error: message });
|
||||||
|
return { status: 'failed', error: message };
|
||||||
|
}
|
||||||
this.setStatus(blueprint.id, node.id, 'name_conflict', {
|
this.setStatus(blueprint.id, node.id, 'name_conflict', {
|
||||||
last_error: `Marker on this node points to a different blueprint (id=${marker.blueprintId}); refusing to withdraw.`,
|
last_error: `Stack "${blueprint.name}" exists without a matching blueprint marker; refusing to withdraw.`,
|
||||||
});
|
});
|
||||||
return { status: 'name_conflict' };
|
return { status: 'name_conflict' };
|
||||||
}
|
}
|
||||||
@@ -431,6 +505,10 @@ export class BlueprintService {
|
|||||||
* on a remote node receiving a blueprint apply from its hub (so the file
|
* on a remote node receiving a blueprint apply from its hub (so the file
|
||||||
* writes hold the remote's lock, not just the deploy). On lock conflict
|
* writes hold the remote's lock, not just the deploy). On lock conflict
|
||||||
* nothing is written and { ran: false } is returned.
|
* nothing is written and { ran: false } is returned.
|
||||||
|
*
|
||||||
|
* Ownership is re-validated inside the lock: an existing directory is only
|
||||||
|
* overwritten when its marker matches the marker being applied. A missing or
|
||||||
|
* foreign marker throws BlueprintNameConflictError without writing.
|
||||||
*/
|
*/
|
||||||
async applyLocalUnderLock(
|
async applyLocalUnderLock(
|
||||||
nodeId: number,
|
nodeId: number,
|
||||||
@@ -439,11 +517,22 @@ export class BlueprintService {
|
|||||||
markerContent: string,
|
markerContent: string,
|
||||||
auditPath: string,
|
auditPath: string,
|
||||||
): Promise<{ ran: true } | { ran: false; existingAction: StackOpAction }> {
|
): Promise<{ ran: true } | { ran: false; existingAction: StackOpAction }> {
|
||||||
|
const expected = BlueprintService.parseMarker(markerContent);
|
||||||
|
if (!expected) {
|
||||||
|
throw new Error('Invalid blueprint marker');
|
||||||
|
}
|
||||||
const fs = FileSystemService.getInstance(nodeId);
|
const fs = FileSystemService.getInstance(nodeId);
|
||||||
const lock = await StackOpLockService.getInstance().runExclusive(
|
const lock = await StackOpLockService.getInstance().runExclusive(
|
||||||
nodeId, stackName, 'deploy', 'system',
|
nodeId, stackName, 'deploy', 'system',
|
||||||
async () => {
|
async () => {
|
||||||
if (!(await this.stackDirExists(nodeId, stackName))) {
|
if (await this.stackDirExists(nodeId, stackName)) {
|
||||||
|
const existing = await this.readLocalMarkerFromDisk(nodeId, stackName);
|
||||||
|
if (!existing || existing.blueprintId !== expected.blueprintId) {
|
||||||
|
throw new BlueprintNameConflictError(
|
||||||
|
`A stack named "${stackName}" already exists on this node and is not managed by this blueprint.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
await fs.createStack(stackName);
|
await fs.createStack(stackName);
|
||||||
}
|
}
|
||||||
await fs.writeStackFile(stackName, COMPOSE_FILENAME, composeContent);
|
await fs.writeStackFile(stackName, COMPOSE_FILENAME, composeContent);
|
||||||
@@ -519,6 +608,16 @@ export class BlueprintService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (res.status === 409) {
|
if (res.status === 409) {
|
||||||
|
const body = res.data;
|
||||||
|
const code = body && typeof body === 'object' && typeof (body as { code?: unknown }).code === 'string'
|
||||||
|
? (body as { code: string }).code
|
||||||
|
: '';
|
||||||
|
if (code === 'name_conflict') {
|
||||||
|
throw new BlueprintNameConflictError(
|
||||||
|
BlueprintService.extractApiError(res.data) ||
|
||||||
|
`A stack named "${blueprint.name}" already exists on this node and is not managed by this blueprint.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
throw new Error(`blueprint apply skipped: ${BlueprintService.extractApiError(res.data) || 'another operation is already in progress'}`);
|
throw new Error(`blueprint apply skipped: ${BlueprintService.extractApiError(res.data) || 'another operation is already in progress'}`);
|
||||||
}
|
}
|
||||||
if (res.status >= 400) {
|
if (res.status >= 400) {
|
||||||
|
|||||||
@@ -56,6 +56,8 @@ const PROTECTED_STACK_FILES = new Set([
|
|||||||
'docker-compose.yaml',
|
'docker-compose.yaml',
|
||||||
'docker-compose.yml',
|
'docker-compose.yml',
|
||||||
'.env',
|
'.env',
|
||||||
|
// Blueprint ownership trust root — must not be deleted via the file explorer.
|
||||||
|
'.blueprint.json',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Bookkeeping markers Sencho writes into the backup slot. They are never copied
|
// Bookkeeping markers Sencho writes into the backup slot. They are never copied
|
||||||
|
|||||||
@@ -457,12 +457,16 @@ function asApprovedBlueprint(blueprint: Blueprint): Blueprint & BlueprintApprova
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** Upgrade create rows to blockers when an unmanaged same-name stack already exists. */
|
/** Upgrade create rows to blockers when an unmanaged same-name stack already exists. */
|
||||||
async function applyCreateNameConflictBlockers(blueprintName: string, raw: RawAction[]): Promise<void> {
|
async function applyCreateNameConflictBlockers(
|
||||||
|
blueprintName: string,
|
||||||
|
blueprintId: number,
|
||||||
|
raw: RawAction[],
|
||||||
|
): Promise<void> {
|
||||||
const { BlueprintService } = await import('./BlueprintService');
|
const { BlueprintService } = await import('./BlueprintService');
|
||||||
const svc = BlueprintService.getInstance();
|
const svc = BlueprintService.getInstance();
|
||||||
for (const row of raw) {
|
for (const row of raw) {
|
||||||
if (row.action !== 'create') continue;
|
if (row.action !== 'create') continue;
|
||||||
if (!(await svc.hasNameConflict(blueprintName, row.node))) continue;
|
if (!(await svc.hasNameConflict(blueprintName, row.node, blueprintId))) continue;
|
||||||
row.action = 'blocked_name_conflict';
|
row.action = 'blocked_name_conflict';
|
||||||
row.severity = 'blocker';
|
row.severity = 'blocker';
|
||||||
row.detail = 'Unmanaged stack with this name already exists on this node';
|
row.detail = 'Unmanaged stack with this name already exists on this node';
|
||||||
@@ -477,7 +481,7 @@ export async function buildBlueprintPreview(blueprintId: number): Promise<Bluepr
|
|||||||
const deployments = db.listDeployments(blueprintId);
|
const deployments = db.listDeployments(blueprintId);
|
||||||
const decision = BlueprintReconciler.getInstance().computeDecisionForPreview(blueprint, allNodes);
|
const decision = BlueprintReconciler.getInstance().computeDecisionForPreview(blueprint, allNodes);
|
||||||
const raw = projectActions(blueprint, allNodes, deployments, decision);
|
const raw = projectActions(blueprint, allNodes, deployments, decision);
|
||||||
await applyCreateNameConflictBlockers(blueprint.name, raw);
|
await applyCreateNameConflictBlockers(blueprint.name, blueprint.id, raw);
|
||||||
|
|
||||||
const changes: PreviewChangeRow[] = [];
|
const changes: PreviewChangeRow[] = [];
|
||||||
for (const row of raw) {
|
for (const row of raw) {
|
||||||
|
|||||||
Reference in New Issue
Block a user