be3bfd0513
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>
185 lines
5.7 KiB
TypeScript
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`;
|
|
}
|