diff --git a/backend/src/__tests__/compose-images.test.ts b/backend/src/__tests__/compose-images.test.ts index 0745e424..a38ea2e3 100644 --- a/backend/src/__tests__/compose-images.test.ts +++ b/backend/src/__tests__/compose-images.test.ts @@ -1,170 +1,170 @@ -/** - * Exercises ComposeService.listStackImages, the helper the policy gate calls - * to enumerate the images a stack will pull before `docker compose up`. - * - * The stdout from `docker compose config --images` can contain duplicates - * (multiple services running the same image), trailing whitespace, blank - * lines, and `sha256:` digest lines we must not pass to Trivy. The gate - * feeds this list directly to `scanImagePreflight`, so dedupe + filter - * correctness here directly affects what gets scanned and what silently - * passes through. - */ -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { EventEmitter } from 'events'; - -const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })); - -vi.mock('child_process', () => ({ spawn: mockSpawn })); - -vi.mock('../services/NodeRegistry', () => ({ - NodeRegistry: { - getInstance: () => ({ - getDefaultNodeId: () => 1, - getComposeDir: () => '/test/compose', - }), - }, -})); - -vi.mock('../services/DockerController', () => ({ - default: { - getInstance: () => ({ - getContainersByStack: vi.fn().mockResolvedValue([]), - removeContainers: vi.fn().mockResolvedValue([]), - getDocker: () => ({ - listContainers: vi.fn().mockResolvedValue([]), - }), - }), - }, -})); - -vi.mock('../services/DatabaseService', () => ({ - DatabaseService: { getInstance: () => ({ getRegistries: () => [] }) }, -})); - -vi.mock('../services/RegistryService', () => ({ - RegistryService: { - getInstance: () => ({ - resolveDockerConfig: vi.fn().mockResolvedValue({ config: { auths: {} }, warnings: [] }), - }), - }, -})); - -vi.mock('../services/FileSystemService', () => ({ - FileSystemService: { - getInstance: () => ({ - backupStackFiles: vi.fn().mockResolvedValue(undefined), - restoreStackFiles: vi.fn().mockResolvedValue(undefined), - }), - }, -})); - -vi.mock('../services/LogFormatter', () => ({ - LogFormatter: { formatLine: (line: string) => line }, -})); - -import { ComposeService } from '../services/ComposeService'; - -function mockComposeConfig(stdout: string, exitCode = 0): void { - mockSpawn.mockImplementation(() => { - const proc = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - kill: ReturnType; - }; - proc.stdout = new EventEmitter(); - proc.stderr = new EventEmitter(); - proc.kill = vi.fn(); - Promise.resolve().then(() => { - if (stdout) proc.stdout.emit('data', Buffer.from(stdout)); - proc.emit('close', exitCode); - }); - return proc; - }); -} - -describe('ComposeService.listStackImages', () => { - beforeEach(() => { - mockSpawn.mockReset(); - }); - - it('returns the list of images, trimmed and deduped', async () => { - mockComposeConfig('nginx:1.14\nredis:7\nnginx:1.14\n'); - - const images = await ComposeService.getInstance(1).listStackImages('my-stack'); - - expect(images).toEqual(['nginx:1.14', 'redis:7']); - }); - - it('invokes `docker compose config --images` in the stack directory', async () => { - mockComposeConfig('nginx:1.14\n'); - - await ComposeService.getInstance(1).listStackImages('my-stack'); - - expect(mockSpawn).toHaveBeenCalledWith( - 'docker', - ['compose', 'config', '--images'], - expect.objectContaining({ cwd: expect.stringContaining('my-stack') }), - ); - }); - - it('filters out sha256 digest lines', async () => { - mockComposeConfig('nginx:1.14\nsha256:deadbeefcafebabe\nredis:7\n'); - - const images = await ComposeService.getInstance(1).listStackImages('my-stack'); - - expect(images).toEqual(['nginx:1.14', 'redis:7']); - }); - - it('handles trailing / leading whitespace and CRLF endings', async () => { - mockComposeConfig(' nginx:1.14 \r\n\r\n\tredis:7\r\n'); - - const images = await ComposeService.getInstance(1).listStackImages('my-stack'); - - expect(images).toEqual(['nginx:1.14', 'redis:7']); - }); - - it('returns an empty list when stdout is empty', async () => { - mockComposeConfig(''); - - const images = await ComposeService.getInstance(1).listStackImages('my-stack'); - - expect(images).toEqual([]); - }); - - it('rejects stack names that traverse outside the compose base', async () => { - await expect( - ComposeService.getInstance(1).listStackImages('../evil'), - ).rejects.toThrow(/Invalid stack path/); - expect(mockSpawn).not.toHaveBeenCalled(); - }); - - it('rejects when docker compose exits non-zero', async () => { - mockSpawn.mockImplementation(() => { - const proc = new EventEmitter() as EventEmitter & { - stdout: EventEmitter; - stderr: EventEmitter; - kill: ReturnType; - }; - proc.stdout = new EventEmitter(); - proc.stderr = new EventEmitter(); - proc.kill = vi.fn(); - Promise.resolve().then(() => { - proc.stderr.emit('data', Buffer.from('compose file missing')); - proc.emit('close', 1); - }); - return proc; - }); - - await expect( - ComposeService.getInstance(1).listStackImages('my-stack'), - ).rejects.toThrow(/compose file missing/); - }); - - it('preserves image-ref ordering for deterministic downstream scans', async () => { - mockComposeConfig('redis:7\npostgres:15\nnginx:1.14\n'); - - const images = await ComposeService.getInstance(1).listStackImages('my-stack'); - - expect(images).toEqual(['redis:7', 'postgres:15', 'nginx:1.14']); - }); -}); +/** + * Exercises ComposeService.listStackImages, the helper the policy gate calls + * to enumerate the images a stack will pull before `docker compose up`. + * + * The stdout from `docker compose config --images` can contain duplicates + * (multiple services running the same image), trailing whitespace, blank + * lines, and `sha256:` digest lines we must not pass to Trivy. The gate + * feeds this list directly to `scanImagePreflight`, so dedupe + filter + * correctness here directly affects what gets scanned and what silently + * passes through. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'events'; + +const { mockSpawn } = vi.hoisted(() => ({ mockSpawn: vi.fn() })); + +vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() })); + +vi.mock('../services/NodeRegistry', () => ({ + NodeRegistry: { + getInstance: () => ({ + getDefaultNodeId: () => 1, + getComposeDir: () => '/test/compose', + }), + }, +})); + +vi.mock('../services/DockerController', () => ({ + default: { + getInstance: () => ({ + getContainersByStack: vi.fn().mockResolvedValue([]), + removeContainers: vi.fn().mockResolvedValue([]), + getDocker: () => ({ + listContainers: vi.fn().mockResolvedValue([]), + }), + }), + }, +})); + +vi.mock('../services/DatabaseService', () => ({ + DatabaseService: { getInstance: () => ({ getRegistries: () => [] }) }, +})); + +vi.mock('../services/RegistryService', () => ({ + RegistryService: { + getInstance: () => ({ + resolveDockerConfig: vi.fn().mockResolvedValue({ config: { auths: {} }, warnings: [] }), + }), + }, +})); + +vi.mock('../services/FileSystemService', () => ({ + FileSystemService: { + getInstance: () => ({ + backupStackFiles: vi.fn().mockResolvedValue(undefined), + restoreStackFiles: vi.fn().mockResolvedValue(undefined), + }), + }, +})); + +vi.mock('../services/LogFormatter', () => ({ + LogFormatter: { formatLine: (line: string) => line }, +})); + +import { ComposeService } from '../services/ComposeService'; + +function mockComposeConfig(stdout: string, exitCode = 0): void { + mockSpawn.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = vi.fn(); + Promise.resolve().then(() => { + if (stdout) proc.stdout.emit('data', Buffer.from(stdout)); + proc.emit('close', exitCode); + }); + return proc; + }); +} + +describe('ComposeService.listStackImages', () => { + beforeEach(() => { + mockSpawn.mockReset(); + }); + + it('returns the list of images, trimmed and deduped', async () => { + mockComposeConfig('nginx:1.14\nredis:7\nnginx:1.14\n'); + + const images = await ComposeService.getInstance(1).listStackImages('my-stack'); + + expect(images).toEqual(['nginx:1.14', 'redis:7']); + }); + + it('invokes `docker compose config --images` in the stack directory', async () => { + mockComposeConfig('nginx:1.14\n'); + + await ComposeService.getInstance(1).listStackImages('my-stack'); + + expect(mockSpawn).toHaveBeenCalledWith( + 'docker', + ['compose', 'config', '--images'], + expect.objectContaining({ cwd: expect.stringContaining('my-stack') }), + ); + }); + + it('filters out sha256 digest lines', async () => { + mockComposeConfig('nginx:1.14\nsha256:deadbeefcafebabe\nredis:7\n'); + + const images = await ComposeService.getInstance(1).listStackImages('my-stack'); + + expect(images).toEqual(['nginx:1.14', 'redis:7']); + }); + + it('handles trailing / leading whitespace and CRLF endings', async () => { + mockComposeConfig(' nginx:1.14 \r\n\r\n\tredis:7\r\n'); + + const images = await ComposeService.getInstance(1).listStackImages('my-stack'); + + expect(images).toEqual(['nginx:1.14', 'redis:7']); + }); + + it('returns an empty list when stdout is empty', async () => { + mockComposeConfig(''); + + const images = await ComposeService.getInstance(1).listStackImages('my-stack'); + + expect(images).toEqual([]); + }); + + it('rejects stack names that traverse outside the compose base', async () => { + await expect( + ComposeService.getInstance(1).listStackImages('../evil'), + ).rejects.toThrow(/Invalid stack path/); + expect(mockSpawn).not.toHaveBeenCalled(); + }); + + it('rejects when docker compose exits non-zero', async () => { + mockSpawn.mockImplementation(() => { + const proc = new EventEmitter() as EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + kill: ReturnType; + }; + proc.stdout = new EventEmitter(); + proc.stderr = new EventEmitter(); + proc.kill = vi.fn(); + Promise.resolve().then(() => { + proc.stderr.emit('data', Buffer.from('compose file missing')); + proc.emit('close', 1); + }); + return proc; + }); + + await expect( + ComposeService.getInstance(1).listStackImages('my-stack'), + ).rejects.toThrow(/compose file missing/); + }); + + it('preserves image-ref ordering for deterministic downstream scans', async () => { + mockComposeConfig('redis:7\npostgres:15\nnginx:1.14\n'); + + const images = await ComposeService.getInstance(1).listStackImages('my-stack'); + + expect(images).toEqual(['redis:7', 'postgres:15', 'nginx:1.14']); + }); +}); diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index bcd8f5bf..98d607c1 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -31,7 +31,7 @@ const { mockRmdirSync: vi.fn(), })); -vi.mock('child_process', () => ({ spawn: mockSpawn })); +vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() })); vi.mock('fs', () => ({ default: { @@ -186,6 +186,20 @@ describe('ComposeService - runCommand', () => { await expect(promise).rejects.toThrow('service not found'); }); + it('redacts secrets from command failure errors', async () => { + const proc = createMockProcess(); + mockSpawn.mockReturnValue(proc); + + const svc = ComposeService.getInstance(1); + const promise = svc.runCommand('my-stack', 'stop'); + proc.stderr.emit('data', Buffer.from('token=abc123SECRET password=hunter2 Authorization: Bearer abc.def.ghi')); + proc.emit('close', 1); + + await expect(promise).rejects.toThrow('token=[redacted]'); + await expect(promise).rejects.toThrow('password=[redacted]'); + await expect(promise).rejects.not.toThrow('abc.def.ghi'); + }); + it('sends output to WebSocket when provided', async () => { const proc = createMockProcess(); mockSpawn.mockReturnValue(proc); diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index 9a3a68be..89273238 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -51,6 +51,7 @@ beforeEach(() => { // Wipe persisted git sources between tests const db = DatabaseService.getInstance(); for (const s of db.getGitSources()) db.deleteGitSource(s.stack_name); + for (const p of db.getScanPolicies()) db.deleteScanPolicy(p.id); }); // ── Helpers ──────────────────────────────────────────────────────────── @@ -852,4 +853,72 @@ describe('GitSourceService.apply', () => { validateSpy.mockRestore(); saveSpy.mockRestore(); }); + + it('returns deployError and skips compose deploy when policy blocks apply deploy', async () => { + const sha = 'dddd444dddd444dddd444dddd444dddd444dddd4'; + const svc = await seedPending('apply-policy-block', 'services:\n x:\n image: nginx:bad\n', sha); + const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); + const { FileSystemService } = await import('../services/FileSystemService'); + const { ComposeService } = await import('../services/ComposeService'); + const { LicenseService } = await import('../services/LicenseService'); + const TrivyService = (await import('../services/TrivyService')).default; + const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + const listImagesSpy = vi.spyOn(ComposeService.prototype, 'listStackImages').mockResolvedValue(['nginx:bad']); + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockResolvedValue(); + const tierSpy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + const trivy = TrivyService.getInstance(); + const trivyAvailableSpy = vi.spyOn(trivy, 'isTrivyAvailable').mockReturnValue(true); + const scanSpy = vi.spyOn(trivy, 'scanImagePreflight').mockResolvedValue({ + id: 77, + node_id: 1, + image_ref: 'nginx:bad', + image_digest: null, + scanned_at: Date.now(), + total_vulnerabilities: 1, + critical_count: 1, + high_count: 0, + medium_count: 0, + low_count: 0, + unknown_count: 0, + fixable_count: 0, + secret_count: 0, + misconfig_count: 0, + scanners_used: 'vuln', + highest_severity: 'CRITICAL', + os_info: null, + trivy_version: '0.50.0', + scan_duration_ms: null, + triggered_by: 'deploy-preflight', + status: 'completed', + error: null, + stack_context: 'apply-policy-block', + policy_evaluation: null, + }); + + DatabaseService.getInstance().createScanPolicy({ + name: 'block-high', + node_id: null, + node_identity: '', + stack_pattern: 'apply-policy-block', + max_severity: 'HIGH', + block_on_deploy: 1, + enabled: 1, + replicated_from_control: 0, + }); + + const result = await svc.apply('apply-policy-block', sha, { deploy: true }); + + expect(result.applied).toBe(true); + expect(result.deployed).toBe(false); + expect(result.deployError).toContain('Policy "block-high" blocked deploy'); + expect(deploySpy).not.toHaveBeenCalled(); + + validateSpy.mockRestore(); + saveSpy.mockRestore(); + listImagesSpy.mockRestore(); + deploySpy.mockRestore(); + tierSpy.mockRestore(); + trivyAvailableSpy.mockRestore(); + scanSpy.mockRestore(); + }); }); diff --git a/backend/src/__tests__/policy-enforcement.test.ts b/backend/src/__tests__/policy-enforcement.test.ts index 523cd151..763acdbf 100644 --- a/backend/src/__tests__/policy-enforcement.test.ts +++ b/backend/src/__tests__/policy-enforcement.test.ts @@ -150,6 +150,21 @@ describe('enforcePolicyPreDeploy', () => { expect(composeStub.listStackImages).not.toHaveBeenCalled(); }); + it('allows deploy without scanning when paid-tier blocking is disabled', async () => { + dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); + + const result = await enforcePolicyPreDeploy('web', 1, { + bypass: false, + actor: 'u', + blockingEnabled: false, + }); + + expect(result.ok).toBe(true); + expect(result.violations).toEqual([]); + expect(trivyStub.isTrivyAvailable).not.toHaveBeenCalled(); + expect(composeStub.listStackImages).not.toHaveBeenCalled(); + }); + it('fails open with a warning alert when Trivy is not installed', async () => { dbStub.getMatchingPolicy.mockReturnValue(mkPolicy()); trivyStub.isTrivyAvailable.mockReturnValue(false); diff --git a/backend/src/__tests__/safe-log.test.ts b/backend/src/__tests__/safe-log.test.ts new file mode 100644 index 00000000..179d72e0 --- /dev/null +++ b/backend/src/__tests__/safe-log.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, it } from 'vitest'; +import { redactSensitiveText } from '../utils/safeLog'; + +describe('redactSensitiveText', () => { + it('redacts credentials from durable log text', () => { + const text = redactSensitiveText( + 'connect https://user:pass@example.invalid failed Authorization: Bearer abc.def.ghi token=secret123 password=hunter2', + ); + + expect(text).toContain('https://[redacted]@example.invalid'); + expect(text).toContain('Authorization: [redacted]'); + expect(text).toContain('token=[redacted]'); + expect(text).toContain('password=[redacted]'); + expect(text).not.toContain('user:pass'); + expect(text).not.toContain('abc.def.ghi'); + expect(text).not.toContain('secret123'); + expect(text).not.toContain('hunter2'); + }); +}); diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 4e06f86b..f4edad3c 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -23,6 +23,7 @@ const { mockScanAllNodeImages, mockGetStackAutoUpdateSettingsForNode, mockDeleteScheduledTask, + mockGetMatchingPolicy, mockRunCommand, mockDeployStack, mockBackupStackFiles, @@ -63,6 +64,7 @@ const { }), mockGetStackAutoUpdateSettingsForNode: vi.fn().mockReturnValue({}), mockDeleteScheduledTask: vi.fn(), + mockGetMatchingPolicy: vi.fn().mockReturnValue(null), mockRunCommand: vi.fn().mockResolvedValue(undefined), mockDeployStack: vi.fn().mockResolvedValue(undefined), mockBackupStackFiles: vi.fn().mockResolvedValue(undefined), @@ -86,10 +88,17 @@ vi.mock('../services/DatabaseService', () => ({ deleteOldScans: mockDeleteOldScans, getStackAutoUpdateSettingsForNode: mockGetStackAutoUpdateSettingsForNode, deleteScheduledTask: mockDeleteScheduledTask, + getMatchingPolicy: mockGetMatchingPolicy, }), }, })); +vi.mock('../services/FleetSyncService', () => ({ + FleetSyncService: { + getSelfIdentity: () => 'self-node', + }, +})); + vi.mock('../services/LicenseService', () => ({ LicenseService: { getInstance: () => ({ diff --git a/backend/src/helpers/policyGate.ts b/backend/src/helpers/policyGate.ts index 8dae391a..cda3061e 100644 --- a/backend/src/helpers/policyGate.ts +++ b/backend/src/helpers/policyGate.ts @@ -4,6 +4,8 @@ import DockerController from '../services/DockerController'; import { DatabaseService } from '../services/DatabaseService'; import { NotificationService } from '../services/NotificationService'; import TrivyService, { DIGEST_CACHE_TTL_MS } from '../services/TrivyService'; +import { LicenseService } from '../services/LicenseService'; +import { effectiveTier } from '../middleware/tierGates'; import { getErrorMessage } from '../utils/errors'; import { sanitizeForLog } from '../utils/safeLog'; @@ -18,12 +20,37 @@ export function buildPolicyGateOptions( return { bypass: overrides.bypass ?? defaultBypass, actor: overrides.actor ?? req.user?.username ?? 'unknown', + blockingEnabled: effectiveTier(req) === 'paid', ip: (req.ip ?? req.socket.remoteAddress ?? '') as string, auditMethod: req.method, auditPath: req.originalUrl || req.url, }; } +export function buildSystemPolicyGateOptions( + actor: string, + overrides: { bypass?: boolean; blockingEnabled?: boolean; auditPath?: string; auditMethod?: string } = {}, +): PolicyEnforcementOptions { + return { + bypass: overrides.bypass ?? false, + actor, + blockingEnabled: overrides.blockingEnabled ?? LicenseService.getInstance().getTier() === 'paid', + auditMethod: overrides.auditMethod ?? 'POST', + auditPath: overrides.auditPath, + }; +} + +export async function assertPolicyGateAllows( + stackName: string, + nodeId: number, + options: PolicyEnforcementOptions, +): Promise { + const gate = await enforcePolicyPreDeploy(stackName, nodeId, options); + if (!gate.ok) { + throw new Error(`Policy "${gate.policy?.name}" blocked deploy: ${gate.violations.length} image(s) exceed ${gate.policy?.max_severity}`); + } +} + /** * Returns true if the deploy may proceed. Returns false after sending a 409, * in which case the caller must return immediately. diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index a8d073f9..e1bb703a 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -5,6 +5,8 @@ import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from '../services/license-hea import { LicenseService } from '../services/LicenseService'; import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; import { getErrorMessage } from '../utils/errors'; +import { DatabaseService } from '../services/DatabaseService'; +import { redactSensitiveText } from '../utils/safeLog'; /** * Build the remote-node HTTP proxy middleware. Mount once at `/api/` after @@ -74,8 +76,25 @@ export function createRemoteProxyMiddleware(): RequestHandler { // a bad token causes an immediate logout loop. proxyRes.headers['x-sencho-proxy'] = '1'; }, - error: (err, _req, proxyRes) => { + error: (err, req, proxyRes) => { console.error('[Proxy] Remote node error:', getErrorMessage(err, 'unknown')); + const path = req.originalUrl || req.url; + if (req.method === 'POST' && /^\/api\/stacks\/[^/]+\/(?:deploy|update)(?:\?|$)/.test(path)) { + try { + DatabaseService.getInstance().insertAuditLog({ + timestamp: Date.now(), + username: req.user?.username ?? 'unknown', + method: req.method, + path, + status_code: 502, + node_id: req.nodeId, + ip_address: req.ip ?? '', + summary: `remote deploy proxy error: ${redactSensitiveText(getErrorMessage(err, 'unknown'))}`, + }); + } catch (auditErr) { + console.warn('[Proxy] Failed to record remote deploy proxy error:', getErrorMessage(auditErr, 'unknown')); + } + } // proxyRes can be either a ServerResponse (HTTP) or a raw Socket // (WS/TCP errors). Only attempt to send an HTTP 502 if it is a // proper ServerResponse with a headersSent flag; otherwise silently diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index 6a535d63..66dcb869 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -16,6 +16,7 @@ import { fetchRemoteMeta, getSenchoVersion, isValidVersion } from '../services/C import { authMiddleware } from '../middleware/auth'; import { requirePaid, requireAdmin, requireNodeProxy } from '../middleware/tierGates'; import { scheduleLocalUpdate } from './license'; +import { runPolicyGate } from '../helpers/policyGate'; import { captureLocalNodeFiles, captureRemoteNodeFiles, type SnapshotNodeData } from '../utils/snapshot-capture'; import { getLatestVersion } from '../utils/version-check'; import { isValidStackName } from '../utils/validation'; @@ -1325,6 +1326,7 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request, } if (redeploy) { + if (!(await runPolicyGate(req, res, stackName, node.id))) return; const composeService = ComposeService.getInstance(node.id); await composeService.deployStack(stackName); } @@ -1335,9 +1337,12 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request, } const baseUrl = node.api_url.replace(/\/$/, ''); + const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); const headers: Record = { Authorization: `Bearer ${node.api_token}`, 'Content-Type': 'application/json', + [PROXY_TIER_HEADER]: proxyHeaders.tier, + [PROXY_VARIANT_HEADER]: proxyHeaders.variant ?? '', }; for (const file of files) { @@ -1361,11 +1366,12 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request, } if (redeploy) { - await fetch(`${baseUrl}/api/compose/${encodeURIComponent(stackName)}/up`, { + const deployRes = await fetch(`${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/deploy`, { method: 'POST', headers, signal: AbortSignal.timeout(30000), }); + if (!deployRes.ok) throw new Error('Failed to redeploy stack on remote node'); } } diff --git a/backend/src/routes/gitSources.ts b/backend/src/routes/gitSources.ts index 40a1dfe8..da62dd72 100644 --- a/backend/src/routes/gitSources.ts +++ b/backend/src/routes/gitSources.ts @@ -2,6 +2,7 @@ import { Router, type Request, type Response } from 'express'; import path from 'path'; import { GitSourceService } from '../services/GitSourceService'; import { FileSystemService } from '../services/FileSystemService'; +import { DatabaseService } from '../services/DatabaseService'; import { checkPermission, requirePermission } from '../middleware/permissions'; import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; import { triggerPostDeployScan } from '../helpers/policyGate'; @@ -199,10 +200,17 @@ stackGitSourceRouter.post('/:stackName/git-source/apply', async (req: Request, r res.status(400).json({ error: 'commitSha is required' }); return; } + const source = DatabaseService.getInstance().getGitSource(stackName); + const willDeploy = typeof deploy === 'boolean' ? deploy : source?.auto_deploy_on_apply === true; + if (willDeploy && !requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; const result = await GitSourceService.getInstance().apply( stackName, commitSha.trim(), - { deploy: typeof deploy === 'boolean' ? deploy : undefined }, + { + deploy: typeof deploy === 'boolean' ? deploy : undefined, + actor: req.user?.username ?? 'unknown', + bypassPolicy: req.query.ignorePolicy === 'true' && req.user?.role === 'admin', + }, ); invalidateNodeCaches(req.nodeId); const shortSha = commitSha.trim().slice(0, 7); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index fed8c4db..6fcf5bea 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -805,6 +805,7 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) => } console.log(`[Stacks] Rollback initiated: ${sanitizeForLog(stackName)}`); await fsSvc.restoreStackFiles(stackName); + if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return; await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), false); invalidateNodeCaches(req.nodeId); console.log(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`); diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts index 656e937f..2650e6dd 100644 --- a/backend/src/services/BlueprintService.ts +++ b/backend/src/services/BlueprintService.ts @@ -1,561 +1,568 @@ -import path from 'path'; -import { promises as fsPromises } from 'fs'; -import axios, { AxiosError } from 'axios'; -import { - DatabaseService, - type Blueprint, - type BlueprintDeployment, - type BlueprintDeploymentStatus, - type Node, -} from './DatabaseService'; -import { ComposeService } from './ComposeService'; -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): 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; - lastApplied: number; -} - -export interface DeployOutcome { - status: BlueprintDeploymentStatus; - error?: string; -} - -/** - * BlueprintService is the orchestration layer between the reconciler and the - * concrete deploy/withdraw primitives. It owns: - * - per-target marker-file management (writes, reads, validates ownership) - * - name-conflict guard (refuses to touch a stack directory missing the marker) - * - local deploy via ComposeService + FileSystemService - * - remote deploy via direct HTTP calls to the remote Sencho instance - * - per-(blueprint,node) concurrency lock so overlapping ticks don't collide - * - * The reconciler decides *what* needs to happen; this service performs it. - */ -export class BlueprintService { - private static instance: BlueprintService | null = null; - private readonly inflight = new Set(); - - static getInstance(): BlueprintService { - if (!BlueprintService.instance) { - BlueprintService.instance = new BlueprintService(); - } - return BlueprintService.instance; - } - - private constructor() { /* singleton */ } - - private lockKey(blueprintId: number, nodeId: number): string { - return `${blueprintId}:${nodeId}`; - } - - private acquireLock(blueprintId: number, nodeId: number): boolean { - const key = this.lockKey(blueprintId, nodeId); - if (this.inflight.has(key)) return false; - this.inflight.add(key); - return true; - } - - private releaseLock(blueprintId: number, nodeId: number): void { - this.inflight.delete(this.lockKey(blueprintId, nodeId)); - } - - private buildMarker(blueprint: Blueprint): BlueprintMarker { - return { - blueprintId: blueprint.id, - revision: blueprint.revision, - lastApplied: Date.now(), - }; - } - - private setStatus( - blueprintId: number, - nodeId: number, - status: BlueprintDeploymentStatus, - extras: Partial<{ - applied_revision: number | null; - last_deployed_at: number | null; - last_drift_at: number | null; - drift_summary: string | null; - last_error: string | null; - }> = {}, - ): BlueprintDeployment { - return DatabaseService.getInstance().upsertDeployment({ - blueprint_id: blueprintId, - node_id: nodeId, - status, - last_checked_at: Date.now(), - ...extras, - }); - } - - /** - * Read the marker file from a target node. Returns null when missing, - * malformed, or unreadable. The reconciler treats null as "we do not - * own this directory" and refuses to touch it. - */ - async readMarker(blueprintName: string, node: Node): Promise { - try { - if (node.type === 'local') { - const baseDir = NodeRegistry.getInstance().getComposeDir(node.id); - const markerPath = path.resolve(baseDir, blueprintName, MARKER_FILENAME); - if (!markerPath.startsWith(path.resolve(baseDir))) return null; - const content = await fsPromises.readFile(markerPath, 'utf-8'); - return BlueprintService.parseMarker(content); - } - const target = NodeRegistry.getInstance().getProxyTarget(node.id); - if (!target) return null; - const url = `${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(blueprintName)}/files/content?path=${encodeURIComponent(MARKER_FILENAME)}`; - const res = await axios.get(url, { - headers: this.remoteHeaders(target.apiToken), - timeout: REMOTE_HTTP_TIMEOUT_MS, - validateStatus: () => true, - }); - if (res.status !== 200) return null; - const body = res.data; - const content = typeof body === 'string' ? body : (typeof body?.content === 'string' ? body.content : null); - if (content == null) return null; - return BlueprintService.parseMarker(content); - } catch { - return null; - } - } - - /** - * 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 - * deploy in that case: there is a real user-authored stack with the - * same name and we must not overwrite it. - */ - async hasNameConflict(blueprintName: string, node: Node): Promise { - try { - if (node.type === 'local') { - const baseDir = NodeRegistry.getInstance().getComposeDir(node.id); - const stackDir = path.resolve(baseDir, blueprintName); - if (!stackDir.startsWith(path.resolve(baseDir))) return true; - try { - const stat = await fsPromises.stat(stackDir); - if (!stat.isDirectory()) return false; - } catch { - return false; // directory doesn't exist → no conflict - } - const markerPath = path.join(stackDir, MARKER_FILENAME); - try { - 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); - if (!target) return false; - const baseUrl = target.apiUrl.replace(/\/$/, ''); - const listUrl = `${baseUrl}/api/stacks`; - const listRes = await axios.get(listUrl, { - headers: this.remoteHeaders(target.apiToken), - timeout: REMOTE_HTTP_TIMEOUT_MS, - validateStatus: () => true, - }); - if (listRes.status !== 200) return false; - const stacks = Array.isArray(listRes.data) ? listRes.data as Array<{ name?: string }> : []; - const exists = stacks.some(s => s?.name === blueprintName); - if (!exists) return false; - const marker = await this.readMarker(blueprintName, node); - return marker == null; - } catch { - return false; - } - } - - /** - * Deploy this blueprint to the given target node. Caller must have already - * resolved that the target should receive this blueprint (selector match - * passed, no state-review pending, etc.). This method handles the - * name-conflict guard and the local/remote dispatch. - */ - async deployToNode(blueprint: Blueprint, node: Node): Promise { - 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', { - applied_revision: blueprint.revision, - last_deployed_at: Date.now(), - last_drift_at: null, - 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); - } - } - - /** - * Withdraw a blueprint from the target node: docker compose down, delete - * the directory. Caller must have already cleared the eviction guard - * (stateful blueprints require explicit operator confirmation). - */ - async withdrawFromNode(blueprint: Blueprint, node: Node): Promise { - 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 - const marker = await this.readMarker(blueprint.name, node); - 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' }; - } - 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); - } - } - - /** - * Inspect the actual state of a deployment on its node and report - * whether it has drifted from the desired state. The reconciler decides - * what to do with the result based on drift_mode. - */ - async checkForDrift(blueprint: Blueprint, node: Node): Promise<{ drifted: boolean; reason?: string }> { - try { - const marker = await this.readMarker(blueprint.name, node); - if (!marker) { - return { drifted: true, reason: 'marker file missing on node' }; - } - if (marker.blueprintId !== blueprint.id) { - return { drifted: true, reason: 'marker references a different blueprint' }; - } - if (marker.revision !== blueprint.revision) { - return { drifted: true, reason: `revision drift (node has ${marker.revision}, blueprint is ${blueprint.revision})` }; - } - // Check container state - const containerState = await this.containerHealth(blueprint.name, node); - if (!containerState.allRunning) { - return { drifted: true, reason: containerState.detail }; - } - return { drifted: false }; - } catch (err) { - return { drifted: true, reason: BlueprintService.formatError(err) }; - } - } - - private async containerHealth(blueprintName: string, node: Node): Promise<{ allRunning: boolean; detail: string }> { - try { - // Docker Compose normalizes the project name to lowercase. Match the same canonical form. - const projectName = blueprintName.toLowerCase(); - if (node.type === 'local') { - const docker = NodeRegistry.getInstance().getDocker(node.id); - const containers = await docker.listContainers({ - all: true, - filters: { label: [`com.docker.compose.project=${projectName}`] }, - }); - if (containers.length === 0) return { allRunning: false, detail: 'no containers running for this blueprint' }; - const notRunning = containers.filter(c => c.State !== 'running'); - if (notRunning.length > 0) { - const first = notRunning[0]; - return { allRunning: false, detail: `container "${first.Names[0] ?? first.Id.slice(0, 12)}" is ${first.State}` }; - } - return { allRunning: true, detail: '' }; - } - const target = NodeRegistry.getInstance().getProxyTarget(node.id); - if (!target) return { allRunning: false, detail: 'remote node not reachable (no proxy target)' }; - const url = `${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(blueprintName)}/containers`; - const res = await axios.get(url, { - headers: this.remoteHeaders(target.apiToken), - timeout: REMOTE_HTTP_TIMEOUT_MS, - validateStatus: () => true, - }); - if (res.status !== 200) { - return { allRunning: false, detail: `remote stack lookup returned HTTP ${res.status}` }; - } - const list = Array.isArray(res.data) ? res.data as Array<{ State?: string; Names?: string[]; Id?: string }> : []; - if (list.length === 0) return { allRunning: false, detail: 'remote stack has no containers' }; - const notRunning = list.filter(c => (c.State ?? '') !== 'running'); - if (notRunning.length > 0) { - const first = notRunning[0]; - return { allRunning: false, detail: `remote container "${first.Names?.[0] ?? first.Id?.slice(0, 12)}" is ${first.State}` }; - } - return { allRunning: true, detail: '' }; - } catch (err) { - return { allRunning: false, detail: BlueprintService.formatError(err) }; - } - } - - // ---- local primitives ---- - - private async stackDirExists(node: Node, blueprintName: string): Promise { - const baseDir = NodeRegistry.getInstance().getComposeDir(node.id); - const stackDir = path.resolve(baseDir, blueprintName); - if (!stackDir.startsWith(path.resolve(baseDir))) return false; - try { - const stat = await fsPromises.stat(stackDir); - return stat.isDirectory(); - } catch { - return false; - } - } - - private async deployLocal(blueprint: Blueprint, node: Node, marker: BlueprintMarker): Promise { - 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); - } - 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 { - try { - await ComposeService.getInstance(node.id).downStack(blueprint.name); - } catch (err) { - // best-effort: continue to delete the directory even if down fails - console.warn(`[BlueprintService] downStack failed for "${blueprint.name}" on node ${node.id}: ${BlueprintService.formatError(err)}`); - } - if (await this.stackDirExists(node, blueprint.name)) { - await FileSystemService.getInstance(node.id).deleteStack(blueprint.name); - } - } - - // ---- remote primitives ---- - - private remoteHeaders(apiToken: string): Record { - const proxy = LicenseService.getInstance().getProxyHeaders(); - return { - Authorization: `Bearer ${apiToken}`, - [PROXY_TIER_HEADER]: proxy.tier, - [PROXY_VARIANT_HEADER]: proxy.variant ?? '', - 'Content-Type': 'application/json', - }; - } - - private async deployRemote(blueprint: Blueprint, node: Node, marker: BlueprintMarker): Promise { - const target = NodeRegistry.getInstance().getProxyTarget(node.id); - if (!target) throw new Error(`Remote node "${node.name}" has no proxy target configured`); - const baseUrl = target.apiUrl.replace(/\/$/, ''); - const headers = this.remoteHeaders(target.apiToken); - - // 1. Ensure stack exists. POST returns 409 when already exists; we treat that as success. - const createRes = await axios.post(`${baseUrl}/api/stacks`, - { stackName: blueprint.name }, - { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, - ); - if (createRes.status >= 400 && createRes.status !== 409) { - throw new Error(`create stack: HTTP ${createRes.status} ${BlueprintService.extractApiError(createRes.data)}`); - } - - // 2. Write the compose file - await this.remotePutFile(baseUrl, headers, blueprint.name, COMPOSE_FILENAME, blueprint.compose_content); - - // 3. Write the marker (last so a partial failure leaves us in name_conflict-recoverable state) - await this.remotePutFile(baseUrl, headers, blueprint.name, MARKER_FILENAME, JSON.stringify(marker, null, 2)); - - // 4. Deploy - const deployRes = await axios.post( - `${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}/deploy`, - {}, - { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, - ); - if (deployRes.status >= 400) { - throw new Error(`deploy: HTTP ${deployRes.status} ${BlueprintService.extractApiError(deployRes.data)}`); - } - } - - private async withdrawRemote(blueprint: Blueprint, node: Node): Promise { - const target = NodeRegistry.getInstance().getProxyTarget(node.id); - if (!target) throw new Error(`Remote node "${node.name}" has no proxy target configured`); - const baseUrl = target.apiUrl.replace(/\/$/, ''); - const headers = this.remoteHeaders(target.apiToken); - - // down (best-effort) - try { - await axios.post( - `${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}/down`, - {}, - { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, - ); - } catch (err) { - console.warn(`[BlueprintService] remote down failed for "${blueprint.name}" on node ${node.id}: ${BlueprintService.formatError(err)}`); - } - - // delete the stack directory entirely - const delRes = await axios.delete( - `${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}`, - { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, - ); - if (delRes.status >= 400 && delRes.status !== 404) { - throw new Error(`remote delete: HTTP ${delRes.status} ${BlueprintService.extractApiError(delRes.data)}`); - } - } - - private async remotePutFile( - baseUrl: string, - headers: Record, - stackName: string, - relPath: string, - content: string, - ): Promise { - const url = `${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/files/content?path=${encodeURIComponent(relPath)}`; - const res = await axios.put(url, - { content }, - { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, - ); - if (res.status >= 400) { - throw new Error(`PUT ${relPath}: HTTP ${res.status} ${BlueprintService.extractApiError(res.data)}`); - } - } - - static parseMarker(content: string): BlueprintMarker | null { - try { - const parsed = JSON.parse(content); - if (parsed && typeof parsed === 'object' - && typeof parsed.blueprintId === 'number' - && typeof parsed.revision === 'number') { - return { - blueprintId: parsed.blueprintId, - revision: parsed.revision, - lastApplied: typeof parsed.lastApplied === 'number' ? parsed.lastApplied : 0, - }; - } - } catch { - // fall through - } - return null; - } - - static formatError(err: unknown): string { - if (axios.isAxiosError(err)) { - const ax = err as AxiosError<{ error?: string; message?: string }>; - if (ax.response?.data) { - const body = ax.response.data; - if (body && typeof body === 'object') { - if (typeof body.error === 'string') return body.error; - if (typeof body.message === 'string') return body.message; - } - } - if (ax.code) return `${ax.code}: ${ax.message}`; - return ax.message; - } - if (err instanceof Error) return err.message; - return String(err); - } - - static extractApiError(body: unknown): string { - if (!body || typeof body !== 'object') return ''; - const obj = body as Record; - if (typeof obj.error === 'string') return obj.error; - if (typeof obj.message === 'string') return obj.message; - return ''; - } -} +import path from 'path'; +import { promises as fsPromises } from 'fs'; +import axios, { AxiosError } from 'axios'; +import { + DatabaseService, + type Blueprint, + type BlueprintDeployment, + type BlueprintDeploymentStatus, + type Node, +} from './DatabaseService'; +import { ComposeService } from './ComposeService'; +import { FileSystemService } from './FileSystemService'; +import { NodeRegistry } from './NodeRegistry'; +import { PROXY_TIER_HEADER, PROXY_VARIANT_HEADER } from './license-headers'; +import { LicenseService } from './LicenseService'; +import { assertPolicyGateAllows, buildSystemPolicyGateOptions, triggerPostDeployScan } from '../helpers/policyGate'; +import { enforcePolicyForImageRefs } from './PolicyEnforcement'; +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): 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; + lastApplied: number; +} + +export interface DeployOutcome { + status: BlueprintDeploymentStatus; + error?: string; +} + +/** + * BlueprintService is the orchestration layer between the reconciler and the + * concrete deploy/withdraw primitives. It owns: + * - per-target marker-file management (writes, reads, validates ownership) + * - name-conflict guard (refuses to touch a stack directory missing the marker) + * - local deploy via ComposeService + FileSystemService + * - remote deploy via direct HTTP calls to the remote Sencho instance + * - per-(blueprint,node) concurrency lock so overlapping ticks don't collide + * + * The reconciler decides *what* needs to happen; this service performs it. + */ +export class BlueprintService { + private static instance: BlueprintService | null = null; + private readonly inflight = new Set(); + + static getInstance(): BlueprintService { + if (!BlueprintService.instance) { + BlueprintService.instance = new BlueprintService(); + } + return BlueprintService.instance; + } + + private constructor() { /* singleton */ } + + private lockKey(blueprintId: number, nodeId: number): string { + return `${blueprintId}:${nodeId}`; + } + + private acquireLock(blueprintId: number, nodeId: number): boolean { + const key = this.lockKey(blueprintId, nodeId); + if (this.inflight.has(key)) return false; + this.inflight.add(key); + return true; + } + + private releaseLock(blueprintId: number, nodeId: number): void { + this.inflight.delete(this.lockKey(blueprintId, nodeId)); + } + + private buildMarker(blueprint: Blueprint): BlueprintMarker { + return { + blueprintId: blueprint.id, + revision: blueprint.revision, + lastApplied: Date.now(), + }; + } + + private setStatus( + blueprintId: number, + nodeId: number, + status: BlueprintDeploymentStatus, + extras: Partial<{ + applied_revision: number | null; + last_deployed_at: number | null; + last_drift_at: number | null; + drift_summary: string | null; + last_error: string | null; + }> = {}, + ): BlueprintDeployment { + return DatabaseService.getInstance().upsertDeployment({ + blueprint_id: blueprintId, + node_id: nodeId, + status, + last_checked_at: Date.now(), + ...extras, + }); + } + + /** + * Read the marker file from a target node. Returns null when missing, + * malformed, or unreadable. The reconciler treats null as "we do not + * own this directory" and refuses to touch it. + */ + async readMarker(blueprintName: string, node: Node): Promise { + try { + if (node.type === 'local') { + const baseDir = NodeRegistry.getInstance().getComposeDir(node.id); + const markerPath = path.resolve(baseDir, blueprintName, MARKER_FILENAME); + if (!markerPath.startsWith(path.resolve(baseDir))) return null; + const content = await fsPromises.readFile(markerPath, 'utf-8'); + return BlueprintService.parseMarker(content); + } + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) return null; + const url = `${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(blueprintName)}/files/content?path=${encodeURIComponent(MARKER_FILENAME)}`; + const res = await axios.get(url, { + headers: this.remoteHeaders(target.apiToken), + timeout: REMOTE_HTTP_TIMEOUT_MS, + validateStatus: () => true, + }); + if (res.status !== 200) return null; + const body = res.data; + const content = typeof body === 'string' ? body : (typeof body?.content === 'string' ? body.content : null); + if (content == null) return null; + return BlueprintService.parseMarker(content); + } catch { + return null; + } + } + + /** + * 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 + * deploy in that case: there is a real user-authored stack with the + * same name and we must not overwrite it. + */ + async hasNameConflict(blueprintName: string, node: Node): Promise { + try { + if (node.type === 'local') { + const baseDir = NodeRegistry.getInstance().getComposeDir(node.id); + const stackDir = path.resolve(baseDir, blueprintName); + if (!stackDir.startsWith(path.resolve(baseDir))) return true; + try { + const stat = await fsPromises.stat(stackDir); + if (!stat.isDirectory()) return false; + } catch { + return false; // directory doesn't exist → no conflict + } + const markerPath = path.join(stackDir, MARKER_FILENAME); + try { + 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); + if (!target) return false; + const baseUrl = target.apiUrl.replace(/\/$/, ''); + const listUrl = `${baseUrl}/api/stacks`; + const listRes = await axios.get(listUrl, { + headers: this.remoteHeaders(target.apiToken), + timeout: REMOTE_HTTP_TIMEOUT_MS, + validateStatus: () => true, + }); + if (listRes.status !== 200) return false; + const stacks = Array.isArray(listRes.data) ? listRes.data as Array<{ name?: string }> : []; + const exists = stacks.some(s => s?.name === blueprintName); + if (!exists) return false; + const marker = await this.readMarker(blueprintName, node); + return marker == null; + } catch { + return false; + } + } + + /** + * Deploy this blueprint to the given target node. Caller must have already + * resolved that the target should receive this blueprint (selector match + * passed, no state-review pending, etc.). This method handles the + * name-conflict guard and the local/remote dispatch. + */ + async deployToNode(blueprint: Blueprint, node: Node): Promise { + 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', { + applied_revision: blueprint.revision, + last_deployed_at: Date.now(), + last_drift_at: null, + 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); + } + } + + /** + * Withdraw a blueprint from the target node: docker compose down, delete + * the directory. Caller must have already cleared the eviction guard + * (stateful blueprints require explicit operator confirmation). + */ + async withdrawFromNode(blueprint: Blueprint, node: Node): Promise { + 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 + const marker = await this.readMarker(blueprint.name, node); + 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' }; + } + 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); + } + } + + /** + * Inspect the actual state of a deployment on its node and report + * whether it has drifted from the desired state. The reconciler decides + * what to do with the result based on drift_mode. + */ + async checkForDrift(blueprint: Blueprint, node: Node): Promise<{ drifted: boolean; reason?: string }> { + try { + const marker = await this.readMarker(blueprint.name, node); + if (!marker) { + return { drifted: true, reason: 'marker file missing on node' }; + } + if (marker.blueprintId !== blueprint.id) { + return { drifted: true, reason: 'marker references a different blueprint' }; + } + if (marker.revision !== blueprint.revision) { + return { drifted: true, reason: `revision drift (node has ${marker.revision}, blueprint is ${blueprint.revision})` }; + } + // Check container state + const containerState = await this.containerHealth(blueprint.name, node); + if (!containerState.allRunning) { + return { drifted: true, reason: containerState.detail }; + } + return { drifted: false }; + } catch (err) { + return { drifted: true, reason: BlueprintService.formatError(err) }; + } + } + + private async containerHealth(blueprintName: string, node: Node): Promise<{ allRunning: boolean; detail: string }> { + try { + // Docker Compose normalizes the project name to lowercase. Match the same canonical form. + const projectName = blueprintName.toLowerCase(); + if (node.type === 'local') { + const docker = NodeRegistry.getInstance().getDocker(node.id); + const containers = await docker.listContainers({ + all: true, + filters: { label: [`com.docker.compose.project=${projectName}`] }, + }); + if (containers.length === 0) return { allRunning: false, detail: 'no containers running for this blueprint' }; + const notRunning = containers.filter(c => c.State !== 'running'); + if (notRunning.length > 0) { + const first = notRunning[0]; + return { allRunning: false, detail: `container "${first.Names[0] ?? first.Id.slice(0, 12)}" is ${first.State}` }; + } + return { allRunning: true, detail: '' }; + } + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) return { allRunning: false, detail: 'remote node not reachable (no proxy target)' }; + const url = `${target.apiUrl.replace(/\/$/, '')}/api/stacks/${encodeURIComponent(blueprintName)}/containers`; + const res = await axios.get(url, { + headers: this.remoteHeaders(target.apiToken), + timeout: REMOTE_HTTP_TIMEOUT_MS, + validateStatus: () => true, + }); + if (res.status !== 200) { + return { allRunning: false, detail: `remote stack lookup returned HTTP ${res.status}` }; + } + const list = Array.isArray(res.data) ? res.data as Array<{ State?: string; Names?: string[]; Id?: string }> : []; + if (list.length === 0) return { allRunning: false, detail: 'remote stack has no containers' }; + const notRunning = list.filter(c => (c.State ?? '') !== 'running'); + if (notRunning.length > 0) { + const first = notRunning[0]; + return { allRunning: false, detail: `remote container "${first.Names?.[0] ?? first.Id?.slice(0, 12)}" is ${first.State}` }; + } + return { allRunning: true, detail: '' }; + } catch (err) { + return { allRunning: false, detail: BlueprintService.formatError(err) }; + } + } + + // ---- local primitives ---- + + private async stackDirExists(node: Node, blueprintName: string): Promise { + const baseDir = NodeRegistry.getInstance().getComposeDir(node.id); + const stackDir = path.resolve(baseDir, blueprintName); + if (!stackDir.startsWith(path.resolve(baseDir))) return false; + try { + const stat = await fsPromises.stat(stackDir); + return stat.isDirectory(); + } catch { + return false; + } + } + + private async deployLocal(blueprint: Blueprint, node: Node, marker: BlueprintMarker): Promise { + 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); + } + await fs.writeStackFile(blueprint.name, COMPOSE_FILENAME, blueprint.compose_content); + await fs.writeStackFile(blueprint.name, MARKER_FILENAME, JSON.stringify(marker, null, 2)); + await assertPolicyGateAllows( + blueprint.name, + node.id, + buildSystemPolicyGateOptions('blueprint', { + auditPath: `/api/blueprints/${blueprint.id}/deployments/${node.id}`, + }), + ); + 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 { + try { + await ComposeService.getInstance(node.id).downStack(blueprint.name); + } catch (err) { + // best-effort: continue to delete the directory even if down fails + console.warn(`[BlueprintService] downStack failed for "${blueprint.name}" on node ${node.id}: ${BlueprintService.formatError(err)}`); + } + if (await this.stackDirExists(node, blueprint.name)) { + await FileSystemService.getInstance(node.id).deleteStack(blueprint.name); + } + } + + // ---- remote primitives ---- + + private remoteHeaders(apiToken: string): Record { + const proxy = LicenseService.getInstance().getProxyHeaders(); + return { + Authorization: `Bearer ${apiToken}`, + [PROXY_TIER_HEADER]: proxy.tier, + [PROXY_VARIANT_HEADER]: proxy.variant ?? '', + 'Content-Type': 'application/json', + }; + } + + private async deployRemote(blueprint: Blueprint, node: Node, marker: BlueprintMarker): Promise { + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) throw new Error(`Remote node "${node.name}" has no proxy target configured`); + const baseUrl = target.apiUrl.replace(/\/$/, ''); + const headers = this.remoteHeaders(target.apiToken); + + // 1. Ensure stack exists. POST returns 409 when already exists; we treat that as success. + const createRes = await axios.post(`${baseUrl}/api/stacks`, + { stackName: blueprint.name }, + { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, + ); + if (createRes.status >= 400 && createRes.status !== 409) { + throw new Error(`create stack: HTTP ${createRes.status} ${BlueprintService.extractApiError(createRes.data)}`); + } + + // 2. Write the compose file + await this.remotePutFile(baseUrl, headers, blueprint.name, COMPOSE_FILENAME, blueprint.compose_content); + + // 3. Write the marker (last so a partial failure leaves us in name_conflict-recoverable state) + await this.remotePutFile(baseUrl, headers, blueprint.name, MARKER_FILENAME, JSON.stringify(marker, null, 2)); + + // 4. Deploy + const deployRes = await axios.post( + `${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}/deploy`, + {}, + { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, + ); + if (deployRes.status >= 400) { + throw new Error(`deploy: HTTP ${deployRes.status} ${BlueprintService.extractApiError(deployRes.data)}`); + } + } + + private async withdrawRemote(blueprint: Blueprint, node: Node): Promise { + const target = NodeRegistry.getInstance().getProxyTarget(node.id); + if (!target) throw new Error(`Remote node "${node.name}" has no proxy target configured`); + const baseUrl = target.apiUrl.replace(/\/$/, ''); + const headers = this.remoteHeaders(target.apiToken); + + // down (best-effort) + try { + await axios.post( + `${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}/down`, + {}, + { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, + ); + } catch (err) { + console.warn(`[BlueprintService] remote down failed for "${blueprint.name}" on node ${node.id}: ${BlueprintService.formatError(err)}`); + } + + // delete the stack directory entirely + const delRes = await axios.delete( + `${baseUrl}/api/stacks/${encodeURIComponent(blueprint.name)}`, + { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, + ); + if (delRes.status >= 400 && delRes.status !== 404) { + throw new Error(`remote delete: HTTP ${delRes.status} ${BlueprintService.extractApiError(delRes.data)}`); + } + } + + private async remotePutFile( + baseUrl: string, + headers: Record, + stackName: string, + relPath: string, + content: string, + ): Promise { + const url = `${baseUrl}/api/stacks/${encodeURIComponent(stackName)}/files/content?path=${encodeURIComponent(relPath)}`; + const res = await axios.put(url, + { content }, + { headers, timeout: REMOTE_HTTP_TIMEOUT_MS, validateStatus: () => true }, + ); + if (res.status >= 400) { + throw new Error(`PUT ${relPath}: HTTP ${res.status} ${BlueprintService.extractApiError(res.data)}`); + } + } + + static parseMarker(content: string): BlueprintMarker | null { + try { + const parsed = JSON.parse(content); + if (parsed && typeof parsed === 'object' + && typeof parsed.blueprintId === 'number' + && typeof parsed.revision === 'number') { + return { + blueprintId: parsed.blueprintId, + revision: parsed.revision, + lastApplied: typeof parsed.lastApplied === 'number' ? parsed.lastApplied : 0, + }; + } + } catch { + // fall through + } + return null; + } + + static formatError(err: unknown): string { + if (axios.isAxiosError(err)) { + const ax = err as AxiosError<{ error?: string; message?: string }>; + if (ax.response?.data) { + const body = ax.response.data; + if (body && typeof body === 'object') { + if (typeof body.error === 'string') return body.error; + if (typeof body.message === 'string') return body.message; + } + } + if (ax.code) return `${ax.code}: ${ax.message}`; + return ax.message; + } + if (err instanceof Error) return err.message; + return String(err); + } + + static extractApiError(body: unknown): string { + if (!body || typeof body !== 'object') return ''; + const obj = body as Record; + if (typeof obj.error === 'string') return obj.error; + if (typeof obj.message === 'string') return obj.message; + return ''; + } +} diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 0c14f0f5..8a32653f 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -14,7 +14,7 @@ import { RegistryService } from './RegistryService'; import { isDebugEnabled } from '../utils/debug'; import { getErrorMessage } from '../utils/errors'; import { isPathWithinBase, isValidStackName } from '../utils/validation'; -import { sanitizeForLog } from '../utils/safeLog'; +import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog'; export class ComposeRollbackError extends Error { public readonly rollbackAttempted: boolean; @@ -115,15 +115,15 @@ export class ComposeService { ws.send(`Command exited with code ${code}\n`); } if (code === 0) resolve(); - else if (throwOnError) reject(new Error(errorLog.trim() || `Command failed with code ${code}`)); + else if (throwOnError) reject(new Error(redactSensitiveText(errorLog.trim()) || `Command failed with code ${code}`)); else resolve(); }); child.on('error', (error: Error) => { if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(`Error: ${error.message}\n`); + ws.send(`Error: ${redactSensitiveText(error.message)}\n`); } - if (throwOnError) reject(error); + if (throwOnError) reject(new Error(redactSensitiveText(error.message))); else resolve(); }); }); diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index 5649dd85..a41bb51b 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -8,6 +8,8 @@ import { CryptoService } from './CryptoService'; import { DatabaseService, type StackGitSource, type GitSourceAuthType } from './DatabaseService'; import { FileSystemService } from './FileSystemService'; import { ComposeService } from './ComposeService'; +import { NodeRegistry } from './NodeRegistry'; +import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate'; import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; @@ -751,7 +753,7 @@ export class GitSourceService { public async apply( stackName: string, commitSha: string, - opts: { deploy?: boolean } = {}, + opts: { deploy?: boolean; actor?: string; bypassPolicy?: boolean } = {}, ): Promise<{ applied: boolean; deployed: boolean; deployError?: string }> { return this.withStackLock(stackName, async () => { const diag = isDebugEnabled(); @@ -795,6 +797,15 @@ export class GitSourceService { if (shouldDeploy) { try { + const nodeId = NodeRegistry.getInstance().getDefaultNodeId(); + await assertPolicyGateAllows( + stackName, + nodeId, + buildSystemPolicyGateOptions(opts.actor ?? 'git-source', { + bypass: opts.bypassPolicy === true, + auditPath: `/api/stacks/${stackName}/git-source/apply`, + }), + ); await ComposeService.getInstance().deployStack(stackName); console.log(`[GitSource] Applied and deployed ${stackName} at ${commitSha.slice(0, 7)}`); return { applied: true, deployed: true }; diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index 4f9734e1..b57ffb54 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -16,6 +16,7 @@ import { generateOverrideYaml, MeshAlias, SENCHO_MESH_NETWORK } from './MeshComp import { sanitizeForLog } from '../utils/safeLog'; import { isPathWithinBase, isValidStackName } from '../utils/validation'; import { PORT as SENCHO_LISTEN_PORT } from '../helpers/constants'; +import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate'; const ACTIVITY_BUFFER_SIZE = 1000; const ALIAS_REFRESH_INTERVAL_MS = 60_000; @@ -1188,6 +1189,13 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { if (!node) throw new Error(`unknown node ${nodeId}`); if (node.type !== 'remote') { + await assertPolicyGateAllows( + stackName, + nodeId, + buildSystemPolicyGateOptions(actor, { + auditPath: `/api/mesh/nodes/${nodeId}/stacks/${stackName}/redeploy`, + }), + ); await ComposeService.getInstance(nodeId).deployStack(stackName); this.logActivity({ source: 'mesh', level: 'info', type: 'mesh.enable', diff --git a/backend/src/services/PolicyEnforcement.ts b/backend/src/services/PolicyEnforcement.ts index 1ab1152d..6640be6e 100644 --- a/backend/src/services/PolicyEnforcement.ts +++ b/backend/src/services/PolicyEnforcement.ts @@ -1,182 +1,191 @@ -/** - * Pre-deploy policy gate. - * - * Extracted from `index.ts` so route handlers and the scheduler can call a - * single, unit-testable function rather than copy-paste the gate logic. - * - * The gate fails open when Trivy is missing (users are never locked out by - * tooling state) and fails closed when the compose file cannot be parsed - * (a broken stack must not silently bypass a block policy). - */ -import { ComposeService } from './ComposeService'; -import { DatabaseService } from './DatabaseService'; -import type { ScanPolicy, VulnSeverity } from './DatabaseService'; -import { FleetSyncService } from './FleetSyncService'; -import { NotificationService } from './NotificationService'; -import { sanitizeForLog } from '../utils/safeLog'; -import TrivyService from './TrivyService'; -import { isSeverityAtLeast } from '../utils/severity'; -import { validateImageRef } from '../utils/image-ref'; -import { getErrorMessage } from '../utils/errors'; - -export interface PolicyViolation { - imageRef: string; - severity: VulnSeverity; - criticalCount: number; - highCount: number; - scanId: number; -} - -export interface PolicyEnforcementOptions { - bypass: boolean; - actor: string; - ip?: string; - /** HTTP method of the originating request; used for audit attribution. */ - auditMethod?: string; - /** Request path of the originating route; used for audit attribution. */ - auditPath?: string; -} - -export interface PolicyEnforcementResult { - ok: boolean; - bypassed: boolean; - policy?: ScanPolicy; - violations: PolicyViolation[]; - trivyMissing?: boolean; -} - -export async function enforcePolicyPreDeploy( - stackName: string, - nodeId: number, - opts: PolicyEnforcementOptions, -): Promise { - const db = DatabaseService.getInstance(); - const policy = 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 }; - } - - let imageRefs: string[] = []; - try { - imageRefs = await ComposeService.getInstance(nodeId).listStackImages(stackName); - } catch (err) { - const message = getErrorMessage(err, 'compose parse failed'); - console.error('[Policy] listStackImages failed for %s:', sanitizeForLog(stackName), sanitizeForLog(message)); - return { - ok: false, - bypassed: false, - policy, - violations: [{ - imageRef: '(compose parse error)', - severity: 'UNKNOWN', - criticalCount: 0, - highCount: 0, - scanId: 0, - }], - }; - } - - return enforcePolicyForImageRefs(stackName, nodeId, imageRefs, opts, policy); -} - -export async function enforcePolicyForImageRefs( - stackName: string, - nodeId: number, - imageRefs: string[], - opts: PolicyEnforcementOptions, - matchedPolicy?: ScanPolicy, - failClosedInvalidRefs = false, -): Promise { - 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)) { - 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'; - if (isSeverityAtLeast(severity, policy.max_severity)) { - violations.push({ - imageRef, - severity, - criticalCount: scan.critical_count, - highCount: scan.high_count, - scanId: scan.id, - }); - } - } catch (err) { - const message = getErrorMessage(err, 'pre-flight scan failed'); - console.error(`[Policy] scanImagePreflight failed for ${imageRef}:`, message); - violations.push({ - imageRef, - severity: 'UNKNOWN', - criticalCount: 0, - highCount: 0, - scanId: 0, - }); - } - } - - if (violations.length === 0) { - return { ok: true, bypassed: false, policy, violations: [] }; - } - - if (opts.bypass) { - try { - db.insertAuditLog({ - timestamp: Date.now(), - username: opts.actor, - method: opts.auditMethod ?? 'POST', - path: opts.auditPath ?? `/api/stacks/${stackName}/deploy`, - status_code: 200, - node_id: nodeId, - ip_address: opts.ip ?? '', - summary: `policy.bypass stack="${stackName}" policy="${policy.name}" violations=${violations.length} images=[${violations.map((v) => v.imageRef).join(',')}]`, - }); - } catch (err) { - console.error('[Policy] Failed to record bypass audit entry:', err); - } - return { ok: true, bypassed: true, policy, violations }; - } - - return { ok: false, bypassed: false, policy, violations }; -} +/** + * Pre-deploy policy gate. + * + * Extracted from `index.ts` so route handlers and the scheduler can call a + * single, unit-testable function rather than copy-paste the gate logic. + * + * The gate fails open when Trivy is missing (users are never locked out by + * tooling state) and fails closed when the compose file cannot be parsed + * (a broken stack must not silently bypass a block policy). + */ +import { ComposeService } from './ComposeService'; +import { DatabaseService } from './DatabaseService'; +import type { ScanPolicy, VulnSeverity } from './DatabaseService'; +import { FleetSyncService } from './FleetSyncService'; +import { NotificationService } from './NotificationService'; +import { sanitizeForLog } from '../utils/safeLog'; +import TrivyService from './TrivyService'; +import { isSeverityAtLeast } from '../utils/severity'; +import { validateImageRef } from '../utils/image-ref'; +import { getErrorMessage } from '../utils/errors'; + +export interface PolicyViolation { + imageRef: string; + severity: VulnSeverity; + criticalCount: number; + highCount: number; + scanId: number; +} + +export interface PolicyEnforcementOptions { + bypass: boolean; + actor: string; + /** + * Paid-tier deploy enforcement switch. Community keeps policies as + * evaluation-only and must not block compose starts. + */ + blockingEnabled?: boolean; + ip?: string; + /** HTTP method of the originating request; used for audit attribution. */ + auditMethod?: string; + /** Request path of the originating route; used for audit attribution. */ + auditPath?: string; +} + +export interface PolicyEnforcementResult { + ok: boolean; + bypassed: boolean; + policy?: ScanPolicy; + violations: PolicyViolation[]; + trivyMissing?: boolean; +} + +export async function enforcePolicyPreDeploy( + stackName: string, + nodeId: number, + opts: PolicyEnforcementOptions, +): Promise { + const db = DatabaseService.getInstance(); + const policy = db.getMatchingPolicy(nodeId, stackName, FleetSyncService.getSelfIdentity()); + + if (!policy || !policy.enabled || !policy.block_on_deploy) { + return { ok: true, bypassed: false, policy: policy ?? undefined, violations: [] }; + } + + if (opts.blockingEnabled === false) { + return { ok: true, bypassed: false, policy, 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 }; + } + + let imageRefs: string[] = []; + try { + imageRefs = await ComposeService.getInstance(nodeId).listStackImages(stackName); + } catch (err) { + const message = getErrorMessage(err, 'compose parse failed'); + console.error('[Policy] listStackImages failed for %s:', sanitizeForLog(stackName), sanitizeForLog(message)); + return { + ok: false, + bypassed: false, + policy, + violations: [{ + imageRef: '(compose parse error)', + severity: 'UNKNOWN', + criticalCount: 0, + highCount: 0, + scanId: 0, + }], + }; + } + + return enforcePolicyForImageRefs(stackName, nodeId, imageRefs, opts, policy); +} + +export async function enforcePolicyForImageRefs( + stackName: string, + nodeId: number, + imageRefs: string[], + opts: PolicyEnforcementOptions, + matchedPolicy?: ScanPolicy, + failClosedInvalidRefs = false, +): Promise { + 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)) { + 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'; + if (isSeverityAtLeast(severity, policy.max_severity)) { + violations.push({ + imageRef, + severity, + criticalCount: scan.critical_count, + highCount: scan.high_count, + scanId: scan.id, + }); + } + } catch (err) { + const message = getErrorMessage(err, 'pre-flight scan failed'); + console.error(`[Policy] scanImagePreflight failed for ${imageRef}:`, message); + violations.push({ + imageRef, + severity: 'UNKNOWN', + criticalCount: 0, + highCount: 0, + scanId: 0, + }); + } + } + + if (violations.length === 0) { + return { ok: true, bypassed: false, policy, violations: [] }; + } + + if (opts.bypass) { + try { + db.insertAuditLog({ + timestamp: Date.now(), + username: opts.actor, + method: opts.auditMethod ?? 'POST', + path: opts.auditPath ?? `/api/stacks/${stackName}/deploy`, + status_code: 200, + node_id: nodeId, + ip_address: opts.ip ?? '', + summary: `policy.bypass stack="${stackName}" policy="${policy.name}" violations=${violations.length} images=[${violations.map((v) => v.imageRef).join(',')}]`, + }); + } catch (err) { + console.error('[Policy] Failed to record bypass audit entry:', err); + } + return { ok: true, bypassed: true, policy, violations }; + } + + return { ok: false, bypassed: false, policy, violations }; +} diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 2d190887..02ecb4cb 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -18,6 +18,7 @@ import TrivyService from './TrivyService'; import type { ScanAllNodeImagesResult } from './TrivyService'; import TrivyInstaller from './TrivyInstaller'; import { CloudBackupService } from './CloudBackupService'; +import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate'; const TRIVY_UPDATE_CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; const TRIVY_UPDATE_CHECK_STARTUP_DELAY_MS = 5 * 60 * 1000; @@ -441,6 +442,13 @@ export class SchedulerService { private async executeAutoStart(task: ScheduledTask): Promise { this.assertStackTarget(task, 'Auto-start'); + await assertPolicyGateAllows( + task.target_id, + task.node_id, + buildSystemPolicyGateOptions('scheduler:auto-start', { + auditPath: `/api/scheduled-tasks/${task.id}/run`, + }), + ); await ComposeService.getInstance(task.node_id).deployStack(task.target_id); return `Started stack "${task.target_id}"`; } @@ -717,6 +725,13 @@ export class SchedulerService { return `Stack "${stackName}": all images up to date.`; } + await assertPolicyGateAllows( + stackName, + nodeId, + buildSystemPolicyGateOptions('scheduler:auto-update', { + auditPath: `/api/scheduled-tasks/auto-update/${stackName}`, + }), + ); await compose.updateStack(stackName, undefined, true); db.clearStackUpdateStatus(nodeId, stackName); diff --git a/backend/src/services/WebhookService.ts b/backend/src/services/WebhookService.ts index f5d55b35..162b9d16 100644 --- a/backend/src/services/WebhookService.ts +++ b/backend/src/services/WebhookService.ts @@ -4,6 +4,7 @@ import { ComposeService } from './ComposeService'; import { FileSystemService } from './FileSystemService'; import { GitSourceService } from './GitSourceService'; import { NodeRegistry } from './NodeRegistry'; +import { assertPolicyGateAllows, buildSystemPolicyGateOptions } from '../helpers/policyGate'; export class WebhookService { private static instance: WebhookService; @@ -63,6 +64,11 @@ export class WebhookService { const compose = ComposeService.getInstance(defaultNodeId); switch (action) { case 'deploy': + await assertPolicyGateAllows( + webhook.stack_name, + defaultNodeId, + buildSystemPolicyGateOptions('webhook', { auditPath: `/api/webhooks/${webhookId}/execute` }), + ); await compose.deployStack(webhook.stack_name, undefined, atomic); break; case 'restart': @@ -75,6 +81,11 @@ export class WebhookService { await compose.runCommand(webhook.stack_name, 'start'); break; case 'pull': + await assertPolicyGateAllows( + webhook.stack_name, + defaultNodeId, + buildSystemPolicyGateOptions('webhook', { auditPath: `/api/webhooks/${webhookId}/execute` }), + ); await compose.updateStack(webhook.stack_name, undefined, atomic); break; case 'git-pull': { diff --git a/backend/src/utils/safeLog.ts b/backend/src/utils/safeLog.ts index 0cf6ac57..7dfe6105 100644 --- a/backend/src/utils/safeLog.ts +++ b/backend/src/utils/safeLog.ts @@ -13,3 +13,12 @@ export function sanitizeForLog(value: unknown): string { const s = typeof value === 'string' ? value : String(value); return s.replace(CONTROL_CHARS_REGEX, ''); } + +export function redactSensitiveText(value: unknown): string { + const s = typeof value === 'string' ? value : String(value); + return s + .replace(/Bearer\s+[A-Za-z0-9\-._~+/=]+/gi, 'Bearer [redacted]') + .replace(/[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}/g, '[redacted-jwt]') + .replace(/https?:\/\/[^/\s:@]+:[^/\s@]+@/gi, 'https://[redacted]@') + .replace(/((?:authorization|token|password|secret|api[_-]?key)\s*[:=]\s*)[^\s,;]+/gi, '$1[redacted]'); +}