Files
portainer/app/react/docker/containers/update/useBulkUpdateContainerImages.ts
T
vvzvlad e63d2ffe9b fix(automation): update a single container instead of redeploying its stack
Clicking "Update" on a stack member (and the native auto-update daemon
updating one) redeployed the whole compose stack instead of updating just
that container. Match Watchtower behaviour: always recreate the single
container with a re-pull. The recreate endpoint preserves config + compose
labels, so the container stays part of its project.

Collapse all update surfaces to a single-container recreate and drop the
now-dead stack-aware routing:
- frontend: "Update now" button, list badge and bulk "Update selected" now
  recreate each container individually; remove standalone/stack/external
  routing, the external refusal, the PortainerStackUpdate gate and the
  stack-update confirm dialog.
- daemon: route every outdated candidate through updateStandalone; remove
  updateStack, the stack/external grouping and the stackDeployer dependency.
- add a regression test asserting a Portainer-managed compose-stack member is
  recreated individually, not stack-redeployed.

Behavioural notes: git/external compose containers are now auto-updated too
(were detect-only), and updating a stack member no longer requires
PortainerStackUpdate (same auth as the normal Recreate action).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-02 15:21:29 +03:00

129 lines
3.4 KiB
TypeScript

import { useMutation, useQueryClient } from '@tanstack/react-query';
import {
notifyError,
notifySuccess,
notifyWarning,
} from '@/portainer/services/notifications';
import {
getContainerImageStatus,
STALE_TIME,
} from '../queries/useContainerImageStatus';
import { queryKeys as containerQueryKeys } from '../queries/query-keys';
import { applyContainerUpdate } from './applyContainerUpdate';
import { invalidateContainerUpdateQueries } from './useUpdateContainerImage';
import { ContainerUpdateContext } from './types';
interface BulkUpdateParams {
contexts: ContainerUpdateContext[];
}
/**
* Bulk "Update selected": recreates each selected container that is `outdated`
* with a fresh image pull (Watchtower-style, one recreate per container), and
* skips up-to-date/unknown ones with a summary. Reports per-item success/failure.
*/
export function useBulkUpdateContainerImages() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contexts }: BulkUpdateParams) =>
bulkUpdate(queryClient, contexts),
});
}
async function bulkUpdate(
queryClient: ReturnType<typeof useQueryClient>,
contexts: ContainerUpdateContext[]
) {
// 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: STALE_TIME }
)
.then((status) => status.Status)
.catch(() => 'error' as const)
)
);
const outdated = contexts.filter((_c, i) => statuses[i] === 'outdated');
const skippedNotOutdated = contexts.length - outdated.length;
// Nothing actionable: make the no-op explicit instead of a vague "skipped".
if (outdated.length === 0) {
notifyWarning(
'Nothing to update',
'None of the selected containers have updates available.'
);
return { containersUpdated: 0, failures: [], skipped: 0 };
}
let containersUpdated = 0;
const failures: string[] = [];
// Recreate each outdated container individually (Watchtower-style).
await runSequential(outdated, async (context) => {
try {
await applyContainerUpdate(context);
invalidateContainerUpdateQueries(queryClient, context);
containersUpdated += 1;
} catch (err) {
failures.push(context.name);
notifyError('Failure', err as Error, `Unable to update ${context.name}`);
}
});
if (containersUpdated > 0) {
notifySuccess(
'Success',
`${containersUpdated} ${pluralize(
containersUpdated,
'container'
)} updated`
);
}
if (skippedNotOutdated > 0) {
notifyWarning(
'Some containers were skipped',
`${skippedNotOutdated} not outdated`
);
}
return {
containersUpdated,
failures,
skipped: skippedNotOutdated,
};
}
async function runSequential<T>(
items: T[],
fn: (item: T) => Promise<void>
): Promise<void> {
// Sequential to avoid hammering registries with parallel pulls.
await items.reduce(
(chain, item) => chain.then(() => fn(item)),
Promise.resolve()
);
}
function pluralize(count: number, word: string) {
return count === 1 ? word : `${word}s`;
}