Files
claude code agent 922f506fe5 feat(automation): guard update→rollback loop; name Settings types; tests & doc fixes (F1-F7)
F1: record rolled-back targets per service (endpointID/containerName + remote
    digest) and skip auto-update during a 24h cooldown unless the remote digest
    changes — breaks the infinite update→rollback loop on a persistently
    unhealthy image, without blocking a genuinely new image.
F2: unit-test applyContainerUpdate dispatch/payload mapping.
F3: settings_update.go comments mention auto-heal AND auto-update.
F4: drop stale '(future M4)' TS docs; primitives are frontend-only.
F5: replace the anonymous ContainerAutomation settings struct with named
    types (identical JSON tags).
F6: drop parseEnable (duplicate of boolLabel).
F7: remove the unused gitService dependency.

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

87 lines
3.3 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 frontend code path behind the
* "Update now" button and the bulk "Update selected" action, guaranteeing both
* manual flows behave identically. The backend auto-update daemon does NOT call
* this primitive: it is a separate Go implementation
* (resolveContainerUpdateRouting / groupContainersForUpdate in
* api/containerautomation) that mirrors the same routing on the server.
*
* 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);
}
}