From f69eb3f9eb1e88ce3ea54fae9c3f93413fb6409c Mon Sep 17 00:00:00 2001 From: claude code agent Date: Mon, 29 Jun 2026 08:59:54 +0300 Subject: [PATCH 1/4] feat(automation): CE container image update detection endpoint + badge (#9, epic #3 M2) Add native CE detection of "a newer image is available" for running containers, surfaced as a read-only HTTP endpoint and a containers-list badge/column. No applying of updates (M3/M4), no auto-heal (M1). Backend: - New CE handler GET /docker/{id}/containers/{containerId}/image_status backed by the existing zlib/CE digest engine (images.NewClientWithRegistry + ContainerImageStatus). Honors nodeName, authz, and routes registry calls through the credential store / SSRF AllowList. Engine failures degrade to a 200 {Status:"error"} so the UI stays graceful. Response shape: {Status, Message?}. Frontend (CE-only, no isBE gating; the EE ImageStatus component is left untouched): - useContainerImageStatus TanStack Query hook (5min staleTime, no refetch-on-focus; backend caches 24h) calling the non-proxied endpoint. - UpdateStatusBadge component (own assets, neutral on skipped/error). - "Update available" column in the containers datatable; one cached, non-blocking query per visible row. Tests: Go response-shape unit test; vitest for the badge (all statuses) and the hook (url + nodeName query param via msw). Co-Authored-By: Claude Opus 4.8 (1M context) --- api/http/handler/docker/containers/handler.go | 1 + .../handler/docker/containers/image_status.go | 76 +++++++++++++++++++ .../docker/containers/image_status_test.go | 53 +++++++++++++ .../ContainersDatatable/columns/index.tsx | 2 + .../columns/updateAvailable.tsx | 37 +++++++++ .../UpdateStatusBadge.test.tsx | 44 +++++++++++ .../UpdateStatusBadge/UpdateStatusBadge.tsx | 52 +++++++++++++ .../components/UpdateStatusBadge/index.ts | 1 + .../docker/containers/queries/query-keys.ts | 3 + .../queries/useContainerImageStatus.test.tsx | 57 ++++++++++++++ .../queries/useContainerImageStatus.ts | 68 +++++++++++++++++ 11 files changed, 394 insertions(+) create mode 100644 api/http/handler/docker/containers/image_status.go create mode 100644 api/http/handler/docker/containers/image_status_test.go create mode 100644 app/react/docker/containers/ListView/ContainersDatatable/columns/updateAvailable.tsx create mode 100644 app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.test.tsx create mode 100644 app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.tsx create mode 100644 app/react/docker/containers/components/UpdateStatusBadge/index.ts create mode 100644 app/react/docker/containers/queries/useContainerImageStatus.test.tsx create mode 100644 app/react/docker/containers/queries/useContainerImageStatus.ts 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..f3c027c7c --- /dev/null +++ b/api/http/handler/docker/containers/image_status.go @@ -0,0 +1,76 @@ +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" +// @success 200 {object} imageStatusResponse "Success" +// @failure 400 "Invalid request" +// @failure 403 "Permission denied" +// @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. + log.Warn().Err(err).Str("containerId", containerID).Msg("unable to determine container image status") + + return response.JSON(w, &imageStatusResponse{Status: string(images.Error), Message: err.Error()}) + } + + 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/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..5b7c01ede 100644 --- a/app/react/docker/containers/queries/query-keys.ts +++ b/app/react/docker/containers/queries/query-keys.ts @@ -19,6 +19,9 @@ export const queryKeys = { gpus: (environmentId: EnvironmentId, id: string) => [...queryKeys.container(environmentId, id), 'gpus'] as const, + imageStatus: (environmentId: EnvironmentId, id: string) => + [...queryKeys.container(environmentId, id), 'imageStatus'] 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..274e3aef2 --- /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 + +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), + () => getContainerImageStatus(environmentId, containerId, nodeName), + { + enabled, + staleTime: STALE_TIME, + refetchOnWindowFocus: false, + } + ); +} From 7eaff4dab0dab719d48ae77715be1144c398c6d2 Mon Sep 17 00:00:00 2001 From: claude code agent Date: Mon, 29 Jun 2026 09:09:18 +0300 Subject: [PATCH 2/4] fix(automation): real status cache read + nodeName key + honest errors (#9 review) F1: ContainerImageStatus now reads the 24h statusCache (keyed by imageID) before the remote registry digest lookup, so the cache is effective on the input side for all callers instead of being write-only. This avoids the rate-limited registry HEAD on repeat loads. F2: add nodeName to the imageStatus query key so cached results cannot be reused across nodes. F3: correct the swagger annotations to reflect that engine-level issues degrade to a 200 skipped/error status rather than 400/404. F4: return a generic error message to the client instead of the raw registry/engine error; the raw error is still logged server-side. Co-Authored-By: Claude Opus 4.8 (1M context) --- api/docker/images/status.go | 12 ++++++++++++ .../handler/docker/containers/image_status.go | 16 +++++++++++----- .../docker/containers/queries/query-keys.ts | 8 ++++++-- .../queries/useContainerImageStatus.ts | 2 +- 4 files changed, 30 insertions(+), 8 deletions(-) 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/image_status.go b/api/http/handler/docker/containers/image_status.go index f3c027c7c..78303f730 100644 --- a/api/http/handler/docker/containers/image_status.go +++ b/api/http/handler/docker/containers/image_status.go @@ -34,9 +34,13 @@ type imageStatusResponse struct { // @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" -// @success 200 {object} imageStatusResponse "Success" -// @failure 400 "Invalid request" -// @failure 403 "Permission denied" +// @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 { @@ -66,10 +70,12 @@ func (handler *Handler) imageStatus(w http.ResponseWriter, r *http.Request) *htt 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. + // 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: err.Error()}) + 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/app/react/docker/containers/queries/query-keys.ts b/app/react/docker/containers/queries/query-keys.ts index 5b7c01ede..8d95d8382 100644 --- a/app/react/docker/containers/queries/query-keys.ts +++ b/app/react/docker/containers/queries/query-keys.ts @@ -19,8 +19,12 @@ export const queryKeys = { gpus: (environmentId: EnvironmentId, id: string) => [...queryKeys.container(environmentId, id), 'gpus'] as const, - imageStatus: (environmentId: EnvironmentId, id: string) => - [...queryKeys.container(environmentId, id), 'imageStatus'] 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.ts b/app/react/docker/containers/queries/useContainerImageStatus.ts index 274e3aef2..f674c179e 100644 --- a/app/react/docker/containers/queries/useContainerImageStatus.ts +++ b/app/react/docker/containers/queries/useContainerImageStatus.ts @@ -57,7 +57,7 @@ export function useContainerImageStatus( enabled = true ) { return useQuery( - queryKeys.imageStatus(environmentId, containerId), + queryKeys.imageStatus(environmentId, containerId, nodeName), () => getContainerImageStatus(environmentId, containerId, nodeName), { enabled, From f7cb0f3241efc75ac9b1fed31893e07244a10653 Mon Sep 17 00:00:00 2001 From: claude code agent Date: Mon, 29 Jun 2026 09:24:10 +0300 Subject: [PATCH 3/4] feat(automation): "Update now" action (stack-aware) + bulk update (#10, epic #3 M3) Add a discoverable per-container "Update now" action, shown only when the image status is `outdated`, plus a bulk "Update selected" action in the containers list. Both manual paths share ONE apply primitive (applyContainerUpdate / useUpdateContainerImage) that also backs the future M4 auto-update job: - standalone container -> recreate-with-pull (existing recreate endpoint) - stack-managed -> stack redeploy-with-pull (existing git/file stack update mutations), so the container stays in its stack and is never recreated out-of-band - externally-managed -> refused; the details button is disabled with an compose explanatory tooltip and the bulk action skips it Decision logic lives in the pure, unit-tested resolveContainerUpdatePath / groupContainersForUpdate helpers. The bulk action filters to outdated containers and redeploys each owning stack exactly once even when several of its containers are selected, reporting per-item success/failure. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ContainerActionsSection.tsx | 3 + .../SecondaryActions.test.tsx | 1 + .../SecondaryActions.tsx | 20 ++- .../SecondaryActions/UpdateNowButton.test.tsx | 81 ++++++++++ .../SecondaryActions/UpdateNowButton.tsx | 131 ++++++++++++++++ .../ContainersDatatableActions.tsx | 57 ++++++- .../queries/useContainerImageStatus.ts | 2 +- .../containers/update/applyContainerUpdate.ts | 83 ++++++++++ .../update/groupContainersForUpdate.test.ts | 65 ++++++++ .../update/groupContainersForUpdate.ts | 50 ++++++ app/react/docker/containers/update/index.ts | 16 ++ .../update/resolveContainerUpdatePath.test.ts | 80 ++++++++++ .../update/resolveContainerUpdatePath.ts | 41 +++++ app/react/docker/containers/update/types.ts | 40 +++++ .../update/useBulkUpdateContainerImages.ts | 142 ++++++++++++++++++ .../update/useUpdateContainerImage.ts | 56 +++++++ 16 files changed, 861 insertions(+), 7 deletions(-) create mode 100644 app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.test.tsx create mode 100644 app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.tsx create mode 100644 app/react/docker/containers/update/applyContainerUpdate.ts create mode 100644 app/react/docker/containers/update/groupContainersForUpdate.test.ts create mode 100644 app/react/docker/containers/update/groupContainersForUpdate.ts create mode 100644 app/react/docker/containers/update/index.ts create mode 100644 app/react/docker/containers/update/resolveContainerUpdatePath.test.ts create mode 100644 app/react/docker/containers/update/resolveContainerUpdatePath.ts create mode 100644 app/react/docker/containers/update/types.ts create mode 100644 app/react/docker/containers/update/useBulkUpdateContainerImages.ts create mode 100644 app/react/docker/containers/update/useUpdateContainerImage.ts 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..f544c832b 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,13 +44,20 @@ export function SecondaryActions({ partOfSwarmService, }); - if (!displayRecreateButton && !displayDuplicateEditButton) { - return null; - } - return ( + {/* Self-hides unless the image is outdated; shown for stack & standalone. */} + + {displayRecreateButton && ( ({ + 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/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 }); + }); + + 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(); + }); +}); 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..b31541aa4 --- /dev/null +++ b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.tsx @@ -0,0 +1,131 @@ +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 { + resolveContainerUpdatePath, + useUpdateContainerImage, + ContainerUpdateContext, +} from '@/react/docker/containers/update'; + +import { 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. + */ +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(); + + // 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'; + + const button = ( + + Update now + + ); + + if (isExternal) { + 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..547b6da3c 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx @@ -1,5 +1,13 @@ 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'; @@ -20,8 +28,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, @@ -71,6 +84,8 @@ export function ContainersDatatableActions({ ]); const router = useRouter(); + const stacksQuery = useStacks(); + const bulkUpdateMutation = useBulkUpdateContainerImages(); if (!authorized) { return null; @@ -162,6 +177,20 @@ export function ContainersDatatableActions({ Remove + + + onUpdateClick(selectedItems)} + disabled={selectedItemCount === 0 || stacksQuery.isLoading} + isLoading={bulkUpdateMutation.isLoading} + loadingText="Updating..." + icon={Download} + > + Update + + {isAddActionVisible && (
@@ -241,6 +270,30 @@ 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, + name: container.Names[0], + image: container.Image, + labels: container.Labels, + environmentId: endpointId, + nodeName: container.NodeName, + }) + ); + + bulkUpdateMutation.mutate( + { contexts, stacks: stacksQuery.data ?? [] }, + { + onSettled: () => { + router.stateService.reload(); + }, + } + ); + } + async function onRemoveClick(selectedItems: ContainerListViewModel[]) { const isOneContainerRunning = selectedItems.some( (container) => container.State === 'running' diff --git a/app/react/docker/containers/queries/useContainerImageStatus.ts b/app/react/docker/containers/queries/useContainerImageStatus.ts index f674c179e..5515aa4ca 100644 --- a/app/react/docker/containers/queries/useContainerImageStatus.ts +++ b/app/react/docker/containers/queries/useContainerImageStatus.ts @@ -34,7 +34,7 @@ export interface ContainerImageStatus { // is enough and avoids hammering the endpoint when many rows are visible at once. const STALE_TIME = 5 * 60 * 1000; // 5 minutes -async function getContainerImageStatus( +export async function getContainerImageStatus( environmentId: EnvironmentId, containerId: ContainerId, nodeName?: string 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..c7d76f29b --- /dev/null +++ b/app/react/docker/containers/update/groupContainersForUpdate.test.ts @@ -0,0 +1,65 @@ +import { Stack } 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, +} 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..9e76ab94e --- /dev/null +++ b/app/react/docker/containers/update/resolveContainerUpdatePath.test.ts @@ -0,0 +1,80 @@ +import { Stack } 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, + ...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('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..297c65b04 --- /dev/null +++ b/app/react/docker/containers/update/resolveContainerUpdatePath.ts @@ -0,0 +1,41 @@ +import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants'; +import { Stack } 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 `Stack` (same name + endpoint) -> + * `stack` (redeploy-with-pull, keeps the container in its stack). + * - Compose project with no matching Portainer stack -> `external`: managed + * outside Portainer, 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' }; + } + + const stack = stacks.find( + (s) => s.Name === projectName && s.EndpointId === context.environmentId + ); + + 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..b7ba6596c --- /dev/null +++ b/app/react/docker/containers/update/useBulkUpdateContainerImages.ts @@ -0,0 +1,142 @@ +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[]; +} + +/** + * 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 }: BulkUpdateParams) => + bulkUpdate(queryClient, contexts, stacks), + }); +} + +async function bulkUpdate( + queryClient: ReturnType, + contexts: ContainerUpdateContext[], + stacks: Stack[] +) { + // 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; + + const { standalone, stacks: stackGroups, external } = + groupContainersForUpdate(outdated, stacks); + + let succeeded = 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); + succeeded += 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(stackGroups, async ({ context }) => { + try { + await applyContainerUpdate(context, stacks); + invalidateContainerUpdateQueries(queryClient, context); + succeeded += 1; + } catch (err) { + failures.push(context.name); + notifyError( + 'Failure', + err as Error, + `Unable to redeploy stack for ${context.name}` + ); + } + }); + + if (succeeded > 0) { + notifySuccess( + 'Success', + `${succeeded} ${pluralize(succeeded, 'update')} applied` + ); + } + + const skippedExternal = external.length; + if (skippedNotOutdated > 0 || skippedExternal > 0) { + const parts: string[] = []; + if (skippedNotOutdated > 0) { + parts.push(`${skippedNotOutdated} not outdated`); + } + if (skippedExternal > 0) { + parts.push(`${skippedExternal} managed outside Portainer`); + } + notifyWarning('Some containers were skipped', parts.join(', ')); + } + + return { succeeded, failures, skipped: skippedNotOutdated + skippedExternal }; +} + +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'), + }); +} From ccd58979153c4d9102ccea2465aaf3ab2a1c34c2 Mon Sep 17 00:00:00 2001 From: claude code agent Date: Mon, 29 Jun 2026 09:42:33 +0300 Subject: [PATCH 4/4] fix(automation): gate stack redeploy on PortainerStackUpdate + bulk/name polish (#10 review) F1: single-container "Update now" and bulk "Update" now require PortainerStackUpdate when the resolved path is a stack, disabling the action with a tooltip / skipping it rather than letting the click 403. F2: resolveContainerUpdatePath only matches a Docker Compose stack; a same-named swarm/kubernetes stack is treated as external. F3: SecondaryActions no longer renders an empty ButtonGroup when all of recreate/duplicate/update-now are hidden. F4: bulk update reports an explicit no-op toast and counts containers vs stacks honestly in the success summary. F5: bulk toasts use trimmed container names (no leading slash). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../SecondaryActions.tsx | 61 ++++++++-------- .../SecondaryActions/UpdateNowButton.test.tsx | 36 ++++++++++ .../SecondaryActions/UpdateNowButton.tsx | 42 +++++++++-- .../ContainersDatatableActions.tsx | 12 +++- .../update/groupContainersForUpdate.test.ts | 3 +- .../update/resolveContainerUpdatePath.test.ts | 21 +++++- .../update/resolveContainerUpdatePath.ts | 19 +++-- .../update/useBulkUpdateContainerImages.ts | 71 +++++++++++++++---- 8 files changed, 207 insertions(+), 58 deletions(-) diff --git a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.tsx b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.tsx index f544c832b..eb5d232f8 100644 --- a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.tsx +++ b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions.tsx @@ -46,36 +46,41 @@ export function SecondaryActions({ return ( - - {/* Self-hides unless the image is outdated; shown for stack & standalone. */} - + {/* + Self-hides unless the image is outdated; renders its own ButtonGroup so + the recreate/duplicate group never shows up empty when both are hidden. + */} + - {displayRecreateButton && ( - - )} + {(displayRecreateButton || displayDuplicateEditButton) && ( + + {displayRecreateButton && ( + + )} - {displayDuplicateEditButton && ( - - )} - + {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 index dfc7e14a3..53dc60a22 100644 --- a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.test.tsx +++ b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.test.tsx @@ -2,12 +2,14 @@ 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', () => ({ @@ -22,6 +24,17 @@ 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) => ({ @@ -51,6 +64,7 @@ describe('UpdateNowButton', () => { beforeEach(() => { vi.clearAllMocks(); mockUseStacks.mockReturnValue({ data: [], isLoading: false }); + mockUseAuthorizations.mockReturnValue({ authorized: true }); }); it('renders when the image is outdated', () => { @@ -78,4 +92,26 @@ describe('UpdateNowButton', () => { }); 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 index b31541aa4..b45585d21 100644 --- a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.tsx +++ b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.tsx @@ -7,13 +7,14 @@ import { confirmStackUpdate } from '@/react/common/stacks/common/confirm-stack-u 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 { LoadingButton } from '@@/buttons'; +import { ButtonGroup, LoadingButton } from '@@/buttons'; import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { ContainerId } from '../../../types'; @@ -34,6 +35,10 @@ interface UpdateNowButtonProps { * 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, @@ -52,6 +57,11 @@ export function UpdateNowButton({ ); 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') { @@ -61,13 +71,21 @@ export function UpdateNowButton({ 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 = ( - {button} - + + + {button} + + ); } - return button; + if (stackUpdateForbidden) { + return ( + + + {button} + + + ); + } + + return {button}; async function handleClick() { const context: ContainerUpdateContext = { diff --git a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx index 547b6da3c..2e196e198 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/ContainersDatatableActions.tsx @@ -11,6 +11,7 @@ import { 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 { @@ -83,6 +84,12 @@ 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(); @@ -276,7 +283,8 @@ export function ContainersDatatableActions({ const contexts: ContainerUpdateContext[] = selectedItems.map( (container) => ({ id: container.Id, - name: container.Names[0], + // 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, @@ -285,7 +293,7 @@ export function ContainersDatatableActions({ ); bulkUpdateMutation.mutate( - { contexts, stacks: stacksQuery.data ?? [] }, + { contexts, stacks: stacksQuery.data ?? [], canUpdateStack }, { onSettled: () => { router.stateService.reload(); diff --git a/app/react/docker/containers/update/groupContainersForUpdate.test.ts b/app/react/docker/containers/update/groupContainersForUpdate.test.ts index c7d76f29b..08d65bf82 100644 --- a/app/react/docker/containers/update/groupContainersForUpdate.test.ts +++ b/app/react/docker/containers/update/groupContainersForUpdate.test.ts @@ -1,4 +1,4 @@ -import { Stack } from '@/react/common/stacks/types'; +import { Stack, StackType } from '@/react/common/stacks/types'; import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants'; import { groupContainersForUpdate } from './groupContainersForUpdate'; @@ -20,6 +20,7 @@ const stack = { Id: 7, Name: 'my-stack', EndpointId: 3, + Type: StackType.DockerCompose, } as Stack; describe('groupContainersForUpdate', () => { diff --git a/app/react/docker/containers/update/resolveContainerUpdatePath.test.ts b/app/react/docker/containers/update/resolveContainerUpdatePath.test.ts index 9e76ab94e..56ccd5daa 100644 --- a/app/react/docker/containers/update/resolveContainerUpdatePath.test.ts +++ b/app/react/docker/containers/update/resolveContainerUpdatePath.test.ts @@ -1,4 +1,4 @@ -import { Stack } from '@/react/common/stacks/types'; +import { Stack, StackType } from '@/react/common/stacks/types'; import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants'; import { resolveContainerUpdatePath } from './resolveContainerUpdatePath'; @@ -8,6 +8,7 @@ function buildStack(overrides: Partial): Stack { Id: 1, Name: 'my-stack', EndpointId: 3, + Type: StackType.DockerCompose, ...overrides, } as Stack; } @@ -64,6 +65,24 @@ describe('resolveContainerUpdatePath', () => { ).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 }); diff --git a/app/react/docker/containers/update/resolveContainerUpdatePath.ts b/app/react/docker/containers/update/resolveContainerUpdatePath.ts index 297c65b04..29c03425f 100644 --- a/app/react/docker/containers/update/resolveContainerUpdatePath.ts +++ b/app/react/docker/containers/update/resolveContainerUpdatePath.ts @@ -1,5 +1,5 @@ import { COMPOSE_STACK_NAME_LABEL } from '@/react/constants'; -import { Stack } from '@/react/common/stacks/types'; +import { Stack, StackType } from '@/react/common/stacks/types'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { ContainerUpdatePath } from './types'; @@ -10,10 +10,12 @@ import { ContainerUpdatePath } from './types'; * 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 `Stack` (same name + endpoint) -> - * `stack` (redeploy-with-pull, keeps the container in its stack). - * - Compose project with no matching Portainer stack -> `external`: managed - * outside Portainer, so we must not recreate it (would detach it / drift). + * - 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 }, @@ -25,8 +27,13 @@ export function resolveContainerUpdatePath( 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) => + s.Name === projectName && + s.EndpointId === context.environmentId && + s.Type === StackType.DockerCompose ); if (!stack) { diff --git a/app/react/docker/containers/update/useBulkUpdateContainerImages.ts b/app/react/docker/containers/update/useBulkUpdateContainerImages.ts index b7ba6596c..5f5f20205 100644 --- a/app/react/docker/containers/update/useBulkUpdateContainerImages.ts +++ b/app/react/docker/containers/update/useBulkUpdateContainerImages.ts @@ -22,6 +22,11 @@ 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; } /** @@ -34,15 +39,16 @@ export function useBulkUpdateContainerImages() { const queryClient = useQueryClient(); return useMutation({ - mutationFn: ({ contexts, stacks }: BulkUpdateParams) => - bulkUpdate(queryClient, contexts, stacks), + mutationFn: ({ contexts, stacks, canUpdateStack }: BulkUpdateParams) => + bulkUpdate(queryClient, contexts, stacks, canUpdateStack), }); } async function bulkUpdate( queryClient: ReturnType, contexts: ContainerUpdateContext[], - stacks: Stack[] + stacks: Stack[], + canUpdateStack: boolean ) { // Resolve each container's status (cached where the badge already loaded it). const statuses = await Promise.all( @@ -70,10 +76,24 @@ async function bulkUpdate( 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); - let succeeded = 0; + // 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. @@ -81,7 +101,7 @@ async function bulkUpdate( try { await applyContainerUpdate(context, stacks); invalidateContainerUpdateQueries(queryClient, context); - succeeded += 1; + containersUpdated += 1; } catch (err) { failures.push(context.name); notifyError('Failure', err as Error, `Unable to update ${context.name}`); @@ -89,11 +109,11 @@ async function bulkUpdate( }); // Stack-managed containers: redeploy each owning stack exactly once. - await runSequential(stackGroups, async ({ context }) => { + await runSequential(allowedStackGroups, async ({ context }) => { try { await applyContainerUpdate(context, stacks); invalidateContainerUpdateQueries(queryClient, context); - succeeded += 1; + stacksUpdated += 1; } catch (err) { failures.push(context.name); notifyError( @@ -104,15 +124,25 @@ async function bulkUpdate( } }); - if (succeeded > 0) { - notifySuccess( - 'Success', - `${succeeded} ${pluralize(succeeded, 'update')} applied` - ); + // 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) { + if ( + skippedNotOutdated > 0 || + skippedExternal > 0 || + skippedUnauthorizedStacks > 0 + ) { const parts: string[] = []; if (skippedNotOutdated > 0) { parts.push(`${skippedNotOutdated} not outdated`); @@ -120,10 +150,23 @@ async function bulkUpdate( 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 { succeeded, failures, skipped: skippedNotOutdated + skippedExternal }; + return { + containersUpdated, + stacksUpdated, + failures, + skipped: skippedNotOutdated + skippedExternal + skippedUnauthorizedStacks, + }; } async function runSequential(