diff --git a/app/react/docker/proxy/queries/useInfo.ts b/app/react/docker/proxy/queries/useInfo.ts index 3e756bc714..cd6a89f927 100644 --- a/app/react/docker/proxy/queries/useInfo.ts +++ b/app/react/docker/proxy/queries/useInfo.ts @@ -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; diff --git a/app/react/docker/proxy/queries/useSwarm.ts b/app/react/docker/proxy/queries/useSwarm.ts index b80ff077e5..a77ee3839a 100644 --- a/app/react/docker/proxy/queries/useSwarm.ts +++ b/app/react/docker/proxy/queries/useSwarm.ts @@ -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( 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, }); } diff --git a/app/react/docker/stacks/CreateView/CreateView.tsx b/app/react/docker/stacks/CreateView/CreateView.tsx index 6fecf9a74f..565186afef 100644 --- a/app/react/docker/stacks/CreateView/CreateView.tsx +++ b/app/react/docker/stacks/CreateView/CreateView.tsx @@ -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) { diff --git a/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useIsDeployable.ts b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useIsDeployable.ts index 8e0ea976f3..b1cb02a0b1 100644 --- a/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useIsDeployable.ts +++ b/app/react/portainer/templates/app-templates/DeployFormWidget/StackDeployForm/useIsDeployable.ts @@ -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; } diff --git a/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx b/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx index c19d8f10f1..6774353ed7 100644 --- a/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx +++ b/app/react/portainer/templates/custom-templates/CreateView/CreateView.tsx @@ -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 (
diff --git a/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/useIsDeployable.test.tsx b/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/useIsDeployable.test.tsx new file mode 100644 index 0000000000..a8c5795bda --- /dev/null +++ b/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/useIsDeployable.test.tsx @@ -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); + }); +}); diff --git a/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/useIsDeployable.ts b/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/useIsDeployable.ts index c8370b09bc..40dfc9b734 100644 --- a/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/useIsDeployable.ts +++ b/app/react/portainer/templates/custom-templates/ListView/StackFromCustomTemplateFormWidget/useIsDeployable.ts @@ -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;