feat: guide missing external network creation during deploy (#1645)

* feat: guide missing external network creation during deploy

Detect missing external networks before Compose runs, prompt or auto-create safe bridge networks, and keep unsupported declarations blocked with trusted deploy provenance.

* test: align deploy context and settings fixtures with missing-network gate

Update caller spies, EffResource expectations, StacksSection save keys, and git-source spy cleanup so CI matches the new deployStack context and auto-create setting.

* fix: drop unused renderError binding in missing-network resolver

Satisfies no-unused-vars so backend ESLint CI passes; callers already key only on model presence.

* fix: use HTTP-safe clipboard helper in missing-network dialog

navigator.clipboard fails on plain HTTP LAN hosts; route copy actions through copyToClipboard so Docker and Compose copy buttons work on self-hosted instances.

* fix: simplify missing-network dialog actions and copy label

Drop the Compose snippet escape hatch, move secondary actions under More, and rename the terminal copy action to Copy create command so the footer is a clear Cancel / Create decision.
This commit is contained in:
Anso
2026-07-17 14:51:00 -04:00
committed by GitHub
parent 66ec4ebdd2
commit 35bb74425b
61 changed files with 2291 additions and 141 deletions
+4 -1
View File
@@ -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);
@@ -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'));
@@ -34,6 +34,7 @@ function stubFacts(overrides: Partial<StackNetworkFacts> = {}): StackNetworkFact
networks: [],
services: [{ name: 'web', networks: [], publishedPorts: [], extraHosts: [] }],
drift: { runtimeOnlyAttachments: [], declaredButUnused: [], missingFromRuntime: [], foreignNetworkAttachments: [] },
missingExternalNetworks: [],
...overrides,
};
}
@@ -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');
});
});
@@ -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();
}
});
});
@@ -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 () => {
@@ -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<string, unknown>) =>
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<string, string>,
): 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<void>((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();
});
});
@@ -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<EffResource> & Pick<EffResource, 'name' | 'external'>): EffResource {
return {
internal: false,
driverKind: 'default',
hasDriverOpts: false,
hasCustomIpam: false,
attachable: false,
ipv4Enabled: true,
ipv6Enabled: false,
hasLabels: false,
...partial,
};
}
function model(networks: Record<string, EffResource>) {
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');
});
});
@@ -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');
});
@@ -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', () => {
@@ -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);
});
});
@@ -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' }));
});
@@ -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');
+19 -1
View File
@@ -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;
}
+11 -1
View File
@@ -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
+11 -3
View File
@@ -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<voi
if (node.type === 'local') {
const lock = await StackOpLockService.getInstance().runExclusive(
node.id, stackName, 'deploy', 'system',
() => 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<voi
if (!ctx) throw new SnapshotProxyTargetError(formatNoTargetError(node));
const deployRes = await fetch(`${ctx.baseUrl}/api/stacks/${encodeURIComponent(stackName)}/deploy`, {
method: 'POST',
headers: ctx.headers,
headers: {
...ctx.headers,
...deployProvenanceHeaders('fleet_snapshot', 'system:fleet-snapshot'),
},
signal: AbortSignal.timeout(30000),
});
if (!deployRes.ok) throw await remoteStackError('Failed to redeploy stack', deployRes);
+6 -1
View File
@@ -239,7 +239,12 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
// deploy/update/rollback/backup on the same stack and node.
const lock = await StackOpLockService.getInstance().runExclusive(
req.nodeId, stackName, 'deploy', 'system',
() => 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) });
+2
View File
@@ -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();
+70 -4
View File
@@ -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<void>) | 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) {
+6 -1
View File
@@ -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`);
+8 -2
View File
@@ -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'),
};
}
@@ -55,6 +55,7 @@ export const CAPABILITIES = [
'compose-storage',
'cross-node-rbac',
'stack-down-remove-volumes',
'guided-external-network-preflight',
] as const;
/**
+144 -1
View File
@@ -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<void> {
/**
* 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<void> {
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<void> {
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();
+2
View File
@@ -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 = `
+2 -1
View File
@@ -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);
+6 -1
View File
@@ -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}.`;
+10 -3
View File
@@ -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<string, string>,
): Promise<Response> {
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<string, string> = {};
const headers: Record<string, string> = { ...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(() => '');
@@ -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. */
+18 -4
View File
@@ -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<void> {
private async postToRemoteStack(
nodeId: number,
routeSuffix: string,
extraHeaders?: Record<string, string>,
): Promise<void> {
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),
});
+6 -1
View File
@@ -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':
+41
View File
@@ -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<string, string> {
return {
[PROXY_DEPLOY_SOURCE_HEADER]: source,
[PROXY_DEPLOY_ACTOR_HEADER]: actor,
};
}
@@ -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. */
@@ -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<string> = new Set([
'bridge',
'host',
'none',
]);
@@ -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>): 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<string>,
): MissingExternalNetwork[] {
const byRuntimeName = new Map<string, KeyDeclaration[]>();
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;
}
@@ -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';
}
@@ -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
@@ -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,
));
}
}
@@ -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<MissingExternalNetworksEnvelope> {
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<string>;
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,
};
}
+5
View File
@@ -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[];
}
@@ -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<string> = 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<string, unknown>).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<string, unknown>;
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<string, unknown>;
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<string, EffResource> {
const out: Record<string, EffResource> = {};
if (value && typeof value === 'object' && !Array.isArray(value)) {
for (const [key, entry] of Object.entries(value as Record<string, unknown>)) {
const o = (entry ?? {}) as Record<string, unknown>;
out[key] = { name: str(o.name) ?? key, external: o.external === true, internal: o.internal === true };
out[key] = parseResourceEntry(key, entry);
}
}
return out;
+30 -11
View File
@@ -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<PreflightSeverity, number> = { 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.',
};
});
},
};
+4
View File
@@ -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';
}
}
}