Files
portainer/app/react/docker/containers/update/applyContainerUpdate.ts
T
claude code agent f7cb0f3241 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) <noreply@anthropic.com>
2026-06-29 09:24:10 +03:00

84 lines
3.1 KiB
TypeScript

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<ContainerUpdateKind> {
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);
}
}