fix(deploy): selective compose recreate on Save and Deploy (#1565) (#1568)

* feat: move core Blueprint orchestration to Community tier

Blueprints CRUD, reconciliation, and drift modes are now available on
Community. Pin remains Admiral-only via Federation placement controls.

* test: update NodeCard cordon tests for Admiral-only tier gate

Cordon now requires both isPaid and node:manage permission, matching the
backend requirePaid + requirePermission guard. Three tests still used
isPaid:false but expected the menu to be visible.
This commit is contained in:
Anso
2026-07-05 21:55:32 -04:00
committed by GitHub
parent adcc27e2cf
commit b3bc223c45
5 changed files with 175 additions and 58 deletions
+46 -4
View File
@@ -12,7 +12,7 @@ import type WebSocket from 'ws';
const {
mockSpawn,
mockGetContainersByStack, mockRemoveContainers, mockListContainers,
mockGetContainersByStack, mockGetLegacyOrphanContainersByStack, mockRemoveContainers, mockListContainers,
mockContainerInspect, mockContainerLogs,
mockGetRegistries, mockResolveDockerConfig,
mockBackupStackFiles, mockRestoreStackFiles,
@@ -24,6 +24,7 @@ const {
} = vi.hoisted(() => ({
mockSpawn: vi.fn(),
mockGetContainersByStack: vi.fn().mockResolvedValue([]),
mockGetLegacyOrphanContainersByStack: vi.fn().mockResolvedValue([]),
mockRemoveContainers: vi.fn().mockResolvedValue([]),
mockListContainers: vi.fn().mockResolvedValue([]),
mockContainerInspect: vi.fn().mockResolvedValue({ State: { ExitCode: 0 } }),
@@ -75,6 +76,7 @@ vi.mock('../services/DockerController', () => ({
default: {
getInstance: () => ({
getContainersByStack: mockGetContainersByStack,
getLegacyOrphanContainersByStack: mockGetLegacyOrphanContainersByStack,
removeContainers: mockRemoveContainers,
pruneDanglingImages: mockPruneDanglingImages,
getDocker: () => ({
@@ -588,7 +590,7 @@ describe('ComposeService - deployStack', () => {
const error = await result;
expect(error?.message).toContain('1:1');
expect(mockGetContainersByStack).not.toHaveBeenCalled();
expect(mockGetLegacyOrphanContainersByStack).not.toHaveBeenCalled();
expect(mockRemoveContainers).not.toHaveBeenCalled();
expect(mockSpawn).toHaveBeenCalledTimes(1);
});
@@ -629,7 +631,47 @@ describe('ComposeService - deployStack', () => {
await vi.advanceTimersByTimeAsync(3100);
await expect(promise).resolves.toBeUndefined();
expect(mockGetContainersByStack).toHaveBeenCalledWith('my-stack');
expect(mockGetLegacyOrphanContainersByStack).toHaveBeenCalledWith('my-stack');
});
it('does not remove compose-managed containers before deploy (#1565)', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
mockGetLegacyOrphanContainersByStack.mockResolvedValue([]);
const svc = ComposeService.getInstance(1);
const promise = svc.deployStack('my-stack');
await vi.advanceTimersByTimeAsync(3100);
await promise;
expect(mockGetLegacyOrphanContainersByStack).toHaveBeenCalledWith('my-stack');
expect(mockRemoveContainers).not.toHaveBeenCalled();
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'up', '-d', '--remove-orphans'],
expect.any(Object),
);
});
it('removes legacy orphan containers when compose ps is empty', async () => {
setupAutoCloseSpawn();
mockListContainers.mockResolvedValue([]);
mockGetLegacyOrphanContainersByStack.mockResolvedValue([
{ Id: 'legacy-c1' },
{ Id: 'legacy-c2' },
]);
const svc = ComposeService.getInstance(1);
const promise = svc.deployStack('my-stack');
await vi.advanceTimersByTimeAsync(3100);
await promise;
expect(mockRemoveContainers).toHaveBeenCalledWith(['legacy-c1', 'legacy-c2']);
expect(mockSpawn).toHaveBeenCalledWith(
'docker',
['compose', 'up', '-d', '--remove-orphans'],
expect.any(Object),
);
});
it('runs docker compose up -d --remove-orphans', async () => {
@@ -672,7 +714,7 @@ describe('ComposeService - deployStack', () => {
'Atomic deployment backup failed',
);
expect(mockSpawn).not.toHaveBeenCalled();
expect(mockGetContainersByStack).not.toHaveBeenCalled();
expect(mockGetLegacyOrphanContainersByStack).not.toHaveBeenCalled();
});
it('throws sanitized CONTAINER_CRASHED when exited container has non-zero exit code', async () => {
@@ -31,6 +31,7 @@ vi.mock('../services/NodeRegistry', () => ({
getInstance: () => ({
getDocker: () => mockDocker,
getDefaultNodeId: () => 1,
getComposeDir: () => '/test/compose',
}),
},
}));
@@ -1567,3 +1568,44 @@ describe('DockerController - getBulkStackStatuses partial status', () => {
expect(result['empty-stack'].total).toBeUndefined();
});
});
// ── getLegacyOrphanContainersByStack (#1565) ───────────────────────────
describe('DockerController - getLegacyOrphanContainersByStack', () => {
it('returns [] when compose ps already manages containers', async () => {
const dc = DockerController.getInstance(1);
const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]> }, 'fetchComposePsContainers')
.mockResolvedValue([{ ID: 'managed-c1', Name: 'web-1' }]);
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, 'smartFallback')
.mockResolvedValue([{ Id: 'legacy-c1' }]);
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([]);
expect(fallbackSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
fallbackSpy.mockRestore();
});
it('returns legacy orphan IDs when compose ps is empty', async () => {
const dc = DockerController.getInstance(1);
const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]> }, 'fetchComposePsContainers')
.mockResolvedValue([]);
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, 'smartFallback')
.mockResolvedValue([{ Id: 'legacy-c1' }, { Id: '' }, {}]);
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c1' }]);
fetchSpy.mockRestore();
fallbackSpy.mockRestore();
});
it('falls back to legacy orphan lookup when compose ps throws', async () => {
const dc = DockerController.getInstance(1);
const fetchSpy = vi.spyOn(dc as unknown as { fetchComposePsContainers: (...a: unknown[]) => Promise<unknown[]> }, 'fetchComposePsContainers')
.mockRejectedValue(new Error('compose ps failed'));
const fallbackSpy = vi.spyOn(dc as unknown as { smartFallback: (...a: unknown[]) => Promise<unknown[]> }, 'smartFallback')
.mockResolvedValue([{ Id: 'legacy-c2' }]);
await expect(dc.getLegacyOrphanContainersByStack('my-stack')).resolves.toEqual([{ Id: 'legacy-c2' }]);
fetchSpy.mockRestore();
fallbackSpy.mockRestore();
});
});