mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-18 14:33:19 +00:00
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:
@@ -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`);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ blueprintsRouter.use(authMiddleware);
|
||||
const VALID_DRIFT_MODES: readonly DriftMode[] = ['observe', 'suggest', 'enforce'];
|
||||
const MAX_SELECTOR_ENTRIES = 200;
|
||||
const MAX_DESCRIPTION_LENGTH = 2048;
|
||||
export const MAX_BLUEPRINT_COMPOSE_BYTES = 96 * 1024;
|
||||
const BLUEPRINT_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]*$/;
|
||||
|
||||
interface BlueprintBody {
|
||||
@@ -85,6 +86,18 @@ function validateDriftMode(mode: unknown): string | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateComposeContent(composeContent: unknown): string | null {
|
||||
if (typeof composeContent !== 'string' || composeContent.trim().length === 0) {
|
||||
return 'compose_content must be a non-empty string';
|
||||
}
|
||||
if (Buffer.byteLength(composeContent, 'utf8') > MAX_BLUEPRINT_COMPOSE_BYTES) {
|
||||
return `compose_content must be ${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer`;
|
||||
}
|
||||
const analysis = BlueprintAnalyzer.analyze(composeContent);
|
||||
if (analysis.parseError) return `compose_content must be valid YAML: ${analysis.parseError}`;
|
||||
return null;
|
||||
}
|
||||
|
||||
function summarizeBlueprint(blueprintId: number) {
|
||||
const db = DatabaseService.getInstance();
|
||||
const blueprint = db.getBlueprint(blueprintId);
|
||||
@@ -121,10 +134,8 @@ blueprintsRouter.post('/', (req: Request, res: Response): void => {
|
||||
const body = req.body as BlueprintBody;
|
||||
const nameError = validateName(body.name);
|
||||
if (nameError) { res.status(400).json({ error: nameError }); return; }
|
||||
if (typeof body.compose_content !== 'string' || body.compose_content.trim().length === 0) {
|
||||
res.status(400).json({ error: 'compose_content must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
const composeError = validateComposeContent(body.compose_content);
|
||||
if (composeError) { res.status(400).json({ error: composeError }); return; }
|
||||
const descError = validateDescription(body.description);
|
||||
if (descError) { res.status(400).json({ error: descError }); return; }
|
||||
const selectorResult = parseSelector(body.selector);
|
||||
@@ -132,11 +143,12 @@ blueprintsRouter.post('/', (req: Request, res: Response): void => {
|
||||
const driftModeError = validateDriftMode(body.drift_mode ?? 'suggest');
|
||||
if (driftModeError) { res.status(400).json({ error: driftModeError }); return; }
|
||||
try {
|
||||
const analysis = BlueprintAnalyzer.analyze(body.compose_content);
|
||||
const composeContent = body.compose_content as string;
|
||||
const analysis = BlueprintAnalyzer.analyze(composeContent);
|
||||
const blueprint = DatabaseService.getInstance().createBlueprint({
|
||||
name: (body.name as string).trim(),
|
||||
description: typeof body.description === 'string' ? body.description : null,
|
||||
compose_content: body.compose_content,
|
||||
compose_content: composeContent,
|
||||
selector: selectorResult.selector,
|
||||
drift_mode: (body.drift_mode as DriftMode | undefined) ?? 'suggest',
|
||||
classification: analysis.classification,
|
||||
@@ -188,12 +200,11 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
updates.description = body.description as string | null;
|
||||
}
|
||||
if (body.compose_content !== undefined) {
|
||||
if (typeof body.compose_content !== 'string' || body.compose_content.trim().length === 0) {
|
||||
res.status(400).json({ error: 'compose_content must be a non-empty string' });
|
||||
return;
|
||||
}
|
||||
const analysis = BlueprintAnalyzer.analyze(body.compose_content);
|
||||
updates.compose_content = body.compose_content;
|
||||
const composeError = validateComposeContent(body.compose_content);
|
||||
if (composeError) { res.status(400).json({ error: composeError }); return; }
|
||||
const composeContent = body.compose_content as string;
|
||||
const analysis = BlueprintAnalyzer.analyze(composeContent);
|
||||
updates.compose_content = composeContent;
|
||||
updates.classification = analysis.classification;
|
||||
updates.classification_reasons = analysis.reasons;
|
||||
updates.bumpRevision = true;
|
||||
@@ -211,7 +222,7 @@ blueprintsRouter.put('/:id', (req: Request, res: Response): void => {
|
||||
if (body.enabled !== undefined) {
|
||||
const next = Boolean(body.enabled);
|
||||
if (!next) {
|
||||
// Refuse to disable a blueprint with active deployments — operator must withdraw explicitly.
|
||||
// Refuse to disable a blueprint with active deployments. Operator must withdraw explicitly.
|
||||
const existing = DatabaseService.getInstance().getBlueprint(id);
|
||||
if (existing?.enabled) {
|
||||
const deployments = DatabaseService.getInstance().listDeployments(id);
|
||||
@@ -251,7 +262,7 @@ blueprintsRouter.delete('/:id', async (req: Request, res: Response): Promise<voi
|
||||
try {
|
||||
const blueprint = DatabaseService.getInstance().getBlueprint(id);
|
||||
if (!blueprint) { res.status(404).json({ error: 'Blueprint not found' }); return; }
|
||||
// Refuse delete on stateful blueprints with active deployments — operator must withdraw explicitly first
|
||||
// Refuse delete on stateful blueprints with active deployments. Operator must withdraw explicitly first
|
||||
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
|
||||
const deployments = DatabaseService.getInstance().listDeployments(id);
|
||||
const blocking = deployments.filter(d => d.status === 'active' || d.status === 'evict_blocked' || d.status === 'pending_state_review');
|
||||
@@ -489,6 +500,10 @@ blueprintsRouter.post('/analyze', (req: Request, res: Response): void => {
|
||||
res.status(400).json({ error: 'compose_content is required' });
|
||||
return;
|
||||
}
|
||||
if (Buffer.byteLength(composeContent, 'utf8') > MAX_BLUEPRINT_COMPOSE_BYTES) {
|
||||
res.status(400).json({ error: `compose_content must be ${MAX_BLUEPRINT_COMPOSE_BYTES} bytes or fewer` });
|
||||
return;
|
||||
}
|
||||
const result = BlueprintAnalyzer.analyze(composeContent);
|
||||
res.json(result);
|
||||
});
|
||||
|
||||
@@ -17,6 +17,7 @@ interface ComposeShape {
|
||||
}
|
||||
|
||||
interface ComposeService {
|
||||
image?: string | null;
|
||||
volumes?: Array<string | ComposeServiceVolume> | null;
|
||||
tmpfs?: string | string[] | null;
|
||||
}
|
||||
@@ -208,6 +209,20 @@ export class BlueprintAnalyzer {
|
||||
return false;
|
||||
}
|
||||
|
||||
static extractImageRefs(composeContent: string): string[] {
|
||||
const doc = (parseYaml(composeContent) ?? {}) as ComposeShape;
|
||||
const services = doc.services ?? {};
|
||||
const seen = new Set<string>();
|
||||
const images: string[] = [];
|
||||
for (const serviceDef of Object.values(services)) {
|
||||
const image = typeof serviceDef?.image === 'string' ? serviceDef.image.trim() : '';
|
||||
if (!image || image.startsWith('sha256:') || seen.has(image)) continue;
|
||||
seen.add(image);
|
||||
images.push(image);
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
private static extractNamedVolumes(composeContent: string): Set<string> {
|
||||
try {
|
||||
const doc = (parseYaml(composeContent) ?? {}) as ComposeShape;
|
||||
|
||||
@@ -8,10 +8,27 @@ import { BlueprintService } from './BlueprintService';
|
||||
import { BlueprintAnalyzer } from './BlueprintAnalyzer';
|
||||
import { NodeLabelService } from './NodeLabelService';
|
||||
import { NotificationService } from './NotificationService';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const RECONCILER_INTERVAL_MS = 60_000;
|
||||
const RECONCILER_INITIAL_DELAY_MS = 5_000;
|
||||
|
||||
function isDeveloperModeEnabled(): boolean {
|
||||
try {
|
||||
return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticLog(message: string, fields: Record<string, string | number | boolean | null | undefined>): void {
|
||||
if (!isDeveloperModeEnabled()) return;
|
||||
const safeFields = Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, typeof value === 'string' ? sanitizeForLog(value) : value]),
|
||||
);
|
||||
console.info(`[BlueprintReconciler:diag] ${message}`, safeFields);
|
||||
}
|
||||
|
||||
export interface ReconcileDecision {
|
||||
deploy: Node[];
|
||||
withdraw: Node[];
|
||||
@@ -77,17 +94,21 @@ export class BlueprintReconciler {
|
||||
const blueprint = DatabaseService.getInstance().getBlueprint(blueprintId);
|
||||
if (!blueprint || !blueprint.enabled) return;
|
||||
const nodes = DatabaseService.getInstance().getNodes();
|
||||
diagnosticLog('manual reconcile requested', { blueprintId, nodeCount: nodes.length });
|
||||
await this.reconcileBlueprint(blueprint, nodes);
|
||||
}
|
||||
|
||||
private async evaluate(): Promise<void> {
|
||||
if (this.running) return; // prevent overlap on slow ticks
|
||||
this.running = true;
|
||||
const started = Date.now();
|
||||
try {
|
||||
const db = DatabaseService.getInstance();
|
||||
const blueprints = db.listEnabledBlueprints();
|
||||
if (blueprints.length === 0) return;
|
||||
const nodes = db.getNodes();
|
||||
console.info('[BlueprintReconciler] tick start blueprints=%s nodes=%s', blueprints.length, nodes.length);
|
||||
diagnosticLog('tick inputs', { blueprintCount: blueprints.length, nodeCount: nodes.length });
|
||||
for (const blueprint of blueprints) {
|
||||
try {
|
||||
await this.reconcileBlueprint(blueprint, nodes);
|
||||
@@ -95,6 +116,7 @@ export class BlueprintReconciler {
|
||||
console.error(`[BlueprintReconciler] failed for blueprint "${blueprint.name}":`, err);
|
||||
}
|
||||
}
|
||||
console.info('[BlueprintReconciler] tick complete blueprints=%s durationMs=%s', blueprints.length, Date.now() - started);
|
||||
} finally {
|
||||
this.running = false;
|
||||
}
|
||||
@@ -102,15 +124,28 @@ export class BlueprintReconciler {
|
||||
|
||||
private async reconcileBlueprint(blueprint: Blueprint, allNodes: Node[]): Promise<void> {
|
||||
const decision = this.computeDecision(blueprint, allNodes);
|
||||
diagnosticLog('decision computed', {
|
||||
blueprintId: blueprint.id,
|
||||
blueprintName: blueprint.name,
|
||||
revision: blueprint.revision,
|
||||
deploy: decision.deploy.length,
|
||||
withdraw: decision.withdraw.length,
|
||||
check: decision.check.length,
|
||||
stateReview: decision.stateReview.length,
|
||||
evictBlocked: decision.evictBlocked.length,
|
||||
});
|
||||
|
||||
// 1. State-review guard for stateful blueprints reaching new nodes.
|
||||
for (const node of decision.stateReview) {
|
||||
const existing = DatabaseService.getInstance().getDeployment(blueprint.id, node.id);
|
||||
DatabaseService.getInstance().upsertDeployment({
|
||||
blueprint_id: blueprint.id,
|
||||
node_id: node.id,
|
||||
status: 'pending_state_review',
|
||||
last_checked_at: Date.now(),
|
||||
drift_summary: 'Stateful blueprint awaiting operator confirmation before first deploy',
|
||||
drift_summary: existing
|
||||
? 'Stateful blueprint revision change awaits operator confirmation'
|
||||
: 'Stateful blueprint awaiting operator confirmation before first deploy',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -213,8 +248,11 @@ export class BlueprintReconciler {
|
||||
continue;
|
||||
}
|
||||
if (dep.applied_revision !== blueprint.revision) {
|
||||
// revision drift: re-deploy (stateful never auto-redeploys volume-destroying changes; handled in handleDrift)
|
||||
decision.deploy.push(node);
|
||||
if (blueprint.classification === 'stateful' || blueprint.classification === 'unknown') {
|
||||
decision.stateReview.push(node);
|
||||
} else {
|
||||
decision.deploy.push(node);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (dep.status === 'failed' || dep.status === 'pending') {
|
||||
|
||||
@@ -13,11 +13,31 @@ import { FileSystemService } from './FileSystemService';
|
||||
import { NodeRegistry } from './NodeRegistry';
|
||||
import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers';
|
||||
import { LicenseService } from './LicenseService';
|
||||
import { enforcePolicyForImageRefs } from './PolicyEnforcement';
|
||||
import { triggerPostDeployScan } from '../helpers/policyGate';
|
||||
import { BlueprintAnalyzer } from './BlueprintAnalyzer';
|
||||
import { sanitizeForLog } from '../utils/safeLog';
|
||||
|
||||
const MARKER_FILENAME = '.blueprint.json';
|
||||
const COMPOSE_FILENAME = 'docker-compose.yml';
|
||||
const REMOTE_HTTP_TIMEOUT_MS = 30_000;
|
||||
|
||||
function isDeveloperModeEnabled(): boolean {
|
||||
try {
|
||||
return DatabaseService.getInstance().getGlobalSettings().developer_mode === '1';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function diagnosticLog(message: string, fields: Record<string, string | number | boolean | null | undefined>): void {
|
||||
if (!isDeveloperModeEnabled()) return;
|
||||
const safeFields = Object.fromEntries(
|
||||
Object.entries(fields).map(([key, value]) => [key, typeof value === 'string' ? sanitizeForLog(value) : value]),
|
||||
);
|
||||
console.info(`[BlueprintService:diag] ${message}`, safeFields);
|
||||
}
|
||||
|
||||
export interface BlueprintMarker {
|
||||
blueprintId: number;
|
||||
revision: number;
|
||||
@@ -185,18 +205,34 @@ export class BlueprintService {
|
||||
if (!this.acquireLock(blueprint.id, node.id)) {
|
||||
return { status: 'pending' };
|
||||
}
|
||||
const started = Date.now();
|
||||
console.info('[BlueprintService] deploy start blueprint=%s node=%s type=%s revision=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, node.type, blueprint.revision);
|
||||
diagnosticLog('deploy inputs', {
|
||||
blueprintId: blueprint.id,
|
||||
blueprintName: blueprint.name,
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
revision: blueprint.revision,
|
||||
classification: blueprint.classification,
|
||||
driftMode: blueprint.drift_mode,
|
||||
});
|
||||
try {
|
||||
this.setStatus(blueprint.id, node.id, 'deploying');
|
||||
if (await this.hasNameConflict(blueprint.name, node)) {
|
||||
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.`,
|
||||
});
|
||||
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 marker = this.buildMarker(blueprint);
|
||||
if (node.type === 'local') {
|
||||
diagnosticLog('deploy branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'local' });
|
||||
await this.deployLocal(blueprint, node, marker);
|
||||
} else {
|
||||
diagnosticLog('deploy branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'remote' });
|
||||
await this.deployRemote(blueprint, node, marker);
|
||||
}
|
||||
this.setStatus(blueprint.id, node.id, 'active', {
|
||||
@@ -206,10 +242,14 @@ export class BlueprintService {
|
||||
drift_summary: null,
|
||||
last_error: null,
|
||||
});
|
||||
console.info('[BlueprintService] deploy complete blueprint=%s node=%s durationMs=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
||||
return { status: 'active' };
|
||||
} catch (err) {
|
||||
const message = BlueprintService.formatError(err);
|
||||
this.setStatus(blueprint.id, node.id, 'failed', { last_error: message });
|
||||
console.error('[BlueprintService] deploy failed blueprint=%s node=%s durationMs=%s error=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, Date.now() - started, sanitizeForLog(message));
|
||||
return { status: 'failed', error: message };
|
||||
} finally {
|
||||
this.releaseLock(blueprint.id, node.id);
|
||||
@@ -225,6 +265,16 @@ export class BlueprintService {
|
||||
if (!this.acquireLock(blueprint.id, node.id)) {
|
||||
return { status: 'pending' };
|
||||
}
|
||||
const started = Date.now();
|
||||
console.info('[BlueprintService] withdraw start blueprint=%s node=%s type=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, node.type);
|
||||
diagnosticLog('withdraw inputs', {
|
||||
blueprintId: blueprint.id,
|
||||
blueprintName: blueprint.name,
|
||||
nodeId: node.id,
|
||||
nodeType: node.type,
|
||||
classification: blueprint.classification,
|
||||
});
|
||||
try {
|
||||
this.setStatus(blueprint.id, node.id, 'withdrawing');
|
||||
// Refuse to withdraw a directory we do not own
|
||||
@@ -236,15 +286,21 @@ export class BlueprintService {
|
||||
return { status: 'name_conflict' };
|
||||
}
|
||||
if (node.type === 'local') {
|
||||
diagnosticLog('withdraw branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'local' });
|
||||
await this.withdrawLocal(blueprint, node);
|
||||
} else {
|
||||
diagnosticLog('withdraw branch', { blueprintId: blueprint.id, nodeId: node.id, target: 'remote' });
|
||||
await this.withdrawRemote(blueprint, node);
|
||||
}
|
||||
DatabaseService.getInstance().deleteDeployment(blueprint.id, node.id);
|
||||
console.info('[BlueprintService] withdraw complete blueprint=%s node=%s durationMs=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, Date.now() - started);
|
||||
return { status: 'withdrawn' };
|
||||
} catch (err) {
|
||||
const message = BlueprintService.formatError(err);
|
||||
this.setStatus(blueprint.id, node.id, 'failed', { last_error: `withdraw failed: ${message}` });
|
||||
console.error('[BlueprintService] withdraw failed blueprint=%s node=%s durationMs=%s error=%s',
|
||||
sanitizeForLog(blueprint.name), node.id, Date.now() - started, sanitizeForLog(message));
|
||||
return { status: 'failed', error: message };
|
||||
} finally {
|
||||
this.releaseLock(blueprint.id, node.id);
|
||||
@@ -336,6 +392,17 @@ export class BlueprintService {
|
||||
}
|
||||
|
||||
private async deployLocal(blueprint: Blueprint, node: Node, marker: BlueprintMarker): Promise<void> {
|
||||
const imageRefs = BlueprintAnalyzer.extractImageRefs(blueprint.compose_content);
|
||||
const gate = await enforcePolicyForImageRefs(blueprint.name, node.id, imageRefs, {
|
||||
bypass: false,
|
||||
actor: 'blueprint-reconciler',
|
||||
auditMethod: 'POST',
|
||||
auditPath: `/api/blueprints/${blueprint.id}/apply`,
|
||||
}, undefined, true);
|
||||
if (!gate.ok) {
|
||||
throw new Error(`Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`);
|
||||
}
|
||||
|
||||
const fs = FileSystemService.getInstance(node.id);
|
||||
if (!(await this.stackDirExists(node, blueprint.name))) {
|
||||
await fs.createStack(blueprint.name);
|
||||
@@ -343,6 +410,10 @@ export class BlueprintService {
|
||||
await fs.writeStackFile(blueprint.name, COMPOSE_FILENAME, blueprint.compose_content);
|
||||
await fs.writeStackFile(blueprint.name, MARKER_FILENAME, JSON.stringify(marker, null, 2));
|
||||
await ComposeService.getInstance(node.id).deployStack(blueprint.name, undefined, false);
|
||||
triggerPostDeployScan(blueprint.name, node.id).catch(err => {
|
||||
console.error('[BlueprintService] post-deploy scan failed for "%s" on node %s: %s',
|
||||
sanitizeForLog(blueprint.name), node.id, sanitizeForLog(BlueprintService.formatError(err)));
|
||||
});
|
||||
}
|
||||
|
||||
private async withdrawLocal(blueprint: Blueprint, node: Node): Promise<void> {
|
||||
|
||||
@@ -50,7 +50,6 @@ export async function enforcePolicyPreDeploy(
|
||||
nodeId: number,
|
||||
opts: PolicyEnforcementOptions,
|
||||
): Promise<PolicyEnforcementResult> {
|
||||
const svc = TrivyService.getInstance();
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity());
|
||||
|
||||
@@ -58,6 +57,7 @@ export async function enforcePolicyPreDeploy(
|
||||
return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] };
|
||||
}
|
||||
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
@@ -88,9 +88,49 @@ export async function enforcePolicyPreDeploy(
|
||||
};
|
||||
}
|
||||
|
||||
return enforcePolicyForImageRefs(stackName, nodeId, imageRefs, opts, policy);
|
||||
}
|
||||
|
||||
export async function enforcePolicyForImageRefs(
|
||||
stackName: string,
|
||||
nodeId: number,
|
||||
imageRefs: string[],
|
||||
opts: PolicyEnforcementOptions,
|
||||
matchedPolicy?: ScanPolicy,
|
||||
failClosedInvalidRefs = false,
|
||||
): Promise<PolicyEnforcementResult> {
|
||||
const db = DatabaseService.getInstance();
|
||||
const policy = matchedPolicy ?? db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity());
|
||||
|
||||
if (!policy || !policy.enabled || !policy.block_on_deploy) {
|
||||
return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] };
|
||||
}
|
||||
|
||||
const svc = TrivyService.getInstance();
|
||||
if (!svc.isTrivyAvailable()) {
|
||||
NotificationService.getInstance().dispatchAlert(
|
||||
'warning',
|
||||
'scan_finding',
|
||||
`Pre-deploy scan for "${stackName}" skipped: Trivy not installed on this node`,
|
||||
{ stackName },
|
||||
);
|
||||
return { ok: true, bypassed: false, policy, violations: [], trivyMissing: true };
|
||||
}
|
||||
|
||||
const violations: PolicyViolation[] = [];
|
||||
for (const imageRef of imageRefs) {
|
||||
if (!validateImageRef(imageRef)) continue;
|
||||
if (!validateImageRef(imageRef)) {
|
||||
if (failClosedInvalidRefs) {
|
||||
violations.push({
|
||||
imageRef,
|
||||
severity: 'UNKNOWN',
|
||||
criticalCount: 0,
|
||||
highCount: 0,
|
||||
scanId: 0,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const scan = await svc.scanImagePreflight(imageRef, nodeId, stackName);
|
||||
const severity = scan.highest_severity ?? 'UNKNOWN';
|
||||
|
||||
Reference in New Issue
Block a user