diff --git a/backend/src/__tests__/blueprints.test.ts b/backend/src/__tests__/blueprints.test.ts index 2a90a822..c350c164 100644 --- a/backend/src/__tests__/blueprints.test.ts +++ b/backend/src/__tests__/blueprints.test.ts @@ -412,7 +412,10 @@ describe('BlueprintService per-stack lock', () => { const outcome = await BlueprintService.getInstance().deployToNode(bp, node); expect(outcome.status).toBe('active'); - expect(deploySpy).toHaveBeenCalledWith(bp.name, undefined, false); + expect(deploySpy).toHaveBeenCalledWith(bp.name, undefined, false, { + source: 'blueprint', + actor: 'system:blueprint', + }); // Compose is written first, then the marker, both before the deploy. expect(writeSpy).toHaveBeenCalledTimes(2); expect(writeSpy.mock.calls[0][2]).toBe(bp.compose_content); diff --git a/backend/src/__tests__/compose-service.test.ts b/backend/src/__tests__/compose-service.test.ts index 3ff706f8..d603b79b 100644 --- a/backend/src/__tests__/compose-service.test.ts +++ b/backend/src/__tests__/compose-service.test.ts @@ -21,6 +21,9 @@ const { mockGetGlobalSettings, mockPruneDanglingImages, mockGetBindMounts, mockGetStackContent, mockGetEnvContent, mockLoadStackBuildServices, + mockResolveMissingExternalNetworks, + mockCreateNetwork, + mockAddNotificationHistory, } = vi.hoisted(() => ({ mockSpawn: vi.fn(), mockGetContainersByStack: vi.fn().mockResolvedValue([]), @@ -46,6 +49,15 @@ const { mockGetStackContent: vi.fn().mockResolvedValue(''), mockGetEnvContent: vi.fn().mockResolvedValue(''), mockLoadStackBuildServices: vi.fn().mockResolvedValue([]), + mockResolveMissingExternalNetworks: vi.fn().mockResolvedValue({ + status: 'ok', + autoCreateEnabled: false, + stackName: 'my-stack', + networks: [], + declaredExternalCount: 0, + }), + mockCreateNetwork: vi.fn().mockResolvedValue({ id: 'net-1' }), + mockAddNotificationHistory: vi.fn(), })); vi.mock('child_process', () => ({ spawn: mockSpawn, execFile: vi.fn() })); @@ -79,6 +91,7 @@ vi.mock('../services/DockerController', () => ({ getLegacyOrphanContainersByStack: mockGetLegacyOrphanContainersByStack, removeContainers: mockRemoveContainers, pruneDanglingImages: mockPruneDanglingImages, + createNetwork: mockCreateNetwork, getDocker: () => ({ listContainers: mockListContainers, getContainer: () => ({ @@ -97,6 +110,7 @@ vi.mock('../services/DatabaseService', () => ({ getGlobalSettings: mockGetGlobalSettings, getGitSource: () => undefined, getStackProjectEnvFiles: () => [], + addNotificationHistory: mockAddNotificationHistory, }), }, })); @@ -147,6 +161,10 @@ vi.mock('../services/ImageUpdateService', async (importOriginal) => ({ loadStackBuildServices: (...args: unknown[]) => mockLoadStackBuildServices(...args), })); +vi.mock('../services/network/resolveMissingExternalNetworks', () => ({ + resolveMissingExternalNetworks: (...args: unknown[]) => mockResolveMissingExternalNetworks(...args), +})); + import { ComposeService, getComposeRollbackInfo } from '../services/ComposeService'; import { DriftLedgerService } from '../services/DriftLedgerService'; @@ -221,6 +239,14 @@ beforeEach(() => { mockEnsureStackOverride.mockResolvedValue(null); mockGetBindMounts.mockResolvedValue(null); mockLoadStackBuildServices.mockResolvedValue([]); + mockResolveMissingExternalNetworks.mockResolvedValue({ + status: 'ok', + autoCreateEnabled: false, + stackName: 'my-stack', + networks: [], + declaredExternalCount: 0, + }); + mockCreateNetwork.mockResolvedValue({ id: 'net-1' }); delete process.env.SENCHO_MODE; vi.useFakeTimers({ shouldAdvanceTime: true }); }); @@ -705,6 +731,102 @@ describe('ComposeService - deployStack', () => { expect(mockBackupStackFiles).toHaveBeenCalledWith('my-stack'); }); + it('blocks deploy before backup when missing external networks need a prompt', async () => { + const { MissingExternalNetworksError } = await import('../services/network/missingExternalNetworksError'); + mockResolveMissingExternalNetworks.mockResolvedValue({ + status: 'ok', + autoCreateEnabled: false, + stackName: 'my-stack', + networks: [{ + name: 'arr-net', + keys: ['arr'], + declarations: [{ key: 'arr', driverKind: 'bridge', unsupportedFeatures: [] }], + safe: true, + unsupportedFeatures: [], + creationSpec: { driver: 'bridge', options: 'default' }, + }], + declaredExternalCount: 1, + }); + + const svc = ComposeService.getInstance(1); + await expect(svc.deployStack('my-stack', undefined, true)).rejects.toBeInstanceOf(MissingExternalNetworksError); + expect(mockBackupStackFiles).not.toHaveBeenCalled(); + expect(mockSpawn).not.toHaveBeenCalled(); + expect(mockAddNotificationHistory).not.toHaveBeenCalled(); + }); + + it('auto-creates safe missing networks and records history-only activity', async () => { + const missing = [{ + name: 'arr-net', + keys: ['arr'], + declarations: [{ key: 'arr', driverKind: 'bridge' as const, unsupportedFeatures: [] as const }], + safe: true, + unsupportedFeatures: [] as const, + creationSpec: { driver: 'bridge' as const, options: 'default' as const }, + }]; + mockResolveMissingExternalNetworks + .mockResolvedValueOnce({ + status: 'ok', + autoCreateEnabled: true, + stackName: 'my-stack', + networks: missing, + declaredExternalCount: 1, + }) + .mockResolvedValueOnce({ + status: 'ok', + autoCreateEnabled: true, + stackName: 'my-stack', + networks: [], + declaredExternalCount: 1, + }); + setupAutoCloseSpawn(); + mockListContainers.mockResolvedValue([]); + + const svc = ComposeService.getInstance(1); + const promise = svc.deployStack( + 'my-stack', + undefined, + true, + { source: 'scheduler', actor: 'system:scheduler' }, + ); + await vi.advanceTimersByTimeAsync(3100); + await promise; + + expect(mockCreateNetwork).toHaveBeenCalledWith({ Name: 'arr-net', Driver: 'bridge' }); + expect(mockAddNotificationHistory).toHaveBeenCalledWith( + 1, + expect.objectContaining({ + category: 'network_auto_created', + level: 'info', + actor_username: 'system:scheduler', + stack_name: 'my-stack', + }), + ); + expect(mockBackupStackFiles).toHaveBeenCalled(); + }); + + it('does not wrap a missing-external prompt in ComposeRollbackError when atomic', async () => { + mockResolveMissingExternalNetworks.mockResolvedValue({ + status: 'ok', + autoCreateEnabled: false, + stackName: 'my-stack', + networks: [{ + name: 'arr-net', + keys: ['arr'], + declarations: [{ key: 'arr', driverKind: 'bridge', unsupportedFeatures: [] }], + safe: true, + unsupportedFeatures: [], + creationSpec: { driver: 'bridge', options: 'default' }, + }], + declaredExternalCount: 1, + }); + + const svc = ComposeService.getInstance(1); + const error = await svc.deployStack('my-stack', undefined, true).then(() => null, (e: Error) => e); + expect(getComposeRollbackInfo(error)).toBeNull(); + expect(error?.name).toBe('MissingExternalNetworksError'); + }); + it('aborts atomic deploy before docker side effects when backup fails', async () => { mockBackupStackFiles.mockRejectedValueOnce(new Error('disk full')); diff --git a/backend/src/__tests__/doctor-networking-findings.test.ts b/backend/src/__tests__/doctor-networking-findings.test.ts index 150fd019..e08dcf22 100644 --- a/backend/src/__tests__/doctor-networking-findings.test.ts +++ b/backend/src/__tests__/doctor-networking-findings.test.ts @@ -34,6 +34,7 @@ function stubFacts(overrides: Partial = {}): StackNetworkFact networks: [], services: [{ name: 'web', networks: [], publishedPorts: [], extraHosts: [] }], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] }, + missingExternalNetworks: [], ...overrides, }; } diff --git a/backend/src/__tests__/fleet-snapshot-routes.test.ts b/backend/src/__tests__/fleet-snapshot-routes.test.ts index 6b6a39c0..8af05052 100644 --- a/backend/src/__tests__/fleet-snapshot-routes.test.ts +++ b/backend/src/__tests__/fleet-snapshot-routes.test.ts @@ -236,7 +236,10 @@ describe('Single-stack snapshot restore (behavior lock)', () => { .send({ nodeId: LOCAL_NODE_ID, stackName: 'redeploy-web', redeploy: true }); expect(res.status).toBe(200); expect(res.body.redeployed).toBe(true); - expect(deploySpy).toHaveBeenCalledWith('redeploy-web'); + expect(deploySpy).toHaveBeenCalledWith('redeploy-web', undefined, undefined, { + source: 'fleet_snapshot', + actor: 'system:fleet-snapshot', + }); }); }); @@ -596,7 +599,10 @@ describe('Restore-all', () => { expect(res.status).toBe(200); expect(res.body.restored).toBe(1); expect(res.body.results[0].redeployed).toBe(true); - expect(deploySpy).toHaveBeenCalledWith('redeploy-all-web'); + expect(deploySpy).toHaveBeenCalledWith('redeploy-all-web', undefined, undefined, { + source: 'fleet_snapshot', + actor: 'system:fleet-snapshot', + }); }); it('records a policy-blocked redeploy as a per-stack failure and still restores the rest', async () => { @@ -622,7 +628,10 @@ describe('Restore-all', () => { const blocked = (res.body.results as Array<{ stackName: string; success: boolean; error?: string }>).find(r => r.stackName === 'blocked-web'); expect(blocked?.success).toBe(false); expect(blocked?.error).toMatch(/blocked deploy/i); - expect(deploySpy).toHaveBeenCalledWith('ok-web'); - expect(deploySpy).not.toHaveBeenCalledWith('blocked-web'); + expect(deploySpy).toHaveBeenCalledWith('ok-web', undefined, undefined, { + source: 'fleet_snapshot', + actor: 'system:fleet-snapshot', + }); + expect(deploySpy.mock.calls.map((c) => c[0])).not.toContain('blocked-web'); }); }); diff --git a/backend/src/__tests__/git-source-service.test.ts b/backend/src/__tests__/git-source-service.test.ts index 56291127..c52194c3 100644 --- a/backend/src/__tests__/git-source-service.test.ts +++ b/backend/src/__tests__/git-source-service.test.ts @@ -1171,15 +1171,20 @@ describe('GitSourceService.apply', () => { const beginSpy = vi.spyOn(HealthGateService.getInstance(), 'begin').mockReturnValue('gate-git'); const nodeId = DatabaseService.getInstance().getDefaultNode()!.id!; - const result = await svc.apply('apply-deploy-gate', sha, { deploy: true }); - expect(result.deployed).toBe(true); - expect(deploySpy).toHaveBeenCalledWith('apply-deploy-gate'); - expect(beginSpy).toHaveBeenCalledWith(nodeId, 'apply-deploy-gate', 'deploy', 'system:git-source'); - - validateSpy.mockRestore(); - saveSpy.mockRestore(); - deploySpy.mockRestore(); - beginSpy.mockRestore(); + try { + const result = await svc.apply('apply-deploy-gate', sha, { deploy: true }); + expect(result.deployed).toBe(true); + expect(deploySpy).toHaveBeenCalledWith('apply-deploy-gate', undefined, undefined, { + source: 'git_apply', + actor: 'system:git-source', + }); + expect(beginSpy).toHaveBeenCalledWith(nodeId, 'apply-deploy-gate', 'deploy', 'system:git-source'); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + deploySpy.mockRestore(); + beginSpy.mockRestore(); + } }); it('returns deployError when the deploy step fails after writing to disk', async () => { @@ -1190,24 +1195,31 @@ describe('GitSourceService.apply', () => { const validateSpy = vi.spyOn(svc, 'validateCompose').mockResolvedValue({ ok: true }); // Stub file write (FileSystemService expects a real stack dir) const { FileSystemService } = await import('../services/FileSystemService'); + const { ComposeService } = await import('../services/ComposeService'); const saveSpy = vi.spyOn(FileSystemService.prototype, 'saveStackContent').mockResolvedValue(); + // Force deploy failure so the return shape is deterministic (no Docker / mock leakage). + const deploySpy = vi.spyOn(ComposeService.prototype, 'deployStack').mockRejectedValue( + new Error('compose up failed: docker unavailable'), + ); - // Deploy will fail organically (docker CLI unavailable in the test env). - // We only assert the return SHAPE: apply must not throw, deployError must - // carry the failure detail so the UI can surface "applied but not deployed". - const result = await svc.apply('apply-deploy-fail', sha, { deploy: true }); - expect(result.applied).toBe(true); - expect(result.deployed).toBe(false); - expect(result.deployError).toBeTruthy(); + try { + // Assert the return SHAPE: apply must not throw, deployError must + // carry the failure detail so the UI can surface "applied but not deployed". + const result = await svc.apply('apply-deploy-fail', sha, { deploy: true }); + expect(result.applied).toBe(true); + expect(result.deployed).toBe(false); + expect(result.deployError).toBeTruthy(); - // Disk write happened; DB was marked applied even though deploy failed. - expect(saveSpy).toHaveBeenCalled(); - const row = DatabaseService.getInstance().getGitSource('apply-deploy-fail'); - expect(row?.last_applied_commit_sha).toBe(sha); - expect(row?.pending_commit_sha).toBeNull(); - - validateSpy.mockRestore(); - saveSpy.mockRestore(); + // Disk write happened; DB was marked applied even though deploy failed. + expect(saveSpy).toHaveBeenCalled(); + const row = DatabaseService.getInstance().getGitSource('apply-deploy-fail'); + expect(row?.last_applied_commit_sha).toBe(sha); + expect(row?.pending_commit_sha).toBeNull(); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + deploySpy.mockRestore(); + } }); it('returns deployError and skips compose deploy when policy blocks apply deploy', async () => { @@ -1260,20 +1272,22 @@ describe('GitSourceService.apply', () => { replicated_from_control: 0, }); - const result = await svc.apply('apply-policy-block', sha, { deploy: true }); + try { + 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(scanSpy).toHaveBeenCalled(); - expect(deploySpy).not.toHaveBeenCalled(); - - validateSpy.mockRestore(); - saveSpy.mockRestore(); - listImagesSpy.mockRestore(); - deploySpy.mockRestore(); - trivyAvailableSpy.mockRestore(); - scanSpy.mockRestore(); + expect(result.applied).toBe(true); + expect(result.deployed).toBe(false); + expect(result.deployError).toContain('Policy "block-high" blocked deploy'); + expect(scanSpy).toHaveBeenCalled(); + expect(deploySpy).not.toHaveBeenCalled(); + } finally { + validateSpy.mockRestore(); + saveSpy.mockRestore(); + listImagesSpy.mockRestore(); + deploySpy.mockRestore(); + trivyAvailableSpy.mockRestore(); + scanSpy.mockRestore(); + } }); }); diff --git a/backend/src/__tests__/labels-bulk-actions.test.ts b/backend/src/__tests__/labels-bulk-actions.test.ts index 04918e33..1a54b1ad 100644 --- a/backend/src/__tests__/labels-bulk-actions.test.ts +++ b/backend/src/__tests__/labels-bulk-actions.test.ts @@ -114,7 +114,10 @@ describe('Stack Labels bulk actions', () => { expect(res.status).toBe(200); expect(res.body.results).toEqual([{ stackName: 'alpha', success: true }]); expect(enforcePolicyPreDeploy).toHaveBeenCalledWith('alpha', label.node_id, expect.any(Object)); - expect(deployStack).toHaveBeenCalledWith('alpha', undefined, false); + expect(deployStack).toHaveBeenCalledWith('alpha', undefined, false, { + source: 'labels', + actor: TEST_USERNAME, + }); expect(invalidateNodeCaches).toHaveBeenCalledWith(label.node_id); }); @@ -171,7 +174,10 @@ describe('Stack Labels bulk actions', () => { expect(beta).toEqual({ stackName: 'beta', success: true }); // 'alpha' was skipped; only 'beta' reached ComposeService. expect(deployStack).toHaveBeenCalledTimes(1); - expect(deployStack).toHaveBeenCalledWith('beta', undefined, false); + expect(deployStack).toHaveBeenCalledWith('beta', undefined, false, { + source: 'labels', + actor: TEST_USERNAME, + }); }); it('dry-run deploy runs the policy gate and reports blocked stacks honestly', async () => { diff --git a/backend/src/__tests__/missing-external-networks-route.test.ts b/backend/src/__tests__/missing-external-networks-route.test.ts new file mode 100644 index 00000000..4b2e7105 --- /dev/null +++ b/backend/src/__tests__/missing-external-networks-route.test.ts @@ -0,0 +1,148 @@ +/** + * Route + provenance tests for missing-external-network preflight. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { + setupTestDb, + cleanupTestDb, + loginAsTestAdmin, + TEST_JWT_SECRET, +} from './helpers/setupTestDb'; +import { TEST_USERNAME } from './helpers/testConstants'; +import { + PROXY_DEPLOY_SOURCE_HEADER, + PROXY_DEPLOY_ACTOR_HEADER, +} from '../services/license-headers'; + +let tmpDir: string; +let app: import('express').Express; +let adminCookie: string; +let composeDir: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + composeDir = process.env.COMPOSE_DIR!; + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const stackDir = path.join(composeDir, 'netcheck'); + fs.mkdirSync(stackDir, { recursive: true }); + fs.writeFileSync( + path.join(stackDir, 'compose.yaml'), + [ + 'services:', + ' web:', + ' image: nginx:alpine', + 'networks:', + ' arr:', + ' external: true', + ' name: arr-net', + '', + ].join('\n'), + 'utf8', + ); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +const signToken = (payload: Record) => + jwt.sign(payload, TEST_JWT_SECRET, { expiresIn: '1m' }); + +describe('GET /api/stacks/:stackName/missing-external-networks', () => { + it('requires auth', async () => { + const res = await request(app).get('/api/stacks/netcheck/missing-external-networks'); + expect(res.status).toBe(401); + }); + + it('returns 404 for an unknown stack', async () => { + const res = await request(app) + .get('/api/stacks/does-not-exist/missing-external-networks') + .set('Cookie', adminCookie); + expect(res.status).toBe(404); + }); + + it('returns a typed envelope for an existing stack', async () => { + const res = await request(app) + .get('/api/stacks/netcheck/missing-external-networks') + .set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body).toEqual(expect.objectContaining({ + stackName: 'netcheck', + autoCreateEnabled: expect.any(Boolean), + declaredExternalCount: expect.any(Number), + networks: expect.any(Array), + })); + expect(['ok', 'render_unavailable', 'runtime_unavailable']).toContain(res.body.status); + }); +}); + +describe('deploy provenance trust boundary', () => { + async function runAuth( + headers: Record, + ): Promise<{ nextCalled: boolean; deployContext: import('express').Request['deployContext'] }> { + const { authMiddleware } = await import('../middleware/auth'); + const req = { + headers, + cookies: {}, + } as unknown as import('express').Request; + let nextCalled = false; + await new Promise((resolve) => { + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn(() => resolve()), + } as unknown as import('express').Response; + void Promise.resolve(authMiddleware(req, res, () => { + nextCalled = true; + resolve(); + })).catch(() => resolve()); + }); + return { nextCalled, deployContext: req.deployContext }; + } + + it('ignores browser-supplied deploy provenance headers on a user session', async () => { + const res = await request(app) + .get('/api/stacks') + .set('Cookie', adminCookie) + .set(PROXY_DEPLOY_SOURCE_HEADER, 'scheduler') + .set(PROXY_DEPLOY_ACTOR_HEADER, 'system:scheduler'); + expect(res.status).toBe(200); + }); + + it('stores trusted deployContext for node_proxy with valid provenance headers', async () => { + const token = signToken({ scope: 'node_proxy' }); + const result = await runAuth({ + authorization: `Bearer ${token}`, + [PROXY_DEPLOY_SOURCE_HEADER]: 'scheduler', + [PROXY_DEPLOY_ACTOR_HEADER]: 'system:scheduler', + }); + expect(result.nextCalled).toBe(true); + expect(result.deployContext).toEqual({ + source: 'scheduler', + actor: 'system:scheduler', + }); + }); + + it('does not trust deploy provenance headers on user session JWTs', async () => { + const { DatabaseService } = await import('../services/DatabaseService'); + const user = DatabaseService.getInstance().getUserByUsername(TEST_USERNAME); + expect(user).toBeTruthy(); + const token = signToken({ + username: TEST_USERNAME, + role: 'admin', + tv: user!.token_version, + }); + const result = await runAuth({ + authorization: `Bearer ${token}`, + [PROXY_DEPLOY_SOURCE_HEADER]: 'scheduler', + [PROXY_DEPLOY_ACTOR_HEADER]: 'system:scheduler', + }); + expect(result.nextCalled).toBe(true); + expect(result.deployContext).toBeUndefined(); + }); +}); diff --git a/backend/src/__tests__/missing-external-networks.test.ts b/backend/src/__tests__/missing-external-networks.test.ts new file mode 100644 index 00000000..39d4a40e --- /dev/null +++ b/backend/src/__tests__/missing-external-networks.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from 'vitest'; +import { parseEffectiveModel, type EffResource } from '../services/preflight/effectiveModel'; +import { classifyMissingExternalNetworks } from '../services/network/missingExternalNetworks'; +import { isValidDockerNetworkName } from '../services/network/dockerNetworkName'; + +function net(partial: Partial & Pick): EffResource { + return { + internal: false, + driverKind: 'default', + hasDriverOpts: false, + hasCustomIpam: false, + attachable: false, + ipv4Enabled: true, + ipv6Enabled: false, + hasLabels: false, + ...partial, + }; +} + +function model(networks: Record) { + return { projectName: 'proj', services: [], networks, volumes: {} }; +} + +describe('isValidDockerNetworkName', () => { + it('accepts docker-safe names', () => { + expect(isValidDockerNetworkName('proxy')).toBe(true); + expect(isValidDockerNetworkName('a.b_c-1')).toBe(true); + }); + it('rejects invalid names', () => { + expect(isValidDockerNetworkName('')).toBe(false); + expect(isValidDockerNetworkName('-bad')).toBe(false); + expect(isValidDockerNetworkName('has space')).toBe(false); + }); +}); + +describe('parseEffectiveModel network metadata', () => { + it('treats empty ipam {} as safe (no custom IPAM)', () => { + const m = parseEffectiveModel({ + name: 'proj', + networks: { + proxy: { name: 'proxy', external: true, ipam: {} }, + }, + }, 'proj'); + expect(m.networks.proxy.hasCustomIpam).toBe(false); + expect(m.networks.proxy.driverKind).toBe('default'); + }); + + it('detects custom IPAM and never retains option values', () => { + const m = parseEffectiveModel({ + name: 'proj', + networks: { + proxy: { + name: 'proxy', + external: true, + ipam: { driver: 'default', config: [{ subnet: '10.0.0.0/24' }] }, + labels: { secret: 'should-not-leak' }, + driver_opts: { 'com.example': 'x' }, + }, + }, + }, 'proj'); + expect(m.networks.proxy.hasCustomIpam).toBe(true); + expect(m.networks.proxy.hasLabels).toBe(true); + expect(m.networks.proxy.hasDriverOpts).toBe(true); + expect(JSON.stringify(m.networks.proxy)).not.toContain('should-not-leak'); + expect(JSON.stringify(m.networks.proxy)).not.toContain('10.0.0.0'); + }); + + it('maps arbitrary interpolated drivers to custom without retaining the raw string', () => { + const m = parseEffectiveModel({ + name: 'proj', + networks: { + proxy: { name: 'proxy', external: true, driver: 'resolved-from-env-SECRET' }, + }, + }, 'proj'); + expect(m.networks.proxy.driverKind).toBe('custom'); + expect(JSON.stringify(m.networks.proxy)).not.toContain('SECRET'); + expect(JSON.stringify(m.networks.proxy)).not.toContain('resolved-from-env'); + }); + + it('flags internal, attachable, ipv4 disabled, ipv6 enabled', () => { + const m = parseEffectiveModel({ + name: 'proj', + networks: { + n: { + name: 'n', + external: true, + internal: true, + attachable: true, + enable_ipv4: false, + enable_ipv6: true, + }, + }, + }, 'proj'); + expect(m.networks.n.internal).toBe(true); + expect(m.networks.n.attachable).toBe(true); + expect(m.networks.n.ipv4Enabled).toBe(false); + expect(m.networks.n.ipv6Enabled).toBe(true); + }); +}); + +describe('classifyMissingExternalNetworks', () => { + it('returns nothing when the network exists', () => { + const missing = classifyMissingExternalNetworks( + model({ proxy: net({ name: 'proxy', external: true }) }), + new Set(['proxy']), + ); + expect(missing).toEqual([]); + }); + + it('classifies a safe missing bridge/default network', () => { + const missing = classifyMissingExternalNetworks( + model({ proxy: net({ name: 'proxy', external: true }) }), + new Set(), + ); + expect(missing).toHaveLength(1); + expect(missing[0]).toMatchObject({ + name: 'proxy', + keys: ['proxy'], + safe: true, + creationSpec: { driver: 'bridge', options: 'default' }, + unsupportedFeatures: [], + }); + }); + + it('dedupes by runtime name and keeps all Compose keys', () => { + const missing = classifyMissingExternalNetworks( + model({ + a: net({ name: 'shared-net', external: true, driverKind: 'bridge' }), + b: net({ name: 'shared-net', external: true, driverKind: 'overlay' }), + }), + new Set(), + ); + expect(missing).toHaveLength(1); + expect(missing[0].keys).toEqual(['a', 'b']); + expect(missing[0].safe).toBe(false); + expect(missing[0].blockReason).toBe('unsupported_driver'); + expect(missing[0].declarations.map((d) => d.driverKind).sort()).toEqual(['bridge', 'overlay']); + expect(missing[0].creationSpec).toBeNull(); + }); + + it('unions unsupported features across keys and stable-sorts them', () => { + const missing = classifyMissingExternalNetworks( + model({ + a: net({ name: 'n', external: true, internal: true }), + b: net({ name: 'n', external: true, hasLabels: true, ipv6Enabled: true }), + }), + new Set(), + ); + expect(missing[0].unsupportedFeatures).toEqual(['labels', 'internal', 'ipv6_enabled']); + expect(missing[0].blockReason).toBe('unsupported_options'); + }); + + it('blocks reserved system names', () => { + const missing = classifyMissingExternalNetworks( + model({ hostnet: net({ name: 'host', external: true }) }), + new Set(), + ); + expect(missing[0].blockReason).toBe('reserved_system'); + expect(missing[0].safe).toBe(false); + }); + + it('blocks invalid names', () => { + const missing = classifyMissingExternalNetworks( + model({ bad: net({ name: '-nope', external: true }) }), + new Set(), + ); + expect(missing[0].blockReason).toBe('invalid_name'); + }); + + it('never serializes a raw custom driver string', () => { + const missing = classifyMissingExternalNetworks( + model({ + x: net({ name: 'x', external: true, driverKind: 'custom' }), + }), + new Set(), + ); + expect(JSON.stringify(missing)).not.toMatch(/driver:\s*"/); + expect(missing[0].declarations[0].driverKind).toBe('custom'); + }); +}); diff --git a/backend/src/__tests__/networking-operator.test.ts b/backend/src/__tests__/networking-operator.test.ts index 78279b28..466fcb95 100644 --- a/backend/src/__tests__/networking-operator.test.ts +++ b/backend/src/__tests__/networking-operator.test.ts @@ -190,7 +190,7 @@ describe('evaluateNetworkDeleteGuard', () => { volumes: [], }; const guard = evaluateNetworkDeleteGuard('n1', snapshot, [ - { stack: STACK, renderable: false, renderError: 'x', runtime: 'available', networks: [], services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] } }, + { stack: STACK, renderable: false, renderError: 'x', runtime: 'available', networks: [], services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] }, missingExternalNetworks: [] }, ]); expect(guard.blocked).toBe(true); expect(guard.code).toBe('stack-declaration-unknown'); @@ -234,7 +234,8 @@ describe('evaluateNetworkDeleteGuard', () => { const guard = evaluateNetworkDeleteGuard('n1', snapshot, [ { stack: STACK, renderable: true, renderError: null, runtime: 'available', networks: [{ key: 'app_net', name: 'app_net', external: false, internal: false, createdByStack: true }], - services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] } }, + services: [], drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] }, + missingExternalNetworks: [] }, ]); expect(guard.code).toBe('stack-declared'); }); diff --git a/backend/src/__tests__/preflight-effective-model.test.ts b/backend/src/__tests__/preflight-effective-model.test.ts index a0792c42..4bd7f69a 100644 --- a/backend/src/__tests__/preflight-effective-model.test.ts +++ b/backend/src/__tests__/preflight-effective-model.test.ts @@ -78,11 +78,20 @@ describe('parseEffectiveModel', () => { it('resolves top-level networks and volumes with external and internal flags', () => { const m = parseEffectiveModel(render(), 'fallback'); - expect(m.networks.shared).toEqual({ name: 'shared_net', external: true, internal: false }); - expect(m.networks.default).toEqual({ name: 'myapp_default', external: false, internal: false }); - expect(m.networks.backend).toEqual({ name: 'myapp_backend', external: false, internal: true }); - expect(m.volumes.ext).toEqual({ name: 'shared_vol', external: true, internal: false }); - expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false, internal: false }); + const emptyResourceExtras = { + driverKind: 'default', + hasDriverOpts: false, + hasCustomIpam: false, + attachable: false, + ipv4Enabled: true, + ipv6Enabled: false, + hasLabels: false, + } as const; + expect(m.networks.shared).toEqual({ name: 'shared_net', external: true, internal: false, ...emptyResourceExtras }); + expect(m.networks.default).toEqual({ name: 'myapp_default', external: false, internal: false, ...emptyResourceExtras }); + expect(m.networks.backend).toEqual({ name: 'myapp_backend', external: false, internal: true, ...emptyResourceExtras }); + expect(m.volumes.ext).toEqual({ name: 'shared_vol', external: true, internal: false, ...emptyResourceExtras }); + expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false, internal: false, ...emptyResourceExtras }); }); it('parses service network membership (key-space) with aliases', () => { @@ -224,7 +233,18 @@ describe('parseStorageMounts (storage inventory field)', () => { volumes: { cache: { name: 'myapp_cache' } }, }, 'p'); expect(m.services[0].storageMounts).toEqual([{ type: 'named', source: 'cache', target: '/c', readOnly: false }]); - expect(m.volumes.cache).toEqual({ name: 'myapp_cache', external: false, internal: false }); + expect(m.volumes.cache).toEqual({ + name: 'myapp_cache', + external: false, + internal: false, + driverKind: 'default', + hasDriverOpts: false, + hasCustomIpam: false, + attachable: false, + ipv4Enabled: true, + ipv6Enabled: false, + hasLabels: false, + }); }); it('leaves the rule-facing binds and namedVolumes byte-identical', () => { diff --git a/backend/src/__tests__/proxy-role-forwarding.test.ts b/backend/src/__tests__/proxy-role-forwarding.test.ts index 98257fd7..2a7ea3fa 100644 --- a/backend/src/__tests__/proxy-role-forwarding.test.ts +++ b/backend/src/__tests__/proxy-role-forwarding.test.ts @@ -19,7 +19,7 @@ import { loginAsTestAdmin, TEST_JWT_SECRET, } from './helpers/setupTestDb'; -import { PROXY_ROLE_HEADER } from '../services/license-headers'; +import { PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER } from '../services/license-headers'; let tmpDir: string; let app: import('express').Express; @@ -222,4 +222,17 @@ describe('remote proxy gateway - actor role header forwarding', () => { expect(res.status).toBe(200); expect(captured?.[PROXY_ROLE_HEADER]).toBe('viewer'); }); + + it('strips smuggled deploy provenance and overwrites interactive manual + username', async () => { + captured = null; + const res = await request(app) + .get(READ_ONLY) + .set('Authorization', `Bearer ${viewerBearer}`) + .set('x-node-id', String(remoteNodeId)) + .set(PROXY_DEPLOY_SOURCE_HEADER, 'scheduler') + .set(PROXY_DEPLOY_ACTOR_HEADER, 'system:scheduler'); + expect(res.status).toBe(200); + expect(captured?.[PROXY_DEPLOY_SOURCE_HEADER]).toBe('manual'); + expect(captured?.[PROXY_DEPLOY_ACTOR_HEADER]).toBe(VIEWER_USER); + }); }); diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index 029b7e6a..cf3eca2b 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -1802,7 +1802,10 @@ describe('SchedulerService - lifecycle actions', () => { it('auto_start calls deployStack', async () => { mockGetScheduledTask.mockReturnValue(makeLifecycleTask('auto_start')); await SchedulerService.getInstance().triggerTask(300); - expect(mockDeployStack).toHaveBeenCalledWith('my-stack'); + expect(mockDeployStack).toHaveBeenCalledWith('my-stack', undefined, undefined, { + source: 'scheduler', + actor: 'system:scheduler', + }); expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'success' })); }); diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index 305f3e7c..2afd30ac 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -377,6 +377,45 @@ describe('env_block_deploy_on_missing_required setting', () => { }); }); +describe('auto_create_missing_external_networks setting', () => { + it('seeds to "0" (opt-in) in a fresh database', () => { + expect(DatabaseService.getInstance().getGlobalSettings().auto_create_missing_external_networks).toBe('0'); + }); + + it('is exposed through the settings GET projection', async () => { + const res = await request(app).get('/api/settings').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.auto_create_missing_external_networks).toBeDefined(); + }); + + it('rejects a non-admin write with 403', async () => { + const res = await request(app) + .post('/api/settings') + .set('Cookie', viewerCookie) + .send({ key: 'auto_create_missing_external_networks', value: '1' }); + expect(res.status).toBe(403); + }); + + it('accepts a well-formed write and rejects a non-enum value', async () => { + const ok = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'auto_create_missing_external_networks', value: '1' }); + expect(ok.status).toBe(200); + expect(DatabaseService.getInstance().getGlobalSettings().auto_create_missing_external_networks).toBe('1'); + + const bad = await request(app) + .post('/api/settings') + .set('Cookie', adminCookie) + .send({ key: 'auto_create_missing_external_networks', value: 'banana' }); + expect(bad.status).toBe(400); + expect(bad.body.error).toBe('Validation failed'); + expect(DatabaseService.getInstance().getGlobalSettings().auto_create_missing_external_networks).toBe('1'); + + DatabaseService.getInstance().updateGlobalSetting('auto_create_missing_external_networks', '0'); + }); +}); + describe('host_alerts_enabled toggle', () => { it('seeds to "0" (off) in a fresh database', () => { expect(DatabaseService.getInstance().getGlobalSettings().host_alerts_enabled).toBe('0'); diff --git a/backend/src/middleware/auth.ts b/backend/src/middleware/auth.ts index b28119e4..d704d9cc 100644 --- a/backend/src/middleware/auth.ts +++ b/backend/src/middleware/auth.ts @@ -8,7 +8,8 @@ import { type ApiTokenScope, } from '../services/DatabaseService'; import { getErrorMessage } from '../utils/errors'; -import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER } from '../services/license-headers'; +import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER, isDeploySourceHeader } from '../services/license-headers'; +import type { DeployInvocationContext } from '../services/network/missingExternalNetworksError'; import { isLicenseTier, normalizeTier } from '../services/license-normalize'; import { isDebugEnabled } from '../utils/debug'; import { @@ -121,6 +122,7 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response role = isUserRole(forwardedRole) ? forwardedRole : 'viewer'; } req.user = { username: 'node-proxy', role, userId: 0 }; + req.machineAuthScope = decoded.scope; // Distributed License Enforcement: trust tier headers only from authenticated node proxy requests. // Browser sessions and API tokens cannot set these; only a valid node_proxy JWT (signed with @@ -129,6 +131,22 @@ export const authMiddleware: RequestHandler = async (req: Request, res: Response if (isLicenseTier(tierHeader)) { req.proxyTier = normalizeTier(tierHeader); } + + // Deploy provenance: only trust headers on this machine-auth path. The + // gateway strips client values and overwrites interactive deploys with + // manual + username; background callers set their own mapping. + const sourceHeader = req.headers[PROXY_DEPLOY_SOURCE_HEADER]; + const actorHeader = req.headers[PROXY_DEPLOY_ACTOR_HEADER]; + if (isDeploySourceHeader(sourceHeader)) { + const ctx: DeployInvocationContext = { + source: sourceHeader, + actor: typeof actorHeader === 'string' && actorHeader.trim() + ? actorHeader.trim() + : null, + }; + req.deployContext = ctx; + } + next(); return; } diff --git a/backend/src/proxy/remoteNodeProxy.ts b/backend/src/proxy/remoteNodeProxy.ts index 156ddcd8..e0cf6c12 100644 --- a/backend/src/proxy/remoteNodeProxy.ts +++ b/backend/src/proxy/remoteNodeProxy.ts @@ -1,7 +1,7 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express'; import { createProxyMiddleware } from 'http-proxy-middleware'; import { NodeRegistry } from '../services/NodeRegistry'; -import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER } from '../services/license-headers'; +import { PROXY_TIER_HEADER, PROXY_ROLE_HEADER, PROXY_DEPLOY_SOURCE_HEADER, PROXY_DEPLOY_ACTOR_HEADER } from '../services/license-headers'; import { LicenseService } from '../services/LicenseService'; import { isProxyExemptPath } from '../helpers/proxyExemptPaths'; import { remoteSupportsCrossNodeRbac, remoteAdvertisesCapability } from '../helpers/remoteCapabilities'; @@ -121,6 +121,16 @@ export function createRemoteProxyMiddleware(): RequestHandler { if (req.user?.role) { proxyReq.setHeader(PROXY_ROLE_HEADER, req.user.role); } + // Deploy provenance: always strip client-supplied values, then set + // interactive manual + authenticated username for proxied browser/API + // deploys. Background machine callers do not go through this gateway + // with browser credentials; they set headers on direct machine HTTP. + proxyReq.removeHeader(PROXY_DEPLOY_SOURCE_HEADER); + proxyReq.removeHeader(PROXY_DEPLOY_ACTOR_HEADER); + proxyReq.setHeader(PROXY_DEPLOY_SOURCE_HEADER, 'manual'); + if (req.user?.username) { + proxyReq.setHeader(PROXY_DEPLOY_ACTOR_HEADER, req.user.username); + } // Strip the ?nodeId= query param so the remote's nodeContextMiddleware // doesn't reject the request with 404 ("Node X not found") - the remote // has no record of the gateway's node IDs and should treat the request diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index adddb89f..abc739ff 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -53,7 +53,7 @@ import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashb import { buildLocalGraph, mergeFleetGraph, isLocalDependencyGraph, type FleetNodeGraphResult } from '../services/DependencyGraphService'; import { buildNodeLabelInventory, VALID_LABEL_SOURCES, type NodeLabelInventory } from '../services/LabelInventoryService'; import { labelInventoryOptionsFromRequest, requireRevealAdmin } from '../helpers/labelInventoryRequest'; -import { PROXY_TIER_HEADER } from '../services/license-headers'; +import { PROXY_TIER_HEADER, deployProvenanceHeaders } from '../services/license-headers'; import { LicenseService } from '../services/LicenseService'; const updateTracker = FleetUpdateTrackerService.getInstance(); @@ -2734,7 +2734,12 @@ async function redeploySnapshotStack(node: Node, stackName: string): Promise ComposeService.getInstance(node.id).deployStack(stackName), + () => ComposeService.getInstance(node.id).deployStack( + stackName, + undefined, + undefined, + { source: 'fleet_snapshot', actor: 'system:fleet-snapshot' }, + ), ); if (!lock.ran) { throw new Error(`Cannot redeploy "${stackName}": another operation (${lock.existing.action}) is already in progress.`); @@ -2745,7 +2750,10 @@ async function redeploySnapshotStack(node: Node, stackName: string): Promise ComposeService.getInstance(req.nodeId).deployStack(stackName, undefined, false), + () => ComposeService.getInstance(req.nodeId).deployStack( + stackName, + undefined, + false, + req.deployContext ?? { source: 'labels', actor: req.user?.username ?? null }, + ), ); if (!lock.ran) { results.push({ stackName, success: false, error: stackOpSkipMessage(stackName, lock.existing.action) }); diff --git a/backend/src/routes/settings.ts b/backend/src/routes/settings.ts index d4b83435..7919ebfc 100644 --- a/backend/src/routes/settings.ts +++ b/backend/src/routes/settings.ts @@ -32,6 +32,7 @@ const ALLOWED_SETTING_KEYS = new Set([ 'health_gate_enabled', 'health_gate_window_seconds', 'env_block_deploy_on_missing_required', + 'auto_create_missing_external_networks', 'image_update_sidebar_indicators', ]); @@ -63,6 +64,7 @@ const SettingsPatchSchema = z.object({ health_gate_enabled: z.enum(['0', '1']), health_gate_window_seconds: z.coerce.number().int().min(15).max(600).transform(String), env_block_deploy_on_missing_required: z.enum(['0', '1']), + auto_create_missing_external_networks: z.enum(['0', '1']), image_update_sidebar_indicators: z.enum(['0', '1']), }).partial(); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index f184ab29..fabfe6b7 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -1048,7 +1048,12 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { deployError = describePolicyBlock(gate.policy, gate.violations); } else { try { - await ComposeService.getInstance(req.nodeId).deployStack(stack_name); + await ComposeService.getInstance(req.nodeId).deployStack( + stack_name, + undefined, + undefined, + req.deployContext ?? { source: 'from_git', actor: req.user?.username ?? null }, + ); deployed = true; invalidateNodeCaches(req.nodeId); } catch (e) { @@ -1328,6 +1333,20 @@ stacksRouter.get('/:stackName/preflight', async (req: Request, res: Response) => } }); +stacksRouter.get('/:stackName/missing-external-networks', async (req: Request, res: Response) => { + const stackName = req.params.stackName as string; + if (!requirePermission(req, res, 'stack:read', 'stack', stackName)) return; + if (!(await requireStackExists(req.nodeId, stackName, res))) return; + try { + const { resolveMissingExternalNetworks } = await import('../services/network/resolveMissingExternalNetworks'); + res.json(await resolveMissingExternalNetworks(req.nodeId, stackName)); + } catch (error) { + console.error('[Stacks] Failed to resolve missing external networks for %s:', sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(error, 'unknown'))); + res.status(500).json({ error: 'Failed to check external networks' }); + } +}); + // Running preflight renders the effective model and stores the result, replacing // any prior run. It is advisory and never blocks a deploy; stack:read is the // correct gate since it mutates only the preflight tables, never the stack. @@ -1695,7 +1714,12 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => { const debug = isDebugEnabled(); const atomic = true; if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId }); - await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic); + await ComposeService.getInstance(req.nodeId).deployStack( + stackName, + getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), + atomic, + req.deployContext ?? { source: 'manual', actor: req.user?.username ?? null }, + ); invalidateNodeCaches(req.nodeId); dlog(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`); if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`); @@ -1710,6 +1734,21 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => { } } catch (error: unknown) { console.error('[Stacks] Deploy failed: %s', sanitizeForLog(stackName), error); + const { isMissingExternalNetworksError } = await import('../services/network/missingExternalNetworksError'); + if (isMissingExternalNetworksError(error)) { + const status = error.kind === 'unavailable' ? 503 : error.kind === 'create_failed' ? 500 : 409; + if (!res.headersSent) { + res.status(status).json({ + error: error.message, + code: error.code, + kind: error.kind, + networks: error.networks, + createdNames: error.createdNames, + remainingNames: error.remainingNames, + }); + } + return; + } const rollbackInfo = getComposeRollbackInfo(error); const rolledBack = rollbackInfo?.rolledBack ?? false; if (rolledBack) { @@ -2033,6 +2072,7 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) => // the same project. Lock held below: all early-returns stay inside the try so // finally fires. if (!tryAcquireStackOpLock(req, res, stackName, 'rollback')) return; + let revertRestore: (() => Promise) | null = null; try { const fsSvc = FileSystemService.getInstance(req.nodeId); const backupInfo = await fsSvc.getBackupInfo(stackName); @@ -2045,7 +2085,7 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) => // the restored target can be undone: restoreStackFiles commits to disk, and // without this a blocked rollback would leave disk rolled back while the // deployed state is unchanged. - const revertRestore = await fsSvc.snapshotStackFiles(stackName); + revertRestore = await fsSvc.snapshotStackFiles(stackName); await fsSvc.restoreStackFiles(stackName); if (!(await runPolicyGate(req, res, stackName, req.nodeId))) { try { @@ -2059,13 +2099,39 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) => } return; } - await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), false); + await ComposeService.getInstance(req.nodeId).deployStack( + stackName, + getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), + false, + req.deployContext ?? { source: 'rollback', actor: req.user?.username ?? null }, + ); invalidateNodeCaches(req.nodeId); dlog(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`); res.json({ message: 'Stack rolled back: compose and env files restored.' }); notifyActionSuccess('deploy_success', `${stackName} rolled back`, stackName, req.user?.username ?? 'system'); } catch (error: unknown) { console.error('[Stacks] Rollback failed: %s', sanitizeForLog(stackName), error); + const { isMissingExternalNetworksError } = await import('../services/network/missingExternalNetworksError'); + if (isMissingExternalNetworksError(error)) { + if (revertRestore) { + try { + await revertRestore(); + } catch (revertError) { + console.error('[Stacks] Failed to revert files after a network-blocked rollback: %s', sanitizeForLog(stackName), revertError); + notifyActionFailure('rollback', stackName, revertError, req.user?.username ?? 'system'); + } + } + const status = error.kind === 'unavailable' ? 503 : error.kind === 'create_failed' ? 500 : 409; + if (!res.headersSent) { + res.status(status).json({ + error: error.message, + code: error.code, + kind: error.kind, + networks: error.networks, + }); + } + return; + } const message = getErrorMessage(error, 'Rollback failed.'); notifyActionFailure('rollback', stackName, error, req.user?.username ?? 'system'); if (!res.headersSent) { diff --git a/backend/src/routes/templates.ts b/backend/src/routes/templates.ts index a83dfb4a..cb15873a 100644 --- a/backend/src/routes/templates.ts +++ b/backend/src/routes/templates.ts @@ -135,7 +135,12 @@ templatesRouter.post('/deploy', authMiddleware, async (req: Request, res: Respon return; } const atomic = true; - await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), atomic); + await ComposeService.getInstance(req.nodeId).deployStack( + stackName, + getTerminalWs(req.get(DEPLOY_SESSION_HEADER)), + atomic, + req.deployContext ?? { source: 'template', actor: req.user?.username ?? null }, + ); invalidateNodeCaches(req.nodeId); console.log(`[Templates] Deploy completed: ${stackName}`); if (debug) console.debug(`[Templates:debug] Deploy timing: ${stackName} took ${Date.now() - deployStartedAt}ms`); diff --git a/backend/src/services/BlueprintService.ts b/backend/src/services/BlueprintService.ts index b43c0085..80a1459b 100644 --- a/backend/src/services/BlueprintService.ts +++ b/backend/src/services/BlueprintService.ts @@ -12,7 +12,7 @@ import { ComposeService } from './ComposeService'; import { StackOpLockService, stackOpSkipMessage, type StackOpAction } from './StackOpLockService'; import { FileSystemService } from './FileSystemService'; import { NodeRegistry } from './NodeRegistry'; -import { PROXY_TIER_HEADER } from './license-headers'; +import { PROXY_TIER_HEADER, deployProvenanceHeaders } from './license-headers'; import { LicenseService } from './LicenseService'; import { assertPolicyGateAllows, buildSystemPolicyGateOptions, describePolicyBlock, triggerPostDeployScan } from '../helpers/policyGate'; import { enforcePolicyForImageRefs } from './PolicyEnforcement'; @@ -451,7 +451,12 @@ export class BlueprintService { nodeId, buildSystemPolicyGateOptions('blueprint', { auditPath }), ); - await ComposeService.getInstance(nodeId).deployStack(stackName, undefined, false); + await ComposeService.getInstance(nodeId).deployStack( + stackName, + undefined, + false, + { source: 'blueprint', actor: 'system:blueprint' }, + ); }, ); return lock.ran ? { ran: true } : { ran: false, existingAction: lock.existing.action }; @@ -495,6 +500,7 @@ export class BlueprintService { Authorization: `Bearer ${apiToken}`, [PROXY_TIER_HEADER]: proxy.tier, 'Content-Type': 'application/json', + ...deployProvenanceHeaders('blueprint', 'system:blueprint'), }; } diff --git a/backend/src/services/CapabilityRegistry.ts b/backend/src/services/CapabilityRegistry.ts index 81766785..17f5d31b 100644 --- a/backend/src/services/CapabilityRegistry.ts +++ b/backend/src/services/CapabilityRegistry.ts @@ -55,6 +55,7 @@ export const CAPABILITIES = [ 'compose-storage', 'cross-node-rbac', 'stack-down-remove-volumes', + 'guided-external-network-preflight', ] as const; /** diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 3c325035..6d7caf6a 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -25,6 +25,41 @@ import { parseMissingRequiredVars } from '../helpers/envVarParse'; import { redactSensitiveText, sanitizeForLog } from '../utils/safeLog'; import { pathsMatch, resolveHostBindPath } from '../utils/composePathMapping'; import { loadStackBuildServices } from './ImageUpdateService'; +import { resolveMissingExternalNetworks } from './network/resolveMissingExternalNetworks'; +import { + MissingExternalNetworksError, + type DeployInvocationContext, +} from './network/missingExternalNetworksError'; +import { invalidateNodeCaches } from '../helpers/cacheInvalidation'; +import type { NotificationCategory } from './NotificationService'; + +function recordNetworkAutoCreatedActivity( + nodeId: number, + stackName: string, + createdNames: string[], + level: 'info' | 'warning', + ctx?: DeployInvocationContext, +): void { + if (createdNames.length === 0) return; + const names = [...createdNames].sort((a, b) => a.localeCompare(b)).join(', '); + const source = ctx?.source ?? 'manual'; + try { + DatabaseService.getInstance().addNotificationHistory(nodeId, { + level, + category: 'network_auto_created' as NotificationCategory, + message: `Auto-created external network(s) for ${stackName}: ${names} (source: ${source})`, + timestamp: Date.now(), + stack_name: stackName, + actor_username: ctx?.actor ?? null, + }); + } catch (error) { + console.error( + '[ComposeService] Failed to record network_auto_created activity for %s:', + sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + } +} export class ComposeRollbackError extends Error { public readonly rollbackAttempted: boolean; @@ -461,9 +496,117 @@ export class ComposeService { ); } - async deployStack(stackName: string, ws?: WebSocket, atomic?: boolean): Promise { + /** + * Missing-external gate: after env/Pilot asserts, before atomic backup. + * Creates safe bridge networks only when the opt-in setting is on. + */ + private async ensureExternalNetworksForDeploy( + stackName: string, + ctx?: DeployInvocationContext, + ): Promise { + const resolved = await resolveMissingExternalNetworks(this.nodeId, stackName); + if (resolved.status === 'render_unavailable') { + throw new MissingExternalNetworksError({ + kind: 'unavailable', + message: 'Sencho could not render this stack\'s Compose model to check external networks.', + }); + } + if (resolved.status === 'runtime_unavailable') { + // No declared externals: nothing to verify; proceed. + if (resolved.declaredExternalCount === 0) return; + throw new MissingExternalNetworksError({ + kind: 'unavailable', + message: 'Sencho could not read Docker networking state to check external networks.', + }); + } + + if (resolved.networks.length === 0) return; + + const unsafe = resolved.networks.filter((n) => !n.safe); + if (unsafe.length > 0) { + throw new MissingExternalNetworksError({ + kind: 'unsupported', + message: 'One or more missing external networks cannot be created safely by Sencho.', + networks: resolved.networks, + }); + } + + if (!resolved.autoCreateEnabled) { + throw new MissingExternalNetworksError({ + kind: 'prompt', + message: 'One or more external networks required by this stack are missing on this node.', + networks: resolved.networks, + }); + } + + const docker = DockerController.getInstance(this.nodeId); + const createdNames: string[] = []; + const recordCreatedNetworks = (level: 'info' | 'warning') => { + if (createdNames.length === 0) return; + invalidateNodeCaches(this.nodeId); + recordNetworkAutoCreatedActivity(this.nodeId, stackName, createdNames, level, ctx); + }; + + for (const network of resolved.networks) { + try { + await docker.createNetwork({ Name: network.name, Driver: 'bridge' }); + createdNames.push(network.name); + } catch (createErr) { + // Authoritative re-check: continue only if the network now exists. + let exists = false; + try { + const knownStacks = await FileSystemService.getInstance(this.nodeId).getStacks(); + const snapshot = await docker.getDependencySnapshot(knownStacks); + exists = snapshot.networks.some((n) => n.name === network.name); + } catch (snapErr) { + console.warn( + '[ComposeService] Post-create snapshot failed for %s:', + sanitizeForLog(network.name), + sanitizeForLog(getErrorMessage(snapErr, 'unknown')), + ); + } + if (!exists) { + recordCreatedNetworks('warning'); + throw new MissingExternalNetworksError({ + kind: 'create_failed', + message: `Failed to create external network "${network.name}".`, + networks: resolved.networks, + createdNames, + remainingNames: resolved.networks + .map((missingNetwork) => missingNetwork.name) + .filter((name) => !createdNames.includes(name)), + }); + } + // Race-existing: do not record in createdNames. + } + } + + // Re-resolve before Compose. + const recheck = await resolveMissingExternalNetworks(this.nodeId, stackName); + if (recheck.status !== 'ok' || recheck.networks.length > 0) { + recordCreatedNetworks('warning'); + throw new MissingExternalNetworksError({ + kind: recheck.status === 'ok' ? 'create_failed' : 'unavailable', + message: 'External networks were still missing after automatic creation.', + networks: recheck.networks, + createdNames, + remainingNames: recheck.networks.map((n) => n.name), + }); + } + + recordCreatedNetworks('info'); + } + + async deployStack( + stackName: string, + ws?: WebSocket, + atomic?: boolean, + ctx?: DeployInvocationContext, + ): Promise { await this.assertRequiredEnvPresent(stackName); await this.assertSafePilotBindMapping(stackName); + await this.ensureExternalNetworksForDeploy(stackName, ctx); + const stackDir = path.join(this.baseDir, stackName); const debug = isDebugEnabled(); const t0 = Date.now(); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index fba0988f..4f2568a0 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -1709,6 +1709,7 @@ export class DatabaseService { stmt.run('image_update_check_cron', ''); stmt.run('image_update_sidebar_indicators', '1'); stmt.run('env_block_deploy_on_missing_required', '0'); + stmt.run('auto_create_missing_external_networks', '0'); // Seed the default local node if none exists const nodeCount = (this.db.prepare('SELECT COUNT(*) as count FROM nodes').get() as any)?.count || 0; @@ -3117,6 +3118,7 @@ export class DatabaseService { const categories = [ 'deploy_success', 'deploy_failure', 'stack_started', 'stack_stopped', 'stack_restarted', 'image_update_applied', 'update_started', 'health_gate_passed', 'health_gate_failed', + 'network_auto_created', ]; const placeholders = categories.map(() => '?').join(', '); const sql = ` diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 39ba0242..6b4e537f 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -27,6 +27,7 @@ import { isDebugEnabled } from '../utils/debug'; import { sanitizeForLog } from '../utils/safeLog'; import { describeSpawnError } from '../utils/spawnErrors'; import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs'; +import { isValidDockerNetworkName } from './network/dockerNetworkName'; export type { PruneItemOutcome, @@ -1418,7 +1419,7 @@ class DockerController { } public async createNetwork(options: CreateNetworkOptions) { - if (!options.Name || !/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/.test(options.Name)) { + if (!options.Name || !isValidDockerNetworkName(options.Name)) { throw new Error('Invalid network name. Use alphanumeric characters, hyphens, underscores, and dots.'); } return await this.docker.createNetwork(options); diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index e504375e..fa3b9899 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -1325,7 +1325,12 @@ export class GitSourceService { ); const lock = await StackOpLockService.getInstance().runExclusive( nodeId, stackName, 'deploy', 'system', - () => ComposeService.getInstance(nodeId).deployStack(stackName), + () => ComposeService.getInstance(nodeId).deployStack( + stackName, + undefined, + undefined, + { source: 'git_apply', actor: opts.actor ?? 'system:git-source' }, + ), ); if (!lock.ran) { const busy = `Auto-deploy skipped: another operation (${lock.existing.action}) is already in progress for ${stackName}.`; diff --git a/backend/src/services/MeshService.ts b/backend/src/services/MeshService.ts index 63e1854a..b7b0ff85 100644 --- a/backend/src/services/MeshService.ts +++ b/backend/src/services/MeshService.ts @@ -9,7 +9,7 @@ import { DatabaseService, type NodeMode } from './DatabaseService'; import DockerController from './DockerController'; import { FileSystemService } from './FileSystemService'; import { LicenseService } from './LicenseService'; -import { PROXY_TIER_HEADER } from './license-headers'; +import { PROXY_TIER_HEADER, deployProvenanceHeaders } from './license-headers'; import { MeshForwarder, type MeshForwarderHost } from './MeshForwarder'; import { NodeRegistry } from './NodeRegistry'; import { PilotTunnelManager } from './PilotTunnelManager'; @@ -2272,11 +2272,12 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { apiPath: string, body: unknown, timeoutMs: number, + extraHeaders?: Record, ): Promise { const target = NodeRegistry.getInstance().getProxyTarget(nodeId); if (!target) throw new MeshError('no_target', `no proxy target for node ${nodeId}`); const url = `${target.apiUrl.replace(/\/$/, '')}${apiPath}`; - const headers: Record = {}; + const headers: Record = { ...extraHeaders }; if (body !== undefined) headers['Content-Type'] = 'application/json'; if (target.apiToken) headers['Authorization'] = `Bearer ${target.apiToken}`; const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); @@ -2383,7 +2384,12 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { ); const lock = await StackOpLockService.getInstance().runExclusive( nodeId, stackName, 'deploy', 'system', - () => ComposeService.getInstance(nodeId).deployStack(stackName), + () => ComposeService.getInstance(nodeId).deployStack( + stackName, + undefined, + undefined, + { source: 'mesh_redeploy', actor: 'system:mesh' }, + ), ); if (!lock.ran) { throw new Error(`Cannot redeploy "${stackName}": another operation (${lock.existing.action}) is already in progress.`); @@ -2406,6 +2412,7 @@ export class MeshService extends EventEmitter implements MeshForwarderHost { `/api/stacks/${encodeURIComponent(stackName)}/deploy`, {}, 10 * 60 * 1000, + deployProvenanceHeaders('mesh_redeploy', 'system:mesh'), ); if (!res.ok) { const body = await res.text().catch(() => ''); diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index e7756479..de9555f3 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -39,6 +39,8 @@ export type NotificationCategory = | 'update_started' | 'health_gate_passed' | 'health_gate_failed' + // Automatic external-network creation during deploy. History-only. + | 'network_auto_created' | 'node_update_available' | 'system'; @@ -56,6 +58,7 @@ export const ALL_SUPPRESSIBLE_CATEGORIES: readonly NotificationCategory[] = [ ...ALL_NOTIFICATION_CATEGORIES, 'drift_detected', 'drift_resolved', 'update_started', 'health_gate_passed', 'health_gate_failed', + 'network_auto_created', ]; /** Webhook timeout: 10 seconds per external dispatch call. */ diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index cb3f05d5..ae7b5657 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -2,7 +2,7 @@ import { CronExpressionParser } from 'cron-parser'; import { DatabaseService } from './DatabaseService'; import type { ScheduledTask } from './DatabaseService'; import { LicenseService } from './LicenseService'; -import { PROXY_TIER_HEADER } from './license-headers'; +import { PROXY_TIER_HEADER, deployProvenanceHeaders } from './license-headers'; import DockerController from './DockerController'; import { ComposeService } from './ComposeService'; import { StackOpLockService, stackOpSkipMessage as skipMessage } from './StackOpLockService'; @@ -561,7 +561,11 @@ export class SchedulerService { // that node's scan-policy gate against the images it actually holds. The // hub-side enforceSchedulerPolicyGate below is for local nodes only. if (this.isRemoteNode(task.node_id)) { - await this.postToRemoteStack(task.node_id, `${encodeURIComponent(task.target_id)}/deploy`); + await this.postToRemoteStack( + task.node_id, + `${encodeURIComponent(task.target_id)}/deploy`, + deployProvenanceHeaders('scheduler', 'system:scheduler'), + ); return `Started stack "${task.target_id}" on remote node`; } await this.enforceSchedulerPolicyGate( @@ -573,7 +577,12 @@ export class SchedulerService { const localNodeId = task.node_id ?? NodeRegistry.getInstance().getDefaultNodeId(); const lock = await StackOpLockService.getInstance().runExclusive( localNodeId, task.target_id, 'deploy', 'system', - () => ComposeService.getInstance(localNodeId).deployStack(task.target_id), + () => ComposeService.getInstance(localNodeId).deployStack( + task.target_id, + undefined, + undefined, + { source: 'scheduler', actor: 'system:scheduler' }, + ), ); if (!lock.ran) throw new Error(skipMessage(task.target_id, lock.existing.action)); return `Started stack "${task.target_id}"`; @@ -984,7 +993,11 @@ export class SchedulerService { } } - private async postToRemoteStack(nodeId: number, routeSuffix: string): Promise { + private async postToRemoteStack( + nodeId: number, + routeSuffix: string, + extraHeaders?: Record, + ): Promise { const proxyTarget = this.requireRemoteProxyTarget(nodeId); const baseUrl = proxyTarget.apiUrl.replace(/\/$/, ''); const proxyHeaders = LicenseService.getInstance().getProxyHeaders(); @@ -998,6 +1011,7 @@ export class SchedulerService { 'Content-Type': 'application/json', 'Authorization': `Bearer ${proxyTarget.apiToken}`, [PROXY_TIER_HEADER]: proxyHeaders.tier, + ...extraHeaders, }, signal: AbortSignal.timeout(300_000), }); diff --git a/backend/src/services/WebhookService.ts b/backend/src/services/WebhookService.ts index df4b4ae1..a61884e8 100644 --- a/backend/src/services/WebhookService.ts +++ b/backend/src/services/WebhookService.ts @@ -156,7 +156,12 @@ export class WebhookService { nodeId, buildSystemPolicyGateOptions('webhook', { auditPath: `/api/webhooks/${webhookId}/execute` }), ); - await compose.deployStack(stackName, undefined, atomic); + await compose.deployStack( + stackName, + undefined, + atomic, + { source: 'webhook', actor: 'system:webhook' }, + ); HealthGateService.getInstance().begin(nodeId, stackName, 'deploy', 'system:webhook'); break; case 'restart': diff --git a/backend/src/services/license-headers.ts b/backend/src/services/license-headers.ts index cd65028b..e70ead58 100644 --- a/backend/src/services/license-headers.ts +++ b/backend/src/services/license-headers.ts @@ -16,3 +16,44 @@ export const PROXY_TIER_HEADER = 'x-sencho-tier'; * client cannot smuggle a role through. */ export const PROXY_ROLE_HEADER = 'x-sencho-actor-role'; + +/** + * Trusted deploy provenance for machine-to-machine / proxied deploys. + * The gateway always strips client-supplied values and, for interactive + * proxied requests, overwrites with source=manual and the signed-in username. + * Background callers (scheduler, fleet, blueprint, mesh) set these only on + * direct machine-originated HTTP after the strip boundary. + */ +export const PROXY_DEPLOY_SOURCE_HEADER = 'x-sencho-deploy-source'; +export const PROXY_DEPLOY_ACTOR_HEADER = 'x-sencho-deploy-actor'; + +export const DEPLOY_SOURCES = [ + 'manual', + 'rollback', + 'template', + 'from_git', + 'git_apply', + 'fleet_snapshot', + 'labels', + 'scheduler', + 'webhook', + 'blueprint', + 'mesh_redeploy', +] as const; + +export type DeploySourceHeader = (typeof DEPLOY_SOURCES)[number]; + +export function isDeploySourceHeader(value: unknown): value is DeploySourceHeader { + return typeof value === 'string' && (DEPLOY_SOURCES as readonly string[]).includes(value); +} + +/** Headers for direct machine-originated deploy HTTP (never for browser clients). */ +export function deployProvenanceHeaders( + source: DeploySourceHeader, + actor: string, +): Record { + return { + [PROXY_DEPLOY_SOURCE_HEADER]: source, + [PROXY_DEPLOY_ACTOR_HEADER]: actor, + }; +} diff --git a/backend/src/services/network/composeNetworkInspector.ts b/backend/src/services/network/composeNetworkInspector.ts index b8f44799..1cd05f1b 100644 --- a/backend/src/services/network/composeNetworkInspector.ts +++ b/backend/src/services/network/composeNetworkInspector.ts @@ -22,6 +22,7 @@ import { import type { NetworkDriftFacts, NetworkFactNetwork, NetworkFactService, NetworkRuntimeState, StackNetworkFacts, } from './types'; +import { classifyMissingExternalNetworks, type MissingExternalNetwork } from './missingExternalNetworks'; import { getErrorMessage } from '../../utils/errors'; import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog'; @@ -46,7 +47,16 @@ export function assembleStackNetworkFacts( const runtime: NetworkRuntimeState = snapshot ? 'available' : 'unavailable'; if (!model) { - return { stack: stackName, renderable: false, renderError, runtime, networks: [], services: [], drift: EMPTY_DRIFT }; + return { + stack: stackName, + renderable: false, + renderError, + runtime, + networks: [], + services: [], + drift: EMPTY_DRIFT, + missingExternalNetworks: [], + }; } const networks: NetworkFactNetwork[] = Object.entries(model.networks).map(([key, res]) => ({ @@ -73,8 +83,23 @@ export function assembleStackNetworkFacts( })); const drift = snapshot ? compareStackNetworks(fromEffectiveModel(model), snapshot, stackName) : EMPTY_DRIFT; + const missingExternalNetworks: MissingExternalNetwork[] = snapshot + ? classifyMissingExternalNetworks( + model, + new Set(snapshot.networks.map((n) => n.name)), + ) + : []; - return { stack: stackName, renderable: true, renderError: null, runtime, networks, services, drift }; + return { + stack: stackName, + renderable: true, + renderError: null, + runtime, + networks, + services, + drift, + missingExternalNetworks, + }; } /** Render the effective model and assemble facts, optionally reusing a snapshot. */ diff --git a/backend/src/services/network/dockerNetworkName.ts b/backend/src/services/network/dockerNetworkName.ts new file mode 100644 index 00000000..b114bc00 --- /dev/null +++ b/backend/src/services/network/dockerNetworkName.ts @@ -0,0 +1,15 @@ +/** + * Shared Docker network name predicate. Must stay aligned with + * DockerController.createNetwork validation. + */ +const DOCKER_NETWORK_NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/; + +export function isValidDockerNetworkName(name: string): boolean { + return DOCKER_NETWORK_NAME_RE.test(name); +} + +export const RESERVED_SYSTEM_NETWORK_NAMES: ReadonlySet = new Set([ + 'bridge', + 'host', + 'none', +]); diff --git a/backend/src/services/network/missingExternalNetworks.ts b/backend/src/services/network/missingExternalNetworks.ts new file mode 100644 index 00000000..08402fca --- /dev/null +++ b/backend/src/services/network/missingExternalNetworks.ts @@ -0,0 +1,159 @@ +/** + * Pure classifier for missing Compose external networks. + * Async rendering/snapshot resolution lives at API/deploy boundaries only. + */ + +import type { EffectiveModel, EffDriverKind, EffResource } from '../preflight/effectiveModel'; +import { + isValidDockerNetworkName, + RESERVED_SYSTEM_NETWORK_NAMES, +} from './dockerNetworkName'; + +export type DriverKind = EffDriverKind; + +export type UnsupportedFeature = + | 'driver_opts' + | 'custom_ipam' + | 'labels' + | 'internal' + | 'attachable' + | 'ipv4_disabled' + | 'ipv6_enabled'; + +export type BlockReason = + | 'invalid_name' + | 'reserved_system' + | 'unsupported_driver' + | 'unsupported_options'; + +export interface SafeCreationSpec { + driver: 'bridge'; + options: 'default'; +} + +export interface KeyDeclaration { + key: string; + driverKind: DriverKind; + unsupportedFeatures: UnsupportedFeature[]; +} + +export interface MissingExternalNetwork { + name: string; + keys: string[]; + declarations: KeyDeclaration[]; + safe: boolean; + blockReason?: BlockReason; + unsupportedFeatures: UnsupportedFeature[]; + creationSpec: SafeCreationSpec | null; +} + +const UNSUPPORTED_FEATURE_ORDER: readonly UnsupportedFeature[] = [ + 'driver_opts', + 'custom_ipam', + 'labels', + 'internal', + 'attachable', + 'ipv4_disabled', + 'ipv6_enabled', +]; + +const SAFE_CREATION_SPEC: SafeCreationSpec = { driver: 'bridge', options: 'default' }; + +function stableSortFeatures(features: Iterable): UnsupportedFeature[] { + const set = new Set(features); + return UNSUPPORTED_FEATURE_ORDER.filter((f) => set.has(f)); +} + +function featuresForResource(net: EffResource): UnsupportedFeature[] { + const features: UnsupportedFeature[] = []; + if (net.hasDriverOpts) features.push('driver_opts'); + if (net.hasCustomIpam) features.push('custom_ipam'); + if (net.hasLabels) features.push('labels'); + if (net.internal) features.push('internal'); + if (net.attachable) features.push('attachable'); + if (net.ipv4Enabled === false) features.push('ipv4_disabled'); + if (net.ipv6Enabled === true) features.push('ipv6_enabled'); + return features; +} + +function isSafeDriverKind(kind: DriverKind): boolean { + return kind === 'default' || kind === 'bridge'; +} + +function classifyKey(key: string, net: EffResource): KeyDeclaration { + return { + key, + driverKind: net.driverKind ?? 'default', + unsupportedFeatures: featuresForResource(net), + }; +} + +function blockReasonForGroup( + name: string, + declarations: KeyDeclaration[], +): { safe: boolean; blockReason?: BlockReason; unsupportedFeatures: UnsupportedFeature[] } { + if (!isValidDockerNetworkName(name)) { + return { safe: false, blockReason: 'invalid_name', unsupportedFeatures: [] }; + } + if (RESERVED_SYSTEM_NETWORK_NAMES.has(name)) { + return { safe: false, blockReason: 'reserved_system', unsupportedFeatures: [] }; + } + + const unsupportedFeatures = stableSortFeatures( + declarations.flatMap((d) => d.unsupportedFeatures), + ); + const hasUnsafeDriver = declarations.some((d) => !isSafeDriverKind(d.driverKind)); + if (hasUnsafeDriver) { + return { + safe: false, + blockReason: 'unsupported_driver', + unsupportedFeatures, + }; + } + if (unsupportedFeatures.length > 0) { + return { + safe: false, + blockReason: 'unsupported_options', + unsupportedFeatures, + }; + } + return { safe: true, unsupportedFeatures: [] }; +} + +/** + * Classify missing external networks from an already-rendered effective model + * and a set of live Docker network names. Synchronous; no I/O. + */ +export function classifyMissingExternalNetworks( + model: EffectiveModel, + existingNetworkNames: ReadonlySet, +): MissingExternalNetwork[] { + const byRuntimeName = new Map(); + + for (const [key, net] of Object.entries(model.networks)) { + if (!net.external) continue; + if (existingNetworkNames.has(net.name)) continue; + const declaration = classifyKey(key, net); + const list = byRuntimeName.get(net.name) ?? []; + list.push(declaration); + byRuntimeName.set(net.name, list); + } + + const groups: MissingExternalNetwork[] = []; + for (const [name, declarations] of byRuntimeName) { + declarations.sort((a, b) => a.key.localeCompare(b.key)); + const { safe, blockReason, unsupportedFeatures } = blockReasonForGroup(name, declarations); + groups.push({ + name, + keys: declarations.map((d) => d.key), + declarations, + safe, + blockReason, + unsupportedFeatures, + creationSpec: safe ? SAFE_CREATION_SPEC : null, + }); + } + + groups.sort((a, b) => a.name.localeCompare(b.name)); + return groups; +} diff --git a/backend/src/services/network/missingExternalNetworksError.ts b/backend/src/services/network/missingExternalNetworksError.ts new file mode 100644 index 00000000..8c88c77a --- /dev/null +++ b/backend/src/services/network/missingExternalNetworksError.ts @@ -0,0 +1,57 @@ +import type { MissingExternalNetwork } from '../network/missingExternalNetworks'; + +export type MissingExternalNetworksKind = + | 'prompt' + | 'unsupported' + | 'unavailable' + | 'create_failed'; + +/** + * Typed pre-Compose gate error. Must be thrown before atomic backup so it is + * never wrapped in ComposeRollbackError. + */ +export class MissingExternalNetworksError extends Error { + readonly code: 'missing_external_networks' | 'external_network_create_failed'; + readonly kind: MissingExternalNetworksKind; + readonly networks: MissingExternalNetwork[]; + readonly createdNames: string[]; + readonly remainingNames: string[]; + + constructor(opts: { + kind: MissingExternalNetworksKind; + message: string; + networks?: MissingExternalNetwork[]; + createdNames?: string[]; + remainingNames?: string[]; + }) { + super(opts.message); + this.name = 'MissingExternalNetworksError'; + this.kind = opts.kind; + this.code = opts.kind === 'create_failed' + ? 'external_network_create_failed' + : 'missing_external_networks'; + this.networks = opts.networks ?? []; + this.createdNames = opts.createdNames ?? []; + this.remainingNames = opts.remainingNames ?? []; + } +} + +export function isMissingExternalNetworksError(error: unknown): error is MissingExternalNetworksError { + return error instanceof MissingExternalNetworksError; +} + +export interface DeployInvocationContext { + actor?: string | null; + source: + | 'manual' + | 'rollback' + | 'template' + | 'from_git' + | 'git_apply' + | 'fleet_snapshot' + | 'labels' + | 'scheduler' + | 'webhook' + | 'blueprint' + | 'mesh_redeploy'; +} diff --git a/backend/src/services/network/networkingAggregate.ts b/backend/src/services/network/networkingAggregate.ts index 4db42780..085da359 100644 --- a/backend/src/services/network/networkingAggregate.ts +++ b/backend/src/services/network/networkingAggregate.ts @@ -112,9 +112,7 @@ function buildOverview( }); if (hasUnclassifiedPublish) unknownExposureStackCount += 1; - missingExternalCount += facts.networks.filter((network) => - network.external && facts.drift.missingFromRuntime.includes(network.name), - ).length; + missingExternalCount += facts.missingExternalNetworks.length; } const connectedContainerCount = snapshot diff --git a/backend/src/services/network/networkingFindings.ts b/backend/src/services/network/networkingFindings.ts index 3c4831e7..c006537e 100644 --- a/backend/src/services/network/networkingFindings.ts +++ b/backend/src/services/network/networkingFindings.ts @@ -327,21 +327,36 @@ export function buildNodeNetworkingFindings( if (!snapshot) continue; addComposeDriftFindings(out, facts, baseNetworks); - for (const network of facts.networks) { - if (network.external && facts.drift.missingFromRuntime.includes(network.name)) { - const isRunning = snapshot.containers.some(container => container.stack === facts.stack && ['running', 'restarting'].includes(container.state)); - out.push(finding( - 'external-network-missing', isRunning ? 'critical' : 'high', 'External network not found', - `Stack "${facts.stack}" requires the external network "${network.name}", which is not present on this node.`, - { stack: facts.stack, network: network.name }, - [ - { kind: 'create-network', label: 'Create network', networkName: network.name, requiresAdmin: true }, - { kind: 'copy-compose-snippet', label: 'Copy Compose snippet', snippetKind: 'external-network', networkName: network.name }, - { kind: 'copy-docker-command', label: 'Copy Docker command', commandKind: 'network-create', networkName: network.name }, - { kind: 'open-stack-editor', label: 'Open stack editor', stack: facts.stack }, - ], - )); + for (const missing of facts.missingExternalNetworks) { + const isRunning = snapshot.containers.some(container => container.stack === facts.stack && ['running', 'restarting'].includes(container.state)); + const actions: NetworkingRecommendedAction[] = [ + { kind: 'open-stack-editor', label: 'Open stack editor', stack: facts.stack }, + ]; + if (missing.safe) { + actions.unshift( + { kind: 'create-network', label: 'Create network', networkName: missing.name, requiresAdmin: true }, + { kind: 'copy-compose-snippet', label: 'Copy Compose snippet', snippetKind: 'external-network', networkName: missing.name }, + { kind: 'copy-docker-command', label: 'Copy Docker command', commandKind: 'network-create', networkName: missing.name }, + ); } + const reasonSuffix = missing.safe + ? '' + : ` Sencho cannot create it automatically (${[ + missing.blockReason === 'unsupported_driver' + ? `unsupported driver kind(s): ${[...new Set(missing.declarations.map((d) => d.driverKind))].join(', ')}` + : null, + missing.unsupportedFeatures.length > 0 + ? `unsupported options: ${missing.unsupportedFeatures.join(', ')}` + : null, + missing.blockReason === 'invalid_name' ? 'invalid Docker network name' : null, + missing.blockReason === 'reserved_system' ? 'reserved system network name' : null, + ].filter(Boolean).join('; ')}).`; + out.push(finding( + 'external-network-missing', isRunning ? 'critical' : 'high', 'External network not found', + `Stack "${facts.stack}" requires the external network "${missing.name}" (Compose keys: ${missing.keys.join(', ')}), which is not present on this node.${reasonSuffix}`, + { stack: facts.stack, network: missing.name }, + actions, + )); } } diff --git a/backend/src/services/network/resolveMissingExternalNetworks.ts b/backend/src/services/network/resolveMissingExternalNetworks.ts new file mode 100644 index 00000000..dc803414 --- /dev/null +++ b/backend/src/services/network/resolveMissingExternalNetworks.ts @@ -0,0 +1,124 @@ +/** + * Async resolver for missing external networks (deploy + GET only). + * Doctor and Networking facts use the pure classifier with existing I/O. + */ + +import DockerController from '../DockerController'; +import { ComposeService } from '../ComposeService'; +import { DatabaseService } from '../DatabaseService'; +import { FileSystemService } from '../FileSystemService'; +import { parseEffectiveModel, type EffectiveModel } from '../preflight/effectiveModel'; +import { parseMissingRequiredVars } from '../../helpers/envVarParse'; +import { getErrorMessage } from '../../utils/errors'; +import { redactSensitiveText, sanitizeForLog } from '../../utils/safeLog'; +import { + classifyMissingExternalNetworks, + type MissingExternalNetwork, +} from './missingExternalNetworks'; + +export type MissingExternalNetworksStatus = 'ok' | 'render_unavailable' | 'runtime_unavailable'; + +export interface MissingExternalNetworksEnvelope { + status: MissingExternalNetworksStatus; + autoCreateEnabled: boolean; + stackName: string; + networks: MissingExternalNetwork[]; + /** Count of external network declarations when the model rendered; 0 otherwise. */ + declaredExternalCount: number; +} + +const MAX_RENDER_ERROR = 600; + +function isAutoCreateEnabled(nodeId: number): boolean { + try { + return DatabaseService.getInstance().getGlobalSettings()['auto_create_missing_external_networks'] === '1'; + } catch (error) { + console.warn( + '[MissingExternalNetworks] Failed to read auto-create setting for node %s:', + nodeId, + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + return false; + } +} + +async function renderModel( + nodeId: number, + stackName: string, +): Promise<{ model: EffectiveModel | null; renderError: string | null }> { + try { + const result = await ComposeService.getInstance(nodeId).renderConfig(stackName); + if (result.rendered !== null) { + try { + return { model: parseEffectiveModel(JSON.parse(result.rendered), stackName), renderError: null }; + } catch (parseErr) { + console.warn( + '[MissingExternalNetworks] Effective model parse failed for %s:', + sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(parseErr, 'unknown')), + ); + return { model: null, renderError: 'Sencho could not parse the rendered Compose model.' }; + } + } + const missing = parseMissingRequiredVars(result.stderr); + return { + model: null, + renderError: missing.length + ? `Required variable${missing.length > 1 ? 's' : ''} ${missing.join(', ')} ${missing.length > 1 ? 'have' : 'has'} no value, so the effective model cannot be rendered.` + : 'Sencho could not render the effective Compose model. Check the compose and env files for a YAML syntax error, an unresolved include or merge, or a required variable with no value.', + }; + } catch (err) { + const msg = redactSensitiveText(getErrorMessage(err, 'docker compose could not be started.')) + .slice(0, MAX_RENDER_ERROR) + .trim() + || 'Sencho could not run docker compose on this node.'; + return { model: null, renderError: msg }; + } +} + +export async function resolveMissingExternalNetworks( + nodeId: number, + stackName: string, +): Promise { + const autoCreateEnabled = isAutoCreateEnabled(nodeId); + const { model } = await renderModel(nodeId, stackName); + if (!model) { + return { + status: 'render_unavailable', + autoCreateEnabled, + stackName, + networks: [], + declaredExternalCount: 0, + }; + } + + const declaredExternalCount = Object.values(model.networks).filter((n) => n.external).length; + + let existingNames: Set; + try { + const knownStacks = await FileSystemService.getInstance(nodeId).getStacks(); + const snapshot = await DockerController.getInstance(nodeId).getDependencySnapshot(knownStacks); + existingNames = new Set(snapshot.networks.map((n) => n.name)); + } catch (error) { + console.warn( + '[MissingExternalNetworks] Runtime snapshot unavailable for %s:', + sanitizeForLog(stackName), + sanitizeForLog(getErrorMessage(error, 'unknown')), + ); + return { + status: 'runtime_unavailable', + autoCreateEnabled, + stackName, + networks: [], + declaredExternalCount, + }; + } + + return { + status: 'ok', + autoCreateEnabled, + stackName, + networks: classifyMissingExternalNetworks(model, existingNames), + declaredExternalCount, + }; +} diff --git a/backend/src/services/network/types.ts b/backend/src/services/network/types.ts index 07090439..387a60a8 100644 --- a/backend/src/services/network/types.ts +++ b/backend/src/services/network/types.ts @@ -83,4 +83,9 @@ export interface StackNetworkFacts { networks: NetworkFactNetwork[]; services: NetworkFactService[]; drift: NetworkDriftFacts; + /** + * Missing external networks from the pure classifier. Empty when runtime is + * unavailable or the model is not renderable (use `runtime` / `renderable`). + */ + missingExternalNetworks: import('./missingExternalNetworks').MissingExternalNetwork[]; } diff --git a/backend/src/services/preflight/effectiveModel.ts b/backend/src/services/preflight/effectiveModel.ts index 5d0ba1a0..44d6f41e 100644 --- a/backend/src/services/preflight/effectiveModel.ts +++ b/backend/src/services/preflight/effectiveModel.ts @@ -70,12 +70,43 @@ export interface EffService { labelKeys: string[]; } +/** + * Display-safe driver classification. Arbitrary interpolated driver strings + * (which can carry resolved .env values) are collapsed to `custom` and never + * retained as raw text on the model. + */ +export type EffDriverKind = + | 'default' + | 'bridge' + | 'overlay' + | 'macvlan' + | 'ipvlan' + | 'host' + | 'none' + | 'custom'; + export interface EffResource { /** Resolved docker name (compose config fills this in). */ name: string; external: boolean; /** Top-level `internal: true` (no outbound/host connectivity for the network). */ internal: boolean; + /** + * Bounded driver kind; never a raw interpolated driver string. + * Absent on older hand-built test fixtures; treated as `default`. + */ + driverKind?: EffDriverKind; + /** True when `driver_opts` is a non-empty object. */ + hasDriverOpts?: boolean; + /** True when IPAM has a non-empty driver, config, or options (empty `{}` is safe). */ + hasCustomIpam?: boolean; + attachable?: boolean; + /** False only when `enable_ipv4` is explicitly false. Absent means enabled. */ + ipv4Enabled?: boolean; + /** True only when `enable_ipv6` is explicitly true. Absent means disabled. */ + ipv6Enabled?: boolean; + /** True when `labels` is a non-empty object (values are never retained). */ + hasLabels?: boolean; } export interface EffectiveModel { @@ -293,12 +324,53 @@ function parseExtraHosts(extraHosts: unknown): string[] { return []; } +const KNOWN_DRIVER_KINDS: ReadonlySet = new Set([ + 'bridge', 'overlay', 'macvlan', 'ipvlan', 'host', 'none', +]); + +function toDriverKind(raw: string | undefined): EffDriverKind { + if (raw === undefined || raw === '') return 'default'; + const lower = raw.toLowerCase(); + if (KNOWN_DRIVER_KINDS.has(lower)) return lower as EffDriverKind; + return 'custom'; +} + +function isNonEmptyObject(value: unknown): boolean { + return !!value && typeof value === 'object' && !Array.isArray(value) + && Object.keys(value as Record).length > 0; +} + +/** Empty `ipam: {}` is a Compose-normalized default; only non-empty IPAM blocks. */ +function hasCustomIpam(ipam: unknown): boolean { + if (!ipam || typeof ipam !== 'object' || Array.isArray(ipam)) return false; + const o = ipam as Record; + if (typeof o.driver === 'string' && o.driver.trim() !== '') return true; + if (Array.isArray(o.config) && o.config.length > 0) return true; + if (isNonEmptyObject(o.options)) return true; + return false; +} + +function parseResourceEntry(key: string, entry: unknown): EffResource { + const o = (entry ?? {}) as Record; + return { + name: str(o.name) ?? key, + external: o.external === true, + internal: o.internal === true, + driverKind: toDriverKind(str(o.driver)), + hasDriverOpts: isNonEmptyObject(o.driver_opts), + hasCustomIpam: hasCustomIpam(o.ipam), + attachable: o.attachable === true, + ipv4Enabled: o.enable_ipv4 !== false, + ipv6Enabled: o.enable_ipv6 === true, + hasLabels: isNonEmptyObject(o.labels), + }; +} + function parseResources(value: unknown): Record { const out: Record = {}; if (value && typeof value === 'object' && !Array.isArray(value)) { for (const [key, entry] of Object.entries(value as Record)) { - const o = (entry ?? {}) as Record; - out[key] = { name: str(o.name) ?? key, external: o.external === true, internal: o.internal === true }; + out[key] = parseResourceEntry(key, entry); } } return out; diff --git a/backend/src/services/preflight/rules.ts b/backend/src/services/preflight/rules.ts index de0c4148..e58ca8e3 100644 --- a/backend/src/services/preflight/rules.ts +++ b/backend/src/services/preflight/rules.ts @@ -2,6 +2,7 @@ import type { PreflightContext, PreflightFinding, PreflightSeverity, NodePortBin import type { EffService, EffPortSpec } from './effectiveModel'; import type { ExposureIntent } from '../network/types'; import { isLoopback, runtimeResourceName } from '../network/normalize'; +import { classifyMissingExternalNetworks } from '../network/missingExternalNetworks'; /** Higher number = more severe. Used to derive a run's overall status. */ export const SEVERITY_RANK: Record = { info: 0, warning: 1, high: 2, blocker: 3 }; @@ -434,19 +435,37 @@ const externalNetworkMissing: PreflightRule = { id: 'external-network-missing', run(ctx) { if (!ctx.model || !ctx.nodeStateAvailable) return []; - const findings: PreflightFinding[] = []; - for (const [key, net] of Object.entries(ctx.model.networks)) { - if (!net.external || ctx.existingNetworkNames.has(net.name)) continue; - findings.push({ + return classifyMissingExternalNetworks(ctx.model, ctx.existingNetworkNames).map((group) => { + const keysLabel = group.keys.map((k) => `networks.${k}`).join(', '); + if (group.safe) { + return { + ruleId: 'external-network-missing', + severity: 'blocker' as const, + title: 'External network not found', + message: `The model requires the external network "${group.name}" (Compose keys: ${group.keys.join(', ')}), which does not exist on this node. The deploy will fail.`, + sourcePath: keysLabel, + remediation: `Create it with: docker network create ${group.name}`, + }; + } + const reasons = [ + group.blockReason === 'unsupported_driver' + ? `unsupported driver kind(s): ${[...new Set(group.declarations.map((d) => d.driverKind))].join(', ')}` + : null, + group.unsupportedFeatures.length > 0 + ? `unsupported options: ${group.unsupportedFeatures.join(', ')}` + : null, + group.blockReason === 'invalid_name' ? 'invalid Docker network name' : null, + group.blockReason === 'reserved_system' ? 'reserved system network name' : null, + ].filter(Boolean).join('; '); + return { ruleId: 'external-network-missing', - severity: 'blocker', + severity: 'blocker' as const, title: 'External network not found', - message: `The model requires the external network "${net.name}", which does not exist on this node. The deploy will fail.`, - sourcePath: `networks.${key}`, - remediation: `Create it with: docker network create ${net.name}`, - }); - } - return findings; + message: `The model requires the external network "${group.name}" (Compose keys: ${group.keys.join(', ')}), which does not exist on this node. Sencho cannot create it automatically (${reasons}).`, + sourcePath: keysLabel, + remediation: 'Create this network outside Sencho with the required driver and options, or simplify the Compose declaration to a plain external bridge network.', + }; + }); }, }; diff --git a/backend/src/types/express.ts b/backend/src/types/express.ts index 9aa552fa..abd63dbc 100644 --- a/backend/src/types/express.ts +++ b/backend/src/types/express.ts @@ -21,6 +21,10 @@ declare global { mfaPendingSso?: boolean; /** Cached remote-proxy target resolved by `remoteNodeProxy`'s outer gate so the http-proxy router/proxyReq callbacks do not re-resolve. */ proxyTarget?: { apiUrl: string; apiToken: string }; + /** Trusted deploy provenance from machine auth or gateway overwrite. */ + deployContext?: import('../services/network/missingExternalNetworksError').DeployInvocationContext; + /** Verified JWT scope for machine credentials (`node_proxy` / `pilot_tunnel`). */ + machineAuthScope?: 'node_proxy' | 'pilot_tunnel'; } } } diff --git a/docs/features/compose-doctor.mdx b/docs/features/compose-doctor.mdx index 493b265d..acbe41bc 100644 --- a/docs/features/compose-doctor.mdx +++ b/docs/features/compose-doctor.mdx @@ -136,7 +136,7 @@ The rules in this category (except "Node-state checks skipped" itself) read live | Rule | Severity | What it detects | |------|----------|----------------| | Node-state checks skipped | Info | The Docker daemon could not be reached; node-state rules did not run and this result is partial. | -| External network not found | Blocker | A network declared `external: true` does not exist on this node. The deploy will fail. | +| External network not found | Blocker | A network declared `external: true` does not exist on this node. Deploy is blocked until the network exists. For safe default bridge networks, Doctor may offer a create remediation; advanced drivers and custom options stay manual. | | External volume not found | Blocker | A volume declared `external: true` does not exist on this node. The deploy will fail. | | New network will be created | Info | A non-external network that does not yet exist on this node will be created on first deploy. | | New volume will be created | Info | A named volume that does not yet exist on this node will be created on first deploy. | diff --git a/docs/features/compose-networking.mdx b/docs/features/compose-networking.mdx index bc24e7be..9ee1ecc8 100644 --- a/docs/features/compose-networking.mdx +++ b/docs/features/compose-networking.mdx @@ -92,7 +92,7 @@ The Networks section lists every network the effective model declares: When a stack declares no explicit networks, the section shows **default network only**: all services share the implicit Docker bridge that Compose creates automatically. -External networks that do not exist on the node when you deploy will cause the deploy to fail. Compose Doctor flags missing external networks before you apply a change. +External networks that do not exist on the node are detected before Compose runs. Interactive deploy opens a guided dialog so you can create safe bridge networks, copy a terminal create command, cancel, or open the Networking view. Automatic creation of those safe networks is optional under Settings → Stacks. Unsupported drivers or options are never created silently; the deploy stays blocked until you fix the Compose declaration or create the network yourself. Compose Doctor also flags missing external networks before you apply a change. ## Services diff --git a/docs/features/networking.mdx b/docs/features/networking.mdx index ffe6351d..f864d5a3 100644 --- a/docs/features/networking.mdx +++ b/docs/features/networking.mdx @@ -40,7 +40,7 @@ Admins can open **Create network** from the page header. The dialog matches the ## Findings -The **Findings** tab lists Compose-first networking issues Sencho derives from the effective model plus one live Docker snapshot per refresh, grouped into **Needs action**, **Review recommended**, and **Informational**. Examples include missing external networks, duplicate network names across stacks, host-network exposure mismatches, and stacks whose model could not be rendered. +The **Findings** tab lists Compose-first networking issues Sencho derives from the effective model plus one live Docker snapshot per refresh, grouped into **Needs action**, **Review recommended**, and **Informational**. Examples include missing external networks, duplicate network names across stacks, host-network exposure mismatches, and stacks whose model could not be rendered. Missing external findings only offer create actions when the declaration is a safe default bridge network. Findings also fold in the networking-relevant results from your last Compose Doctor run on each stack. A finding both engines detect shows **Live · also found by Doctor**; a finding only Doctor caught (Doctor checks a few things the live engine does not, such as host port conflicts) shows **Last Doctor run** with the timestamp of that run. Doctor's contribution is always the cached result of the last run on that stack, not a fresh check, so run Doctor again on a stack if you want its findings current. An active acknowledgement on a Doctor finding excludes it here too. diff --git a/docs/features/node-compatibility.mdx b/docs/features/node-compatibility.mdx index 169ca889..8248c636 100644 --- a/docs/features/node-compatibility.mdx +++ b/docs/features/node-compatibility.mdx @@ -76,11 +76,16 @@ Every Sencho release ships with a static list of capabilities. The current list | `registries` | Private registry management | | `self-update` | Self-update from the dashboard | | `vulnerability-scanning` | Image vulnerability scanning | +| `compose-doctor` | Compose Doctor preflight | +| `compose-networking` | Stack Networking tab and node Networking overview | +| `guided-external-network-preflight` | Guided missing-external-network check before deploy | `vulnerability-scanning` is advertised only when the Trivy binary is present on the node. If a node does not have Trivy installed, it omits this capability and the scanning UI is replaced by a lock card. +Nodes that do not advertise `guided-external-network-preflight` keep legacy deploy behavior (no guided missing-external dialog). When the capability is present but the check cannot run, deploy fails closed instead of guessing. + ## Handling nodes that do not advertise metadata If a remote node does not respond to `/api/meta` (for example, an unreachable instance, a slow handshake, or one that does not implement the endpoint), Sencho falls back to an offline metadata record with no version and an empty capability list. In that state: diff --git a/docs/features/stack-activity.mdx b/docs/features/stack-activity.mdx index dbb31aed..62e09da1 100644 --- a/docs/features/stack-activity.mdx +++ b/docs/features/stack-activity.mdx @@ -22,7 +22,7 @@ Each entry in the activity list contains four pieces of information: ## Event categories -Ten categories have a dedicated icon. Any other notification scoped to a stack (deploy failures, scan findings, monitor alerts) also flows into the timeline and renders with a generic activity icon. +Ten categories have a dedicated icon. Any other notification scoped to a stack (deploy failures, scan findings, monitor alerts) also flows into the timeline and renders with a generic activity icon. Automatic creation of missing external networks during deploy writes a history-only activity row (`network_auto_created`) on the stack timeline; it does not appear in the notification bell. | Category | Example message | |----------|-----------------| diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 3340fe2c..7144aaf6 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1419,6 +1419,36 @@ paths: when the health gate is disabled on the node. "403": $ref: "#/components/responses/Forbidden" + "409": + description: | + Deploy blocked before Compose ran. Common codes include a stack + operation already in progress, a scan-policy block, or missing + external networks (`code: missing_external_networks`). + content: + application/json: + schema: + type: object + properties: + error: + type: string + code: + type: string + example: missing_external_networks + kind: + type: string + enum: [prompt, unsupported, unavailable, create_failed] + networks: + type: array + items: + type: object + createdNames: + type: array + items: + type: string + remainingNames: + type: array + items: + type: string "500": description: Deployment failed. content: @@ -1434,6 +1464,77 @@ paths: description: Whether the stack was automatically rolled back. failure: $ref: "#/components/schemas/FailureClassification" + "503": + description: | + Deploy blocked because Sencho could not render the Compose model or + read Docker networking state to verify required external networks. + + /api/stacks/{stackName}/missing-external-networks: + get: + operationId: getMissingExternalNetworks + tags: [Stacks] + summary: List missing external networks for a stack + description: | + Returns whether declared `external: true` networks are missing on the + target node, whether automatic creation is enabled, and per-network + safety metadata. Requires `stack:read`. Used by interactive deploy + preflight when the node advertises `guided-external-network-preflight`. + parameters: + - $ref: "#/components/parameters/stackName" + - $ref: "#/components/parameters/nodeId" + responses: + "200": + description: Missing-external-network envelope. + content: + application/json: + schema: + type: object + required: [status, autoCreateEnabled, stackName, networks, declaredExternalCount] + properties: + status: + type: string + enum: [ok, render_unavailable, runtime_unavailable] + autoCreateEnabled: + type: boolean + stackName: + type: string + declaredExternalCount: + type: integer + networks: + type: array + items: + type: object + required: [name, keys, declarations, safe, unsupportedFeatures, creationSpec] + properties: + name: + type: string + keys: + type: array + items: + type: string + declarations: + type: array + items: + type: object + safe: + type: boolean + blockReason: + type: string + unsupportedFeatures: + type: array + items: + type: string + creationSpec: + type: object + nullable: true + "403": + $ref: "#/components/responses/Forbidden" + "404": + description: Stack not found. + content: + application/json: + schema: + $ref: "#/components/schemas/Error" /api/stacks/{stackName}/down: post: diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index fcc1b48b..2ced5066 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -675,6 +675,7 @@ Node-level safety checks and post-deploy observation used during stack deploys a | **Observe health after updates** | On | After a stack deploy or update succeeds, watch its containers for the observation window and record a passed or failed verdict on the stack timeline. Observational only: nothing is restarted or rolled back automatically. | | **Observation window** | 90 s | How long to watch containers before declaring the update healthy. Raise it for stacks that take a while to settle. Range 15 to 600 seconds. | | **Block deploy on missing required env vars** | Off | When on, a deploy or update is refused before it starts if a required `${VAR:?message}` variable is unset or empty, so the stack fails fast with a clear message instead of mid-deploy. | +| **Automatically create missing external networks during deploy** | Off | When on, safe missing external bridge networks are created automatically before deploy continues. When off, interactive deploy prompts first. Advanced drivers and custom options are never auto-created. | Click **Save settings** to apply. diff --git a/frontend/src/components/EditorLayout.tsx b/frontend/src/components/EditorLayout.tsx index 811cd15e..dcefb763 100644 --- a/frontend/src/components/EditorLayout.tsx +++ b/frontend/src/components/EditorLayout.tsx @@ -270,6 +270,7 @@ export default function EditorLayout() { getLastDeployOutputLine, diffPreviewEnabled, hasUpdateGuard: hasCapability('update-guard'), + hasGuidedExternalNetworkPreflight: hasCapability('guided-external-network-preflight'), canEditStack: (stackNameOrFilename) => { const stackName = stackNameOrFilename.replace(/\.(ya?ml)$/, ''); return can('stack:edit', 'stack', stackName); diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index d19f29aa..8e683077 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -1,6 +1,7 @@ import BashExecModal from '../BashExecModal'; import { PolicyBlockDialog } from '../stack/PolicyBlockDialog'; import { PreDeployScanDialog } from '../stack/PreDeployScanDialog'; +import { MissingExternalNetworksDialog } from '../stack/MissingExternalNetworksDialog'; import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog'; import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog'; import { DeleteStackDialog } from './DeleteStackDialog'; @@ -54,6 +55,7 @@ export function ShellOverlays({ policyBlock, setPolicyBlock, policyBypassing, updateReadiness, setUpdateReadiness, preDeployAdvisory, + missingExternalNetworks, setMissingExternalNetworks, selfStackProtectedOpen, setSelfStackProtectedOpen, stackMisconfigScanId, setStackMisconfigScanId, diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, @@ -135,6 +137,24 @@ export function ShellOverlays({ onDeploy={() => preDeployAdvisory?.proceed()} /> + { + missingExternalNetworks?.cancel(); + setMissingExternalNetworks(null); + }} + onOpenNetworking={() => { + missingExternalNetworks?.openNetworking(); + setMissingExternalNetworks(null); + }} + onCreateAndContinue={() => { + void missingExternalNetworks?.createAndContinue(); + }} + /> + {/* Pre-deploy policy block */} void; } | null>(null); + const [missingExternalNetworks, setMissingExternalNetworks] = useState<{ + payload: import('../../stack/MissingExternalNetworksDialog').MissingExternalNetworksPayload; + creating: boolean; + cancel: () => void; + openNetworking: () => void; + createAndContinue: () => void; + } | null>(null); + const [selfStackProtectedOpen, setSelfStackProtectedOpen] = useState(false); const openSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(true), []); const closeSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(false), []); @@ -166,6 +174,7 @@ export function useOverlayState() { policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing, updateReadiness, setUpdateReadiness, preDeployAdvisory, setPreDeployAdvisory, + missingExternalNetworks, setMissingExternalNetworks, selfStackProtectedOpen, setSelfStackProtectedOpen, openSelfStackProtected, closeSelfStackProtected, stackMisconfigScanId, setStackMisconfigScanId, diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index 66e38a96..1d1dfaf9 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -23,6 +23,9 @@ import type { EditorTab, RouteStackLoadResult } from '@/lib/router/routeTypes'; import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView'; import type { NotificationItem } from '../../dashboard/types'; import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog'; +import type { + MissingExternalNetworksPayload, +} from '../../stack/MissingExternalNetworksDialog'; import type { PreDeployScanImage } from '@/types/security'; interface RunResult { @@ -31,8 +34,17 @@ interface RunResult { rolledBack?: boolean; /** Health gate run id from the success body, when the backend started one. */ healthGateId?: string | null; + /** + * Deploy hit a missing-external-networks gate; the dialog owns deployPendingRef + * until the operator cancels or continues. + */ + deferredNetworks?: boolean; } +type MissingExternalNetworksEnvelope = MissingExternalNetworksPayload & { + declaredExternalCount: number; +}; + /** healthGateId from a success body, or null when absent or unreadable. */ const parseHealthGateId = async (response: Response): Promise => { try { @@ -138,6 +150,9 @@ interface UseStackActionsOptions { // the pre-update readiness dialog. Defaults to false: without the // capability, updates run directly with no dialog. hasUpdateGuard?: boolean; + // Active node advertises guided external-network preflight. Absent capability + // keeps legacy deploy (no GET). Advertised-but-broken fails closed. + hasGuidedExternalNetworkPreflight?: boolean; // Target-aware stack:edit check. Pass the loaded stack identity (folder name // or compose path); callers strip extensions when comparing to RBAC stack // names. Evaluated against the load target so post-load auto-edit is not @@ -182,6 +197,108 @@ export async function fetchPreDeployAdvisory( } } +const MISSING_EXTERNAL_PREFLIGHT_TIMEOUT_MS = 10000; + +function parseMissingExternalNetworksPayload(data: unknown): MissingExternalNetworksPayload | null { + if (!isRecord(data)) return null; + if ( + data.status !== 'ok' + && data.status !== 'render_unavailable' + && data.status !== 'runtime_unavailable' + ) { + return null; + } + if (typeof data.stackName !== 'string' || typeof data.autoCreateEnabled !== 'boolean') return null; + if (!Array.isArray(data.networks)) return null; + return { + status: data.status, + autoCreateEnabled: data.autoCreateEnabled, + stackName: data.stackName, + networks: data.networks as MissingExternalNetworksPayload['networks'], + }; +} + +/** + * Authoritative missing-external preflight for the captured deploy node. + * Returns null only when the route is missing or the body is unusable (treat + * as fail-closed when the capability is advertised). + */ +export async function fetchMissingExternalNetworks( + stackName: string, + opNodeId: number | null, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), MISSING_EXTERNAL_PREFLIGHT_TIMEOUT_MS); + try { + const res = await apiFetch( + `/stacks/${encodeURIComponent(stackName)}/missing-external-networks`, + { nodeId: opNodeId, signal: controller.signal }, + ); + if (!res.ok) return null; + const data: unknown = await res.json(); + const payload = parseMissingExternalNetworksPayload(data); + if (!payload || !isRecord(data)) return null; + const declaredExternalCount = typeof data.declaredExternalCount === 'number' + ? data.declaredExternalCount + : 0; + return { ...payload, declaredExternalCount }; + } catch (error) { + console.error('Failed to fetch missing external networks:', error); + return null; + } finally { + clearTimeout(timer); + } +} + +async function createSafeExternalNetworks( + networks: MissingExternalNetworksPayload['networks'], + opNodeId: number | null, +): Promise<{ ok: boolean; errorMessage?: string }> { + for (const network of networks.filter((n) => n.safe)) { + try { + const res = await apiFetch('/system/networks', { + method: 'POST', + nodeId: opNodeId, + body: JSON.stringify({ name: network.name, driver: 'bridge' }), + }); + if (res.ok || res.status === 409) continue; + const body: unknown = await res.json().catch(() => null); + const message = isRecord(body) && typeof body.error === 'string' + ? body.error + : `Failed to create network "${network.name}" (${res.status})`; + return { ok: false, errorMessage: message }; + } catch (error) { + console.error('Failed to create external network:', error); + return { + ok: false, + errorMessage: error instanceof Error ? error.message : `Failed to create network "${network.name}"`, + }; + } + } + return { ok: true }; +} + +function missingExternalBlocksDeploy( + envelope: MissingExternalNetworksEnvelope, +): string | null { + if (envelope.status === 'render_unavailable') { + return 'Sencho could not render this stack\'s Compose model to check external networks.'; + } + if (envelope.status === 'runtime_unavailable' && envelope.declaredExternalCount > 0) { + return 'Sencho could not read Docker networking state to check external networks.'; + } + return null; +} + +function getResponseCode(rawBody: string): string | undefined { + try { + const payload: unknown = JSON.parse(rawBody); + return isRecord(payload) && typeof payload.code === 'string' ? payload.code : undefined; + } catch { + return undefined; + } +} + const parseStackOpInProgress = (rawBody: string): StackOpInProgressInfo | null => { try { const parsed: unknown = JSON.parse(rawBody); @@ -261,6 +378,7 @@ export function useStackActions(options: UseStackActionsOptions) { getLastDeployOutputLine, diffPreviewEnabled, hasUpdateGuard = false, + hasGuidedExternalNetworkPreflight = false, canEditStack, canOfferVolumeRemoval = false, } = options; @@ -857,6 +975,96 @@ export function useStackActions(options: UseStackActionsOptions) { return null; }; + const openMissingExternalNetworksDialog = ( + payload: MissingExternalNetworksPayload, + opNodeId: number | null, + onContinue: () => void, + ) => { + let settled = false; + const finishCancel = () => { + if (settled) return; + settled = true; + overlayState.setMissingExternalNetworks(null); + deployPendingRef.current = false; + }; + overlayState.setMissingExternalNetworks({ + payload, + creating: false, + cancel: finishCancel, + openNetworking: () => { + finishCancel(); + const node = nodes.find((n) => n.id === opNodeId); + if (node && activeNode?.id !== opNodeId) setActiveNode(node); + navState.setActiveView('networking'); + }, + createAndContinue: () => void createAndContinue(), + }); + + function setCreating(creating: boolean, verified?: MissingExternalNetworksPayload): void { + overlayState.setMissingExternalNetworks((current) => ( + current + ? { ...current, creating, ...(verified ? { payload: verified } : {}) } + : current + )); + } + + async function createAndContinue(): Promise { + if (settled) return; + setCreating(true); + + const created = await createSafeExternalNetworks(payload.networks, opNodeId); + if (!created.ok) { + toast.error(created.errorMessage ?? 'Failed to create external networks'); + setCreating(false); + return; + } + + const verified = await fetchMissingExternalNetworks(payload.stackName, opNodeId); + if (!verified || verified.status !== 'ok') { + toast.error('Could not verify external networks after create.'); + setCreating(false); + return; + } + + const stillMissing = payload.networks.some((needed) => ( + verified.networks.some((network) => network.name === needed.name) + )); + if (stillMissing) { + toast.error('Some external networks are still missing after create.'); + setCreating(false, verified); + return; + } + + settled = true; + overlayState.setMissingExternalNetworks(null); + onContinue(); + } + }; + + const finishSuccessfulDeploy = async ( + response: Response, + stackName: string, + stackFile: string, + ignorePolicy: boolean, + ): Promise => { + overlayState.setPolicyBlock(null); + const healthGateId = await parseHealthGateId(response); + if (healthGateId) { + toast.info(ignorePolicy ? 'Stack deployed (policy bypassed). Verifying health...' : 'Stack deployed. Verifying health...'); + } else { + toast.success(ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!'); + } + await refreshSelectedContainers(stackName, stackFile); + try { + const backupRes = await apiFetch(`/stacks/${stackName}/backup`); + if (backupRes.ok) editorState.setBackupInfo(await backupRes.json()); + } catch { + /* ignore */ + } + stackListState.recordActionSuccess(stackFile); + return { ok: true, healthGateId }; + }; + const runDeploy = async ( stackName: string, stackFile: string, @@ -897,27 +1105,47 @@ export function useStackActions(options: UseStackActionsOptions) { toast.error(message); return { ok: false, errorMessage: message }; } + if ( + getResponseCode(rawBody) === 'missing_external_networks' + && hasGuidedExternalNetworkPreflight + ) { + const envelope = await fetchMissingExternalNetworks(stackName, opNodeId ?? null); + if (!envelope) { + toast.error('Deploy needs missing external networks, but Sencho could not re-check them.'); + return { ok: false, errorMessage: 'Missing external networks check failed' }; + } + const blockMessage = missingExternalBlocksDeploy(envelope); + if (blockMessage) { + toast.error(blockMessage); + return { ok: false, errorMessage: blockMessage }; + } + if (envelope.status === 'ok' && envelope.networks.length === 0) { + // Networks appeared between gate and refetch; retry once. + const retry = await apiFetch( + path, + withDeploySession(deploySessionId ?? '', { method: 'POST', nodeId: opNodeId }), + ); + if (retry.ok) { + return finishSuccessfulDeploy(retry, stackName, stackFile, ignorePolicy); + } + throw parseStackActionError(await retry.text(), 'Deploy failed', retry.status); + } + openMissingExternalNetworksDialog(envelope, opNodeId ?? null, () => { + stackListState.setStackAction(stackFile, 'deploy'); + void runWithLog({ stackName, action: 'deploy', nodeId: opNodeId ?? null }, (startedRetry, ds) => + runDeploy(stackName, stackFile, ignorePolicy, startedRetry, ds, opNodeId), + ).finally(() => { + stackListState.clearStackAction(stackFile); + stackListState.refreshStacks(true); + deployPendingRef.current = false; + }); + }); + return { ok: false, deferredNetworks: true }; + } } throw parseStackActionError(rawBody, 'Deploy failed', response.status); } - overlayState.setPolicyBlock(null); - const healthGateId = await parseHealthGateId(response); - // With a health gate observing, the operation finishing is not the - // final verdict yet; soften the toast so success is not claimed twice. - if (healthGateId) { - toast.info(ignorePolicy ? 'Stack deployed (policy bypassed). Verifying health...' : 'Stack deployed. Verifying health...'); - } else { - toast.success(ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!'); - } - await refreshSelectedContainers(stackName, stackFile); - try { - const backupRes = await apiFetch(`/stacks/${stackName}/backup`); - if (backupRes.ok) editorState.setBackupInfo(await backupRes.json()); - } catch { - /* ignore */ - } - stackListState.recordActionSuccess(stackFile); - return { ok: true, healthGateId }; + return finishSuccessfulDeploy(response, stackName, stackFile, ignorePolicy); } catch (error) { console.error('Failed to deploy:', error); if (previousStatus !== undefined) @@ -957,19 +1185,52 @@ export function useStackActions(options: UseStackActionsOptions) { // (not before the advisory) so cancelling the advisory leaves no stuck state. const runDeployFlow = async () => { stackListState.setStackAction(stackFile, 'deploy'); + let deferredNetworks = false; try { - await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) => + const result = await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) => runDeploy(stackName, stackFile, false, started, ds, opNodeId), ); + deferredNetworks = result.deferredNetworks === true; } finally { stackListState.clearStackAction(stackFile); stackListState.refreshStacks(true); - deployPendingRef.current = false; + if (!deferredNetworks) { + deployPendingRef.current = false; + } } }; + const continueAfterExternalNetworks = () => { + void runDeployFlow(); + }; + // Advisory runs before the deploy log opens (fails open: a null result means // setting off / timeout / older node / error, so the deploy proceeds). + const beginDeployAfterAdvisory = async () => { + if (hasGuidedExternalNetworkPreflight) { + const envelope = await fetchMissingExternalNetworks(stackName, opNodeId); + if (!envelope) { + toast.error('Sencho could not check external networks on this node before deploy.'); + deployPendingRef.current = false; + return; + } + const blockMessage = missingExternalBlocksDeploy(envelope); + if (blockMessage) { + toast.error(blockMessage); + deployPendingRef.current = false; + return; + } + if (envelope.status === 'ok' && envelope.networks.length > 0) { + const allSafe = envelope.networks.every((n) => n.safe); + if (!(allSafe && envelope.autoCreateEnabled)) { + openMissingExternalNetworksDialog(envelope, opNodeId, continueAfterExternalNetworks); + return; + } + } + } + await runDeployFlow(); + }; + const advisoryImages = await fetchPreDeployAdvisory(stackName, opNodeId); if (advisoryImages && advisoryImages.length > 0) { let settled = false; @@ -980,7 +1241,7 @@ export function useStackActions(options: UseStackActionsOptions) { if (settled) return; settled = true; overlayState.setPreDeployAdvisory(null); - void runDeployFlow(); + void beginDeployAfterAdvisory(); }, cancel: () => { if (settled) return; @@ -991,7 +1252,7 @@ export function useStackActions(options: UseStackActionsOptions) { }); return; } - await runDeployFlow(); + await beginDeployAfterAdvisory(); }; const handleSaveAndDeploy = async (e: React.MouseEvent) => { diff --git a/frontend/src/components/settings/StacksSection.tsx b/frontend/src/components/settings/StacksSection.tsx index 43757298..de5cd603 100644 --- a/frontend/src/components/settings/StacksSection.tsx +++ b/frontend/src/components/settings/StacksSection.tsx @@ -29,12 +29,13 @@ interface StacksSectionProps { onDirtyChange?: (dirty: boolean) => void; } -type GuardrailFields = Pick; +type GuardrailFields = Pick; const DEFAULT_GUARDRAILS: GuardrailFields = { health_gate_enabled: DEFAULT_SETTINGS.health_gate_enabled, health_gate_window_seconds: DEFAULT_SETTINGS.health_gate_window_seconds, env_block_deploy_on_missing_required: DEFAULT_SETTINGS.env_block_deploy_on_missing_required, + auto_create_missing_external_networks: DEFAULT_SETTINGS.auto_create_missing_external_networks, }; function GuardrailSkeleton() { @@ -87,6 +88,7 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) { health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled, health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds, env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required, + auto_create_missing_external_networks: (nodeData.auto_create_missing_external_networks as '0' | '1') ?? DEFAULT_SETTINGS.auto_create_missing_external_networks, }; reset(safe); } catch (e) { @@ -223,6 +225,15 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) { onChange={(next) => onGuardrailChange('env_block_deploy_on_missing_required', next ? '1' : '0')} /> + + onGuardrailChange('auto_create_missing_external_networks', next ? '1' : '0')} + /> + diff --git a/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx b/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx index 08c9d851..f26e55a4 100644 --- a/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx +++ b/frontend/src/components/settings/__tests__/SectionSavePayloads.test.tsx @@ -57,6 +57,7 @@ const FULL_SETTINGS: Record = { health_gate_enabled: '1', health_gate_window_seconds: '90', env_block_deploy_on_missing_required: '0', + auto_create_missing_external_networks: '0', }; function patchedKeys(): string[] { @@ -110,6 +111,7 @@ describe('split section save payloads', () => { fireEvent.click(save); await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true)); expect(patchedKeys()).toEqual([ + 'auto_create_missing_external_networks', 'env_block_deploy_on_missing_required', 'health_gate_enabled', 'health_gate_window_seconds', diff --git a/frontend/src/components/settings/types.ts b/frontend/src/components/settings/types.ts index efbeba45..f438de89 100644 --- a/frontend/src/components/settings/types.ts +++ b/frontend/src/components/settings/types.ts @@ -20,6 +20,7 @@ export interface PatchableSettings { health_gate_enabled?: '0' | '1'; health_gate_window_seconds?: string; env_block_deploy_on_missing_required?: '0' | '1'; + auto_create_missing_external_networks?: '0' | '1'; image_update_sidebar_indicators?: '0' | '1'; } @@ -45,6 +46,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = { health_gate_enabled: '1', health_gate_window_seconds: '90', env_block_deploy_on_missing_required: '0', + auto_create_missing_external_networks: '0', image_update_sidebar_indicators: '1', }; diff --git a/frontend/src/components/stack/MissingExternalNetworksDialog.tsx b/frontend/src/components/stack/MissingExternalNetworksDialog.tsx new file mode 100644 index 00000000..8b99a5ca --- /dev/null +++ b/frontend/src/components/stack/MissingExternalNetworksDialog.tsx @@ -0,0 +1,185 @@ +import { useState } from 'react'; +import { MoreHorizontal } from 'lucide-react'; +import { + Modal, + ModalHeader, + ModalBody, +} from '@/components/ui/modal'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { canUseNetworkName } from '@/lib/networking'; +import { copyToClipboard } from '@/lib/clipboard'; +import { toast } from '@/components/ui/toast-store'; + +export type MissingExternalNetworkDto = { + name: string; + keys: string[]; + declarations: Array<{ + key: string; + driverKind: string; + unsupportedFeatures: string[]; + }>; + safe: boolean; + blockReason?: string; + unsupportedFeatures: string[]; + creationSpec: { driver: 'bridge'; options: 'default' } | null; +}; + +export type MissingExternalNetworksPayload = { + status: 'ok' | 'render_unavailable' | 'runtime_unavailable'; + autoCreateEnabled: boolean; + stackName: string; + networks: MissingExternalNetworkDto[]; +}; + +interface MissingExternalNetworksDialogProps { + open: boolean; + payload: MissingExternalNetworksPayload | null; + isAdmin: boolean; + creating?: boolean; + onCancel: () => void; + onOpenNetworking: () => void; + onCreateAndContinue: () => void; +} + +function buildCreateCommands(networks: MissingExternalNetworkDto[]): string { + return networks + .filter((n) => n.safe && canUseNetworkName(n.name)) + .map((n) => n.name) + .sort((a, b) => a.localeCompare(b)) + .map((name) => `docker network create ${name}`) + .join('\n'); +} + +function formatUnsafeReason(net: MissingExternalNetworkDto): string { + const parts: string[] = []; + if (net.blockReason === 'unsupported_driver') { + const kinds = [...new Set(net.declarations.map((d) => d.driverKind))]; + parts.push(`Driver kinds: ${kinds.join(', ')}`); + } + if (net.unsupportedFeatures.length > 0) { + parts.push(`Unsupported: ${net.unsupportedFeatures.join(', ')}`); + } + if (net.blockReason === 'invalid_name') { + parts.push('Invalid Docker network name'); + } + if (net.blockReason === 'reserved_system') { + parts.push('Reserved system network name'); + } + return parts.join(' · '); +} + +export function MissingExternalNetworksDialog({ + open, + payload, + isAdmin, + creating = false, + onCancel, + onOpenNetworking, + onCreateAndContinue, +}: MissingExternalNetworksDialogProps) { + const [copying, setCopying] = useState(false); + + const networks = payload?.networks ?? []; + const allSafe = networks.length > 0 && networks.every((n) => n.safe); + const canCreate = isAdmin && allSafe && payload?.status === 'ok'; + const createCommands = buildCreateCommands(networks); + + const copyCreateCommand = async () => { + if (!createCommands) return; + setCopying(true); + try { + await copyToClipboard(createCommands); + toast.success('Create command copied'); + } catch (error) { + console.error('Failed to copy network create command', error); + toast.error('Copy failed'); + } finally { + setCopying(false); + } + }; + + return ( + { if (!next) onCancel(); }} size="xl"> + + +
+ {payload?.status !== 'ok' ? ( +
+ {payload?.status === 'render_unavailable' + ? 'Sencho could not render this stack\'s Compose model to check external networks.' + : 'Sencho could not read Docker networking state on this node.'} +
+ ) : networks.length === 0 ? ( +
No missing external networks.
+ ) : ( + networks.map((net) => ( +
+
{net.name}
+
+ Compose keys: {net.keys.join(', ')} +
+ {net.safe && net.creationSpec ? ( +
+ Sencho can create a {net.creationSpec.driver} network with {net.creationSpec.options} options. +
+ ) : ( +
+ {formatUnsafeReason(net)} +
+ )} +
+ )) + )} +
+
+ {/* Primary Cancel / Create pair; secondary actions live under More. */} +
+ + + + + + + Open Networking + + { void copyCreateCommand(); }} + > + Copy create command + + + +
+ + +
+
+
+ ); +} diff --git a/frontend/src/components/stack/StackNetworkingPanel.tsx b/frontend/src/components/stack/StackNetworkingPanel.tsx index 68b832d1..79e4c341 100644 --- a/frontend/src/components/stack/StackNetworkingPanel.tsx +++ b/frontend/src/components/stack/StackNetworkingPanel.tsx @@ -35,6 +35,13 @@ interface StackNetworkFacts { networks: NetworkFactNetwork[]; services: NetworkFactService[]; drift: NetworkDriftFacts; + missingExternalNetworks?: Array<{ + name: string; + keys: string[]; + safe: boolean; + blockReason?: string; + unsupportedFeatures: string[]; + }>; } interface IntentEntry { service: string; intent: ExposureIntent } diff --git a/frontend/src/components/stack/__tests__/MissingExternalNetworksDialog.test.tsx b/frontend/src/components/stack/__tests__/MissingExternalNetworksDialog.test.tsx new file mode 100644 index 00000000..ed963d75 --- /dev/null +++ b/frontend/src/components/stack/__tests__/MissingExternalNetworksDialog.test.tsx @@ -0,0 +1,118 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { describe, expect, it, vi } from 'vitest'; + +vi.mock('@/components/ui/toast-store', () => ({ + toast: { success: vi.fn(), error: vi.fn() }, +})); +vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) })); + +import { + MissingExternalNetworksDialog, + type MissingExternalNetworksPayload, +} from '../MissingExternalNetworksDialog'; +import { copyToClipboard } from '@/lib/clipboard'; +import { toast } from '@/components/ui/toast-store'; + +const safePayload: MissingExternalNetworksPayload = { + status: 'ok', + autoCreateEnabled: false, + stackName: 'media', + networks: [ + { + name: 'arr-net', + keys: ['arr'], + declarations: [{ key: 'arr', driverKind: 'bridge', unsupportedFeatures: [] }], + safe: true, + unsupportedFeatures: [], + creationSpec: { driver: 'bridge', options: 'default' }, + }, + ], +}; + +describe('MissingExternalNetworksDialog', () => { + it('enables Create and continue for admin when all networks are safe', async () => { + const onCreateAndContinue = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + const create = screen.getByRole('button', { name: 'Create and continue' }); + expect(create).toBeEnabled(); + await user.click(create); + expect(onCreateAndContinue).toHaveBeenCalledOnce(); + }); + + it('disables Create and continue for non-admin', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Create and continue' })).toBeDisabled(); + }); + + it('disables Create when any network is unsafe', () => { + render( + , + ); + expect(screen.getByRole('button', { name: 'Create and continue' })).toBeDisabled(); + }); + + it('exposes secondary actions under More and copies the create command', async () => { + const onOpenNetworking = vi.fn(); + const user = userEvent.setup(); + render( + , + ); + + expect(screen.queryByRole('button', { name: 'Copy Compose snippet' })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: 'Copy Docker command' })).not.toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: 'More' })); + await user.click(await screen.findByRole('menuitem', { name: 'Open Networking' })); + expect(onOpenNetworking).toHaveBeenCalledOnce(); + + await user.click(screen.getByRole('button', { name: 'More' })); + await user.click(await screen.findByRole('menuitem', { name: 'Copy create command' })); + expect(copyToClipboard).toHaveBeenCalledWith('docker network create arr-net'); + expect(toast.success).toHaveBeenCalledWith('Create command copied'); + }); +}); diff --git a/frontend/src/lib/capabilities.ts b/frontend/src/lib/capabilities.ts index b0188d4d..33c7c5ee 100644 --- a/frontend/src/lib/capabilities.ts +++ b/frontend/src/lib/capabilities.ts @@ -33,9 +33,10 @@ export const CAPABILITIES = [ 'compose-storage', 'cross-node-rbac', 'stack-down-remove-volumes', + 'guided-external-network-preflight', ] as const; export type Capability = (typeof CAPABILITIES)[number]; -/** Must stay in sync with backend CapabilityRegistry.STACK_DOWN_REMOVE_VOLUMES_CAPABILITY */ export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability; +export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability; diff --git a/frontend/src/lib/networking.ts b/frontend/src/lib/networking.ts index 81448675..34d21050 100644 --- a/frontend/src/lib/networking.ts +++ b/frontend/src/lib/networking.ts @@ -3,6 +3,7 @@ import { type NetworkingFinding, type NetworkingFindingKind, type NetworkingNetworkRow, type NetworkingOverviewEnvelope, type NetworkingRecommendedAction, type NodeNetworkingOverview, } from '@/types/networking'; +import { stringify as stringifyYaml } from 'yaml'; export type NetworkFilter = 'all' | 'managed' | 'external' | 'system' | 'shared' | 'exposed' | 'drift'; @@ -95,9 +96,40 @@ export function canUseNetworkName(name: string): boolean { return /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name); } -export function buildExternalNetworkSnippet(name: string): string | null { - if (!canUseNetworkName(name)) return null; - return `networks:\n ${name}:\n external: true`; +/** + * Build a single valid `networks:` Compose fragment for one or more + * key → runtime-name mappings. Stable-sorts keys. Returns null if any + * runtime name is unsafe. + */ +export function buildExternalNetworksSnippet( + entries: ReadonlyArray<{ key: string; name: string }>, +): string | null { + if (entries.length === 0) return null; + const networks: Record = {}; + const sorted = [...entries].sort((a, b) => a.key.localeCompare(b.key)); + for (const { key, name } of sorted) { + if (!canUseNetworkName(name)) return null; + networks[key] = key === name + ? { external: true } + : { external: true, name }; + } + // Prefer yaml when available; fallback keeps simple unquoted keys for tests. + try { + return stringifyYaml({ networks }).trimEnd(); + } catch { + const lines = ['networks:']; + for (const { key, name } of sorted) { + lines.push(` ${key}:`); + lines.push(' external: true'); + if (key !== name) lines.push(` name: ${name}`); + } + return lines.join('\n'); + } +} + +/** @deprecated Prefer buildExternalNetworksSnippet with explicit key+name. */ +export function buildExternalNetworkSnippet(name: string, key: string = name): string | null { + return buildExternalNetworksSnippet([{ key, name }]); } /** Normalizes a network row from a schema-2 remote (no serviceNames) so the UI