diff --git a/api/docker/images/status.go b/api/docker/images/status.go index 09a0111d5..bb7439115 100644 --- a/api/docker/images/status.go +++ b/api/docker/images/status.go @@ -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 { diff --git a/api/http/handler/docker/containers/handler.go b/api/http/handler/docker/containers/handler.go index 4446fdf79..073c5e2f0 100644 --- a/api/http/handler/docker/containers/handler.go +++ b/api/http/handler/docker/containers/handler.go @@ -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 diff --git a/api/http/handler/docker/containers/image_status.go b/api/http/handler/docker/containers/image_status.go new file mode 100644 index 000000000..78303f730 --- /dev/null +++ b/api/http/handler/docker/containers/image_status.go @@ -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)}) +} diff --git a/api/http/handler/docker/containers/image_status_test.go b/api/http/handler/docker/containers/image_status_test.go new file mode 100644 index 000000000..2384b2953 --- /dev/null +++ b/api/http/handler/docker/containers/image_status_test.go @@ -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) + } + }) + } +} diff --git a/app/react/docker/containers/ItemView/ContainerActionsSection/ContainerActionsSection.tsx b/app/react/docker/containers/ItemView/ContainerActionsSection/ContainerActionsSection.tsx index 653dd3722..c25e28807 100644 --- a/app/react/docker/containers/ItemView/ContainerActionsSection/ContainerActionsSection.tsx +++ b/app/react/docker/containers/ItemView/ContainerActionsSection/ContainerActionsSection.tsx @@ -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)} diff --git a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.test.tsx b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.test.tsx index f65da7413..daf9608b8 100644 --- a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.test.tsx +++ b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.test.tsx @@ -197,6 +197,7 @@ function renderComponent( containerId: 'test-container-id', nodeName: 'node1', containerImage: 'nginx:latest', + containerName: 'test-container', containerAutoRemove: false, isPortainer: false, partOfSwarmService: false, diff --git a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.tsx b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.tsx index 77beec2c8..eb5d232f8 100644 --- a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.tsx +++ b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.tsx @@ -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; 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 ( - - {displayRecreateButton && ( - - )} + {/* + Self-hides unless the image is outdated; renders its own ButtonGroup so + the recreate/duplicate group never shows up empty when both are hidden. + */} + - {displayDuplicateEditButton && ( - - )} - + {(displayRecreateButton || displayDuplicateEditButton) && ( + + {displayRecreateButton && ( + + )} + + {displayDuplicateEditButton && ( + + )} + + )} ); } diff --git a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.test.tsx b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.test.tsx new file mode 100644 index 000000000..53dc60a22 --- /dev/null +++ b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.test.tsx @@ -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) => ({ + ...(await importOriginal()), + useUpdateContainerImage: () => ({ mutate: mockMutate, isLoading: false }), + }) +); + +function setStatus(status?: ContainerImageStatusValue) { + mockUseImageStatus.mockReturnValue({ data: status ? { Status: status } : undefined }); +} + +function renderButton(props: Partial> = {}) { + return render( + + ); +} + +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(); + }); +}); diff --git a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.tsx b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.tsx new file mode 100644 index 000000000..b45585d21 --- /dev/null +++ b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.tsx @@ -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; + 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 = ( + + Update now + + ); + + if (isExternal) { + return ( + + + {button} + + + ); + } + + if (stackUpdateForbidden) { + return ( + + + {button} + + + ); + } + + return {button}; + + 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 }); + }, + } + ); + } +} diff --git a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx index fe6ea0470..2e196e198 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx @@ -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 + + + onUpdateClick(selectedItems)} + disabled={selectedItemCount === 0 || stacksQuery.isLoading} + isLoading={bulkUpdateMutation.isLoading} + loadingText="Updating..." + icon={Download} + > + Update + + {isAddActionVisible && (
@@ -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' diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx index ad84eda39..5ea533889 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/index.tsx @@ -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, diff --git a/app/react/docker/containers/ListView/ContainersDatatable/columns/updateAvailable.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/updateAvailable.tsx new file mode 100644 index 000000000..031a6864c --- /dev/null +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/updateAvailable.tsx @@ -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) { + 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 ( + + ); +} diff --git a/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.test.tsx b/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.test.tsx new file mode 100644 index 000000000..698297718 --- /dev/null +++ b/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.test.tsx @@ -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(); + + expect(screen.getByText('Update available')).toBeInTheDocument(); + }); + + it('renders an up-to-date indicator when the image is updated', () => { + render(); + + expect(screen.getByText('Up to date')).toBeInTheDocument(); + }); + + it('shows a spinner while loading', () => { + render(); + + expect( + screen.getByLabelText('Checking for image updates') + ).toBeInTheDocument(); + }); + + it.each([ + 'skipped', + 'error', + 'processing', + 'preparing', + ])('renders nothing for the neutral status "%s"', (status) => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when status is undefined and not loading', () => { + const { container } = render(); + + expect(container).toBeEmptyDOMElement(); + }); +}); diff --git a/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.tsx b/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.tsx new file mode 100644 index 000000000..f7704e0e3 --- /dev/null +++ b/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.tsx @@ -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 ( + + + + ); + } + + if (status === 'outdated') { + return ( + + + Update available + + ); + } + + if (status === 'updated') { + return ( + + + Up to date + + ); + } + + // skipped / error / processing / preparing / undefined -> neutral, nothing to show. + return null; +} diff --git a/app/react/docker/containers/components/UpdateStatusBadge/index.ts b/app/react/docker/containers/components/UpdateStatusBadge/index.ts new file mode 100644 index 000000000..1d6d577bc --- /dev/null +++ b/app/react/docker/containers/components/UpdateStatusBadge/index.ts @@ -0,0 +1 @@ +export { UpdateStatusBadge } from './UpdateStatusBadge'; diff --git a/app/react/docker/containers/queries/query-keys.ts b/app/react/docker/containers/queries/query-keys.ts index 30661cdaa..8d95d8382 100644 --- a/app/react/docker/containers/queries/query-keys.ts +++ b/app/react/docker/containers/queries/query-keys.ts @@ -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, }; diff --git a/app/react/docker/containers/queries/useContainerImageStatus.test.tsx b/app/react/docker/containers/queries/useContainerImageStatus.test.tsx new file mode 100644 index 000000000..3cdf6d30f --- /dev/null +++ b/app/react/docker/containers/queries/useContainerImageStatus.test.tsx @@ -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({ Status: 'updated' }); + } + ) + ); + + const { result, waitFor } = renderImageStatusHook('node-2'); + + await waitFor(() => result.current.isSuccess); + expect(receivedNodeName).toBe('node-2'); + }); +}); diff --git a/app/react/docker/containers/queries/useContainerImageStatus.ts b/app/react/docker/containers/queries/useContainerImageStatus.ts new file mode 100644 index 000000000..5515aa4ca --- /dev/null +++ b/app/react/docker/containers/queries/useContainerImageStatus.ts @@ -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( + 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, + } + ); +} diff --git a/app/react/docker/containers/update/applyContainerUpdate.ts b/app/react/docker/containers/update/applyContainerUpdate.ts new file mode 100644 index 000000000..1479360c0 --- /dev/null +++ b/app/react/docker/containers/update/applyContainerUpdate.ts @@ -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 { + 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); + } +} diff --git a/app/react/docker/containers/update/groupContainersForUpdate.test.ts b/app/react/docker/containers/update/groupContainersForUpdate.test.ts new file mode 100644 index 000000000..08d65bf82 --- /dev/null +++ b/app/react/docker/containers/update/groupContainersForUpdate.test.ts @@ -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 { + 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']); + }); +}); diff --git a/app/react/docker/containers/update/groupContainersForUpdate.ts b/app/react/docker/containers/update/groupContainersForUpdate.ts new file mode 100644 index 000000000..026116d93 --- /dev/null +++ b/app/react/docker/containers/update/groupContainersForUpdate.ts @@ -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()) }; +} diff --git a/app/react/docker/containers/update/index.ts b/app/react/docker/containers/update/index.ts new file mode 100644 index 000000000..844b191c2 --- /dev/null +++ b/app/react/docker/containers/update/index.ts @@ -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'; diff --git a/app/react/docker/containers/update/resolveContainerUpdatePath.test.ts b/app/react/docker/containers/update/resolveContainerUpdatePath.test.ts new file mode 100644 index 000000000..56ccd5daa --- /dev/null +++ b/app/react/docker/containers/update/resolveContainerUpdatePath.test.ts @@ -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 { + 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' }); + }); +}); diff --git a/app/react/docker/containers/update/resolveContainerUpdatePath.ts b/app/react/docker/containers/update/resolveContainerUpdatePath.ts new file mode 100644 index 000000000..29c03425f --- /dev/null +++ b/app/react/docker/containers/update/resolveContainerUpdatePath.ts @@ -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; 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, + }; +} diff --git a/app/react/docker/containers/update/types.ts b/app/react/docker/containers/update/types.ts new file mode 100644 index 000000000..bea1e988f --- /dev/null +++ b/app/react/docker/containers/update/types.ts @@ -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; + 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; +} diff --git a/app/react/docker/containers/update/useBulkUpdateContainerImages.ts b/app/react/docker/containers/update/useBulkUpdateContainerImages.ts new file mode 100644 index 000000000..5f5f20205 --- /dev/null +++ b/app/react/docker/containers/update/useBulkUpdateContainerImages.ts @@ -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, + 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( + items: T[], + fn: (item: T) => Promise +): Promise { + // 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`; +} diff --git a/app/react/docker/containers/update/useUpdateContainerImage.ts b/app/react/docker/containers/update/useUpdateContainerImage.ts new file mode 100644 index 000000000..45de5572b --- /dev/null +++ b/app/react/docker/containers/update/useUpdateContainerImage.ts @@ -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'), + }); +}