Files
portainer/app/react/docker/containers/update/useBulkUpdateContainerImages.ts
T
claude code agent be3bfd0513 fix(automation): maintainer pre-merge review — stale detection, daemon edge cases, parity (F1-F9)
F1: cap the image-status cache TTL at 5m (was 24h) — the cache is keyed by the
    LOCAL imageID, which doesn't change when upstream pushes a new image under the
    same tag, so the 24h TTL hid new images from both the badge and the auto-update
    daemon; a short TTL re-resolves the remote digest within the poll window.
F2: document that the update->rollback guard map is in-memory (restart implication).
F3: skip auto-update for an unnamed container when rollback is on (the endpoint+name
    keyed guard can't record it, so it would loop) — pure skipUnnamedForRollback + test.
F4: wrap the pre-update ContainerInspect in context.WithTimeout(endpointTimeout).
F5: document Reload() does not interrupt an in-flight tick.
F6: floor auto-heal CheckInterval at 1s (mirrors auto-update) + test.
F7: wontfix — migration is currently correct; namespace rework is out of scope.
F8: correct the misleading SSRF/AllowList comment (no filter is applied).
F9: front auto-heal interval floor + test; dedup STALE_TIME; fix invalidation comment.
Also refresh three stale '24h/long-lived cache' comments to match the 5m TTL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-29 19:51:15 +03:00

185 lines
5.7 KiB
TypeScript

import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Stack } from '@/react/common/stacks/types';
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 { groupContainersForUpdate } from './groupContainersForUpdate';
import { invalidateContainerUpdateQueries } from './useUpdateContainerImage';
import { ContainerUpdateContext } from './types';
interface BulkUpdateParams {
contexts: ContainerUpdateContext[];
stacks: Stack[];
/**
* Whether the user holds `PortainerStackUpdate`. Stack redeploys are gated on
* it everywhere else, so without it we skip (never 403) stack-managed ones.
*/
canUpdateStack: boolean;
}
/**
* Bulk "Update selected": applies the shared update primitive to each selected
* container that is `outdated`, skipping up-to-date/unknown ones with a summary,
* and grouping stack containers so each owning stack redeploys ONCE even if
* several of its containers were selected. Reports per-item success/failure.
*/
export function useBulkUpdateContainerImages() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ contexts, stacks, canUpdateStack }: BulkUpdateParams) =>
bulkUpdate(queryClient, contexts, stacks, canUpdateStack),
});
}
async function bulkUpdate(
queryClient: ReturnType<typeof useQueryClient>,
contexts: ContainerUpdateContext[],
stacks: Stack[],
canUpdateStack: boolean
) {
// Resolve each container's status (cached where the badge already loaded it).
const statuses = await Promise.all(
contexts.map((context) =>
queryClient
.fetchQuery(
containerQueryKeys.imageStatus(
context.environmentId,
context.id,
context.nodeName
),
() =>
getContainerImageStatus(
context.environmentId,
context.id,
context.nodeName
),
{ staleTime: STALE_TIME }
)
.then((status) => status.Status)
.catch(() => 'error' as const)
)
);
const outdated = contexts.filter((_c, i) => statuses[i] === 'outdated');
const skippedNotOutdated = contexts.length - outdated.length;
// Nothing actionable: make the no-op explicit instead of a vague "skipped".
if (outdated.length === 0) {
notifyWarning(
'Nothing to update',
'None of the selected containers have updates available.'
);
return { containersUpdated: 0, stacksUpdated: 0, failures: [], skipped: 0 };
}
const { standalone, stacks: stackGroups, external } =
groupContainersForUpdate(outdated, stacks);
// Without stack-update rights we must not attempt a stack redeploy (403).
const allowedStackGroups = canUpdateStack ? stackGroups : [];
const skippedUnauthorizedStacks = canUpdateStack ? 0 : stackGroups.length;
let containersUpdated = 0;
let stacksUpdated = 0;
const failures: string[] = [];
// Standalone containers: recreate-with-pull, one per container.
await runSequential(standalone, async (context) => {
try {
await applyContainerUpdate(context, stacks);
invalidateContainerUpdateQueries(queryClient, context);
containersUpdated += 1;
} catch (err) {
failures.push(context.name);
notifyError('Failure', err as Error, `Unable to update ${context.name}`);
}
});
// Stack-managed containers: redeploy each owning stack exactly once.
await runSequential(allowedStackGroups, async ({ context }) => {
try {
await applyContainerUpdate(context, stacks);
invalidateContainerUpdateQueries(queryClient, context);
stacksUpdated += 1;
} catch (err) {
failures.push(context.name);
notifyError(
'Failure',
err as Error,
`Unable to redeploy stack for ${context.name}`
);
}
});
// A stack redeploy updates every container in the stack, so report containers
// and stacks separately rather than conflating both into one "update" count.
if (containersUpdated > 0 || stacksUpdated > 0) {
const parts: string[] = [];
if (containersUpdated > 0) {
parts.push(`${containersUpdated} ${pluralize(containersUpdated, 'container')}`);
}
if (stacksUpdated > 0) {
parts.push(`${stacksUpdated} ${pluralize(stacksUpdated, 'stack')}`);
}
notifySuccess('Success', `${parts.join(' and ')} updated`);
}
const skippedExternal = external.length;
if (
skippedNotOutdated > 0 ||
skippedExternal > 0 ||
skippedUnauthorizedStacks > 0
) {
const parts: string[] = [];
if (skippedNotOutdated > 0) {
parts.push(`${skippedNotOutdated} not outdated`);
}
if (skippedExternal > 0) {
parts.push(`${skippedExternal} managed outside Portainer`);
}
if (skippedUnauthorizedStacks > 0) {
parts.push(
`${skippedUnauthorizedStacks} ${pluralize(
skippedUnauthorizedStacks,
'stack'
)} you can't update`
);
}
notifyWarning('Some containers were skipped', parts.join(', '));
}
return {
containersUpdated,
stacksUpdated,
failures,
skipped: skippedNotOutdated + skippedExternal + skippedUnauthorizedStacks,
};
}
async function runSequential<T>(
items: T[],
fn: (item: T) => Promise<void>
): Promise<void> {
// Sequential to avoid hammering registries / overlapping stack redeploys.
await items.reduce(
(chain, item) => chain.then(() => fn(item)),
Promise.resolve()
);
}
function pluralize(count: number, word: string) {
return count === 1 ? word : `${word}s`;
}