diff --git a/api/docker/images/status.go b/api/docker/images/status.go index fe163497f..dabdaf58b 100644 --- a/api/docker/images/status.go +++ b/api/docker/images/status.go @@ -123,7 +123,23 @@ func AggregateImageStatus(statuses []Status) Status { return Updated } +// ContainerImageStatus returns the image status for a container, serving a recent +// value from the statusCache when available (the default used by per-row badges, +// ContainersImageStatus and the auto-update daemon). func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string) (Status, error) { + return c.containerImageStatus(ctx, containerID, endpoint, nodeName, false) +} + +// ContainerImageStatusForced recomputes the image status against the remote +// registry, bypassing the cached read while still refreshing the cache with the +// fresh result. It backs the UI's manual "re-check" action, where the user +// explicitly asks for an up-to-date registry comparison rather than the value +// cached for statusCacheTTL. +func (c *DigestClient) ContainerImageStatusForced(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string) (Status, error) { + return c.containerImageStatus(ctx, containerID, endpoint, nodeName, true) +} + +func (c *DigestClient) containerImageStatus(ctx context.Context, containerID string, endpoint *portainer.Endpoint, nodeName string, force bool) (Status, error) { cli, err := c.clientFactory.CreateClient(endpoint, nodeName, nil) if err != nil { log.Warn().Str("swarmNodeId", nodeName).Msg("Cannot create new docker client.") @@ -154,8 +170,13 @@ func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID str // status (the full computation would now return "outdated") until it expired. A // short TTL re-checks the remote digest within the poll window. Both Outdated // and Skipped are cached too (only the error paths return early without caching). - if s, err := CachedResourceImageStatus(imageID); err == nil { - return s, nil + // + // A forced re-check (force=true) skips this cached read and recomputes against + // the registry, then writes the fresh result back into the cache below. + if !force { + if s, err := CachedResourceImageStatus(imageID); err == nil { + return s, nil + } } digs := make([]digest.Digest, 0) @@ -178,7 +199,7 @@ func (c *DigestClient) ContainerImageStatus(ctx context.Context, containerID str images = append(images, ParseRepoTags(imageInspect.RepoTags)...) } - s, err := c.checkStatus(ctx, images, digs) + s, err := c.checkStatus(ctx, images, digs, force) if err != nil { log.Debug().Str("image", container.Image).Err(err).Msg("fetching a certain image status") return Error, err @@ -228,7 +249,7 @@ func (c *DigestClient) ServiceImageStatus(ctx context.Context, serviceID string, return c.ContainersImageStatus(ctx, nonExistedOrStoppedContainers, endpoint), nil } -func (c *DigestClient) checkStatus(ctx context.Context, images []*Image, digests []digest.Digest) (Status, error) { +func (c *DigestClient) checkStatus(ctx context.Context, images []*Image, digests []digest.Digest, force bool) (Status, error) { if digests == nil { digests = make([]digest.Digest, 0) } @@ -249,8 +270,14 @@ func (c *DigestClient) checkStatus(ctx context.Context, images []*Image, digests for _, img := range images { var remoteDigest digest.Digest var err error - if rd, ok := remoteDigestCache.Get(img.FullName()); ok { - remoteDigest, _ = rd.(digest.Digest) + // A forced re-check skips the short-lived remoteDigestCache read so it + // actually HEADs the registry; the fresh digest is still written back + // below. The default path keeps reusing the cache (auto-badges and the + // auto-update daemon must not add registry load). + if !force { + if rd, ok := remoteDigestCache.Get(img.FullName()); ok { + remoteDigest, _ = rd.(digest.Digest) + } } if remoteDigest == "" { remoteDigest, err = c.RemoteDigest(ctx, *img) diff --git a/api/docker/images/status_test.go b/api/docker/images/status_test.go index f09bce23e..2aaa54a28 100644 --- a/api/docker/images/status_test.go +++ b/api/docker/images/status_test.go @@ -1,12 +1,31 @@ package images import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" "testing" "time" + portainer "github.com/portainer/portainer/api" + + "github.com/docker/docker/api/types/container" + dockerclient "github.com/docker/docker/client" + "github.com/opencontainers/go-digest" "github.com/stretchr/testify/require" ) +// fakeClientFactory hands the DigestClient a Docker client wired to a test server. +type fakeClientFactory struct { + cli *dockerclient.Client +} + +func (f fakeClientFactory) CreateClient(*portainer.Endpoint, string, *time.Duration) (*dockerclient.Client, error) { + return f.cli, nil +} + // TestStatusCacheTTLIsShort is a regression guard for the stale-detection bug: the // statusCache is keyed by the LOCAL imageID, which does not change when upstream // pushes a new image under the same tag. A long TTL (the previous 24h) would serve @@ -27,6 +46,91 @@ func TestStatusCacheTTLIsShort(t *testing.T) { require.LessOrEqual(t, time.Until(exp), statusCacheTTL) } +// TestContainerImageStatusForcedBypassesCache verifies the manual "re-check" +// contract: the default read serves the value cached under the local imageID, +// while the forced read skips that cached read and recomputes (here the image +// inspect is stubbed to fail, so the forced call surfaces a non-cached result +// instead of the seeded sentinel). +func TestContainerImageStatusForcedBypassesCache(t *testing.T) { + const containerID = "force-test-container" + const imageID = "sha256:0000000000000000000000000000000000000000000000000000000000000001" + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.HasSuffix(r.URL.Path, "/containers/"+containerID+"/json") { + resp := container.InspectResponse{ + ContainerJSONBase: &container.ContainerJSONBase{ + ID: containerID, + Image: imageID, + }, + Config: &container.Config{Image: "nginx:latest"}, + } + _ = json.NewEncoder(w).Encode(resp) + return + } + + // Any other call (notably the image inspect on the forced recompute path) + // fails, so the forced status is deterministically not the cached sentinel + // and no real registry is contacted. + http.Error(w, "not found", http.StatusNotFound) + })) + defer srv.Close() + + cli, err := dockerclient.NewClientWithOpts( + dockerclient.WithHost(srv.URL), + dockerclient.WithHTTPClient(http.DefaultClient), + ) + require.NoError(t, err) + + c := &DigestClient{clientFactory: fakeClientFactory{cli: cli}} + + // Seed the cache for the local imageID with a sentinel the recompute here + // could never legitimately reproduce. + CacheResourceImageStatus(imageID, Updated) + defer EvictImageStatus(imageID) + + // Default (cached) read serves the seeded sentinel without recomputing. + cached, err := c.ContainerImageStatus(context.Background(), containerID, &portainer.Endpoint{}, "") + require.NoError(t, err) + require.Equal(t, Updated, cached) + + // Forced read must bypass the cache: it recomputes and, with the image inspect + // stubbed to fail, does not return the cached sentinel. + forced, _ := c.ContainerImageStatusForced(context.Background(), containerID, &portainer.Endpoint{}, "") + require.NotEqual(t, Updated, forced) +} + +// TestCheckStatusForcedBypassesRemoteDigestCache proves the forced re-check also +// bypasses the short-lived remoteDigestCache (not just the statusCache): with a +// warm cache entry that matches the local digest, the default path returns +// "updated" without any registry HEAD, while the forced path ignores the cache +// and performs a fresh remote lookup (here against an unroutable registry, so it +// errors instead of serving the cached match). +func TestCheckStatusForcedBypassesRemoteDigestCache(t *testing.T) { + // A tag on an unroutable registry: RemoteDigest fails fast (connection + // refused) rather than reaching a real network. + img, err := ParseImage(ParseImageOptions{Name: "127.0.0.1:1/portainer/forcecheck:latest"}) + require.NoError(t, err) + + localDigest := digest.Digest("sha256:" + strings.Repeat("a", 64)) + + // Warm the remote digest cache with a value that matches the local digest. + remoteDigestCache.Set(img.FullName(), localDigest, 0) + defer remoteDigestCache.Delete(img.FullName()) + + c := &DigestClient{} + + // Default path: reads the warm cache, remote == local -> Updated, no HEAD. + cached, err := c.checkStatus(context.Background(), []*Image{&img}, []digest.Digest{localDigest}, false) + require.NoError(t, err) + require.Equal(t, Updated, cached) + + // Forced path: skips the cache and attempts a fresh remote lookup, which + // fails against the unroutable registry instead of serving the cached match. + forced, err := c.checkStatus(context.Background(), []*Image{&img}, []digest.Digest{localDigest}, true) + require.Error(t, err) + require.Equal(t, Error, forced) +} + func TestAggregateImageStatus(t *testing.T) { t.Parallel() diff --git a/api/http/handler/docker/containers/image_status.go b/api/http/handler/docker/containers/image_status.go index 562548e18..9b46e978b 100644 --- a/api/http/handler/docker/containers/image_status.go +++ b/api/http/handler/docker/containers/image_status.go @@ -26,6 +26,10 @@ type imageStatusResponse struct { // @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 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. // @description **Access policy**: authenticated // @tags docker // @security ApiKeyAuth @@ -34,10 +38,7 @@ 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" -// @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. +// @param force query bool false "Bypass the server-side status cache and recompute against the registry (manual re-check)" // @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" @@ -52,6 +53,11 @@ func (handler *Handler) imageStatus(w http.ResponseWriter, r *http.Request) *htt // nodeName is optional and only relevant for Swarm/agent endpoints. nodeName, _ := request.RetrieveQueryParameter(r, "nodeName", true) + // force is set by the UI's manual "re-check" action to bypass the ~5m server + // cache and recompute against the registry. Absent (the default, used by the + // per-row auto-badges) the cached value is served as before. + force, _ := request.RetrieveBooleanQueryParameter(r, "force", true) + endpoint, err := middlewares.FetchEndpoint(r) if err != nil { return httperror.NotFound("Unable to find an environment on request context", err) @@ -68,7 +74,12 @@ func (handler *Handler) imageStatus(w http.ResponseWriter, r *http.Request) *htt // filter; this mirrors upstream ContainersImageStatus behaviour. digestClient := images.NewClientWithRegistry(images.NewRegistryClient(handler.dataStore), handler.dockerClientFactory) - status, err := digestClient.ContainerImageStatus(r.Context(), containerID, endpoint, nodeName) + statusFn := digestClient.ContainerImageStatus + if force { + statusFn = digestClient.ContainerImageStatusForced + } + + status, err := statusFn(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 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 53dc60a22..238e69841 100644 --- a/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.test.tsx +++ b/app/react/docker/containers/ItemView/ContainerActionsSection/SecondaryActions/UpdateNowButton.test.tsx @@ -35,19 +35,23 @@ const composeStack = { Type: StackType.DockerCompose, }; -vi.mock( - '@/react/docker/containers/update', - async (importOriginal: () => Promise) => ({ - ...(await importOriginal()), - useUpdateContainerImage: () => ({ mutate: mockMutate, isLoading: false }), - }) -); +// Mock the concrete mutation module so both the `update` index re-export and the +// shared `useApplyContainerImageUpdate` hook (which imports it directly) resolve +// to the stub. +vi.mock('@/react/docker/containers/update/useUpdateContainerImage', () => ({ + useUpdateContainerImage: () => ({ mutate: mockMutate, isLoading: false }), + invalidateContainerUpdateQueries: vi.fn(), +})); function setStatus(status?: ContainerImageStatusValue) { - mockUseImageStatus.mockReturnValue({ data: status ? { Status: status } : undefined }); + mockUseImageStatus.mockReturnValue({ + data: status ? { Status: status } : undefined, + }); } -function renderButton(props: Partial> = {}) { +function renderButton( + props: Partial> = {} +) { return render( + router.stateService.go('docker.containers', {}, { reload: true }), + }); // 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 = ( {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/columns/updateAvailable.tsx b/app/react/docker/containers/ListView/ContainersDatatable/columns/updateAvailable.tsx index 76175f235..39b5ed8d5 100644 --- a/app/react/docker/containers/ListView/ContainersDatatable/columns/updateAvailable.tsx +++ b/app/react/docker/containers/ListView/ContainersDatatable/columns/updateAvailable.tsx @@ -2,7 +2,13 @@ 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 { + useContainerImageStatus, + useRecheckContainerImageStatus, +} from '@/react/docker/containers/queries/useContainerImageStatus'; +import { useApplyContainerImageUpdate } from '@/react/docker/containers/update'; + +import { TooltipWithChildren } from '@@/Tip/TooltipWithChildren'; import { UpdateStatusBadge } from '../../../components/UpdateStatusBadge'; @@ -28,10 +34,72 @@ function UpdateAvailableCell({ container.NodeName ); - return ( + // Manual re-check forces a cache-bypassing registry comparison and writes the + // fresh result back into the status query cache, so the badge flips if it + // changed. (A plain refetch would only bypass the client staleTime and still + // hit the ~5m server cache.) + const recheckMutation = useRecheckContainerImageStatus( + environmentId, + container.Id, + container.NodeName + ); + + // Same shared apply flow as the details-view "Update now" button (confirm + + // standalone/stack/external routing + permission gating). No `onSuccess`: the + // mutation's query invalidation refreshes this row's badge in place. + const { + apply, + isLoading: isUpdating, + isExternal, + stackUpdateForbidden, + canApply, + } = useApplyContainerImageUpdate({ + environmentId, + containerId: container.Id, + nodeName: container.NodeName, + containerImage: container.Image, + containerName: container.Names?.[0] ?? container.Id, + labels: container.Labels, + isPortainer: container.IsPortainer ?? false, + }); + + const status = statusQuery.data?.Status; + + const badge = ( apply() : undefined + } + isUpdating={isUpdating} + onRecheckClick={ + status === 'updated' ? () => recheckMutation.mutate() : undefined + } + isRechecking={recheckMutation.isLoading} /> ); + + // Mirror the details-view button's explanations for the two gated cases so a + // user understands why the "Update available" badge isn't clickable here. + if (status === 'outdated' && isExternal) { + return ( + + {badge} + + ); + } + + if (status === 'outdated' && stackUpdateForbidden) { + return ( + + {badge} + + ); + } + + return badge; } diff --git a/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.test.tsx b/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.test.tsx index 698297718..1e5c6aa97 100644 --- a/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.test.tsx +++ b/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.test.tsx @@ -1,4 +1,5 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { vi } from 'vitest'; import { ContainerImageStatusValue } from '../../queries/useContainerImageStatus'; @@ -41,4 +42,85 @@ describe('UpdateStatusBadge', () => { expect(container).toBeEmptyDOMElement(); }); + + describe('interactive behavior', () => { + it('renders "Update available" as a plain span when no handler is passed', () => { + render(); + + expect( + screen.queryByRole('button', { name: /update available/i }) + ).not.toBeInTheDocument(); + }); + + it('renders "Update available" as a button and fires the handler on click', () => { + const onUpdateClick = vi.fn(); + render( + + ); + + const button = screen.getByRole('button', { + name: /update available/i, + }); + fireEvent.click(button); + + expect(onUpdateClick).toHaveBeenCalledTimes(1); + }); + + it('renders "Up to date" as a plain span when no handler is passed', () => { + render(); + + expect( + screen.queryByRole('button', { name: /up to date/i }) + ).not.toBeInTheDocument(); + }); + + it('renders "Up to date" as a button and fires the recheck handler on click', () => { + const onRecheckClick = vi.fn(); + render( + + ); + + const button = screen.getByRole('button', { name: /up to date/i }); + fireEvent.click(button); + + expect(onRecheckClick).toHaveBeenCalledTimes(1); + }); + + it('shows a pending affordance and disables the update button while updating', () => { + const onUpdateClick = vi.fn(); + render( + + ); + + const button = screen.getByRole('button', { + name: /updating the image/i, + }); + expect(button).toBeDisabled(); + expect(screen.getByText('Updating...')).toBeInTheDocument(); + + fireEvent.click(button); + expect(onUpdateClick).not.toHaveBeenCalled(); + }); + + it('shows a spinner and disables the recheck button while rechecking', () => { + const onRecheckClick = vi.fn(); + render( + + ); + + const button = screen.getByRole('button', { name: /up to date/i }); + expect(button).toBeDisabled(); + + fireEvent.click(button); + expect(onRecheckClick).not.toHaveBeenCalled(); + }); + }); }); diff --git a/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.tsx b/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.tsx index f7704e0e3..377996978 100644 --- a/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.tsx +++ b/app/react/docker/containers/components/UpdateStatusBadge/UpdateStatusBadge.tsx @@ -1,4 +1,5 @@ import { Loader } from 'lucide-react'; +import clsx from 'clsx'; import UpdatesAvailable from '@/assets/ico/icon_updates-available.svg?c'; import UpToDate from '@/assets/ico/icon_up-to-date.svg?c'; @@ -10,8 +11,32 @@ import { ContainerImageStatusValue } from '../../queries/useContainerImageStatus export interface Props { status?: ContainerImageStatusValue; isLoading?: boolean; + /** + * When provided, the "Update available" badge becomes a button that triggers + * an image update. Omit it to keep the plain, non-interactive span. + */ + onUpdateClick?: () => void; + /** + * When provided, the "Up to date" badge becomes a button that re-checks the + * registry for a newer image. Omit it to keep the plain span. + */ + onRecheckClick?: () => void; + /** Drives the spinner shown on the "Up to date" badge while a recheck runs. */ + isRechecking?: boolean; + /** + * While an update is in flight the "Update available" button shows a pending + * "Updating..." affordance and is disabled, preventing a double-submit (parity + * with the details-view "Update now" button). + */ + isUpdating?: boolean; } +const badgeClass = 'inline-flex items-center gap-1 whitespace-nowrap'; +// Reset the native button chrome so the interactive badge looks identical to the +// plain span, while staying keyboard-focusable with a visible focus ring. +const interactiveClass = + 'cursor-pointer border-0 bg-transparent p-0 hover:underline focus:outline-none focus-visible:rounded-sm focus-visible:ring-2 focus-visible:ring-blue-5'; + /** * CE container image update badge. * @@ -19,8 +44,20 @@ export interface Props { * 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. + * + * The two visible states become interactive when the matching handler is passed: + * clicking "Update available" applies the image update, clicking "Up to date" + * re-checks the registry (showing a spinner meanwhile). Without a handler each + * stays a plain span so non-interactive callers are unaffected. */ -export function UpdateStatusBadge({ status, isLoading }: Props) { +export function UpdateStatusBadge({ + status, + isLoading, + onUpdateClick, + onRecheckClick, + isRechecking, + isUpdating, +}: Props) { if (isLoading) { return ( @@ -30,21 +67,71 @@ export function UpdateStatusBadge({ status, isLoading }: Props) { } if (status === 'outdated') { - return ( - + const content = isUpdating ? ( + <> + + Updating... + + ) : ( + <> Update available - + ); + + if (onUpdateClick) { + return ( + + ); + } + + return {content}; } if (status === 'updated') { - return ( - - + const content = ( + <> + Up to date - + ); + + if (onRecheckClick) { + return ( + + ); + } + + return {content}; } // skipped / error / processing / preparing / undefined -> neutral, nothing to show. diff --git a/app/react/docker/containers/queries/useContainerImageStatus.test.tsx b/app/react/docker/containers/queries/useContainerImageStatus.test.tsx index 3cdf6d30f..546fdc744 100644 --- a/app/react/docker/containers/queries/useContainerImageStatus.test.tsx +++ b/app/react/docker/containers/queries/useContainerImageStatus.test.tsx @@ -7,6 +7,7 @@ import { withTestQueryProvider } from '@/react/test-utils/withTestQuery'; import { ContainerImageStatus, useContainerImageStatus, + useRecheckContainerImageStatus, } from './useContainerImageStatus'; const environmentId = 3; @@ -54,4 +55,53 @@ describe('useContainerImageStatus', () => { await waitFor(() => result.current.isSuccess); expect(receivedNodeName).toBe('node-2'); }); + + it('does not send the force parameter on the background query', async () => { + let receivedForce: string | null = 'unset'; + + server.use( + http.get( + `/api/docker/${environmentId}/containers/${containerId}/image_status`, + ({ request }) => { + receivedForce = new URL(request.url).searchParams.get('force'); + return HttpResponse.json({ Status: 'updated' }); + } + ) + ); + + const { result, waitFor } = renderImageStatusHook(); + + await waitFor(() => result.current.isSuccess); + expect(receivedForce).toBeNull(); + }); +}); + +describe('useRecheckContainerImageStatus', () => { + it('sends force=true and updates the shared query cache with the fresh status', async () => { + let receivedForce: string | null = null; + + server.use( + http.get( + `/api/docker/${environmentId}/containers/${containerId}/image_status`, + ({ request }) => { + receivedForce = new URL(request.url).searchParams.get('force'); + return HttpResponse.json({ + Status: 'outdated', + }); + } + ) + ); + + const wrapper = withTestQueryProvider(({ children }) => <>{children}); + const { result, waitFor } = renderHook( + () => useRecheckContainerImageStatus(environmentId, containerId), + { wrapper } + ); + + result.current.mutate(); + + await waitFor(() => result.current.isSuccess); + expect(receivedForce).toBe('true'); + expect(result.current.data).toEqual({ Status: 'outdated' }); + }); }); diff --git a/app/react/docker/containers/queries/useContainerImageStatus.ts b/app/react/docker/containers/queries/useContainerImageStatus.ts index d81b27d0a..5307d199a 100644 --- a/app/react/docker/containers/queries/useContainerImageStatus.ts +++ b/app/react/docker/containers/queries/useContainerImageStatus.ts @@ -1,6 +1,7 @@ -import { useQuery } from '@tanstack/react-query'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import axios, { parseAxiosError } from '@/portainer/services/axios/axios'; +import { withError } from '@/react-tools/react-query'; import { EnvironmentId } from '@/react/portainer/environments/types'; import { buildDockerUrl } from '../../queries/utils/buildDockerUrl'; @@ -39,12 +40,23 @@ export const STALE_TIME = 5 * 60 * 1000; // 5 minutes export async function getContainerImageStatus( environmentId: EnvironmentId, containerId: ContainerId, - nodeName?: string + nodeName?: string, + // When true, ask the backend to bypass its short-lived status cache and + // recompute against the registry (manual re-check). Off by default so the + // per-row auto-badges keep using the cache. + force = false ) { try { + const params: { nodeName?: string; force?: boolean } = {}; + if (nodeName) { + params.nodeName = nodeName; + } + if (force) { + params.force = true; + } const { data } = await axios.get( buildDockerUrl(environmentId, 'containers', containerId, 'image_status'), - { params: nodeName ? { nodeName } : undefined } + { params: Object.keys(params).length > 0 ? params : undefined } ); return data; } catch (err) { @@ -68,3 +80,30 @@ export function useContainerImageStatus( } ); } + +/** + * Manual "re-check" for a single container: forces a cache-bypassing registry + * comparison and writes the fresh result into the shared image-status query cache, + * so the badge flips in place if the status changed. Used by the interactive + * "Up to date" list badge; the background per-row query keeps using the cache. + */ +export function useRecheckContainerImageStatus( + environmentId: EnvironmentId, + containerId: ContainerId, + nodeName?: string +) { + const queryClient = useQueryClient(); + + return useMutation( + () => getContainerImageStatus(environmentId, containerId, nodeName, true), + { + onSuccess: (data) => { + queryClient.setQueryData( + queryKeys.imageStatus(environmentId, containerId, nodeName), + data + ); + }, + ...withError('Unable to refresh image status'), + } + ); +} diff --git a/app/react/docker/containers/update/index.ts b/app/react/docker/containers/update/index.ts index 844b191c2..e60f5bdc9 100644 --- a/app/react/docker/containers/update/index.ts +++ b/app/react/docker/containers/update/index.ts @@ -8,6 +8,7 @@ export { useUpdateContainerImage, invalidateContainerUpdateQueries, } from './useUpdateContainerImage'; +export { useApplyContainerImageUpdate } from './useApplyContainerImageUpdate'; export { useBulkUpdateContainerImages } from './useBulkUpdateContainerImages'; export type { ContainerUpdateContext, diff --git a/app/react/docker/containers/update/useApplyContainerImageUpdate.ts b/app/react/docker/containers/update/useApplyContainerImageUpdate.ts new file mode 100644 index 000000000..1fd29e5a2 --- /dev/null +++ b/app/react/docker/containers/update/useApplyContainerImageUpdate.ts @@ -0,0 +1,121 @@ +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 { useAuthorizations } from '@/react/hooks/useUser'; + +import { ContainerId } from '../types'; + +import { resolveContainerUpdatePath } from './resolveContainerUpdatePath'; +import { useUpdateContainerImage } from './useUpdateContainerImage'; +import { ContainerUpdateContext } from './types'; + +interface Params { + environmentId: EnvironmentId; + containerId: ContainerId; + nodeName?: string; + containerImage: string; + containerName: string; + labels?: Record; + /** Portainer's own container can't update itself, so the action is disabled. */ + isPortainer: boolean; + /** + * Post-success side effect. The details-view button navigates/reloads here; + * the list badge leaves it empty and lets react-query invalidation refresh the + * row. + */ + onSuccess?: () => void; +} + +/** + * Shared "apply an image update to a single container" action, used by both the + * details-view "Update now" button and the interactive list badge so the confirm + * dialogs, standalone/stack/external routing and permission gating live in ONE + * place. + * + * Routing mirrors the backend: standalone -> recreate-with-pull, stack-managed -> + * stack redeploy-with-pull (container stays in its stack), externally-managed + * compose -> not touched. A stack redeploy is gated by `PortainerStackUpdate`, so + * a user with container-create but without stack-update rights can't apply it + * (surfaced via `stackUpdateForbidden` rather than a 403 on click). + */ +export function useApplyContainerImageUpdate({ + environmentId, + containerId, + nodeName, + containerImage, + containerName, + labels, + isPortainer, + onSuccess, +}: Params) { + const stacksQuery = useStacks(); + const updateMutation = useUpdateContainerImage(); + // A stack redeploy needs stack-update rights, not just container-create. + const { authorized: canUpdateStack } = useAuthorizations( + 'PortainerStackUpdate', + environmentId + ); + + 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. + const stackUpdateForbidden = path.kind === 'stack' && !canUpdateStack; + const canApply = + !isPortainer && + !isExternal && + !stackUpdateForbidden && + !stacksQuery.isLoading; + + return { + apply, + isLoading: updateMutation.isLoading, + isExternal, + stackUpdateForbidden, + canApply, + }; + + async function apply() { + 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'); + onSuccess?.(); + }, + } + ); + } +}