fix(swarm): invalid Swarm ID [BE-12824] (#3357)

This commit is contained in:
Devon Steenberg
2026-08-04 10:50:37 +12:00
committed by GitHub
parent 7085f4764c
commit 5205ca95e2
7 changed files with 96 additions and 16 deletions
+5 -1
View File
@@ -77,9 +77,13 @@ export function useSystemLimits(environmentId: EnvironmentId) {
return { maxCpu, maxMemory };
}
export function useIsSwarmManager(environmentId: EnvironmentId) {
export function useIsSwarmManager(
environmentId?: EnvironmentId,
{ enabled }: { enabled?: boolean } = {}
) {
const query = useInfo(environmentId, {
select: (info) => !!info.Swarm?.NodeID && info.Swarm.ControlAvailable,
enabled,
});
return !!query.data;
+5 -3
View File
@@ -5,20 +5,22 @@ import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
import { EnvironmentId } from '@/react/portainer/environments/types';
import { queryKeys } from './query-keys';
import { useIsSwarm } from './useInfo';
import { useIsSwarmManager } from './useInfo';
import { buildDockerProxyUrl } from './buildDockerProxyUrl';
export function useSwarm<T = Swarm>(
environmentId: EnvironmentId,
{ select }: { select?(value: Swarm): T } = {}
) {
const isSwarm = useIsSwarm(environmentId);
// Swarm info is only available from a manager node; querying it on a worker
// returns 503, so gate the query on manager rather than swarm membership.
const isSwarmManager = useIsSwarmManager(environmentId);
return useQuery({
queryKey: [...queryKeys.base(environmentId), 'swarm'] as const,
queryFn: () => getSwarm(environmentId),
select,
enabled: isSwarm,
enabled: isSwarmManager,
});
}
@@ -1,5 +1,5 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
import { useIsSwarmManager } from '@/react/docker/proxy/queries/useInfo';
import { useSwarmId } from '@/react/docker/proxy/queries/useSwarm';
import { PageHeader } from '@@/PageHeader';
@@ -10,7 +10,8 @@ import { CreateStackForm } from './CreateStackForm/CreateStackForm';
export function CreateView() {
const environmentId = useEnvironmentId();
const isSwarm = useIsSwarm(environmentId);
// Only swarm managers deploy swarm stacks; workers deploy compose stacks.
const isSwarm = useIsSwarmManager(environmentId);
const swarmIdQuery = useSwarmId(environmentId);
if (isSwarm && swarmIdQuery.isLoading) {
@@ -1,18 +1,19 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { TemplateType } from '@/react/portainer/templates/app-templates/types';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
import { useIsSwarmManager } from '@/react/docker/proxy/queries/useInfo';
export function useIsDeployable(type: TemplateType) {
const environmentId = useEnvironmentId();
const isSwarm = useIsSwarm(environmentId);
// Swarm stacks can only be deployed from a manager node, not a worker.
const isSwarmManager = useIsSwarmManager(environmentId);
switch (type) {
case TemplateType.ComposeStack:
case TemplateType.Container:
return true;
case TemplateType.SwarmStack:
return isSwarm;
return isSwarmManager;
default:
return false;
}
@@ -1,5 +1,5 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
import { useIsSwarmManager } from '@/react/docker/proxy/queries/useInfo';
import { StackType } from '@/react/common/stacks/types';
import { ContainerEngine } from '@/react/portainer/environments/types';
@@ -13,10 +13,11 @@ import { CreateForm } from './CreateForm';
export function CreateView() {
const viewType = useViewType();
const environmentId = useEnvironmentId(false);
const isSwarm = useIsSwarm(environmentId, {
// A worker defaults to a compose template; only a manager defaults to swarm.
const isSwarmManager = useIsSwarmManager(environmentId, {
enabled: viewType === ContainerEngine.Docker,
});
const defaultType = getDefaultType(viewType, isSwarm);
const defaultType = getDefaultType(viewType, isSwarmManager);
return (
<div>
@@ -0,0 +1,70 @@
import { renderHook } from '@testing-library/react-hooks';
import { waitFor } from '@testing-library/react';
import { http, HttpResponse } from 'msw';
import { StackType } from '@/react/common/stacks/types';
import { useInfo } from '@/react/docker/proxy/queries/useInfo';
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
import { server } from '@/setup-tests/server';
import { useIsDeployable } from './useIsDeployable';
vi.mock('@/react/hooks/useEnvironmentId', () => ({
useEnvironmentId: () => 1,
}));
function mockInfo(swarm: { NodeID: string; ControlAvailable: boolean } | null) {
server.use(
http.get('/api/endpoints/1/docker/info', () =>
HttpResponse.json(swarm ? { Swarm: swarm } : {})
)
);
}
// The `info` probe shares the same query key as the info query inside
// useIsSwarmManager, so waiting for it guarantees the deployability booleans
// have settled to their post-fetch values before we assert.
function renderDeployability() {
return renderHook(
() => ({
info: useInfo(1),
compose: useIsDeployable(StackType.DockerCompose),
swarm: useIsDeployable(StackType.DockerSwarm),
}),
{ wrapper: withTestQueryProvider(({ children }) => <>{children}</>) }
);
}
describe('useIsDeployable', () => {
it('deploys compose (not swarm) on a standalone node', async () => {
mockInfo(null);
const { result } = renderDeployability();
await waitFor(() => expect(result.current.info.isSuccess).toBe(true));
expect(result.current.compose).toBe(true);
expect(result.current.swarm).toBe(false);
});
it('deploys swarm (not compose) on a swarm manager node', async () => {
mockInfo({ NodeID: 'node-1', ControlAvailable: true });
const { result } = renderDeployability();
await waitFor(() => expect(result.current.info.isSuccess).toBe(true));
expect(result.current.compose).toBe(false);
expect(result.current.swarm).toBe(true);
});
// Regression for BE-12824: a worker joined to a swarm must still deploy
// compose stacks, not swarm stacks (it cannot query swarm management).
it('deploys compose (not swarm) on a swarm worker node', async () => {
mockInfo({ NodeID: 'node-2', ControlAvailable: false });
const { result } = renderDeployability();
await waitFor(() => expect(result.current.info.isSuccess).toBe(true));
expect(result.current.compose).toBe(true);
expect(result.current.swarm).toBe(false);
});
});
@@ -1,17 +1,18 @@
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
import { StackType } from '@/react/common/stacks/types';
import { useIsSwarm } from '@/react/docker/proxy/queries/useInfo';
import { useIsSwarmManager } from '@/react/docker/proxy/queries/useInfo';
export function useIsDeployable(type: StackType | undefined) {
const environmentId = useEnvironmentId();
const isSwarm = useIsSwarm(environmentId);
// Swarm stacks deploy only from a manager; a worker deploys compose stacks.
const isSwarmManager = useIsSwarmManager(environmentId);
switch (type) {
case StackType.DockerCompose:
return !isSwarm;
return !isSwarmManager;
case StackType.DockerSwarm:
return isSwarm;
return isSwarmManager;
case StackType.Kubernetes:
default:
return false;