mirror of
https://github.com/portainer/portainer.git
synced 2026-08-07 09:54:49 +00:00
fix(swarm): invalid Swarm ID [BE-12824] (#3357)
This commit is contained in:
@@ -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,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) {
|
||||
|
||||
+4
-3
@@ -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>
|
||||
|
||||
+70
@@ -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);
|
||||
});
|
||||
});
|
||||
+5
-4
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user