mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-24 08:57:25 +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:
@@ -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'));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user