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');