Merge remote-tracking branch 'origin/feat/10-update-now' into feat/3-auto-update
This commit is contained in:
@@ -134,6 +134,18 @@ func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID str
|
||||
return Skipped, nil
|
||||
}
|
||||
|
||||
// statusCache is the 24h cache keyed by imageID. Reading it here makes the
|
||||
// long-lived cache effective on the input side for every caller (handler,
|
||||
// ContainersImageStatus, the M4 auto-update job): a hit skips the expensive,
|
||||
// rate-limited remote registry digest lookup below. The container/image
|
||||
// inspects above are local Docker calls and cheap; the registry HEAD is the
|
||||
// part worth avoiding. Only successful statuses are ever written (the error
|
||||
// paths return early without caching), so a hit returns the same value the
|
||||
// full computation would have produced.
|
||||
if s, err := CachedResourceImageStatus(imageID); err == nil {
|
||||
return s, nil
|
||||
}
|
||||
|
||||
digs := make([]digest.Digest, 0)
|
||||
images := make([]*Image, 0)
|
||||
if i, err := ParseImage(ParseImageOptions{Name: container.Config.Image}); err == nil {
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewHandler(routePrefix string, bouncer security.BouncerService, dataStore d
|
||||
router.Use(bouncer.AuthenticatedAccess, middlewares.CheckEndpointAuthorization(bouncer))
|
||||
|
||||
router.Handle("/{containerId}/gpus", httperror.LoggerHandler(h.containerGpusInspect)).Methods(http.MethodGet)
|
||||
router.Handle("/{containerId}/image_status", httperror.LoggerHandler(h.imageStatus)).Methods(http.MethodGet)
|
||||
router.Handle("/{containerId}/recreate", httperror.LoggerHandler(h.recreate)).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
"github.com/portainer/portainer/api/http/middlewares"
|
||||
httperror "github.com/portainer/portainer/pkg/libhttp/error"
|
||||
"github.com/portainer/portainer/pkg/libhttp/request"
|
||||
"github.com/portainer/portainer/pkg/libhttp/response"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
// imageStatusResponse is the body returned by the image status endpoint.
|
||||
type imageStatusResponse struct {
|
||||
// Status of the running container image. One of:
|
||||
// "outdated", "updated", "skipped", "processing", "preparing", "error".
|
||||
Status string `json:"Status"`
|
||||
// Message holds an optional human-readable detail, typically the detection error.
|
||||
Message string `json:"Message,omitempty"`
|
||||
}
|
||||
|
||||
// @id ContainerImageStatus
|
||||
// @summary Fetch the image status of a container
|
||||
// @description Detect whether a newer image is available for the running container by
|
||||
// @description comparing the local image digest against the remote registry digest.
|
||||
// @description This is a read-only operation: it never pulls or recreates anything.
|
||||
// @description **Access policy**: authenticated
|
||||
// @tags docker
|
||||
// @security ApiKeyAuth
|
||||
// @security jwt
|
||||
// @produce json
|
||||
// @param id path int true "Environment identifier"
|
||||
// @param containerId path string true "Container identifier"
|
||||
// @param nodeName query string false "Node name for a Swarm/agent endpoint"
|
||||
// @description Engine-level issues (container not found, registry unreachable, auth
|
||||
// @description failure, ...) are not treated as API errors: they degrade gracefully to a
|
||||
// @description 200 response carrying a "skipped" or "error" status. HTTP errors are only
|
||||
// @description returned for request/authorization problems.
|
||||
// @success 200 {object} imageStatusResponse "Image status (also returned with a skipped/error status for engine-level issues)"
|
||||
// @failure 400 "Invalid request: missing container identifier"
|
||||
// @failure 403 "Permission denied to access the environment"
|
||||
// @failure 404 "Environment not found"
|
||||
// @router /docker/{id}/containers/{containerId}/image_status [get]
|
||||
func (handler *Handler) imageStatus(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
|
||||
containerID, err := request.RetrieveRouteVariableValue(r, "containerId")
|
||||
if err != nil {
|
||||
return httperror.BadRequest("Invalid containerId", err)
|
||||
}
|
||||
|
||||
// nodeName is optional and only relevant for Swarm/agent endpoints.
|
||||
nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true)
|
||||
|
||||
endpoint, err := middlewares.FetchEndpoint(r)
|
||||
if err != nil {
|
||||
return httperror.NotFound("Unable to find an environment on request context", err)
|
||||
}
|
||||
|
||||
if err := handler.bouncer.AuthorizedEndpointOperation(r, endpoint); err != nil {
|
||||
return httperror.Forbidden("Permission denied to access environment", err)
|
||||
}
|
||||
|
||||
// The detection engine (zlib/CE) routes outbound registry calls through the
|
||||
// RegistryClient, which honors the encrypted credential store and the outbound
|
||||
// SSRF/AllowList. It caches results for 24h and skips digest-pinned/local-only images.
|
||||
digestClient := images.NewClientWithRegistry(images.NewRegistryClient(handler.dataStore), handler.dockerClientFactory)
|
||||
|
||||
status, err := digestClient.ContainerImageStatus(r.Context(), containerID, endpoint, nodeName)
|
||||
if err != nil {
|
||||
// A detection failure (registry unreachable, auth failure, ...) is not an API
|
||||
// failure: degrade gracefully with a 200 + "error" status so the UI can render a
|
||||
// neutral badge instead of surfacing a hard error. The raw error is logged
|
||||
// server-side only; the response carries a generic message to avoid leaking
|
||||
// registry URLs or credential details to the client.
|
||||
log.Warn().Err(err).Str("containerId", containerID).Msg("unable to determine container image status")
|
||||
|
||||
return response.JSON(w, &imageStatusResponse{Status: string(images.Error), Message: "unable to determine image status"})
|
||||
}
|
||||
|
||||
return response.JSON(w, &imageStatusResponse{Status: string(status)})
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package containers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/portainer/portainer/api/docker/images"
|
||||
)
|
||||
|
||||
// TestImageStatusResponse_JSONShape verifies the wire format of the image status
|
||||
// response: the status string is mapped from the engine enum and the message is
|
||||
// omitted when empty, matching what the CE frontend badge expects.
|
||||
func TestImageStatusResponse_JSONShape(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
resp imageStatusResponse
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "outdated without message",
|
||||
resp: imageStatusResponse{Status: string(images.Outdated)},
|
||||
expected: `{"Status":"outdated"}`,
|
||||
},
|
||||
{
|
||||
name: "updated without message",
|
||||
resp: imageStatusResponse{Status: string(images.Updated)},
|
||||
expected: `{"Status":"updated"}`,
|
||||
},
|
||||
{
|
||||
name: "skipped without message",
|
||||
resp: imageStatusResponse{Status: string(images.Skipped)},
|
||||
expected: `{"Status":"skipped"}`,
|
||||
},
|
||||
{
|
||||
name: "error carries a message",
|
||||
resp: imageStatusResponse{Status: string(images.Error), Message: "registry unreachable"},
|
||||
expected: `{"Status":"error","Message":"registry unreachable"}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := json.Marshal(tt.resp)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected marshal error: %v", err)
|
||||
}
|
||||
|
||||
if string(got) != tt.expected {
|
||||
t.Errorf("unexpected JSON\n got: %s\nwant: %s", got, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
+3
@@ -2,6 +2,7 @@ import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
import { ContainerDetailsViewModel } from '@/docker/models/containerDetails';
|
||||
import { isPartOfSwarmService } from '@/docker/helpers/containers';
|
||||
import { trimContainerName } from '@/docker/filters/utils';
|
||||
|
||||
import { Widget, WidgetBody } from '@@/Widget';
|
||||
|
||||
@@ -57,6 +58,8 @@ export function ContainerActionsSection({
|
||||
environmentId={environmentId}
|
||||
containerId={container.Id}
|
||||
containerImage={container.Config?.Image || ''}
|
||||
containerName={trimContainerName(container.Name) || container.Id}
|
||||
containerLabels={container.Config?.Labels ?? undefined}
|
||||
containerAutoRemove={container.HostConfig?.AutoRemove}
|
||||
nodeName={nodeName}
|
||||
partOfSwarmService={isPartOfSwarmService(container)}
|
||||
|
||||
@@ -197,6 +197,7 @@ function renderComponent(
|
||||
containerId: 'test-container-id',
|
||||
nodeName: 'node1',
|
||||
containerImage: 'nginx:latest',
|
||||
containerName: 'test-container',
|
||||
containerAutoRemove: false,
|
||||
isPortainer: false,
|
||||
partOfSwarmService: false,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { ButtonGroup } from '@@/buttons';
|
||||
import { ContainerId } from '../../types';
|
||||
|
||||
import { RecreateButton } from './SecondaryActions/RecreateButton';
|
||||
import { UpdateNowButton } from './SecondaryActions/UpdateNowButton';
|
||||
import { DuplicateEditButton } from './SecondaryActions/DuplicateEditButton';
|
||||
import { useCanRecreateContainer } from './SecondaryActions/useCanRecreateContainer';
|
||||
import { useCanDuplicateEditContainer } from './SecondaryActions/useCanDuplicateEditContainer';
|
||||
@@ -15,6 +16,8 @@ interface Props {
|
||||
containerId: ContainerId;
|
||||
nodeName?: string;
|
||||
containerImage: string;
|
||||
containerName: string;
|
||||
containerLabels?: Record<string, string>;
|
||||
containerAutoRemove: boolean | undefined;
|
||||
isPortainer: boolean;
|
||||
partOfSwarmService: boolean;
|
||||
@@ -25,6 +28,8 @@ export function SecondaryActions({
|
||||
containerId,
|
||||
nodeName,
|
||||
containerImage,
|
||||
containerName,
|
||||
containerLabels,
|
||||
containerAutoRemove = false,
|
||||
isPortainer,
|
||||
partOfSwarmService,
|
||||
@@ -39,31 +44,43 @@ export function SecondaryActions({
|
||||
partOfSwarmService,
|
||||
});
|
||||
|
||||
if (!displayRecreateButton && !displayDuplicateEditButton) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Authorized authorizations="DockerContainerCreate">
|
||||
<ButtonGroup>
|
||||
{displayRecreateButton && (
|
||||
<RecreateButton
|
||||
environmentId={environmentId}
|
||||
containerId={containerId}
|
||||
nodeName={nodeName}
|
||||
containerImage={containerImage}
|
||||
isPortainer={isPortainer}
|
||||
/>
|
||||
)}
|
||||
{/*
|
||||
Self-hides unless the image is outdated; renders its own ButtonGroup so
|
||||
the recreate/duplicate group never shows up empty when both are hidden.
|
||||
*/}
|
||||
<UpdateNowButton
|
||||
environmentId={environmentId}
|
||||
containerId={containerId}
|
||||
nodeName={nodeName}
|
||||
containerImage={containerImage}
|
||||
containerName={containerName}
|
||||
labels={containerLabels}
|
||||
isPortainer={isPortainer}
|
||||
/>
|
||||
|
||||
{displayDuplicateEditButton && (
|
||||
<DuplicateEditButton
|
||||
containerId={containerId}
|
||||
nodeName={nodeName}
|
||||
isPortainer={isPortainer}
|
||||
/>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
{(displayRecreateButton || displayDuplicateEditButton) && (
|
||||
<ButtonGroup>
|
||||
{displayRecreateButton && (
|
||||
<RecreateButton
|
||||
environmentId={environmentId}
|
||||
containerId={containerId}
|
||||
nodeName={nodeName}
|
||||
containerImage={containerImage}
|
||||
isPortainer={isPortainer}
|
||||
/>
|
||||
)}
|
||||
|
||||
{displayDuplicateEditButton && (
|
||||
<DuplicateEditButton
|
||||
containerId={containerId}
|
||||
nodeName={nodeName}
|
||||
isPortainer={isPortainer}
|
||||
/>
|
||||
)}
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</Authorized>
|
||||
);
|
||||
}
|
||||
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { vi } from 'vitest';
|
||||
|
||||
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
|
||||
import { StackType } from '@/react/common/stacks/types';
|
||||
import { ContainerImageStatusValue } from '@/react/docker/containers/queries/useContainerImageStatus';
|
||||
|
||||
import { UpdateNowButton } from './UpdateNowButton';
|
||||
|
||||
const mockUseImageStatus = vi.fn();
|
||||
const mockUseStacks = vi.fn();
|
||||
const mockUseAuthorizations = vi.fn();
|
||||
const mockMutate = vi.fn();
|
||||
|
||||
vi.mock('@uirouter/react', () => ({
|
||||
useRouter: () => ({ stateService: { go: vi.fn() } }),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/docker/containers/queries/useContainerImageStatus', () => ({
|
||||
useContainerImageStatus: () => mockUseImageStatus(),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/common/stacks/queries/useStacks', () => ({
|
||||
useStacks: () => mockUseStacks(),
|
||||
}));
|
||||
|
||||
vi.mock('@/react/hooks/useUser', () => ({
|
||||
useAuthorizations: () => mockUseAuthorizations(),
|
||||
}));
|
||||
|
||||
const composeStack = {
|
||||
Id: 7,
|
||||
Name: 'my-stack',
|
||||
EndpointId: 3,
|
||||
Type: StackType.DockerCompose,
|
||||
};
|
||||
|
||||
vi.mock(
|
||||
'@/react/docker/containers/update',
|
||||
async (importOriginal: () => Promise<object>) => ({
|
||||
...(await importOriginal()),
|
||||
useUpdateContainerImage: () => ({ mutate: mockMutate, isLoading: false }),
|
||||
})
|
||||
);
|
||||
|
||||
function setStatus(status?: ContainerImageStatusValue) {
|
||||
mockUseImageStatus.mockReturnValue({ data: status ? { Status: status } : undefined });
|
||||
}
|
||||
|
||||
function renderButton(props: Partial<React.ComponentProps<typeof UpdateNowButton>> = {}) {
|
||||
return render(
|
||||
<UpdateNowButton
|
||||
environmentId={3}
|
||||
containerId="abc"
|
||||
containerImage="nginx:latest"
|
||||
containerName="web"
|
||||
isPortainer={false}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('UpdateNowButton', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockUseStacks.mockReturnValue({ data: [], isLoading: false });
|
||||
mockUseAuthorizations.mockReturnValue({ authorized: true });
|
||||
});
|
||||
|
||||
it('renders when the image is outdated', () => {
|
||||
setStatus('outdated');
|
||||
renderButton();
|
||||
expect(screen.getByTestId('update-now-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is hidden when the image is up to date', () => {
|
||||
setStatus('updated');
|
||||
renderButton();
|
||||
expect(screen.queryByTestId('update-now-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('is hidden when the status is unknown', () => {
|
||||
setStatus(undefined);
|
||||
renderButton();
|
||||
expect(screen.queryByTestId('update-now-button')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables the action for externally-managed compose containers', () => {
|
||||
setStatus('outdated');
|
||||
renderButton({
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'not-in-portainer' },
|
||||
});
|
||||
expect(screen.getByTestId('update-now-button')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('enables a stack container when the user can update stacks', () => {
|
||||
setStatus('outdated');
|
||||
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
|
||||
mockUseAuthorizations.mockReturnValue({ authorized: true });
|
||||
renderButton({
|
||||
environmentId: 3,
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
||||
});
|
||||
expect(screen.getByTestId('update-now-button')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('disables a stack container when the user lacks PortainerStackUpdate', () => {
|
||||
setStatus('outdated');
|
||||
mockUseStacks.mockReturnValue({ data: [composeStack], isLoading: false });
|
||||
mockUseAuthorizations.mockReturnValue({ authorized: false });
|
||||
renderButton({
|
||||
environmentId: 3,
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
||||
});
|
||||
expect(screen.getByTestId('update-now-button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
+161
@@ -0,0 +1,161 @@
|
||||
import { Download } from 'lucide-react';
|
||||
import { useRouter } from '@uirouter/react';
|
||||
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { confirmContainerRecreation } from '@/react/docker/containers/ItemView/ConfirmRecreationModal';
|
||||
import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-update';
|
||||
import { useStacks } from '@/react/common/stacks/queries/useStacks';
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { useContainerImageStatus } from '@/react/docker/containers/queries/useContainerImageStatus';
|
||||
import { useAuthorizations } from '@/react/hooks/useUser';
|
||||
import {
|
||||
resolveContainerUpdatePath,
|
||||
useUpdateContainerImage,
|
||||
ContainerUpdateContext,
|
||||
} from '@/react/docker/containers/update';
|
||||
|
||||
import { ButtonGroup, LoadingButton } from '@@/buttons';
|
||||
import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren';
|
||||
|
||||
import { ContainerId } from '../../../types';
|
||||
|
||||
interface UpdateNowButtonProps {
|
||||
environmentId: EnvironmentId;
|
||||
containerId: ContainerId;
|
||||
nodeName?: string;
|
||||
containerImage: string;
|
||||
containerName: string;
|
||||
labels?: Record<string, string>;
|
||||
isPortainer: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Update now" surfaces a discoverable per-container apply action ONLY when the
|
||||
* image is `outdated`. It routes through the shared update primitive:
|
||||
* standalone -> recreate-with-pull, stack-managed -> stack redeploy-with-pull
|
||||
* (container stays in its stack). Externally-managed compose containers are
|
||||
* shown disabled with an explanatory tooltip, never recreated out-of-band.
|
||||
*
|
||||
* A stack redeploy is gated by `PortainerStackUpdate` (as everywhere else in the
|
||||
* app): a user with container-create but without stack-update rights sees the
|
||||
* button disabled with a tooltip rather than getting a 403 on click.
|
||||
*/
|
||||
export function UpdateNowButton({
|
||||
environmentId,
|
||||
containerId,
|
||||
nodeName,
|
||||
containerImage,
|
||||
containerName,
|
||||
labels,
|
||||
isPortainer,
|
||||
}: UpdateNowButtonProps) {
|
||||
const router = useRouter();
|
||||
const statusQuery = useContainerImageStatus(
|
||||
environmentId,
|
||||
containerId,
|
||||
nodeName
|
||||
);
|
||||
const stacksQuery = useStacks();
|
||||
const updateMutation = useUpdateContainerImage();
|
||||
// A stack redeploy needs stack-update rights, not just container-create.
|
||||
const { authorized: canUpdateStack } = useAuthorizations(
|
||||
'PortainerStackUpdate',
|
||||
environmentId
|
||||
);
|
||||
|
||||
// Only meaningful when a newer image is actually available.
|
||||
if (statusQuery.data?.Status !== 'outdated') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const stacks = stacksQuery.data ?? [];
|
||||
const path = resolveContainerUpdatePath({ labels, environmentId }, stacks);
|
||||
const isExternal = path.kind === 'external';
|
||||
// Stack-managed container the user isn't allowed to redeploy: disable rather
|
||||
// than let the click 403.
|
||||
const stackUpdateForbidden = path.kind === 'stack' && !canUpdateStack;
|
||||
|
||||
const button = (
|
||||
<LoadingButton
|
||||
color="primary"
|
||||
size="small"
|
||||
onClick={handleClick}
|
||||
disabled={
|
||||
isPortainer ||
|
||||
isExternal ||
|
||||
stackUpdateForbidden ||
|
||||
stacksQuery.isLoading
|
||||
}
|
||||
isLoading={updateMutation.isLoading}
|
||||
loadingText="Updating..."
|
||||
data-cy="update-now-button"
|
||||
icon={Download}
|
||||
>
|
||||
Update now
|
||||
</LoadingButton>
|
||||
);
|
||||
|
||||
if (isExternal) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<TooltipWithChildren message="This container belongs to a compose project that is managed outside Portainer, so it can't be updated from here.">
|
||||
{button}
|
||||
</TooltipWithChildren>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
if (stackUpdateForbidden) {
|
||||
return (
|
||||
<ButtonGroup>
|
||||
<TooltipWithChildren message="Updating this container redeploys its stack, which requires stack update permission you don't have.">
|
||||
{button}
|
||||
</TooltipWithChildren>
|
||||
</ButtonGroup>
|
||||
);
|
||||
}
|
||||
|
||||
return <ButtonGroup>{button}</ButtonGroup>;
|
||||
|
||||
async function handleClick() {
|
||||
const context: ContainerUpdateContext = {
|
||||
id: containerId,
|
||||
name: containerName,
|
||||
image: containerImage,
|
||||
labels,
|
||||
environmentId,
|
||||
nodeName,
|
||||
};
|
||||
|
||||
let pullImage: boolean;
|
||||
|
||||
if (path.kind === 'stack') {
|
||||
const result = await confirmStackUpdate(
|
||||
'This will redeploy the stack pulling the latest images and may cause a service interruption. Do you wish to continue?',
|
||||
true
|
||||
);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
pullImage = result.repullImageAndRedeploy;
|
||||
} else {
|
||||
const cannotPullImage =
|
||||
!containerImage || containerImage.toLowerCase().startsWith('sha256');
|
||||
const result = await confirmContainerRecreation(cannotPullImage);
|
||||
if (!result) {
|
||||
return;
|
||||
}
|
||||
pullImage = result.pullLatest;
|
||||
}
|
||||
|
||||
updateMutation.mutate(
|
||||
{ context, stacks, pullImage },
|
||||
{
|
||||
onSuccess: () => {
|
||||
notifySuccess('Success', 'Container image update applied');
|
||||
router.stateService.go('docker.containers', {}, { reload: true });
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
+63
-2
@@ -1,8 +1,17 @@
|
||||
import { useRouter } from '@uirouter/react';
|
||||
import { Pause, Play, RefreshCw, Slash, Square, Trash2 } from 'lucide-react';
|
||||
import {
|
||||
Download,
|
||||
Pause,
|
||||
Play,
|
||||
RefreshCw,
|
||||
Slash,
|
||||
Square,
|
||||
Trash2,
|
||||
} from 'lucide-react';
|
||||
|
||||
import * as notifications from '@/portainer/services/notifications';
|
||||
import { useAuthorizations, Authorized } from '@/react/hooks/useUser';
|
||||
import { trimContainerName } from '@/docker/filters/utils';
|
||||
import { confirmContainerDeletion } from '@/react/docker/containers/common/confirm-container-delete-modal';
|
||||
import { setPortainerAgentTargetHeader } from '@/portainer/services/http-request.helper';
|
||||
import {
|
||||
@@ -20,8 +29,13 @@ import {
|
||||
stopContainer,
|
||||
} from '@/react/docker/containers/containers.service';
|
||||
import type { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
import { useStacks } from '@/react/common/stacks/queries/useStacks';
|
||||
import {
|
||||
ContainerUpdateContext,
|
||||
useBulkUpdateContainerImages,
|
||||
} from '@/react/docker/containers/update';
|
||||
|
||||
import { ButtonGroup, Button, AddButton } from '@@/buttons';
|
||||
import { ButtonGroup, Button, LoadingButton, AddButton } from '@@/buttons';
|
||||
|
||||
type ContainerServiceAction = (
|
||||
endpointId: EnvironmentId,
|
||||
@@ -70,7 +84,15 @@ export function ContainersDatatableActions({
|
||||
'DockerContainerCreate',
|
||||
]);
|
||||
|
||||
// Stack redeploys triggered by "Update" need stack-update rights, gated
|
||||
// separately so we never fire a redeploy the user would get a 403 on.
|
||||
const { authorized: canUpdateStack } = useAuthorizations(
|
||||
'PortainerStackUpdate'
|
||||
);
|
||||
|
||||
const router = useRouter();
|
||||
const stacksQuery = useStacks();
|
||||
const bulkUpdateMutation = useBulkUpdateContainerImages();
|
||||
|
||||
if (!authorized) {
|
||||
return null;
|
||||
@@ -162,6 +184,20 @@ export function ContainersDatatableActions({
|
||||
Remove
|
||||
</Button>
|
||||
</Authorized>
|
||||
|
||||
<Authorized authorizations="DockerContainerCreate">
|
||||
<LoadingButton
|
||||
color="light"
|
||||
data-cy="update-selected-docker-container-button"
|
||||
onClick={() => onUpdateClick(selectedItems)}
|
||||
disabled={selectedItemCount === 0 || stacksQuery.isLoading}
|
||||
isLoading={bulkUpdateMutation.isLoading}
|
||||
loadingText="Updating..."
|
||||
icon={Download}
|
||||
>
|
||||
Update
|
||||
</LoadingButton>
|
||||
</Authorized>
|
||||
</ButtonGroup>
|
||||
{isAddActionVisible && (
|
||||
<div className="space-left">
|
||||
@@ -241,6 +277,31 @@ export function ContainersDatatableActions({
|
||||
);
|
||||
}
|
||||
|
||||
function onUpdateClick(selectedItems: ContainerListViewModel[]) {
|
||||
// Apply the shared image-update primitive to every outdated container,
|
||||
// skipping up-to-date/unknown ones and redeploying each owning stack once.
|
||||
const contexts: ContainerUpdateContext[] = selectedItems.map(
|
||||
(container) => ({
|
||||
id: container.Id,
|
||||
// Strip Docker's leading "/" so toast names match the single-container path.
|
||||
name: trimContainerName(container.Names[0]),
|
||||
image: container.Image,
|
||||
labels: container.Labels,
|
||||
environmentId: endpointId,
|
||||
nodeName: container.NodeName,
|
||||
})
|
||||
);
|
||||
|
||||
bulkUpdateMutation.mutate(
|
||||
{ contexts, stacks: stacksQuery.data ?? [], canUpdateStack },
|
||||
{
|
||||
onSettled: () => {
|
||||
router.stateService.reload();
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function onRemoveClick(selectedItems: ContainerListViewModel[]) {
|
||||
const isOneContainerRunning = selectedItems.some(
|
||||
(container) => container.State === 'running'
|
||||
|
||||
@@ -14,6 +14,7 @@ import { quickActions } from './quick-actions';
|
||||
import { stack } from './stack';
|
||||
import { state } from './state';
|
||||
import { gpus } from './gpus';
|
||||
import { updateAvailable } from './updateAvailable';
|
||||
|
||||
export function useColumns(
|
||||
isHostColumnVisible: boolean,
|
||||
@@ -27,6 +28,7 @@ export function useColumns(
|
||||
quickActions,
|
||||
stack,
|
||||
image,
|
||||
updateAvailable,
|
||||
created,
|
||||
ip,
|
||||
isHostColumnVisible && host,
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { CellContext } from '@tanstack/react-table';
|
||||
|
||||
import type { ContainerListViewModel } from '@/react/docker/containers/types';
|
||||
import { useEnvironmentId } from '@/react/hooks/useEnvironmentId';
|
||||
import { useContainerImageStatus } from '@/react/docker/containers/queries/useContainerImageStatus';
|
||||
|
||||
import { UpdateStatusBadge } from '../../../components/UpdateStatusBadge';
|
||||
|
||||
import { columnHelper } from './helper';
|
||||
|
||||
export const updateAvailable = columnHelper.display({
|
||||
header: 'Update available',
|
||||
id: 'updateAvailable',
|
||||
cell: UpdateAvailableCell,
|
||||
});
|
||||
|
||||
function UpdateAvailableCell({
|
||||
row: { original: container },
|
||||
}: CellContext<ContainerListViewModel, unknown>) {
|
||||
const environmentId = useEnvironmentId();
|
||||
// One detection request per visible row is acceptable: the backend caches results
|
||||
// for 24h and the hook keeps a generous client-side staleTime, so re-renders and
|
||||
// pagination don't re-hit the registry. The query is non-blocking, so the table
|
||||
// renders immediately and badges fill in as statuses resolve.
|
||||
const statusQuery = useContainerImageStatus(
|
||||
environmentId,
|
||||
container.Id,
|
||||
container.NodeName
|
||||
);
|
||||
|
||||
return (
|
||||
<UpdateStatusBadge
|
||||
status={statusQuery.data?.Status}
|
||||
isLoading={statusQuery.isLoading}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import { ContainerImageStatusValue } from '../../queries/useContainerImageStatus';
|
||||
|
||||
import { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
|
||||
describe('UpdateStatusBadge', () => {
|
||||
it('renders "Update available" when the image is outdated', () => {
|
||||
render(<UpdateStatusBadge status="outdated" />);
|
||||
|
||||
expect(screen.getByText('Update available')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders an up-to-date indicator when the image is updated', () => {
|
||||
render(<UpdateStatusBadge status="updated" />);
|
||||
|
||||
expect(screen.getByText('Up to date')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a spinner while loading', () => {
|
||||
render(<UpdateStatusBadge isLoading />);
|
||||
|
||||
expect(
|
||||
screen.getByLabelText('Checking for image updates')
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each<ContainerImageStatusValue>([
|
||||
'skipped',
|
||||
'error',
|
||||
'processing',
|
||||
'preparing',
|
||||
])('renders nothing for the neutral status "%s"', (status) => {
|
||||
const { container } = render(<UpdateStatusBadge status={status} />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders nothing when status is undefined and not loading', () => {
|
||||
const { container } = render(<UpdateStatusBadge />);
|
||||
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Loader } from 'lucide-react';
|
||||
|
||||
import UpdatesAvailable from '@/assets/ico/icon_updates-available.svg?c';
|
||||
import UpToDate from '@/assets/ico/icon_up-to-date.svg?c';
|
||||
|
||||
import { Icon } from '@@/Icon';
|
||||
|
||||
import { ContainerImageStatusValue } from '../../queries/useContainerImageStatus';
|
||||
|
||||
export interface Props {
|
||||
status?: ContainerImageStatusValue;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* CE container image update badge.
|
||||
*
|
||||
* Renders "Update available" when the image is outdated, an up-to-date indicator
|
||||
* when it matches the registry, a spinner while the status is being fetched, and
|
||||
* nothing for neutral states (skipped/error/processing/preparing) so that
|
||||
* undetectable images don't clutter the UI.
|
||||
*/
|
||||
export function UpdateStatusBadge({ status, isLoading }: Props) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<span role="status" aria-label="Checking for image updates">
|
||||
<Icon icon={Loader} size="sm" spin className="!mr-1 align-middle" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'outdated') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap text-warning">
|
||||
<Icon icon={UpdatesAvailable} size="sm" className="align-middle" />
|
||||
Update available
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'updated') {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 whitespace-nowrap text-muted">
|
||||
<Icon icon={UpToDate} size="sm" className="align-middle" />
|
||||
Up to date
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// skipped / error / processing / preparing / undefined -> neutral, nothing to show.
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { UpdateStatusBadge } from './UpdateStatusBadge';
|
||||
@@ -19,6 +19,13 @@ export const queryKeys = {
|
||||
gpus: (environmentId: EnvironmentId, id: string) =>
|
||||
[...queryKeys.container(environmentId, id), 'gpus'] as const,
|
||||
|
||||
imageStatus: (environmentId: EnvironmentId, id: string, nodeName?: string) =>
|
||||
[
|
||||
...queryKeys.container(environmentId, id),
|
||||
'imageStatus',
|
||||
nodeName,
|
||||
] as const,
|
||||
|
||||
top: (environmentId: EnvironmentId, id: string) =>
|
||||
[...queryKeys.container(environmentId, id), 'top'] as const,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { renderHook } from '@testing-library/react-hooks';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
|
||||
import { server } from '@/setup-tests/server';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
|
||||
import {
|
||||
ContainerImageStatus,
|
||||
useContainerImageStatus,
|
||||
} from './useContainerImageStatus';
|
||||
|
||||
const environmentId = 3;
|
||||
const containerId = 'abc123';
|
||||
|
||||
function renderImageStatusHook(nodeName?: string) {
|
||||
return renderHook(
|
||||
() => useContainerImageStatus(environmentId, containerId, nodeName),
|
||||
{ wrapper: withTestQueryProvider(({ children }) => <>{children}</>) }
|
||||
);
|
||||
}
|
||||
|
||||
describe('useContainerImageStatus', () => {
|
||||
it('calls the non-proxied image_status endpoint and returns the status', async () => {
|
||||
const payload: ContainerImageStatus = { Status: 'outdated' };
|
||||
|
||||
server.use(
|
||||
http.get(
|
||||
`/api/docker/${environmentId}/containers/${containerId}/image_status`,
|
||||
() => HttpResponse.json(payload)
|
||||
)
|
||||
);
|
||||
|
||||
const { result, waitFor } = renderImageStatusHook();
|
||||
|
||||
await waitFor(() => result.current.isSuccess);
|
||||
expect(result.current.data).toEqual(payload);
|
||||
});
|
||||
|
||||
it('forwards nodeName as a query parameter', async () => {
|
||||
let receivedNodeName: string | null = null;
|
||||
|
||||
server.use(
|
||||
http.get(
|
||||
`/api/docker/${environmentId}/containers/${containerId}/image_status`,
|
||||
({ request }) => {
|
||||
receivedNodeName = new URL(request.url).searchParams.get('nodeName');
|
||||
return HttpResponse.json<ContainerImageStatus>({ Status: 'updated' });
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
const { result, waitFor } = renderImageStatusHook('node-2');
|
||||
|
||||
await waitFor(() => result.current.isSuccess);
|
||||
expect(receivedNodeName).toBe('node-2');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import axios, { parseAxiosError } from '@/portainer/services/axios/axios';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { buildDockerUrl } from '../../queries/utils/buildDockerUrl';
|
||||
import { ContainerId } from '../types';
|
||||
|
||||
import { queryKeys } from './query-keys';
|
||||
|
||||
/**
|
||||
* Status of a running container image as reported by the CE detection engine.
|
||||
*
|
||||
* - `outdated`: a newer image is available in the registry.
|
||||
* - `updated`: the local image matches the remote registry digest.
|
||||
* - `skipped`: detection is not applicable (digest-pinned or local-only image).
|
||||
* - `processing` / `preparing`: detection is in progress (mostly relevant for services).
|
||||
* - `error`: detection failed (registry unreachable, auth failure, ...).
|
||||
*/
|
||||
export type ContainerImageStatusValue =
|
||||
| 'outdated'
|
||||
| 'updated'
|
||||
| 'skipped'
|
||||
| 'processing'
|
||||
| 'preparing'
|
||||
| 'error';
|
||||
|
||||
export interface ContainerImageStatus {
|
||||
Status: ContainerImageStatusValue;
|
||||
Message?: string;
|
||||
}
|
||||
|
||||
// The backend caches detection results for 24h, so a generous client-side staleTime
|
||||
// is enough and avoids hammering the endpoint when many rows are visible at once.
|
||||
const STALE_TIME = 5 * 60 * 1000; // 5 minutes
|
||||
|
||||
export async function getContainerImageStatus(
|
||||
environmentId: EnvironmentId,
|
||||
containerId: ContainerId,
|
||||
nodeName?: string
|
||||
) {
|
||||
try {
|
||||
const { data } = await axios.get<ContainerImageStatus>(
|
||||
buildDockerUrl(environmentId, 'containers', containerId, 'image_status'),
|
||||
{ params: nodeName ? { nodeName } : undefined }
|
||||
);
|
||||
return data;
|
||||
} catch (err) {
|
||||
throw parseAxiosError(err as Error, 'Unable to retrieve image status');
|
||||
}
|
||||
}
|
||||
|
||||
export function useContainerImageStatus(
|
||||
environmentId: EnvironmentId,
|
||||
containerId: ContainerId,
|
||||
nodeName?: string,
|
||||
enabled = true
|
||||
) {
|
||||
return useQuery(
|
||||
queryKeys.imageStatus(environmentId, containerId, nodeName),
|
||||
() => getContainerImageStatus(environmentId, containerId, nodeName),
|
||||
{
|
||||
enabled,
|
||||
staleTime: STALE_TIME,
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { Stack } from '@/react/common/stacks/types';
|
||||
import { getStackFile } from '@/react/common/stacks/queries/useStackFile';
|
||||
import { updateStack } from '@/react/docker/stacks/useUpdateStack';
|
||||
import { updateGitStack } from '@/react/portainer/gitops/queries/useUpdateGitStack';
|
||||
|
||||
import { recreateContainer } from '../containers.service';
|
||||
|
||||
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
|
||||
import { ContainerUpdateContext, ContainerUpdateKind } from './types';
|
||||
|
||||
/** Thrown when an update is attempted on an externally-managed compose container. */
|
||||
export const EXTERNAL_STACK_UPDATE_ERROR =
|
||||
'This container is part of a compose project that is not managed by Portainer, so it cannot be updated from here.';
|
||||
|
||||
/**
|
||||
* Redeploy a Portainer stack, forcing a re-pull, while preserving its current
|
||||
* file content, environment variables and options. Reuses the exact mutations
|
||||
* the stack page uses for "pull and redeploy" so we never invent a new path.
|
||||
*/
|
||||
async function redeployStackWithPull(stack: Stack, pullImage: boolean) {
|
||||
if (stack.GitConfig) {
|
||||
await updateGitStack(stack.Id, stack.EndpointId, {
|
||||
RepullImageAndRedeploy: pullImage,
|
||||
Env: stack.Env ?? [],
|
||||
Prune: stack.Option?.Prune,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// File/standalone compose stack: redeploy with its current file unchanged.
|
||||
const file = await getStackFile({ stackId: stack.Id });
|
||||
await updateStack({
|
||||
stackId: stack.Id,
|
||||
environmentId: stack.EndpointId,
|
||||
payload: {
|
||||
stackFileContent: file.StackFileContent,
|
||||
env: stack.Env ?? [],
|
||||
prune: stack.Option?.Prune,
|
||||
repullImageAndRedeploy: pullImage,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared "apply an image update" primitive. Decides standalone-vs-stack-vs-external
|
||||
* and runs the matching mutation. This is the single code path behind the
|
||||
* "Update now" button, the bulk "Update selected" action and (future M4) the
|
||||
* auto-update job, guaranteeing manual and automatic updates behave identically.
|
||||
*
|
||||
* A stack-managed container is ALWAYS routed through stack redeploy so it stays
|
||||
* part of its stack; an externally-managed compose container is refused rather
|
||||
* than recreated out-of-band (which would detach it).
|
||||
*/
|
||||
export async function applyContainerUpdate(
|
||||
context: ContainerUpdateContext,
|
||||
stacks: Stack[],
|
||||
{ pullImage = true }: { pullImage?: boolean } = {}
|
||||
): Promise<ContainerUpdateKind> {
|
||||
const path = resolveContainerUpdatePath(context, stacks);
|
||||
|
||||
switch (path.kind) {
|
||||
case 'standalone':
|
||||
await recreateContainer(context.environmentId, context.id, pullImage, {
|
||||
nodeName: context.nodeName,
|
||||
});
|
||||
return 'standalone';
|
||||
|
||||
case 'stack': {
|
||||
const stack = stacks.find((s) => s.Id === path.stackId);
|
||||
if (!stack) {
|
||||
// Should not happen (resolve found it), but never fall back to a bare
|
||||
// recreate: that would orphan the container from its stack.
|
||||
throw new Error(EXTERNAL_STACK_UPDATE_ERROR);
|
||||
}
|
||||
await redeployStackWithPull(stack, pullImage);
|
||||
return 'stack';
|
||||
}
|
||||
|
||||
case 'external':
|
||||
default:
|
||||
throw new Error(EXTERNAL_STACK_UPDATE_ERROR);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Stack, StackType } from '@/react/common/stacks/types';
|
||||
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
|
||||
|
||||
import { groupContainersForUpdate } from './groupContainersForUpdate';
|
||||
import { ContainerUpdateContext } from './types';
|
||||
|
||||
function buildContext(
|
||||
overrides: Partial<ContainerUpdateContext>
|
||||
): ContainerUpdateContext {
|
||||
return {
|
||||
id: 'c1',
|
||||
name: 'container',
|
||||
image: 'nginx:latest',
|
||||
environmentId: 3,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
const stack = {
|
||||
Id: 7,
|
||||
Name: 'my-stack',
|
||||
EndpointId: 3,
|
||||
Type: StackType.DockerCompose,
|
||||
} as Stack;
|
||||
|
||||
describe('groupContainersForUpdate', () => {
|
||||
it('redeploys a stack only once when several of its containers are selected', () => {
|
||||
const contexts = [
|
||||
buildContext({
|
||||
id: 'a',
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
||||
}),
|
||||
buildContext({
|
||||
id: 'b',
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
||||
}),
|
||||
];
|
||||
|
||||
const result = groupContainersForUpdate(contexts, [stack]);
|
||||
|
||||
expect(result.stacks).toHaveLength(1);
|
||||
expect(result.stacks[0].stackId).toBe(7);
|
||||
expect(result.standalone).toHaveLength(0);
|
||||
expect(result.external).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('partitions standalone, stack and external containers', () => {
|
||||
const contexts = [
|
||||
buildContext({ id: 'standalone', labels: {} }),
|
||||
buildContext({
|
||||
id: 'stack',
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
||||
}),
|
||||
buildContext({
|
||||
id: 'external',
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'not-in-portainer' },
|
||||
}),
|
||||
];
|
||||
|
||||
const result = groupContainersForUpdate(contexts, [stack]);
|
||||
|
||||
expect(result.standalone.map((c) => c.id)).toEqual(['standalone']);
|
||||
expect(result.stacks.map((s) => s.stackId)).toEqual([7]);
|
||||
expect(result.external.map((c) => c.id)).toEqual(['external']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { Stack, StackId } from '@/react/common/stacks/types';
|
||||
|
||||
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
|
||||
import { ContainerUpdateContext } from './types';
|
||||
|
||||
export interface GroupedContainerUpdates {
|
||||
/** Standalone containers, each recreated individually. */
|
||||
standalone: ContainerUpdateContext[];
|
||||
/**
|
||||
* One entry per owning stack, even if several selected containers belong to
|
||||
* the same stack: the stack is redeployed ONCE (mirrors M4's "one redeploy
|
||||
* per stack per tick"). `context` is a representative container of the stack.
|
||||
*/
|
||||
stacks: Array<{ stackId: StackId; context: ContainerUpdateContext }>;
|
||||
/** Externally-managed compose containers, skipped (never recreated). */
|
||||
external: ContainerUpdateContext[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Partition a set of containers into the apply paths, de-duplicating stack
|
||||
* containers so each stack is redeployed only once regardless of how many of
|
||||
* its containers were selected. Pure and unit-testable.
|
||||
*/
|
||||
export function groupContainersForUpdate(
|
||||
contexts: ContainerUpdateContext[],
|
||||
stacks: Stack[]
|
||||
): GroupedContainerUpdates {
|
||||
const standalone: ContainerUpdateContext[] = [];
|
||||
const external: ContainerUpdateContext[] = [];
|
||||
const stackMap = new Map<
|
||||
StackId,
|
||||
{ stackId: StackId; context: ContainerUpdateContext }
|
||||
>();
|
||||
|
||||
contexts.forEach((context) => {
|
||||
const path = resolveContainerUpdatePath(context, stacks);
|
||||
|
||||
if (path.kind === 'standalone') {
|
||||
standalone.push(context);
|
||||
} else if (path.kind === 'external') {
|
||||
external.push(context);
|
||||
} else if (path.kind === 'stack' && path.stackId != null) {
|
||||
if (!stackMap.has(path.stackId)) {
|
||||
stackMap.set(path.stackId, { stackId: path.stackId, context });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return { standalone, external, stacks: Array.from(stackMap.values()) };
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
export { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
|
||||
export { groupContainersForUpdate } from './groupContainersForUpdate';
|
||||
export {
|
||||
applyContainerUpdate,
|
||||
EXTERNAL_STACK_UPDATE_ERROR,
|
||||
} from './applyContainerUpdate';
|
||||
export {
|
||||
useUpdateContainerImage,
|
||||
invalidateContainerUpdateQueries,
|
||||
} from './useUpdateContainerImage';
|
||||
export { useBulkUpdateContainerImages } from './useBulkUpdateContainerImages';
|
||||
export type {
|
||||
ContainerUpdateContext,
|
||||
ContainerUpdateKind,
|
||||
ContainerUpdatePath,
|
||||
} from './types';
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Stack, StackType } from '@/react/common/stacks/types';
|
||||
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
|
||||
|
||||
import { resolveContainerUpdatePath } from './resolveContainerUpdatePath';
|
||||
|
||||
function buildStack(overrides: Partial<Stack>): Stack {
|
||||
return {
|
||||
Id: 1,
|
||||
Name: 'my-stack',
|
||||
EndpointId: 3,
|
||||
Type: StackType.DockerCompose,
|
||||
...overrides,
|
||||
} as Stack;
|
||||
}
|
||||
|
||||
describe('resolveContainerUpdatePath', () => {
|
||||
it('returns standalone when there is no compose project label', () => {
|
||||
expect(
|
||||
resolveContainerUpdatePath({ labels: {}, environmentId: 3 }, [])
|
||||
).toEqual({ kind: 'standalone' });
|
||||
});
|
||||
|
||||
it('returns stack when the compose project matches a Portainer stack', () => {
|
||||
const stack = buildStack({ Id: 7, Name: 'my-stack', EndpointId: 3 });
|
||||
|
||||
expect(
|
||||
resolveContainerUpdatePath(
|
||||
{
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
||||
environmentId: 3,
|
||||
},
|
||||
[stack]
|
||||
)
|
||||
).toEqual({ kind: 'stack', stackId: 7, isGitStack: false });
|
||||
});
|
||||
|
||||
it('flags git stacks so they redeploy via the git path', () => {
|
||||
const stack = buildStack({
|
||||
Id: 9,
|
||||
Name: 'git-stack',
|
||||
EndpointId: 3,
|
||||
GitConfig: { URL: 'https://example.com/repo.git' } as Stack['GitConfig'],
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveContainerUpdatePath(
|
||||
{
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'git-stack' },
|
||||
environmentId: 3,
|
||||
},
|
||||
[stack]
|
||||
)
|
||||
).toEqual({ kind: 'stack', stackId: 9, isGitStack: true });
|
||||
});
|
||||
|
||||
it('returns external when the compose project has no matching stack', () => {
|
||||
expect(
|
||||
resolveContainerUpdatePath(
|
||||
{
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'unknown-project' },
|
||||
environmentId: 3,
|
||||
},
|
||||
[buildStack({ Name: 'other' })]
|
||||
)
|
||||
).toEqual({ kind: 'external' });
|
||||
});
|
||||
|
||||
it('returns external when a same-named stack is not a compose stack', () => {
|
||||
const stack = buildStack({
|
||||
Name: 'my-stack',
|
||||
EndpointId: 3,
|
||||
Type: StackType.Kubernetes,
|
||||
});
|
||||
|
||||
expect(
|
||||
resolveContainerUpdatePath(
|
||||
{
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
||||
environmentId: 3,
|
||||
},
|
||||
[stack]
|
||||
)
|
||||
).toEqual({ kind: 'external' });
|
||||
});
|
||||
|
||||
it('does not match a stack from a different endpoint', () => {
|
||||
const stack = buildStack({ Name: 'my-stack', EndpointId: 99 });
|
||||
|
||||
expect(
|
||||
resolveContainerUpdatePath(
|
||||
{
|
||||
labels: { [COMPOSE_STACK_NAME_LABEL]: 'my-stack' },
|
||||
environmentId: 3,
|
||||
},
|
||||
[stack]
|
||||
)
|
||||
).toEqual({ kind: 'external' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,48 @@
|
||||
import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants';
|
||||
import { Stack, StackType } from '@/react/common/stacks/types';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { ContainerUpdatePath } from './types';
|
||||
|
||||
/**
|
||||
* Decide how a container's image update must be applied, given the list of
|
||||
* Portainer-managed stacks. Pure and side-effect free so it can be unit-tested
|
||||
* and reused by the details button, the bulk action and the M4 auto-update job.
|
||||
*
|
||||
* - No compose project label -> `standalone` (recreate-with-pull).
|
||||
* - Compose project that matches a Portainer Docker Compose `Stack` (same name +
|
||||
* endpoint + compose type) -> `stack` (redeploy-with-pull, keeps the container
|
||||
* in its stack).
|
||||
* - Compose project with no matching Portainer compose stack -> `external`:
|
||||
* managed outside Portainer (or a same-named stack of another type), so we
|
||||
* must not recreate it (would detach it / drift).
|
||||
*/
|
||||
export function resolveContainerUpdatePath(
|
||||
context: { labels?: Record<string, string>; environmentId: EnvironmentId },
|
||||
stacks: Stack[]
|
||||
): ContainerUpdatePath {
|
||||
const projectName = context.labels?.[COMPOSE_STACK_NAME_LABEL];
|
||||
|
||||
if (!projectName) {
|
||||
return { kind: 'standalone' };
|
||||
}
|
||||
|
||||
// Match by name + endpoint, but only a Docker Compose stack: a same-named
|
||||
// swarm/kubernetes stack must not be redeployed via the compose update path.
|
||||
const stack = stacks.find(
|
||||
(s) =>
|
||||
s.Name === projectName &&
|
||||
s.EndpointId === context.environmentId &&
|
||||
s.Type === StackType.DockerCompose
|
||||
);
|
||||
|
||||
if (!stack) {
|
||||
return { kind: 'external' };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: 'stack',
|
||||
stackId: stack.Id,
|
||||
isGitStack: !!stack.GitConfig,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { StackId } from '@/react/common/stacks/types';
|
||||
import { EnvironmentId } from '@/react/portainer/environments/types';
|
||||
|
||||
import { ContainerId } from '../types';
|
||||
|
||||
/**
|
||||
* Minimal context needed to apply an image update to a single container,
|
||||
* independent of whether it comes from the details view, the bulk action or
|
||||
* (future M4) the auto-update job.
|
||||
*/
|
||||
export interface ContainerUpdateContext {
|
||||
id: ContainerId;
|
||||
/** Display name, used for success/error toasts. */
|
||||
name: string;
|
||||
/** Current image reference, used to detect un-pullable (sha256) images. */
|
||||
image: string;
|
||||
/** Docker labels, used to detect compose/stack membership. */
|
||||
labels?: Record<string, string>;
|
||||
environmentId: EnvironmentId;
|
||||
/** Swarm/agent node hosting the container, threaded through to the API. */
|
||||
nodeName?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a container's image update must be applied:
|
||||
* - `standalone`: recreate-with-pull (no compose project).
|
||||
* - `stack`: redeploy the owning Portainer stack with re-pull, so the
|
||||
* container stays part of its stack.
|
||||
* - `external`: compose-managed but with no matching Portainer stack record;
|
||||
* Portainer must not touch it (would either detach it or drift).
|
||||
*/
|
||||
export type ContainerUpdateKind = 'standalone' | 'stack' | 'external';
|
||||
|
||||
export interface ContainerUpdatePath {
|
||||
kind: ContainerUpdateKind;
|
||||
/** Set when `kind === 'stack'`. */
|
||||
stackId?: StackId;
|
||||
/** Set when `kind === 'stack'`; routes file vs git redeploy. */
|
||||
isGitStack?: boolean;
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { Stack } from '@/react/common/stacks/types';
|
||||
import {
|
||||
notifyError,
|
||||
notifySuccess,
|
||||
notifyWarning,
|
||||
} from '@/portainer/services/notifications';
|
||||
|
||||
import { getContainerImageStatus } from '../queries/useContainerImageStatus';
|
||||
import { queryKeys as containerQueryKeys } from '../queries/query-keys';
|
||||
|
||||
import { applyContainerUpdate } from './applyContainerUpdate';
|
||||
import { groupContainersForUpdate } from './groupContainersForUpdate';
|
||||
import { invalidateContainerUpdateQueries } from './useUpdateContainerImage';
|
||||
import { ContainerUpdateContext } from './types';
|
||||
|
||||
// Mirror useContainerImageStatus's client-side staleTime so the bulk action
|
||||
// reuses cached badge statuses instead of re-hitting the registry per row.
|
||||
const STATUS_STALE_TIME = 5 * 60 * 1000;
|
||||
|
||||
interface BulkUpdateParams {
|
||||
contexts: ContainerUpdateContext[];
|
||||
stacks: Stack[];
|
||||
/**
|
||||
* Whether the user holds `PortainerStackUpdate`. Stack redeploys are gated on
|
||||
* it everywhere else, so without it we skip (never 403) stack-managed ones.
|
||||
*/
|
||||
canUpdateStack: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk "Update selected": applies the shared update primitive to each selected
|
||||
* container that is `outdated`, skipping up-to-date/unknown ones with a summary,
|
||||
* and grouping stack containers so each owning stack redeploys ONCE even if
|
||||
* several of its containers were selected. Reports per-item success/failure.
|
||||
*/
|
||||
export function useBulkUpdateContainerImages() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ contexts, stacks, canUpdateStack }: BulkUpdateParams) =>
|
||||
bulkUpdate(queryClient, contexts, stacks, canUpdateStack),
|
||||
});
|
||||
}
|
||||
|
||||
async function bulkUpdate(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
contexts: ContainerUpdateContext[],
|
||||
stacks: Stack[],
|
||||
canUpdateStack: boolean
|
||||
) {
|
||||
// Resolve each container's status (cached where the badge already loaded it).
|
||||
const statuses = await Promise.all(
|
||||
contexts.map((context) =>
|
||||
queryClient
|
||||
.fetchQuery(
|
||||
containerQueryKeys.imageStatus(
|
||||
context.environmentId,
|
||||
context.id,
|
||||
context.nodeName
|
||||
),
|
||||
() =>
|
||||
getContainerImageStatus(
|
||||
context.environmentId,
|
||||
context.id,
|
||||
context.nodeName
|
||||
),
|
||||
{ staleTime: STATUS_STALE_TIME }
|
||||
)
|
||||
.then((status) => status.Status)
|
||||
.catch(() => 'error' as const)
|
||||
)
|
||||
);
|
||||
|
||||
const outdated = contexts.filter((_c, i) => statuses[i] === 'outdated');
|
||||
const skippedNotOutdated = contexts.length - outdated.length;
|
||||
|
||||
// Nothing actionable: make the no-op explicit instead of a vague "skipped".
|
||||
if (outdated.length === 0) {
|
||||
notifyWarning(
|
||||
'Nothing to update',
|
||||
'None of the selected containers have updates available.'
|
||||
);
|
||||
return { containersUpdated: 0, stacksUpdated: 0, failures: [], skipped: 0 };
|
||||
}
|
||||
|
||||
const { standalone, stacks: stackGroups, external } =
|
||||
groupContainersForUpdate(outdated, stacks);
|
||||
|
||||
// Without stack-update rights we must not attempt a stack redeploy (403).
|
||||
const allowedStackGroups = canUpdateStack ? stackGroups : [];
|
||||
const skippedUnauthorizedStacks = canUpdateStack ? 0 : stackGroups.length;
|
||||
|
||||
let containersUpdated = 0;
|
||||
let stacksUpdated = 0;
|
||||
const failures: string[] = [];
|
||||
|
||||
// Standalone containers: recreate-with-pull, one per container.
|
||||
await runSequential(standalone, async (context) => {
|
||||
try {
|
||||
await applyContainerUpdate(context, stacks);
|
||||
invalidateContainerUpdateQueries(queryClient, context);
|
||||
containersUpdated += 1;
|
||||
} catch (err) {
|
||||
failures.push(context.name);
|
||||
notifyError('Failure', err as Error, `Unable to update ${context.name}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Stack-managed containers: redeploy each owning stack exactly once.
|
||||
await runSequential(allowedStackGroups, async ({ context }) => {
|
||||
try {
|
||||
await applyContainerUpdate(context, stacks);
|
||||
invalidateContainerUpdateQueries(queryClient, context);
|
||||
stacksUpdated += 1;
|
||||
} catch (err) {
|
||||
failures.push(context.name);
|
||||
notifyError(
|
||||
'Failure',
|
||||
err as Error,
|
||||
`Unable to redeploy stack for ${context.name}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// A stack redeploy updates every container in the stack, so report containers
|
||||
// and stacks separately rather than conflating both into one "update" count.
|
||||
if (containersUpdated > 0 || stacksUpdated > 0) {
|
||||
const parts: string[] = [];
|
||||
if (containersUpdated > 0) {
|
||||
parts.push(`${containersUpdated} ${pluralize(containersUpdated, 'container')}`);
|
||||
}
|
||||
if (stacksUpdated > 0) {
|
||||
parts.push(`${stacksUpdated} ${pluralize(stacksUpdated, 'stack')}`);
|
||||
}
|
||||
notifySuccess('Success', `${parts.join(' and ')} updated`);
|
||||
}
|
||||
|
||||
const skippedExternal = external.length;
|
||||
if (
|
||||
skippedNotOutdated > 0 ||
|
||||
skippedExternal > 0 ||
|
||||
skippedUnauthorizedStacks > 0
|
||||
) {
|
||||
const parts: string[] = [];
|
||||
if (skippedNotOutdated > 0) {
|
||||
parts.push(`${skippedNotOutdated} not outdated`);
|
||||
}
|
||||
if (skippedExternal > 0) {
|
||||
parts.push(`${skippedExternal} managed outside Portainer`);
|
||||
}
|
||||
if (skippedUnauthorizedStacks > 0) {
|
||||
parts.push(
|
||||
`${skippedUnauthorizedStacks} ${pluralize(
|
||||
skippedUnauthorizedStacks,
|
||||
'stack'
|
||||
)} you can't update`
|
||||
);
|
||||
}
|
||||
notifyWarning('Some containers were skipped', parts.join(', '));
|
||||
}
|
||||
|
||||
return {
|
||||
containersUpdated,
|
||||
stacksUpdated,
|
||||
failures,
|
||||
skipped: skippedNotOutdated + skippedExternal + skippedUnauthorizedStacks,
|
||||
};
|
||||
}
|
||||
|
||||
async function runSequential<T>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>
|
||||
): Promise<void> {
|
||||
// Sequential to avoid hammering registries / overlapping stack redeploys.
|
||||
await items.reduce(
|
||||
(chain, item) => chain.then(() => fn(item)),
|
||||
Promise.resolve()
|
||||
);
|
||||
}
|
||||
|
||||
function pluralize(count: number, word: string) {
|
||||
return count === 1 ? word : `${word}s`;
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { QueryClient, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
|
||||
import { withError } from '@/react-tools/react-query';
|
||||
import { Stack } from '@/react/common/stacks/types';
|
||||
import { queryKeys as stacksQueryKeys } from '@/react/common/stacks/queries/query-keys';
|
||||
|
||||
import { queryKeys as containerQueryKeys } from '../queries/query-keys';
|
||||
|
||||
import { applyContainerUpdate } from './applyContainerUpdate';
|
||||
import { ContainerUpdateContext } from './types';
|
||||
|
||||
/**
|
||||
* Refresh the data affected by a container image update: the container itself,
|
||||
* its image-status badge (so it flips away from "outdated") and the stacks
|
||||
* list (a stack redeploy bumps its deployment info).
|
||||
*/
|
||||
export function invalidateContainerUpdateQueries(
|
||||
queryClient: QueryClient,
|
||||
context: ContainerUpdateContext
|
||||
) {
|
||||
queryClient.invalidateQueries(
|
||||
containerQueryKeys.container(context.environmentId, context.id)
|
||||
);
|
||||
queryClient.invalidateQueries(
|
||||
containerQueryKeys.imageStatus(
|
||||
context.environmentId,
|
||||
context.id,
|
||||
context.nodeName
|
||||
)
|
||||
);
|
||||
queryClient.invalidateQueries(stacksQueryKeys.base());
|
||||
}
|
||||
|
||||
interface UpdateContainerImageParams {
|
||||
context: ContainerUpdateContext;
|
||||
stacks: Stack[];
|
||||
pullImage?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-container image-update mutation built on the shared apply primitive.
|
||||
* Used by the details-view "Update now" button; the bulk action drives the same
|
||||
* primitive directly (see useBulkUpdateContainerImages).
|
||||
*/
|
||||
export function useUpdateContainerImage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: ({ context, stacks, pullImage }: UpdateContainerImageParams) =>
|
||||
applyContainerUpdate(context, stacks, { pullImage }),
|
||||
onSuccess: (_kind, { context }) => {
|
||||
invalidateContainerUpdateQueries(queryClient, context);
|
||||
},
|
||||
...withError('Unable to update container image'),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user