mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-28 12:49:03 +00:00
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:
@@ -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');
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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`);
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 = `
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}.`;
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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),
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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.',
|
||||
};
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +136,7 @@ The rules in this category (except "Node-state checks skipped" itself) read live
|
||||
| Rule | Severity | What it detects |
|
||||
|------|----------|----------------|
|
||||
| Node-state checks skipped | Info | The Docker daemon could not be reached; node-state rules did not run and this result is partial. |
|
||||
| External network not found | Blocker | A network declared `external: true` does not exist on this node. The deploy will fail. |
|
||||
| External network not found | Blocker | A network declared `external: true` does not exist on this node. Deploy is blocked until the network exists. For safe default bridge networks, Doctor may offer a create remediation; advanced drivers and custom options stay manual. |
|
||||
| External volume not found | Blocker | A volume declared `external: true` does not exist on this node. The deploy will fail. |
|
||||
| New network will be created | Info | A non-external network that does not yet exist on this node will be created on first deploy. |
|
||||
| New volume will be created | Info | A named volume that does not yet exist on this node will be created on first deploy. |
|
||||
|
||||
@@ -92,7 +92,7 @@ The Networks section lists every network the effective model declares:
|
||||
|
||||
When a stack declares no explicit networks, the section shows **default network only**: all services share the implicit Docker bridge that Compose creates automatically.
|
||||
|
||||
External networks that do not exist on the node when you deploy will cause the deploy to fail. Compose Doctor flags missing external networks before you apply a change.
|
||||
External networks that do not exist on the node are detected before Compose runs. Interactive deploy opens a guided dialog so you can create safe bridge networks, copy a terminal create command, cancel, or open the Networking view. Automatic creation of those safe networks is optional under Settings → Stacks. Unsupported drivers or options are never created silently; the deploy stays blocked until you fix the Compose declaration or create the network yourself. Compose Doctor also flags missing external networks before you apply a change.
|
||||
|
||||
## Services
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ Admins can open **Create network** from the page header. The dialog matches the
|
||||
|
||||
## Findings
|
||||
|
||||
The **Findings** tab lists Compose-first networking issues Sencho derives from the effective model plus one live Docker snapshot per refresh, grouped into **Needs action**, **Review recommended**, and **Informational**. Examples include missing external networks, duplicate network names across stacks, host-network exposure mismatches, and stacks whose model could not be rendered.
|
||||
The **Findings** tab lists Compose-first networking issues Sencho derives from the effective model plus one live Docker snapshot per refresh, grouped into **Needs action**, **Review recommended**, and **Informational**. Examples include missing external networks, duplicate network names across stacks, host-network exposure mismatches, and stacks whose model could not be rendered. Missing external findings only offer create actions when the declaration is a safe default bridge network.
|
||||
|
||||
Findings also fold in the networking-relevant results from your last Compose Doctor run on each stack. A finding both engines detect shows **Live · also found by Doctor**; a finding only Doctor caught (Doctor checks a few things the live engine does not, such as host port conflicts) shows **Last Doctor run** with the timestamp of that run. Doctor's contribution is always the cached result of the last run on that stack, not a fresh check, so run Doctor again on a stack if you want its findings current. An active acknowledgement on a Doctor finding excludes it here too.
|
||||
|
||||
|
||||
@@ -76,11 +76,16 @@ Every Sencho release ships with a static list of capabilities. The current list
|
||||
| `registries` | Private registry management |
|
||||
| `self-update` | Self-update from the dashboard |
|
||||
| `vulnerability-scanning` | Image vulnerability scanning |
|
||||
| `compose-doctor` | Compose Doctor preflight |
|
||||
| `compose-networking` | Stack Networking tab and node Networking overview |
|
||||
| `guided-external-network-preflight` | Guided missing-external-network check before deploy |
|
||||
|
||||
<Note>
|
||||
`vulnerability-scanning` is advertised only when the Trivy binary is present on the node. If a node does not have Trivy installed, it omits this capability and the scanning UI is replaced by a lock card.
|
||||
</Note>
|
||||
|
||||
Nodes that do not advertise `guided-external-network-preflight` keep legacy deploy behavior (no guided missing-external dialog). When the capability is present but the check cannot run, deploy fails closed instead of guessing.
|
||||
|
||||
## Handling nodes that do not advertise metadata
|
||||
|
||||
If a remote node does not respond to `/api/meta` (for example, an unreachable instance, a slow handshake, or one that does not implement the endpoint), Sencho falls back to an offline metadata record with no version and an empty capability list. In that state:
|
||||
|
||||
@@ -22,7 +22,7 @@ Each entry in the activity list contains four pieces of information:
|
||||
|
||||
## Event categories
|
||||
|
||||
Ten categories have a dedicated icon. Any other notification scoped to a stack (deploy failures, scan findings, monitor alerts) also flows into the timeline and renders with a generic activity icon.
|
||||
Ten categories have a dedicated icon. Any other notification scoped to a stack (deploy failures, scan findings, monitor alerts) also flows into the timeline and renders with a generic activity icon. Automatic creation of missing external networks during deploy writes a history-only activity row (`network_auto_created`) on the stack timeline; it does not appear in the notification bell.
|
||||
|
||||
| Category | Example message |
|
||||
|----------|-----------------|
|
||||
|
||||
@@ -1419,6 +1419,36 @@ paths:
|
||||
when the health gate is disabled on the node.
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"409":
|
||||
description: |
|
||||
Deploy blocked before Compose ran. Common codes include a stack
|
||||
operation already in progress, a scan-policy block, or missing
|
||||
external networks (`code: missing_external_networks`).
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
error:
|
||||
type: string
|
||||
code:
|
||||
type: string
|
||||
example: missing_external_networks
|
||||
kind:
|
||||
type: string
|
||||
enum: [prompt, unsupported, unavailable, create_failed]
|
||||
networks:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
createdNames:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
remainingNames:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
"500":
|
||||
description: Deployment failed.
|
||||
content:
|
||||
@@ -1434,6 +1464,77 @@ paths:
|
||||
description: Whether the stack was automatically rolled back.
|
||||
failure:
|
||||
$ref: "#/components/schemas/FailureClassification"
|
||||
"503":
|
||||
description: |
|
||||
Deploy blocked because Sencho could not render the Compose model or
|
||||
read Docker networking state to verify required external networks.
|
||||
|
||||
/api/stacks/{stackName}/missing-external-networks:
|
||||
get:
|
||||
operationId: getMissingExternalNetworks
|
||||
tags: [Stacks]
|
||||
summary: List missing external networks for a stack
|
||||
description: |
|
||||
Returns whether declared `external: true` networks are missing on the
|
||||
target node, whether automatic creation is enabled, and per-network
|
||||
safety metadata. Requires `stack:read`. Used by interactive deploy
|
||||
preflight when the node advertises `guided-external-network-preflight`.
|
||||
parameters:
|
||||
- $ref: "#/components/parameters/stackName"
|
||||
- $ref: "#/components/parameters/nodeId"
|
||||
responses:
|
||||
"200":
|
||||
description: Missing-external-network envelope.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [status, autoCreateEnabled, stackName, networks, declaredExternalCount]
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
enum: [ok, render_unavailable, runtime_unavailable]
|
||||
autoCreateEnabled:
|
||||
type: boolean
|
||||
stackName:
|
||||
type: string
|
||||
declaredExternalCount:
|
||||
type: integer
|
||||
networks:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
required: [name, keys, declarations, safe, unsupportedFeatures, creationSpec]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
keys:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
declarations:
|
||||
type: array
|
||||
items:
|
||||
type: object
|
||||
safe:
|
||||
type: boolean
|
||||
blockReason:
|
||||
type: string
|
||||
unsupportedFeatures:
|
||||
type: array
|
||||
items:
|
||||
type: string
|
||||
creationSpec:
|
||||
type: object
|
||||
nullable: true
|
||||
"403":
|
||||
$ref: "#/components/responses/Forbidden"
|
||||
"404":
|
||||
description: Stack not found.
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/Error"
|
||||
|
||||
/api/stacks/{stackName}/down:
|
||||
post:
|
||||
|
||||
@@ -675,6 +675,7 @@ Node-level safety checks and post-deploy observation used during stack deploys a
|
||||
| **Observe health after updates** | On | After a stack deploy or update succeeds, watch its containers for the observation window and record a passed or failed verdict on the stack timeline. Observational only: nothing is restarted or rolled back automatically. |
|
||||
| **Observation window** | 90 s | How long to watch containers before declaring the update healthy. Raise it for stacks that take a while to settle. Range 15 to 600 seconds. |
|
||||
| **Block deploy on missing required env vars** | Off | When on, a deploy or update is refused before it starts if a required `${VAR:?message}` variable is unset or empty, so the stack fails fast with a clear message instead of mid-deploy. |
|
||||
| **Automatically create missing external networks during deploy** | Off | When on, safe missing external bridge networks are created automatically before deploy continues. When off, interactive deploy prompts first. Advanced drivers and custom options are never auto-created. |
|
||||
|
||||
Click **Save settings** to apply.
|
||||
|
||||
|
||||
@@ -270,6 +270,7 @@ export default function EditorLayout() {
|
||||
getLastDeployOutputLine,
|
||||
diffPreviewEnabled,
|
||||
hasUpdateGuard: hasCapability('update-guard'),
|
||||
hasGuidedExternalNetworkPreflight: hasCapability('guided-external-network-preflight'),
|
||||
canEditStack: (stackNameOrFilename) => {
|
||||
const stackName = stackNameOrFilename.replace(/\.(ya?ml)$/, '');
|
||||
return can('stack:edit', 'stack', stackName);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import BashExecModal from '../BashExecModal';
|
||||
import { PolicyBlockDialog } from '../stack/PolicyBlockDialog';
|
||||
import { PreDeployScanDialog } from '../stack/PreDeployScanDialog';
|
||||
import { MissingExternalNetworksDialog } from '../stack/MissingExternalNetworksDialog';
|
||||
import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog';
|
||||
import { SelfStackProtectedDialog } from '../stack/SelfStackProtectedDialog';
|
||||
import { DeleteStackDialog } from './DeleteStackDialog';
|
||||
@@ -54,6 +55,7 @@ export function ShellOverlays({
|
||||
policyBlock, setPolicyBlock, policyBypassing,
|
||||
updateReadiness, setUpdateReadiness,
|
||||
preDeployAdvisory,
|
||||
missingExternalNetworks, setMissingExternalNetworks,
|
||||
selfStackProtectedOpen, setSelfStackProtectedOpen,
|
||||
stackMisconfigScanId, setStackMisconfigScanId,
|
||||
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
|
||||
@@ -135,6 +137,24 @@ export function ShellOverlays({
|
||||
onDeploy={() => preDeployAdvisory?.proceed()}
|
||||
/>
|
||||
|
||||
<MissingExternalNetworksDialog
|
||||
open={missingExternalNetworks !== null}
|
||||
payload={missingExternalNetworks?.payload ?? null}
|
||||
isAdmin={isAdmin}
|
||||
creating={missingExternalNetworks?.creating ?? false}
|
||||
onCancel={() => {
|
||||
missingExternalNetworks?.cancel();
|
||||
setMissingExternalNetworks(null);
|
||||
}}
|
||||
onOpenNetworking={() => {
|
||||
missingExternalNetworks?.openNetworking();
|
||||
setMissingExternalNetworks(null);
|
||||
}}
|
||||
onCreateAndContinue={() => {
|
||||
void missingExternalNetworks?.createAndContinue();
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Pre-deploy policy block */}
|
||||
<PolicyBlockDialog
|
||||
open={policyBlock !== null}
|
||||
|
||||
@@ -143,6 +143,14 @@ export function useOverlayState() {
|
||||
cancel: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const [missingExternalNetworks, setMissingExternalNetworks] = useState<{
|
||||
payload: import('../../stack/MissingExternalNetworksDialog').MissingExternalNetworksPayload;
|
||||
creating: boolean;
|
||||
cancel: () => void;
|
||||
openNetworking: () => void;
|
||||
createAndContinue: () => void;
|
||||
} | null>(null);
|
||||
|
||||
const [selfStackProtectedOpen, setSelfStackProtectedOpen] = useState(false);
|
||||
const openSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(true), []);
|
||||
const closeSelfStackProtected = useCallback(() => setSelfStackProtectedOpen(false), []);
|
||||
@@ -166,6 +174,7 @@ export function useOverlayState() {
|
||||
policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing,
|
||||
updateReadiness, setUpdateReadiness,
|
||||
preDeployAdvisory, setPreDeployAdvisory,
|
||||
missingExternalNetworks, setMissingExternalNetworks,
|
||||
selfStackProtectedOpen, setSelfStackProtectedOpen, openSelfStackProtected, closeSelfStackProtected,
|
||||
stackMisconfigScanId, setStackMisconfigScanId,
|
||||
diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming,
|
||||
|
||||
@@ -23,6 +23,9 @@ import type { EditorTab, RouteStackLoadResult } from '@/lib/router/routeTypes';
|
||||
import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView';
|
||||
import type { NotificationItem } from '../../dashboard/types';
|
||||
import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog';
|
||||
import type {
|
||||
MissingExternalNetworksPayload,
|
||||
} from '../../stack/MissingExternalNetworksDialog';
|
||||
import type { PreDeployScanImage } from '@/types/security';
|
||||
|
||||
interface RunResult {
|
||||
@@ -31,8 +34,17 @@ interface RunResult {
|
||||
rolledBack?: boolean;
|
||||
/** Health gate run id from the success body, when the backend started one. */
|
||||
healthGateId?: string | null;
|
||||
/**
|
||||
* Deploy hit a missing-external-networks gate; the dialog owns deployPendingRef
|
||||
* until the operator cancels or continues.
|
||||
*/
|
||||
deferredNetworks?: boolean;
|
||||
}
|
||||
|
||||
type MissingExternalNetworksEnvelope = MissingExternalNetworksPayload & {
|
||||
declaredExternalCount: number;
|
||||
};
|
||||
|
||||
/** healthGateId from a success body, or null when absent or unreadable. */
|
||||
const parseHealthGateId = async (response: Response): Promise<string | null> => {
|
||||
try {
|
||||
@@ -138,6 +150,9 @@ interface UseStackActionsOptions {
|
||||
// the pre-update readiness dialog. Defaults to false: without the
|
||||
// capability, updates run directly with no dialog.
|
||||
hasUpdateGuard?: boolean;
|
||||
// Active node advertises guided external-network preflight. Absent capability
|
||||
// keeps legacy deploy (no GET). Advertised-but-broken fails closed.
|
||||
hasGuidedExternalNetworkPreflight?: boolean;
|
||||
// Target-aware stack:edit check. Pass the loaded stack identity (folder name
|
||||
// or compose path); callers strip extensions when comparing to RBAC stack
|
||||
// names. Evaluated against the load target so post-load auto-edit is not
|
||||
@@ -182,6 +197,108 @@ export async function fetchPreDeployAdvisory(
|
||||
}
|
||||
}
|
||||
|
||||
const MISSING_EXTERNAL_PREFLIGHT_TIMEOUT_MS = 10000;
|
||||
|
||||
function parseMissingExternalNetworksPayload(data: unknown): MissingExternalNetworksPayload | null {
|
||||
if (!isRecord(data)) return null;
|
||||
if (
|
||||
data.status !== 'ok'
|
||||
&& data.status !== 'render_unavailable'
|
||||
&& data.status !== 'runtime_unavailable'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
if (typeof data.stackName !== 'string' || typeof data.autoCreateEnabled !== 'boolean') return null;
|
||||
if (!Array.isArray(data.networks)) return null;
|
||||
return {
|
||||
status: data.status,
|
||||
autoCreateEnabled: data.autoCreateEnabled,
|
||||
stackName: data.stackName,
|
||||
networks: data.networks as MissingExternalNetworksPayload['networks'],
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Authoritative missing-external preflight for the captured deploy node.
|
||||
* Returns null only when the route is missing or the body is unusable (treat
|
||||
* as fail-closed when the capability is advertised).
|
||||
*/
|
||||
export async function fetchMissingExternalNetworks(
|
||||
stackName: string,
|
||||
opNodeId: number | null,
|
||||
): Promise<MissingExternalNetworksEnvelope | null> {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), MISSING_EXTERNAL_PREFLIGHT_TIMEOUT_MS);
|
||||
try {
|
||||
const res = await apiFetch(
|
||||
`/stacks/${encodeURIComponent(stackName)}/missing-external-networks`,
|
||||
{ nodeId: opNodeId, signal: controller.signal },
|
||||
);
|
||||
if (!res.ok) return null;
|
||||
const data: unknown = await res.json();
|
||||
const payload = parseMissingExternalNetworksPayload(data);
|
||||
if (!payload || !isRecord(data)) return null;
|
||||
const declaredExternalCount = typeof data.declaredExternalCount === 'number'
|
||||
? data.declaredExternalCount
|
||||
: 0;
|
||||
return { ...payload, declaredExternalCount };
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch missing external networks:', error);
|
||||
return null;
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function createSafeExternalNetworks(
|
||||
networks: MissingExternalNetworksPayload['networks'],
|
||||
opNodeId: number | null,
|
||||
): Promise<{ ok: boolean; errorMessage?: string }> {
|
||||
for (const network of networks.filter((n) => n.safe)) {
|
||||
try {
|
||||
const res = await apiFetch('/system/networks', {
|
||||
method: 'POST',
|
||||
nodeId: opNodeId,
|
||||
body: JSON.stringify({ name: network.name, driver: 'bridge' }),
|
||||
});
|
||||
if (res.ok || res.status === 409) continue;
|
||||
const body: unknown = await res.json().catch(() => null);
|
||||
const message = isRecord(body) && typeof body.error === 'string'
|
||||
? body.error
|
||||
: `Failed to create network "${network.name}" (${res.status})`;
|
||||
return { ok: false, errorMessage: message };
|
||||
} catch (error) {
|
||||
console.error('Failed to create external network:', error);
|
||||
return {
|
||||
ok: false,
|
||||
errorMessage: error instanceof Error ? error.message : `Failed to create network "${network.name}"`,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
function missingExternalBlocksDeploy(
|
||||
envelope: MissingExternalNetworksEnvelope,
|
||||
): string | null {
|
||||
if (envelope.status === 'render_unavailable') {
|
||||
return 'Sencho could not render this stack\'s Compose model to check external networks.';
|
||||
}
|
||||
if (envelope.status === 'runtime_unavailable' && envelope.declaredExternalCount > 0) {
|
||||
return 'Sencho could not read Docker networking state to check external networks.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getResponseCode(rawBody: string): string | undefined {
|
||||
try {
|
||||
const payload: unknown = JSON.parse(rawBody);
|
||||
return isRecord(payload) && typeof payload.code === 'string' ? payload.code : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const parseStackOpInProgress = (rawBody: string): StackOpInProgressInfo | null => {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(rawBody);
|
||||
@@ -261,6 +378,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
getLastDeployOutputLine,
|
||||
diffPreviewEnabled,
|
||||
hasUpdateGuard = false,
|
||||
hasGuidedExternalNetworkPreflight = false,
|
||||
canEditStack,
|
||||
canOfferVolumeRemoval = false,
|
||||
} = options;
|
||||
@@ -857,6 +975,96 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
return null;
|
||||
};
|
||||
|
||||
const openMissingExternalNetworksDialog = (
|
||||
payload: MissingExternalNetworksPayload,
|
||||
opNodeId: number | null,
|
||||
onContinue: () => void,
|
||||
) => {
|
||||
let settled = false;
|
||||
const finishCancel = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
overlayState.setMissingExternalNetworks(null);
|
||||
deployPendingRef.current = false;
|
||||
};
|
||||
overlayState.setMissingExternalNetworks({
|
||||
payload,
|
||||
creating: false,
|
||||
cancel: finishCancel,
|
||||
openNetworking: () => {
|
||||
finishCancel();
|
||||
const node = nodes.find((n) => n.id === opNodeId);
|
||||
if (node && activeNode?.id !== opNodeId) setActiveNode(node);
|
||||
navState.setActiveView('networking');
|
||||
},
|
||||
createAndContinue: () => void createAndContinue(),
|
||||
});
|
||||
|
||||
function setCreating(creating: boolean, verified?: MissingExternalNetworksPayload): void {
|
||||
overlayState.setMissingExternalNetworks((current) => (
|
||||
current
|
||||
? { ...current, creating, ...(verified ? { payload: verified } : {}) }
|
||||
: current
|
||||
));
|
||||
}
|
||||
|
||||
async function createAndContinue(): Promise<void> {
|
||||
if (settled) return;
|
||||
setCreating(true);
|
||||
|
||||
const created = await createSafeExternalNetworks(payload.networks, opNodeId);
|
||||
if (!created.ok) {
|
||||
toast.error(created.errorMessage ?? 'Failed to create external networks');
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const verified = await fetchMissingExternalNetworks(payload.stackName, opNodeId);
|
||||
if (!verified || verified.status !== 'ok') {
|
||||
toast.error('Could not verify external networks after create.');
|
||||
setCreating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const stillMissing = payload.networks.some((needed) => (
|
||||
verified.networks.some((network) => network.name === needed.name)
|
||||
));
|
||||
if (stillMissing) {
|
||||
toast.error('Some external networks are still missing after create.');
|
||||
setCreating(false, verified);
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
overlayState.setMissingExternalNetworks(null);
|
||||
onContinue();
|
||||
}
|
||||
};
|
||||
|
||||
const finishSuccessfulDeploy = async (
|
||||
response: Response,
|
||||
stackName: string,
|
||||
stackFile: string,
|
||||
ignorePolicy: boolean,
|
||||
): Promise<RunResult> => {
|
||||
overlayState.setPolicyBlock(null);
|
||||
const healthGateId = await parseHealthGateId(response);
|
||||
if (healthGateId) {
|
||||
toast.info(ignorePolicy ? 'Stack deployed (policy bypassed). Verifying health...' : 'Stack deployed. Verifying health...');
|
||||
} else {
|
||||
toast.success(ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!');
|
||||
}
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return { ok: true, healthGateId };
|
||||
};
|
||||
|
||||
const runDeploy = async (
|
||||
stackName: string,
|
||||
stackFile: string,
|
||||
@@ -897,27 +1105,47 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
toast.error(message);
|
||||
return { ok: false, errorMessage: message };
|
||||
}
|
||||
if (
|
||||
getResponseCode(rawBody) === 'missing_external_networks'
|
||||
&& hasGuidedExternalNetworkPreflight
|
||||
) {
|
||||
const envelope = await fetchMissingExternalNetworks(stackName, opNodeId ?? null);
|
||||
if (!envelope) {
|
||||
toast.error('Deploy needs missing external networks, but Sencho could not re-check them.');
|
||||
return { ok: false, errorMessage: 'Missing external networks check failed' };
|
||||
}
|
||||
const blockMessage = missingExternalBlocksDeploy(envelope);
|
||||
if (blockMessage) {
|
||||
toast.error(blockMessage);
|
||||
return { ok: false, errorMessage: blockMessage };
|
||||
}
|
||||
if (envelope.status === 'ok' && envelope.networks.length === 0) {
|
||||
// Networks appeared between gate and refetch; retry once.
|
||||
const retry = await apiFetch(
|
||||
path,
|
||||
withDeploySession(deploySessionId ?? '', { method: 'POST', nodeId: opNodeId }),
|
||||
);
|
||||
if (retry.ok) {
|
||||
return finishSuccessfulDeploy(retry, stackName, stackFile, ignorePolicy);
|
||||
}
|
||||
throw parseStackActionError(await retry.text(), 'Deploy failed', retry.status);
|
||||
}
|
||||
openMissingExternalNetworksDialog(envelope, opNodeId ?? null, () => {
|
||||
stackListState.setStackAction(stackFile, 'deploy');
|
||||
void runWithLog({ stackName, action: 'deploy', nodeId: opNodeId ?? null }, (startedRetry, ds) =>
|
||||
runDeploy(stackName, stackFile, ignorePolicy, startedRetry, ds, opNodeId),
|
||||
).finally(() => {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
stackListState.refreshStacks(true);
|
||||
deployPendingRef.current = false;
|
||||
});
|
||||
});
|
||||
return { ok: false, deferredNetworks: true };
|
||||
}
|
||||
}
|
||||
throw parseStackActionError(rawBody, 'Deploy failed', response.status);
|
||||
}
|
||||
overlayState.setPolicyBlock(null);
|
||||
const healthGateId = await parseHealthGateId(response);
|
||||
// With a health gate observing, the operation finishing is not the
|
||||
// final verdict yet; soften the toast so success is not claimed twice.
|
||||
if (healthGateId) {
|
||||
toast.info(ignorePolicy ? 'Stack deployed (policy bypassed). Verifying health...' : 'Stack deployed. Verifying health...');
|
||||
} else {
|
||||
toast.success(ignorePolicy ? 'Stack deployed (policy bypassed).' : 'Stack deployed successfully!');
|
||||
}
|
||||
await refreshSelectedContainers(stackName, stackFile);
|
||||
try {
|
||||
const backupRes = await apiFetch(`/stacks/${stackName}/backup`);
|
||||
if (backupRes.ok) editorState.setBackupInfo(await backupRes.json());
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
stackListState.recordActionSuccess(stackFile);
|
||||
return { ok: true, healthGateId };
|
||||
return finishSuccessfulDeploy(response, stackName, stackFile, ignorePolicy);
|
||||
} catch (error) {
|
||||
console.error('Failed to deploy:', error);
|
||||
if (previousStatus !== undefined)
|
||||
@@ -957,19 +1185,52 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
// (not before the advisory) so cancelling the advisory leaves no stuck state.
|
||||
const runDeployFlow = async () => {
|
||||
stackListState.setStackAction(stackFile, 'deploy');
|
||||
let deferredNetworks = false;
|
||||
try {
|
||||
await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) =>
|
||||
const result = await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) =>
|
||||
runDeploy(stackName, stackFile, false, started, ds, opNodeId),
|
||||
);
|
||||
deferredNetworks = result.deferredNetworks === true;
|
||||
} finally {
|
||||
stackListState.clearStackAction(stackFile);
|
||||
stackListState.refreshStacks(true);
|
||||
deployPendingRef.current = false;
|
||||
if (!deferredNetworks) {
|
||||
deployPendingRef.current = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const continueAfterExternalNetworks = () => {
|
||||
void runDeployFlow();
|
||||
};
|
||||
|
||||
// Advisory runs before the deploy log opens (fails open: a null result means
|
||||
// setting off / timeout / older node / error, so the deploy proceeds).
|
||||
const beginDeployAfterAdvisory = async () => {
|
||||
if (hasGuidedExternalNetworkPreflight) {
|
||||
const envelope = await fetchMissingExternalNetworks(stackName, opNodeId);
|
||||
if (!envelope) {
|
||||
toast.error('Sencho could not check external networks on this node before deploy.');
|
||||
deployPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
const blockMessage = missingExternalBlocksDeploy(envelope);
|
||||
if (blockMessage) {
|
||||
toast.error(blockMessage);
|
||||
deployPendingRef.current = false;
|
||||
return;
|
||||
}
|
||||
if (envelope.status === 'ok' && envelope.networks.length > 0) {
|
||||
const allSafe = envelope.networks.every((n) => n.safe);
|
||||
if (!(allSafe && envelope.autoCreateEnabled)) {
|
||||
openMissingExternalNetworksDialog(envelope, opNodeId, continueAfterExternalNetworks);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
await runDeployFlow();
|
||||
};
|
||||
|
||||
const advisoryImages = await fetchPreDeployAdvisory(stackName, opNodeId);
|
||||
if (advisoryImages && advisoryImages.length > 0) {
|
||||
let settled = false;
|
||||
@@ -980,7 +1241,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
overlayState.setPreDeployAdvisory(null);
|
||||
void runDeployFlow();
|
||||
void beginDeployAfterAdvisory();
|
||||
},
|
||||
cancel: () => {
|
||||
if (settled) return;
|
||||
@@ -991,7 +1252,7 @@ export function useStackActions(options: UseStackActionsOptions) {
|
||||
});
|
||||
return;
|
||||
}
|
||||
await runDeployFlow();
|
||||
await beginDeployAfterAdvisory();
|
||||
};
|
||||
|
||||
const handleSaveAndDeploy = async (e: React.MouseEvent) => {
|
||||
|
||||
@@ -29,12 +29,13 @@ interface StacksSectionProps {
|
||||
onDirtyChange?: (dirty: boolean) => void;
|
||||
}
|
||||
|
||||
type GuardrailFields = Pick<PatchableSettings, 'health_gate_enabled' | 'health_gate_window_seconds' | 'env_block_deploy_on_missing_required'>;
|
||||
type GuardrailFields = Pick<PatchableSettings, 'health_gate_enabled' | 'health_gate_window_seconds' | 'env_block_deploy_on_missing_required' | 'auto_create_missing_external_networks'>;
|
||||
|
||||
const DEFAULT_GUARDRAILS: GuardrailFields = {
|
||||
health_gate_enabled: DEFAULT_SETTINGS.health_gate_enabled,
|
||||
health_gate_window_seconds: DEFAULT_SETTINGS.health_gate_window_seconds,
|
||||
env_block_deploy_on_missing_required: DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
|
||||
auto_create_missing_external_networks: DEFAULT_SETTINGS.auto_create_missing_external_networks,
|
||||
};
|
||||
|
||||
function GuardrailSkeleton() {
|
||||
@@ -87,6 +88,7 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
|
||||
health_gate_enabled: (nodeData.health_gate_enabled as '0' | '1') ?? DEFAULT_SETTINGS.health_gate_enabled,
|
||||
health_gate_window_seconds: nodeData.health_gate_window_seconds ?? DEFAULT_SETTINGS.health_gate_window_seconds,
|
||||
env_block_deploy_on_missing_required: (nodeData.env_block_deploy_on_missing_required as '0' | '1') ?? DEFAULT_SETTINGS.env_block_deploy_on_missing_required,
|
||||
auto_create_missing_external_networks: (nodeData.auto_create_missing_external_networks as '0' | '1') ?? DEFAULT_SETTINGS.auto_create_missing_external_networks,
|
||||
};
|
||||
reset(safe);
|
||||
} catch (e) {
|
||||
@@ -223,6 +225,15 @@ export function StacksSection({ onDirtyChange }: StacksSectionProps) {
|
||||
onChange={(next) => onGuardrailChange('env_block_deploy_on_missing_required', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
<SettingsField
|
||||
label="Automatically create missing external networks during deploy"
|
||||
helper="When on, safe missing external bridge networks are created automatically before deploy continues. Off by default: manual deploy prompts first. Advanced drivers and custom options are never auto-created."
|
||||
>
|
||||
<TogglePill
|
||||
checked={settings.auto_create_missing_external_networks === '1'}
|
||||
onChange={(next) => onGuardrailChange('auto_create_missing_external_networks', next ? '1' : '0')}
|
||||
/>
|
||||
</SettingsField>
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsActions hint={readOnly ? 'Read-only · admin access required to edit' : (hasChanges ? `${dirtyCount} unsaved` : undefined)}>
|
||||
|
||||
@@ -57,6 +57,7 @@ const FULL_SETTINGS: Record<string, string> = {
|
||||
health_gate_enabled: '1',
|
||||
health_gate_window_seconds: '90',
|
||||
env_block_deploy_on_missing_required: '0',
|
||||
auto_create_missing_external_networks: '0',
|
||||
};
|
||||
|
||||
function patchedKeys(): string[] {
|
||||
@@ -110,6 +111,7 @@ describe('split section save payloads', () => {
|
||||
fireEvent.click(save);
|
||||
await waitFor(() => expect(mockedFetch.mock.calls.some(c => c[1]?.method === 'PATCH')).toBe(true));
|
||||
expect(patchedKeys()).toEqual([
|
||||
'auto_create_missing_external_networks',
|
||||
'env_block_deploy_on_missing_required',
|
||||
'health_gate_enabled',
|
||||
'health_gate_window_seconds',
|
||||
|
||||
@@ -20,6 +20,7 @@ export interface PatchableSettings {
|
||||
health_gate_enabled?: '0' | '1';
|
||||
health_gate_window_seconds?: string;
|
||||
env_block_deploy_on_missing_required?: '0' | '1';
|
||||
auto_create_missing_external_networks?: '0' | '1';
|
||||
image_update_sidebar_indicators?: '0' | '1';
|
||||
}
|
||||
|
||||
@@ -45,6 +46,7 @@ export const DEFAULT_SETTINGS: PatchableSettings = {
|
||||
health_gate_enabled: '1',
|
||||
health_gate_window_seconds: '90',
|
||||
env_block_deploy_on_missing_required: '0',
|
||||
auto_create_missing_external_networks: '0',
|
||||
image_update_sidebar_indicators: '1',
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useState } from 'react';
|
||||
import { MoreHorizontal } from 'lucide-react';
|
||||
import {
|
||||
Modal,
|
||||
ModalHeader,
|
||||
ModalBody,
|
||||
} from '@/components/ui/modal';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { canUseNetworkName } from '@/lib/networking';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
export type MissingExternalNetworkDto = {
|
||||
name: string;
|
||||
keys: string[];
|
||||
declarations: Array<{
|
||||
key: string;
|
||||
driverKind: string;
|
||||
unsupportedFeatures: string[];
|
||||
}>;
|
||||
safe: boolean;
|
||||
blockReason?: string;
|
||||
unsupportedFeatures: string[];
|
||||
creationSpec: { driver: 'bridge'; options: 'default' } | null;
|
||||
};
|
||||
|
||||
export type MissingExternalNetworksPayload = {
|
||||
status: 'ok' | 'render_unavailable' | 'runtime_unavailable';
|
||||
autoCreateEnabled: boolean;
|
||||
stackName: string;
|
||||
networks: MissingExternalNetworkDto[];
|
||||
};
|
||||
|
||||
interface MissingExternalNetworksDialogProps {
|
||||
open: boolean;
|
||||
payload: MissingExternalNetworksPayload | null;
|
||||
isAdmin: boolean;
|
||||
creating?: boolean;
|
||||
onCancel: () => void;
|
||||
onOpenNetworking: () => void;
|
||||
onCreateAndContinue: () => void;
|
||||
}
|
||||
|
||||
function buildCreateCommands(networks: MissingExternalNetworkDto[]): string {
|
||||
return networks
|
||||
.filter((n) => n.safe && canUseNetworkName(n.name))
|
||||
.map((n) => n.name)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.map((name) => `docker network create ${name}`)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatUnsafeReason(net: MissingExternalNetworkDto): string {
|
||||
const parts: string[] = [];
|
||||
if (net.blockReason === 'unsupported_driver') {
|
||||
const kinds = [...new Set(net.declarations.map((d) => d.driverKind))];
|
||||
parts.push(`Driver kinds: ${kinds.join(', ')}`);
|
||||
}
|
||||
if (net.unsupportedFeatures.length > 0) {
|
||||
parts.push(`Unsupported: ${net.unsupportedFeatures.join(', ')}`);
|
||||
}
|
||||
if (net.blockReason === 'invalid_name') {
|
||||
parts.push('Invalid Docker network name');
|
||||
}
|
||||
if (net.blockReason === 'reserved_system') {
|
||||
parts.push('Reserved system network name');
|
||||
}
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
export function MissingExternalNetworksDialog({
|
||||
open,
|
||||
payload,
|
||||
isAdmin,
|
||||
creating = false,
|
||||
onCancel,
|
||||
onOpenNetworking,
|
||||
onCreateAndContinue,
|
||||
}: MissingExternalNetworksDialogProps) {
|
||||
const [copying, setCopying] = useState(false);
|
||||
|
||||
const networks = payload?.networks ?? [];
|
||||
const allSafe = networks.length > 0 && networks.every((n) => n.safe);
|
||||
const canCreate = isAdmin && allSafe && payload?.status === 'ok';
|
||||
const createCommands = buildCreateCommands(networks);
|
||||
|
||||
const copyCreateCommand = async () => {
|
||||
if (!createCommands) return;
|
||||
setCopying(true);
|
||||
try {
|
||||
await copyToClipboard(createCommands);
|
||||
toast.success('Create command copied');
|
||||
} catch (error) {
|
||||
console.error('Failed to copy network create command', error);
|
||||
toast.error('Copy failed');
|
||||
} finally {
|
||||
setCopying(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal open={open} onOpenChange={(next) => { if (!next) onCancel(); }} size="xl">
|
||||
<ModalHeader
|
||||
kicker={`${(payload?.stackName ?? 'STACK').toUpperCase()} · EXTERNAL NETWORKS`}
|
||||
title="Missing external networks"
|
||||
description="Docker Compose will not create external networks. Create safe bridge networks here, or cancel the deploy."
|
||||
/>
|
||||
<ModalBody>
|
||||
<div className="max-h-[min(50vh,28rem)] overflow-y-auto border border-glass-border bg-card/60 shadow-card-bevel divide-y divide-glass-border">
|
||||
{payload?.status !== 'ok' ? (
|
||||
<div className="px-4 py-3 text-sm text-muted-foreground">
|
||||
{payload?.status === 'render_unavailable'
|
||||
? 'Sencho could not render this stack\'s Compose model to check external networks.'
|
||||
: 'Sencho could not read Docker networking state on this node.'}
|
||||
</div>
|
||||
) : networks.length === 0 ? (
|
||||
<div className="px-4 py-3 text-sm text-muted-foreground">No missing external networks.</div>
|
||||
) : (
|
||||
networks.map((net) => (
|
||||
<div key={net.name} className="px-4 py-3 space-y-1.5">
|
||||
<div className="font-mono text-sm">{net.name}</div>
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">
|
||||
Compose keys: {net.keys.join(', ')}
|
||||
</div>
|
||||
{net.safe && net.creationSpec ? (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
Sencho can create a {net.creationSpec.driver} network with {net.creationSpec.options} options.
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-destructive">
|
||||
{formatUnsafeReason(net)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
{/* Primary Cancel / Create pair; secondary actions live under More. */}
|
||||
<div className="flex flex-col-reverse gap-2 border-t border-glass-border px-4 py-3 max-md:items-stretch md:flex-row md:items-center md:justify-between">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" disabled={creating} className="max-md:w-full md:w-auto">
|
||||
<MoreHorizontal className="mr-1.5 h-3.5 w-3.5" strokeWidth={1.5} aria-hidden />
|
||||
More
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-56">
|
||||
<DropdownMenuItem onSelect={onOpenNetworking}>
|
||||
Open Networking
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
disabled={!createCommands || copying}
|
||||
onSelect={() => { void copyCreateCommand(); }}
|
||||
>
|
||||
Copy create command
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<div className="flex flex-col-reverse gap-2 max-md:w-full md:flex-row md:items-center md:justify-end">
|
||||
<Button variant="outline" size="sm" onClick={onCancel} disabled={creating} className="max-md:w-full">
|
||||
Cancel deploy
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!canCreate || creating}
|
||||
className="max-md:w-full"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
onCreateAndContinue();
|
||||
}}
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create and continue'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -35,6 +35,13 @@ interface StackNetworkFacts {
|
||||
networks: NetworkFactNetwork[];
|
||||
services: NetworkFactService[];
|
||||
drift: NetworkDriftFacts;
|
||||
missingExternalNetworks?: Array<{
|
||||
name: string;
|
||||
keys: string[];
|
||||
safe: boolean;
|
||||
blockReason?: string;
|
||||
unsupportedFeatures: string[];
|
||||
}>;
|
||||
}
|
||||
interface IntentEntry { service: string; intent: ExposureIntent }
|
||||
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@/components/ui/toast-store', () => ({
|
||||
toast: { success: vi.fn(), error: vi.fn() },
|
||||
}));
|
||||
vi.mock('@/lib/clipboard', () => ({ copyToClipboard: vi.fn().mockResolvedValue(undefined) }));
|
||||
|
||||
import {
|
||||
MissingExternalNetworksDialog,
|
||||
type MissingExternalNetworksPayload,
|
||||
} from '../MissingExternalNetworksDialog';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
|
||||
const safePayload: MissingExternalNetworksPayload = {
|
||||
status: 'ok',
|
||||
autoCreateEnabled: false,
|
||||
stackName: 'media',
|
||||
networks: [
|
||||
{
|
||||
name: 'arr-net',
|
||||
keys: ['arr'],
|
||||
declarations: [{ key: 'arr', driverKind: 'bridge', unsupportedFeatures: [] }],
|
||||
safe: true,
|
||||
unsupportedFeatures: [],
|
||||
creationSpec: { driver: 'bridge', options: 'default' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
describe('MissingExternalNetworksDialog', () => {
|
||||
it('enables Create and continue for admin when all networks are safe', async () => {
|
||||
const onCreateAndContinue = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MissingExternalNetworksDialog
|
||||
open
|
||||
payload={safePayload}
|
||||
isAdmin
|
||||
onCancel={vi.fn()}
|
||||
onOpenNetworking={vi.fn()}
|
||||
onCreateAndContinue={onCreateAndContinue}
|
||||
/>,
|
||||
);
|
||||
const create = screen.getByRole('button', { name: 'Create and continue' });
|
||||
expect(create).toBeEnabled();
|
||||
await user.click(create);
|
||||
expect(onCreateAndContinue).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('disables Create and continue for non-admin', () => {
|
||||
render(
|
||||
<MissingExternalNetworksDialog
|
||||
open
|
||||
payload={safePayload}
|
||||
isAdmin={false}
|
||||
onCancel={vi.fn()}
|
||||
onOpenNetworking={vi.fn()}
|
||||
onCreateAndContinue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Create and continue' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('disables Create when any network is unsafe', () => {
|
||||
render(
|
||||
<MissingExternalNetworksDialog
|
||||
open
|
||||
payload={{
|
||||
...safePayload,
|
||||
networks: [
|
||||
{
|
||||
...safePayload.networks[0],
|
||||
safe: false,
|
||||
blockReason: 'unsupported_driver',
|
||||
creationSpec: null,
|
||||
declarations: [{ key: 'arr', driverKind: 'macvlan', unsupportedFeatures: [] }],
|
||||
},
|
||||
],
|
||||
}}
|
||||
isAdmin
|
||||
onCancel={vi.fn()}
|
||||
onOpenNetworking={vi.fn()}
|
||||
onCreateAndContinue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByRole('button', { name: 'Create and continue' })).toBeDisabled();
|
||||
});
|
||||
|
||||
it('exposes secondary actions under More and copies the create command', async () => {
|
||||
const onOpenNetworking = vi.fn();
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<MissingExternalNetworksDialog
|
||||
open
|
||||
payload={safePayload}
|
||||
isAdmin
|
||||
onCancel={vi.fn()}
|
||||
onOpenNetworking={onOpenNetworking}
|
||||
onCreateAndContinue={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Copy Compose snippet' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Copy Docker command' })).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'More' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Open Networking' }));
|
||||
expect(onOpenNetworking).toHaveBeenCalledOnce();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'More' }));
|
||||
await user.click(await screen.findByRole('menuitem', { name: 'Copy create command' }));
|
||||
expect(copyToClipboard).toHaveBeenCalledWith('docker network create arr-net');
|
||||
expect(toast.success).toHaveBeenCalledWith('Create command copied');
|
||||
});
|
||||
});
|
||||
@@ -33,9 +33,10 @@ export const CAPABILITIES = [
|
||||
'compose-storage',
|
||||
'cross-node-rbac',
|
||||
'stack-down-remove-volumes',
|
||||
'guided-external-network-preflight',
|
||||
] as const;
|
||||
|
||||
export type Capability = (typeof CAPABILITIES)[number];
|
||||
|
||||
/** Must stay in sync with backend CapabilityRegistry.STACK_DOWN_REMOVE_VOLUMES_CAPABILITY */
|
||||
export const STACK_DOWN_REMOVE_VOLUMES_CAPABILITY = 'stack-down-remove-volumes' as const satisfies Capability;
|
||||
export const GUIDED_EXTERNAL_NETWORK_PREFLIGHT_CAPABILITY = 'guided-external-network-preflight' as const satisfies Capability;
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
type NetworkingFinding, type NetworkingFindingKind, type NetworkingNetworkRow, type NetworkingOverviewEnvelope,
|
||||
type NetworkingRecommendedAction, type NodeNetworkingOverview,
|
||||
} from '@/types/networking';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
|
||||
export type NetworkFilter = 'all' | 'managed' | 'external' | 'system' | 'shared' | 'exposed' | 'drift';
|
||||
|
||||
@@ -95,9 +96,40 @@ export function canUseNetworkName(name: string): boolean {
|
||||
return /^[A-Za-z0-9][A-Za-z0-9_.-]*$/.test(name);
|
||||
}
|
||||
|
||||
export function buildExternalNetworkSnippet(name: string): string | null {
|
||||
if (!canUseNetworkName(name)) return null;
|
||||
return `networks:\n ${name}:\n external: true`;
|
||||
/**
|
||||
* Build a single valid `networks:` Compose fragment for one or more
|
||||
* key → runtime-name mappings. Stable-sorts keys. Returns null if any
|
||||
* runtime name is unsafe.
|
||||
*/
|
||||
export function buildExternalNetworksSnippet(
|
||||
entries: ReadonlyArray<{ key: string; name: string }>,
|
||||
): string | null {
|
||||
if (entries.length === 0) return null;
|
||||
const networks: Record<string, { external: true; name?: string }> = {};
|
||||
const sorted = [...entries].sort((a, b) => a.key.localeCompare(b.key));
|
||||
for (const { key, name } of sorted) {
|
||||
if (!canUseNetworkName(name)) return null;
|
||||
networks[key] = key === name
|
||||
? { external: true }
|
||||
: { external: true, name };
|
||||
}
|
||||
// Prefer yaml when available; fallback keeps simple unquoted keys for tests.
|
||||
try {
|
||||
return stringifyYaml({ networks }).trimEnd();
|
||||
} catch {
|
||||
const lines = ['networks:'];
|
||||
for (const { key, name } of sorted) {
|
||||
lines.push(` ${key}:`);
|
||||
lines.push(' external: true');
|
||||
if (key !== name) lines.push(` name: ${name}`);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
/** @deprecated Prefer buildExternalNetworksSnippet with explicit key+name. */
|
||||
export function buildExternalNetworkSnippet(name: string, key: string = name): string | null {
|
||||
return buildExternalNetworksSnippet([{ key, name }]);
|
||||
}
|
||||
|
||||
/** Normalizes a network row from a schema-2 remote (no serviceNames) so the UI
|
||||
|
||||
Reference in New Issue
Block a user