fix: harden blueprint deployment guardrails (#1027)

* fix: harden blueprint deployment guardrails

* fix: update Docker toolchain to Go 1.26.3

* fix: repair Dockerfile tr argument split across lines

* fix: bump protobufjs to clear npm audit high-severity advisories
This commit is contained in:
Anso
2026-05-12 15:49:19 -04:00
committed by GitHub
parent eed55f1637
commit 19cdb3681d
14 changed files with 9077 additions and 8671 deletions
@@ -0,0 +1,86 @@
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
import { MAX_BLUEPRINT_COMPOSE_BYTES } from '../routes/blueprints';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let LicenseService: typeof import('../services/LicenseService').LicenseService;
let adminCookie: string;
let counter = 0;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
({ LicenseService } = await import('../services/LicenseService'));
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
});
afterAll(() => cleanupTestDb(tmpDir));
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid');
vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('skipper');
vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null });
const db = DatabaseService.getInstance().getDb();
db.prepare('DELETE FROM blueprint_deployments').run();
db.prepare('DELETE FROM blueprints').run();
});
function validCreateBody(composeContent: string) {
counter += 1;
return {
name: `route-validate-${counter}`,
description: null,
compose_content: composeContent,
selector: { type: 'nodes', ids: [1] },
drift_mode: 'suggest',
enabled: true,
};
}
describe('Blueprint route compose validation', () => {
it('rejects invalid compose YAML on create', async () => {
const res = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validCreateBody('services:\n bad: : nope:'));
expect(res.status).toBe(400);
expect(res.body.error).toContain('compose_content must be valid YAML');
expect(DatabaseService.getInstance().listBlueprints()).toHaveLength(0);
});
it('rejects oversized compose content on create', async () => {
const oversized = `services:\n app:\n image: nginx\n labels:\n filler: "${'x'.repeat(MAX_BLUEPRINT_COMPOSE_BYTES)}"\n`;
const res = await request(app)
.post('/api/blueprints')
.set('Cookie', adminCookie)
.send(validCreateBody(oversized));
expect(res.status).toBe(400);
expect(res.body.error).toContain(`${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer`);
expect(DatabaseService.getInstance().listBlueprints()).toHaveLength(0);
});
it('rejects oversized compose content on analyze', async () => {
const oversized = `services:\n app:\n image: nginx\n labels:\n filler: "${'x'.repeat(MAX_BLUEPRINT_COMPOSE_BYTES)}"\n`;
const res = await request(app)
.post('/api/blueprints/analyze')
.set('Cookie', adminCookie)
.send({ compose_content: oversized });
expect(res.status).toBe(400);
expect(res.body.error).toContain(`${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer`);
});
});
+44 -3
View File
@@ -1,7 +1,7 @@
/**
* BlueprintReconciler decision-logic tests.
*
* The reconciler's `computeDecision` is the load-bearing pure logic — given
* The reconciler's `computeDecision` is the load-bearing pure logic. Given
* a blueprint, an actual deployment table, and a desired node set, it must
* decide for each node whether to deploy, withdraw, drift-check, state-review,
* or evict-block. We test that decision in isolation by accessing the
@@ -9,10 +9,10 @@
* pattern.
*
* Local deploy / remote HTTP / actual `docker compose` invocation are not
* exercised here they're integration concerns covered by the manual
* exercised here; they're integration concerns covered by the manual
* lifecycle in the plan.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
import type { Blueprint, Node } from '../services/DatabaseService';
import type { ReconcileDecision } from '../services/BlueprintReconciler';
@@ -42,6 +42,8 @@ beforeEach(() => {
db.prepare('DELETE FROM blueprints').run();
db.prepare('DELETE FROM node_labels').run();
db.prepare("DELETE FROM nodes WHERE is_default = 0").run();
db.prepare("UPDATE global_settings SET value = '0' WHERE key = 'developer_mode'").run();
vi.restoreAllMocks();
});
function seedNode(): number {
@@ -128,6 +130,22 @@ describe('BlueprintReconciler.computeDecision', () => {
expect(decision.deploy.map((n: { id: number }) => n.id)).toContain(nodeId);
});
it('queues state-review when a stateful deployment revision moves', () => {
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
DatabaseService.getInstance().upsertDeployment({
blueprint_id: bp.id,
node_id: nodeId,
status: 'active',
applied_revision: bp.revision - 1,
});
const reconciler = BlueprintReconciler.getInstance() as unknown as ReconcilerWithCompute;
const allNodes = DatabaseService.getInstance().getNodes();
const decision = reconciler.computeDecision(bp, allNodes);
expect(decision.stateReview.map((n: { id: number }) => n.id)).toContain(nodeId);
expect(decision.deploy).toEqual([]);
});
it('queues stateless eviction when a node leaves the selector', () => {
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateless', nodeIds: [] });
@@ -352,6 +370,29 @@ describe('BlueprintReconciler.computeDecision', () => {
});
});
describe('BlueprintReconciler developer-mode diagnostics', () => {
it('does not emit diagnostic logs when developer mode is off', async () => {
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined);
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
expect(infoSpy.mock.calls.some(([message]) => String(message).includes('[BlueprintReconciler:diag]'))).toBe(false);
});
it('emits diagnostic decision logs when developer mode is on', async () => {
DatabaseService.getInstance().updateGlobalSetting('developer_mode', '1');
const nodeId = seedNode();
const bp = seedBlueprint({ classification: 'stateful', nodeIds: [nodeId] });
const infoSpy = vi.spyOn(console, 'info').mockImplementation(() => undefined);
await BlueprintReconciler.getInstance().reconcileOne(bp.id);
expect(infoSpy.mock.calls.some(([message]) => String(message).includes('[BlueprintReconciler:diag]'))).toBe(true);
});
});
describe('BlueprintService marker parsing + name-conflict guard', () => {
it('parseMarker accepts a well-formed marker', () => {
const marker = BlueprintService.parseMarker(JSON.stringify({ blueprintId: 7, revision: 3, lastApplied: 12345 }));
@@ -57,7 +57,7 @@ vi.mock('../services/FleetSyncService', () => ({
FleetSyncService: { getSelfIdentity: () => 'self-node' },
}));
import { enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
import { enforcePolicyForImageRefs, enforcePolicyPreDeploy } from '../services/PolicyEnforcement';
function mkPolicy(overrides: Partial<ScanPolicy> = {}): ScanPolicy {
return {
@@ -275,4 +275,43 @@ describe('enforcePolicyPreDeploy', () => {
expect(result.violations).toEqual([]);
expect(trivyStub.scanImagePreflight).toHaveBeenCalledTimes(2);
});
it('enforces a supplied image list without reading compose from disk', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(true);
trivyStub.scanImagePreflight.mockResolvedValue(mkScan({
id: 42,
highest_severity: 'HIGH',
high_count: 1,
}));
const result = await enforcePolicyForImageRefs('blueprint-web', 1, ['nginx:1.27-alpine'], { bypass: false, actor: 'u' });
expect(result.ok).toBe(false);
expect(result.violations).toHaveLength(1);
expect(result.violations[0]).toMatchObject({
imageRef: 'nginx:1.27-alpine',
severity: 'HIGH',
highCount: 1,
scanId: 42,
});
expect(composeStub.listStackImages).not.toHaveBeenCalled();
});
it('fails closed on invalid supplied image refs when requested', async () => {
dbStub.getMatchingPolicy.mockReturnValue(mkPolicy());
trivyStub.isTrivyAvailable.mockReturnValue(true);
const result = await enforcePolicyForImageRefs('blueprint-web', 1, ['${IMAGE}'], { bypass: false, actor: 'u' }, undefined, true);
expect(result.ok).toBe(false);
expect(result.violations).toHaveLength(1);
expect(result.violations[0]).toMatchObject({
imageRef: '${IMAGE}',
severity: 'UNKNOWN',
scanId: 0,
});
expect(trivyStub.scanImagePreflight).not.toHaveBeenCalled();
expect(composeStub.listStackImages).not.toHaveBeenCalled();
});
});