mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-12 11:47:11 +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:
@@ -89,6 +89,7 @@ describe('Blueprint compose apply (real filesystem)', () => {
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
expect(await fsPromises.readFile(path.join(stackDir, 'compose.yaml'), 'utf-8')).toBe(composeContent);
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yml'));
|
||||
expect(await fsPromises.readFile(path.join(stackDir, '.blueprint.json'), 'utf-8')).toBe(markerContent);
|
||||
|
||||
const resolved = await FileSystemService.getInstance(nodeId).getComposeFilename(stackName);
|
||||
expect(resolved).toBe('compose.yaml');
|
||||
@@ -104,6 +105,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, '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, '.blueprint.json'),
|
||||
JSON.stringify({ blueprintId: 2, revision: 2, lastApplied: 1 }, null, 2),
|
||||
);
|
||||
|
||||
const composeContent = 'services:\n app:\n image: redis:7\n';
|
||||
const markerContent = JSON.stringify({ blueprintId: 2, revision: 3, lastApplied: Date.now() }, null, 2);
|
||||
@@ -131,8 +136,83 @@ describe('Blueprint compose apply (real filesystem)', () => {
|
||||
await expectMissing(path.join(stackDir, 'compose.yml'));
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yaml'));
|
||||
await expectMissing(path.join(stackDir, 'docker-compose.yml'));
|
||||
expect(await fsPromises.readFile(path.join(stackDir, '.blueprint.json'), 'utf-8')).toBe(markerContent);
|
||||
expect(deploySpy).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not write a new marker when deploy fails; rolls back a newly created stack', async () => {
|
||||
const nodeId = seedLocalNode();
|
||||
const stackName = `bp-partial-${counter}`;
|
||||
const composeContent = 'services:\n web:\n image: traefik:v3\n';
|
||||
const markerContent = JSON.stringify({ blueprintId: 7, revision: 1, lastApplied: Date.now() }, null, 2);
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
|
||||
vi.spyOn(ComposeService.prototype, 'deployStack').mockRejectedValue(new Error('docker unavailable'));
|
||||
|
||||
await expect(
|
||||
BlueprintService.getInstance().applyLocalUnderLock(
|
||||
nodeId,
|
||||
stackName,
|
||||
composeContent,
|
||||
markerContent,
|
||||
'/api/blueprints/test/apply',
|
||||
),
|
||||
).rejects.toThrow(/docker unavailable/);
|
||||
|
||||
await expectMissing(path.join(stackDir, '.blueprint.json'));
|
||||
await expectMissing(stackDir);
|
||||
});
|
||||
|
||||
it('keeps the prior marker when a re-apply deploy fails', async () => {
|
||||
const nodeId = seedLocalNode();
|
||||
const stackName = `bp-reapply-fail-${counter}`;
|
||||
const stackDir = path.join(process.env.COMPOSE_DIR!, stackName);
|
||||
const priorMarker = JSON.stringify({ blueprintId: 8, revision: 2, lastApplied: 1 }, null, 2);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services:\n old:\n image: nginx\n');
|
||||
await fsPromises.writeFile(path.join(stackDir, '.blueprint.json'), priorMarker);
|
||||
|
||||
vi.spyOn(ComposeService.prototype, 'deployStack').mockRejectedValue(new Error('deploy blew up'));
|
||||
|
||||
await expect(
|
||||
BlueprintService.getInstance().applyLocalUnderLock(
|
||||
nodeId,
|
||||
stackName,
|
||||
'services:\n new:\n image: redis:7\n',
|
||||
JSON.stringify({ blueprintId: 8, revision: 3, lastApplied: Date.now() }, null, 2),
|
||||
'/api/blueprints/test/apply',
|
||||
),
|
||||
).rejects.toThrow(/deploy blew up/);
|
||||
|
||||
expect(await fsPromises.readFile(path.join(stackDir, '.blueprint.json'), 'utf-8')).toBe(priorMarker);
|
||||
expect(await fsPromises.access(stackDir).then(() => true, () => false)).toBe(true);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
|
||||
@@ -111,9 +111,9 @@ describe('Blueprint route edge cases', () => {
|
||||
|
||||
describe('Blueprint delete guard', () => {
|
||||
// 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
|
||||
// down/delete a same-name stack Sencho never owned. A name_conflict is exactly that unmanaged
|
||||
// stack, so it is excluded even though it carries a last_deployed_at timestamp.
|
||||
// withdraw primitive for them: there is nothing Sencho owns to remove. A name_conflict is an
|
||||
// unmanaged same-name stack, so it is excluded even though it may carry a last_deployed_at
|
||||
// timestamp. When withdraw does run, ownership is enforced under the delete lock.
|
||||
it.each([
|
||||
{ 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 },
|
||||
@@ -184,6 +184,34 @@ describe('Blueprint delete guard', () => {
|
||||
expect(DatabaseService.getInstance().getBlueprint(bp.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('refuses to delete a stateless blueprint when pre-delete withdraw fails', async () => {
|
||||
const node = seedNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
DatabaseService.getInstance().upsertDeployment({
|
||||
blueprint_id: bp.id,
|
||||
node_id: node.id,
|
||||
status: 'failed',
|
||||
applied_revision: bp.revision,
|
||||
last_deployed_at: Date.now(),
|
||||
last_error: 'Remote node lacks withdraw-local',
|
||||
});
|
||||
const withdrawSpy = vi.spyOn(BlueprintService.getInstance(), 'withdrawFromNode').mockResolvedValue({
|
||||
status: 'failed',
|
||||
error: 'Remote node does not support atomic blueprint withdraw',
|
||||
});
|
||||
|
||||
const res = await request(app)
|
||||
.delete(`/api/blueprints/${bp.id}`)
|
||||
.set('Cookie', adminCookie);
|
||||
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('withdraw_failed_blocking_delete');
|
||||
expect(res.body.nodeId).toBe(node.id);
|
||||
expect(withdrawSpy).toHaveBeenCalledTimes(1);
|
||||
expect(DatabaseService.getInstance().getBlueprint(bp.id)).toBeDefined();
|
||||
expect(DatabaseService.getInstance().listDeployments(bp.id)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('refuses to delete when a pending review still has a deployed stack (revision drift)', async () => {
|
||||
const node = seedNode();
|
||||
const bp = seedBlueprint([node.id], 'stateful');
|
||||
@@ -253,38 +281,54 @@ describe('BlueprintService marker edge cases', () => {
|
||||
});
|
||||
|
||||
it('refuses to withdraw when the marker belongs to a different blueprint', async () => {
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
const localNode = DatabaseService.getInstance().getNodes()[0];
|
||||
const bp = seedBlueprint([localNode.id]);
|
||||
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)!;
|
||||
|
||||
vi.spyOn(BlueprintService.getInstance(), 'readMarker').mockResolvedValue({
|
||||
blueprintId: bp.id + 999,
|
||||
revision: 1,
|
||||
lastApplied: 0,
|
||||
DatabaseService.getInstance().upsertDeployment({
|
||||
blueprint_id: bp.id,
|
||||
node_id: refreshed.id,
|
||||
status: 'active',
|
||||
applied_revision: bpObj.revision,
|
||||
last_deployed_at: Date.now(),
|
||||
});
|
||||
|
||||
const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, localNode);
|
||||
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');
|
||||
fs.writeFileSync(
|
||||
path.join(stackDir, '.blueprint.json'),
|
||||
JSON.stringify({ blueprintId: bp.id + 999, revision: 1, lastApplied: 0 }),
|
||||
);
|
||||
|
||||
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');
|
||||
// The deployment row must record the conflict, not silently disappear.
|
||||
const dep = DatabaseService.getInstance().getDeployment(bp.id, localNode.id);
|
||||
// deleteDeployedStack is invoked but returns name_conflict without mutating.
|
||||
expect(deleteSpy).toHaveBeenCalled();
|
||||
const dep = DatabaseService.getInstance().getDeployment(bp.id, refreshed.id);
|
||||
expect(dep).toBeDefined();
|
||||
expect(dep?.status).toBe('name_conflict');
|
||||
expect(fs.existsSync(path.join(stackDir, 'compose.yaml'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('BlueprintService developer-mode diagnostics', () => {
|
||||
// withdrawFromNode emits its "withdraw inputs" diagnostic line before reading the
|
||||
// marker, so a cross-blueprint marker stub lets us assert the gate without Docker.
|
||||
// withdrawFromNode emits its "withdraw inputs" diagnostic before the deletion
|
||||
// service runs. An absent stack directory is enough to exercise logging without Docker.
|
||||
function arrangeWithdraw() {
|
||||
const localNode = DatabaseService.getInstance().getNodes()[0];
|
||||
const bp = seedBlueprint([localNode.id]);
|
||||
const bpObj = DatabaseService.getInstance().getBlueprint(bp.id)!;
|
||||
vi.spyOn(BlueprintService.getInstance(), 'readMarker').mockResolvedValue({
|
||||
blueprintId: bp.id + 999,
|
||||
revision: 1,
|
||||
lastApplied: 0,
|
||||
});
|
||||
return { bpObj, localNode };
|
||||
}
|
||||
|
||||
|
||||
@@ -326,6 +326,7 @@ describe('POST /api/blueprints/:id/apply confirm binding', () => {
|
||||
const conflict = await BlueprintService.getInstance().hasNameConflict(
|
||||
created.body.name as string,
|
||||
DatabaseService.getInstance().getNode(node.id)!,
|
||||
created.body.id as number,
|
||||
);
|
||||
expect(conflict).toBe(true);
|
||||
|
||||
@@ -342,6 +343,41 @@ describe('POST /api/blueprints/:id/apply confirm binding', () => {
|
||||
.toBe('services:\n app:\n image: nginx\n');
|
||||
});
|
||||
|
||||
it('hasNameConflict is false for a matching marker and true for a foreign marker', async () => {
|
||||
const node = seedNode();
|
||||
counter += 1;
|
||||
const created = await request(app)
|
||||
.post('/api/blueprints')
|
||||
.set('Cookie', adminCookie)
|
||||
.send(validBlueprintBody(node.id));
|
||||
expect(created.status).toBe(201);
|
||||
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
DatabaseService.getInstance().getDb()
|
||||
.prepare('UPDATE nodes SET compose_dir = ? WHERE id = ?')
|
||||
.run(composeDir, node.id);
|
||||
const nodeObj = DatabaseService.getInstance().getNode(node.id)!;
|
||||
const stackName = created.body.name as string;
|
||||
const blueprintId = created.body.id as number;
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(stackDir, 'compose.yaml'), 'services:\n app:\n image: nginx\n');
|
||||
fs.writeFileSync(
|
||||
path.join(stackDir, '.blueprint.json'),
|
||||
JSON.stringify({ blueprintId, revision: 1, lastApplied: 1 }),
|
||||
);
|
||||
|
||||
const { BlueprintService } = await import('../services/BlueprintService');
|
||||
const svc = BlueprintService.getInstance();
|
||||
expect(await svc.hasNameConflict(stackName, nodeObj, blueprintId)).toBe(false);
|
||||
|
||||
fs.writeFileSync(
|
||||
path.join(stackDir, '.blueprint.json'),
|
||||
JSON.stringify({ blueprintId: blueprintId + 99, revision: 1, lastApplied: 1 }),
|
||||
);
|
||||
expect(await svc.hasNameConflict(stackName, nodeObj, blueprintId)).toBe(true);
|
||||
});
|
||||
|
||||
it('blocks rename while a non-withdrawn deployment exists', async () => {
|
||||
const node = seedNode();
|
||||
counter += 1;
|
||||
|
||||
@@ -101,7 +101,7 @@ describe('BlueprintService remote deploy', () => {
|
||||
expect(dep?.applied_revision).toBe(bpObj.revision);
|
||||
});
|
||||
|
||||
it('falls back to the legacy create/write/deploy flow when the remote lacks apply-local (404)', async () => {
|
||||
it('fails closed when the remote lacks apply-local (404); no legacy mutations', async () => {
|
||||
const node = seedRemoteNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
const nodeObj = DatabaseService.getInstance().getNode(node.id)!;
|
||||
@@ -110,25 +110,17 @@ describe('BlueprintService remote deploy', () => {
|
||||
vi.spyOn(axios, 'get').mockResolvedValue({ status: 200, data: [] });
|
||||
const putSpy = vi.spyOn(axios, 'put').mockResolvedValue({ status: 200, data: {} });
|
||||
const postSpy = vi.spyOn(axios, 'post')
|
||||
.mockResolvedValueOnce({ status: 404, data: {} }) // apply-local missing on older node
|
||||
.mockResolvedValueOnce({ status: 201, data: {} }) // legacy create stack
|
||||
.mockResolvedValueOnce({ status: 200, data: {} }); // legacy deploy
|
||||
.mockResolvedValueOnce({ status: 404, data: {} });
|
||||
|
||||
const result = await BlueprintService.getInstance().deployToNode(bpObj, nodeObj);
|
||||
|
||||
expect(result.status).toBe('active');
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toMatch(/apply-local|Upgrade/i);
|
||||
expect(postSpy).toHaveBeenCalledTimes(1);
|
||||
expect(postSpy.mock.calls[0][0]).toMatch(/\/api\/blueprints\/apply-local$/);
|
||||
expect(postSpy.mock.calls[1][0]).toMatch(/\/api\/stacks$/);
|
||||
expect(putSpy).toHaveBeenCalledTimes(2); // compose + marker
|
||||
const composePutUrl = String(putSpy.mock.calls[0][0]);
|
||||
const markerPutUrl = String(putSpy.mock.calls[1][0]);
|
||||
expect(composePutUrl).toMatch(/[?&]path=compose\.yaml(?:&|$)/);
|
||||
expect(markerPutUrl).toMatch(/[?&]path=\.blueprint\.json(?:&|$)/);
|
||||
expect(postSpy.mock.calls[2][0]).toMatch(/\/deploy$/);
|
||||
const [composePutOrder, markerPutOrder] = putSpy.mock.invocationCallOrder;
|
||||
const deployOrder = postSpy.mock.invocationCallOrder[2];
|
||||
expect(composePutOrder).toBeLessThan(markerPutOrder);
|
||||
expect(markerPutOrder).toBeLessThan(deployOrder);
|
||||
expect(putSpy).not.toHaveBeenCalled();
|
||||
const dep = DatabaseService.getInstance().getDeployment(bp.id, node.id);
|
||||
expect(dep?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('maps a remote apply lock-conflict (409) to status=failed', async () => {
|
||||
@@ -192,7 +184,7 @@ describe('BlueprintService remote deploy', () => {
|
||||
expect(dep?.status).toBe('name_conflict');
|
||||
});
|
||||
|
||||
it('withdraws a remote deployment by deleting the stack and removing the row', async () => {
|
||||
it('withdraws a remote deployment via withdraw-local and removes the row', async () => {
|
||||
const node = seedRemoteNode();
|
||||
const bp = seedBlueprint([node.id]);
|
||||
const nodeObj = DatabaseService.getInstance().getNode(node.id)!;
|
||||
@@ -204,17 +196,73 @@ describe('BlueprintService remote deploy', () => {
|
||||
applied_revision: bpObj.revision,
|
||||
});
|
||||
|
||||
vi.spyOn(axios, 'get').mockResolvedValue({ status: 404, data: {} }); // readMarker → null → proceed
|
||||
vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: {} }); // remote down (best-effort)
|
||||
const delSpy = vi.spyOn(axios, 'delete').mockResolvedValue({ status: 200, data: {} });
|
||||
const postSpy = vi.spyOn(axios, 'post').mockResolvedValue({
|
||||
status: 200,
|
||||
data: { status: 'withdrawn' },
|
||||
});
|
||||
const delSpy = vi.spyOn(axios, 'delete');
|
||||
|
||||
const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj);
|
||||
|
||||
expect(result.status).toBe('withdrawn');
|
||||
expect(delSpy.mock.calls[0][0]).toMatch(/\/api\/stacks\//);
|
||||
expect(postSpy.mock.calls[0][0]).toMatch(/\/api\/blueprints\/withdraw-local$/);
|
||||
expect(delSpy).not.toHaveBeenCalled();
|
||||
expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('maps remote withdraw-local lock conflict to failed without fallback delete', 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,
|
||||
});
|
||||
|
||||
vi.spyOn(axios, 'post').mockResolvedValue({
|
||||
status: 409,
|
||||
data: {
|
||||
error: `${bpObj.name} is busy: another operation (update) is already in progress`,
|
||||
code: 'stack_op_in_progress',
|
||||
},
|
||||
});
|
||||
const delSpy = vi.spyOn(axios, 'delete');
|
||||
|
||||
const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toMatch(/already in progress/);
|
||||
expect(delSpy).not.toHaveBeenCalled();
|
||||
expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('fails closed when the remote lacks withdraw-local (404); no legacy delete', 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,
|
||||
});
|
||||
|
||||
const postSpy = vi.spyOn(axios, 'post').mockResolvedValue({ status: 404, data: { error: 'Not Found' } });
|
||||
const delSpy = vi.spyOn(axios, 'delete');
|
||||
|
||||
const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj);
|
||||
|
||||
expect(result.status).toBe('failed');
|
||||
expect(result.error).toMatch(/withdraw-local|Upgrade/i);
|
||||
expect(postSpy.mock.calls[0][0]).toMatch(/\/api\/blueprints\/withdraw-local$/);
|
||||
expect(delSpy).not.toHaveBeenCalled();
|
||||
expect(DatabaseService.getInstance().getDeployment(bp.id, node.id)?.status).toBe('failed');
|
||||
});
|
||||
|
||||
it('does not clear hub role assignments when withdrawing a remote deployment', async () => {
|
||||
const bcrypt = await import('bcrypt');
|
||||
const db = DatabaseService.getInstance();
|
||||
@@ -237,15 +285,14 @@ describe('BlueprintService remote deploy', () => {
|
||||
user_id: userId, role: 'deployer', resource_type: 'stack', resource_id: bpObj.name,
|
||||
});
|
||||
|
||||
vi.spyOn(axios, 'get').mockResolvedValue({ status: 404, data: {} });
|
||||
vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: {} });
|
||||
const delSpy = vi.spyOn(axios, 'delete').mockResolvedValue({ status: 200, data: {} });
|
||||
vi.spyOn(axios, 'post').mockResolvedValue({ status: 200, data: { status: 'withdrawn' } });
|
||||
const delSpy = vi.spyOn(axios, 'delete');
|
||||
const rbacSpy = vi.spyOn(db, 'deleteRoleAssignmentsByResource');
|
||||
|
||||
const result = await BlueprintService.getInstance().withdrawFromNode(bpObj, nodeObj);
|
||||
|
||||
expect(result.status).toBe('withdrawn');
|
||||
expect(delSpy.mock.calls[0][0]).toMatch(/\/api\/stacks\//);
|
||||
expect(delSpy).not.toHaveBeenCalled();
|
||||
expect(rbacSpy).not.toHaveBeenCalled();
|
||||
expect(db.getAllRoleAssignments(userId)
|
||||
.some((a) => a.resource_type === 'stack' && a.resource_id === bpObj.name)).toBe(true);
|
||||
|
||||
@@ -129,3 +129,71 @@ describe('POST /api/blueprints/apply-local (node-to-node atomic apply)', () => {
|
||||
expect(StackOpLockService.getInstance().get(1, 'apply-local-busy')?.action).toBe('update');
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/blueprints/withdraw-local', () => {
|
||||
let viewerCookie: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const bcrypt = (await import('bcrypt')).default;
|
||||
const passwordHash = await bcrypt.hash('bp-wd-viewer-pass', 1);
|
||||
DatabaseService.getInstance().addUser({
|
||||
username: 'bp-wd-viewer',
|
||||
password_hash: passwordHash,
|
||||
role: 'viewer',
|
||||
});
|
||||
const res = await request(app)
|
||||
.post('/api/auth/login')
|
||||
.send({ username: 'bp-wd-viewer', password: 'bp-wd-viewer-pass' });
|
||||
const cookies = res.headers['set-cookie'] as string | string[];
|
||||
viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies;
|
||||
});
|
||||
|
||||
it('rejects an invalid stack name', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints/withdraw-local')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ stackName: '../escape', blueprintId: 1 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain('Invalid stack name');
|
||||
});
|
||||
|
||||
it('rejects a non-positive blueprintId', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints/withdraw-local')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ stackName: 'wd-local-stack', blueprintId: 0 });
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toMatch(/blueprintId/i);
|
||||
});
|
||||
|
||||
it('returns 403 for a viewer without stack:delete', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints/withdraw-local')
|
||||
.set('Cookie', viewerCookie)
|
||||
.send({ stackName: 'wd-local-stack', blueprintId: 1 });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it('returns 409 self_stack_protected for Sencho own stack', async () => {
|
||||
const selfStackGuard = await import('../helpers/selfStackGuard');
|
||||
vi.spyOn(selfStackGuard, 'refuseIfSelfStack').mockImplementation(async (_req, res) => {
|
||||
res.status(409).json({ error: 'self', code: 'self_stack_protected' });
|
||||
return true;
|
||||
});
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints/withdraw-local')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ stackName: 'sencho-self', blueprintId: 1 });
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('self_stack_protected');
|
||||
});
|
||||
|
||||
it('returns already_absent when the stack directory is missing', async () => {
|
||||
const res = await request(app)
|
||||
.post('/api/blueprints/withdraw-local')
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ stackName: `wd-absent-${Date.now()}`, blueprintId: 42 });
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body.status).toBe('already_absent');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -399,7 +399,7 @@ describe('BlueprintReconciler developer-mode diagnostics', () => {
|
||||
});
|
||||
|
||||
describe('BlueprintService per-stack lock', () => {
|
||||
it('deploy under a free lock writes compose then marker, then deploys', async () => {
|
||||
it('deploy under a free lock writes compose, cleans siblings, deploys, then writes the marker', async () => {
|
||||
const nodeId = seedNode();
|
||||
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [nodeId] });
|
||||
const node = DatabaseService.getInstance().getNode(nodeId)!;
|
||||
@@ -419,7 +419,7 @@ describe('BlueprintService per-stack lock', () => {
|
||||
source: 'blueprint',
|
||||
actor: 'system:blueprint',
|
||||
});
|
||||
// Compose is written first, then the marker, both before sibling cleanup and deploy.
|
||||
// Compose is written first; the marker is deferred until after sibling cleanup and deploy.
|
||||
expect(writeSpy).toHaveBeenCalledTimes(2);
|
||||
expect(writeSpy.mock.calls[0][1]).toBe('compose.yaml');
|
||||
expect(writeSpy.mock.calls[0][2]).toBe(bp.compose_content);
|
||||
@@ -429,9 +429,9 @@ describe('BlueprintService per-stack lock', () => {
|
||||
const [composeOrder, markerOrder] = writeSpy.mock.invocationCallOrder;
|
||||
const [cleanupOrder] = cleanupSpy.mock.invocationCallOrder;
|
||||
const [deployOrder] = deploySpy.mock.invocationCallOrder;
|
||||
expect(composeOrder).toBeLessThan(markerOrder);
|
||||
expect(markerOrder).toBeLessThan(cleanupOrder);
|
||||
expect(composeOrder).toBeLessThan(cleanupOrder);
|
||||
expect(cleanupOrder).toBeLessThan(deployOrder);
|
||||
expect(deployOrder).toBeLessThan(markerOrder);
|
||||
});
|
||||
|
||||
it('deploy skips, writes no stack files, and records failed when the stack lock is held', async () => {
|
||||
@@ -476,7 +476,10 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
|
||||
|
||||
async function arrangeLocalWithdraw() {
|
||||
const db = DatabaseService.getInstance();
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
const nodeId = seedNode();
|
||||
db.getDb().prepare('UPDATE nodes SET compose_dir = ? WHERE id = ?').run(composeDir, nodeId);
|
||||
|
||||
const bp = seedBlueprint({ name: `rbac-wd-${counter}`, classification: 'stateless', nodeIds: [nodeId] });
|
||||
const node = db.getNode(nodeId)!;
|
||||
db.upsertDeployment({
|
||||
@@ -499,7 +502,15 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
|
||||
user_id: userId, role: 'deployer', resource_type: 'node', resource_id: String(otherNodeId),
|
||||
});
|
||||
|
||||
vi.spyOn(BlueprintService.getInstance(), 'readMarker').mockResolvedValue(null);
|
||||
// Matching on-disk marker so ownership passes; FS/down are stubbed.
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
const stackDir = path.join(composeDir, bp.name);
|
||||
fs.mkdirSync(stackDir, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(stackDir, '.blueprint.json'),
|
||||
JSON.stringify({ blueprintId: bp.id, revision: bp.revision, lastApplied: Date.now() }),
|
||||
);
|
||||
const { ComposeService } = await import('../services/ComposeService');
|
||||
const { FileSystemService } = await import('../services/FileSystemService');
|
||||
vi.spyOn(ComposeService.prototype, 'downStack').mockResolvedValue(undefined);
|
||||
@@ -522,15 +533,16 @@ describe('BlueprintService local withdraw clears stack-scoped role assignments',
|
||||
db.deleteUser(userId);
|
||||
});
|
||||
|
||||
it('clears the target assignment when deleteStack treats the directory as already absent (ENOENT)', async () => {
|
||||
// Shared deletion always calls deleteStack; FileSystemService maps ENOENT to success.
|
||||
it('clears the target assignment when the stack directory is already absent', async () => {
|
||||
const { bp, node, userId, deleteStackSpy, db } = await arrangeLocalWithdraw();
|
||||
deleteStackSpy.mockResolvedValue(undefined);
|
||||
const fs = await import('fs');
|
||||
const path = await import('path');
|
||||
fs.rmSync(path.join(process.env.COMPOSE_DIR!, bp.name), { recursive: true, force: true });
|
||||
|
||||
const outcome = await BlueprintService.getInstance().withdrawFromNode(bp, node);
|
||||
|
||||
expect(outcome.status).toBe('withdrawn');
|
||||
expect(deleteStackSpy).toHaveBeenCalledWith(bp.name);
|
||||
expect(deleteStackSpy).not.toHaveBeenCalled();
|
||||
expect(hasAssignment(userId, 'stack', bp.name)).toBe(false);
|
||||
expect(db.getAllRoleAssignments(userId)).toHaveLength(2);
|
||||
db.deleteUser(userId);
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Legacy installations upgrade stack_update_cleanup_pending with nullable
|
||||
* required_blueprint_id via maybeAddCol.
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
||||
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
|
||||
|
||||
let tmpDir: string;
|
||||
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
|
||||
|
||||
beforeAll(async () => {
|
||||
tmpDir = await setupTestDb();
|
||||
({ DatabaseService } = await import('../services/DatabaseService'));
|
||||
});
|
||||
|
||||
afterAll(() => cleanupTestDb(tmpDir));
|
||||
|
||||
describe('stack_update_cleanup_pending required_blueprint_id migration', () => {
|
||||
it('upgrades a legacy table without the column and preserves null for old rows', () => {
|
||||
const db = DatabaseService.getInstance();
|
||||
const raw = db.getDb();
|
||||
|
||||
// Simulate a pre-migration installation: drop the column if present, then
|
||||
// re-add it the same way initSchema's maybeAddCol does.
|
||||
try {
|
||||
raw.exec('ALTER TABLE stack_update_cleanup_pending DROP COLUMN required_blueprint_id');
|
||||
} catch {
|
||||
// Column may already be absent in a hand-built fixture.
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
raw.prepare(`
|
||||
INSERT INTO stack_update_cleanup_pending (
|
||||
id, node_id, stack_name, status, target_kind, rollback_tags_json,
|
||||
override_paths_json, prune_volumes_requested, created_at, updated_at
|
||||
) VALUES (?, ?, ?, 'prepared', 'local_socket', '[]', '[]', 0, ?, ?)
|
||||
`).run('legacy-1', 1, 'legacy-stack', now, now);
|
||||
|
||||
try {
|
||||
raw.exec('ALTER TABLE stack_update_cleanup_pending ADD COLUMN required_blueprint_id INTEGER');
|
||||
} catch {
|
||||
// Idempotent if a parallel path re-added it.
|
||||
}
|
||||
|
||||
const legacy = db.getCleanupPending('legacy-1');
|
||||
expect(legacy).toBeDefined();
|
||||
expect(legacy?.required_blueprint_id ?? null).toBeNull();
|
||||
|
||||
db.insertCleanupPending({
|
||||
id: 'owned-1',
|
||||
node_id: 2,
|
||||
stack_name: 'bp-stack',
|
||||
status: 'prepared',
|
||||
target_kind: 'local_socket',
|
||||
rollback_tags_json: '[]',
|
||||
override_paths_json: '[]',
|
||||
prune_volumes_requested: 0,
|
||||
required_blueprint_id: 42,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
expect(db.getCleanupPending('owned-1')?.required_blueprint_id).toBe(42);
|
||||
});
|
||||
});
|
||||
@@ -89,6 +89,7 @@ describe('DeployedStackDeletionService ready transaction', () => {
|
||||
rollback_tags_json: '[]',
|
||||
override_paths_json: '[]',
|
||||
prune_volumes_requested: 0,
|
||||
required_blueprint_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
};
|
||||
@@ -111,6 +112,7 @@ describe('DeployedStackDeletionService ready transaction', () => {
|
||||
rollback_tags_json: '[]',
|
||||
override_paths_json: '[]',
|
||||
prune_volumes_requested: 0,
|
||||
required_blueprint_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
@@ -129,6 +131,7 @@ describe('DeployedStackDeletionService ready transaction', () => {
|
||||
rollback_tags_json: '[]',
|
||||
override_paths_json: '[]',
|
||||
prune_volumes_requested: 0,
|
||||
required_blueprint_id: null,
|
||||
created_at: now,
|
||||
updated_at: now,
|
||||
});
|
||||
@@ -154,3 +157,39 @@ describe('overrideDeletionContainmentBase', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeployedStackDeletionService blueprint ownership probe', () => {
|
||||
it('returns failed (not name_conflict) when marker read fails with non-ENOENT I/O', async () => {
|
||||
const { promises: fsPromises } = await import('fs');
|
||||
const { vi } = await import('vitest');
|
||||
const composeDir = process.env.COMPOSE_DIR!;
|
||||
const stackName = `del-probe-${Date.now()}`;
|
||||
const stackDir = path.join(composeDir, stackName);
|
||||
await fsPromises.mkdir(stackDir, { recursive: true });
|
||||
await fsPromises.writeFile(path.join(stackDir, 'compose.yaml'), 'services: {}\n');
|
||||
await fsPromises.writeFile(
|
||||
path.join(stackDir, '.blueprint.json'),
|
||||
JSON.stringify({ blueprintId: 7, revision: 1, lastApplied: 0 }),
|
||||
);
|
||||
|
||||
const accessErr = Object.assign(new Error('EACCES'), { code: 'EACCES' });
|
||||
const readSpy = vi.spyOn(fsPromises, 'readFile').mockRejectedValueOnce(accessErr);
|
||||
|
||||
const result = await DeployedStackDeletionService.getInstance().deleteDeployedStack({
|
||||
nodeId: NODE,
|
||||
stackName,
|
||||
pruneVolumes: false,
|
||||
actor: 'test',
|
||||
requireBlueprintId: 7,
|
||||
});
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.code).toBe('failed');
|
||||
expect(result.error).toMatch(/EACCES|Failed to read|permission/i);
|
||||
}
|
||||
expect(readSpy).toHaveBeenCalled();
|
||||
readSpy.mockRestore();
|
||||
await fsPromises.rm(stackDir, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -95,9 +95,10 @@ describe('FileSystemService stack methods', () => {
|
||||
expect(fileNames).toEqual(['.env', 'compose.yaml']);
|
||||
});
|
||||
|
||||
it('marks compose.yaml and .env as protected', async () => {
|
||||
it('marks compose.yaml, .env, and .blueprint.json as protected', async () => {
|
||||
await fs.writeFile(path.join(stackDir, 'compose.yaml'), '');
|
||||
await fs.writeFile(path.join(stackDir, '.env'), '');
|
||||
await fs.writeFile(path.join(stackDir, '.blueprint.json'), '{}');
|
||||
await fs.writeFile(path.join(stackDir, 'custom.conf'), '');
|
||||
|
||||
const service = FileSystemService.getInstance();
|
||||
@@ -106,6 +107,7 @@ describe('FileSystemService stack methods', () => {
|
||||
const byName = Object.fromEntries(entries.map(e => [e.name, e]));
|
||||
expect(byName['compose.yaml'].isProtected).toBe(true);
|
||||
expect(byName['.env'].isProtected).toBe(true);
|
||||
expect(byName['.blueprint.json'].isProtected).toBe(true);
|
||||
expect(byName['custom.conf'].isProtected).toBe(false);
|
||||
});
|
||||
|
||||
|
||||
@@ -1116,6 +1116,37 @@ describe('PUT /api/stacks/:stackName/files/content', () => {
|
||||
expect(content).toBe('community-write');
|
||||
});
|
||||
|
||||
it('blocks root trust file writes while a stack op lock is held', async () => {
|
||||
const { StackOpLockService } = await import('../services/StackOpLockService');
|
||||
StackOpLockService.getInstance().tryAcquire(1, STACK, 'deploy', 'admin');
|
||||
try {
|
||||
const composeRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'compose.yaml' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ content: 'services:\n app:\n image: nginx\n' });
|
||||
expect(composeRes.status).toBe(409);
|
||||
expect(composeRes.body.code).toBe('stack_op_in_progress');
|
||||
|
||||
const markerRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: '.blueprint.json' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ content: '{"blueprintId":1,"revision":1,"lastApplied":0}' });
|
||||
expect(markerRes.status).toBe(409);
|
||||
expect(markerRes.body.code).toBe('stack_op_in_progress');
|
||||
|
||||
const nestedRes = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
.query({ path: 'config/app.conf' })
|
||||
.set('Cookie', adminCookie)
|
||||
.send({ content: 'ok' });
|
||||
expect(nestedRes.status).toBe(204);
|
||||
} finally {
|
||||
StackOpLockService.getInstance().release(1, STACK);
|
||||
}
|
||||
});
|
||||
|
||||
it('returns 400 when content is not a string', async () => {
|
||||
const res = await request(app)
|
||||
.put(`/api/stacks/${STACK}/files/content`)
|
||||
@@ -1931,6 +1962,16 @@ describe('protected stack files', () => {
|
||||
expect(res.body.code).toBe('PROTECTED_FILE');
|
||||
});
|
||||
|
||||
it('DELETE /files refuses .blueprint.json with 409 PROTECTED_FILE', async () => {
|
||||
await fs.writeFile(path.join(stacksDir, STACK, '.blueprint.json'), '{"blueprintId":1,"revision":1}\n');
|
||||
const res = await request(app)
|
||||
.delete(`/api/stacks/${STACK}/files`)
|
||||
.query({ path: '.blueprint.json' })
|
||||
.set('Cookie', adminCookie);
|
||||
expect(res.status).toBe(409);
|
||||
expect(res.body.code).toBe('PROTECTED_FILE');
|
||||
});
|
||||
|
||||
it('PATCH /files/rename refuses compose.yaml as source with 409 PROTECTED_FILE', async () => {
|
||||
const res = await request(app)
|
||||
.patch(`/api/stacks/${STACK}/files/rename`)
|
||||
|
||||
Reference in New Issue
Block a user