Repository navigation
[Feature] Trigger Portainer container actions from the runtime container page and record them as action runs #280
Description
Summary
The Portainer integration can already authenticate, check capability and execute container actions, but nothing calls it: no page or job triggers an action, no ContainerActionRun is ever written, and the client's action set (start/stop/restart/kill/pause/unpause on containers) does not match the action-run model (restart container, redeploy stack, update service on container/service/stack). This feature makes the integration usable: an operator triggers Portainer actions from the runtime container detail page after an explicit confirmation, every requested action is recorded as an action run with its status, and the client's action set and the model's action types become one contract. Because the app has no authentication, the feature is a security-relevant change and must be designed around audit and confirmation.
Area
docs/areas/container-actions.md(main),docs/areas/web-ui.md(runtime container detail page, Actions),docs/areas/docker-instances.md(snapshots used as the action target),docs/areas/scan-engine.md(retention of action runs)- Security areas (
.squad/project.md): Container actions (PortainerClientrestart/redeploy/update actions and the UI that triggers them), Secrets in configuration (Portainer credentials), Database and migrations (if the entity model changes), Logging and telemetry of external data
Current behavior
IPortainerClient(src/DockerUpdateGuard/Portainer/Interfaces/IPortainerClient.cs) is registered as a singleton (src/DockerUpdateGuard/ServiceCollectionExtensions.cs:104) and never injected anywhere:GetCapabilityAsyncandExecuteActionAsynchave no caller outside tests.- Allowed actions are a string allowlist (
src/DockerUpdateGuard/Portainer/PortainerClient.cs:25-33); the target is found by name via Portainer's container list (PortainerClient.cs:96-121) and the action is aPOST /api/endpoints/{endpointId}/docker/containers/{containerId}/{action}(PortainerClient.cs:335-336). Results arePortainerActionResult { Succeeded, Message }andPortainerCapabilityData, notExternalOperationResult<T>as.squad/project.md(Guarantees) requires for integration clients. - Model:
ContainerActionRun(src/DockerUpdateGuard.Data/Entities/ContainerActionRun.cs) withContainerActionType { NotSet, RestartContainer, RedeployStack, UpdateService },PortainerResourceType { NotSet, Container, Service, Stack },ContainerActionStatus { NotSet, Requested, Running, Succeeded, Failed, Cancelled },RequestedBy(100),PortainerTaskId(100),ErrorMessage(text),ResourceName(200); configured insrc/DockerUpdateGuard.Data/Configurations/ContainerActionRunConfiguration.cs. The only writer is a mapping test (src/Tests/DockerUpdateGuard.Data.Tests/MappingTests.cs:248). - Retention never removes a snapshot referenced by an action run (
src/DockerUpdateGuard/Images/ScanCleanupBackgroundService.cs:234); action runs themselves are never cleaned up. - UI: the runtime container list shows
PortainerAvailable(src/DockerUpdateGuard/UI/ApplicationViewService.cs:847); the detail page/runtime-containers/{DockerInstanceId}/{ContainerId}(src/DockerUpdateGuard/Components/Pages/RuntimeContainerDetail.razor) offers only the manual tag selection.docs/areas/web-ui.mdstates "The UI offers no container actions (restart, redeploy, update)".
Expected behavior
Decision (product manager): build the feature — trigger Portainer actions from the UI (runtime container detail page), record every requested action as an action run with status, align the action types with the model.
Action contract (one set for client, model and UI)
ContainerActionTypebecomes the single contract; the client accepts the enum, not free strings. Recommended v1 set:- container lifecycle on resource type Container: StartContainer, StopContainer, RestartContainer (existing value);
- update actions: RedeployStack (resource type Stack, Portainer-managed stack, redeploy with image re-pull) and UpdateService (resource type Service, Swarm service, forced update with image re-pull) — both existing values.
kill,pause,unpauseare removed from the client (not needed for update management; kill is destructive). The spec may re-add any of them deliberately.
- Existing numeric enum values keep their numbers (stored as integers); new values are appended.
- Which actions are offered for a container is derived from its current snapshot: Start only when not running, Stop/Restart only when running, RedeployStack only when the snapshot has a stack/compose project that Portainer manages as a stack, UpdateService only when it has a Swarm service name. If the spec decides to ship update actions in a later step, v1 still uses the aligned enum and hides them.
Triggering from the UI
- The runtime container detail page shows a Container actions section when the instance's Portainer integration is enabled. On the interactive render (not during prerender) it runs the capability check; when actions are not supported it shows the capability message (e.g. "Portainer manages several environments; set EndpointId") instead of buttons.
- Every action requires a confirmation dialog naming the action, container, Docker instance and Portainer environment; Stop and the update actions are styled as destructive and state the consequence (downtime, image re-pull). Nothing is executed without confirmation.
- While an action runs the button shows progress and all action buttons of that container are disabled (as the existing page actions do,
docs/areas/web-ui.md, Actions). - After completion the page shows the result message and reloads the action history. The runtime status shown stays the snapshot's until the next runtime scan; the page says so (recommendation: no automatic rescan; the operator can start one).
Action runs (audit)
- Every confirmed request writes an action run before Portainer is contacted: status Requested, Docker instance, Portainer endpoint, container snapshot, action type, resource type, resource name,
RequestedBy,RequestedAtUtc. - Status flow: Requested → Running (when the action request is sent) → Succeeded or Failed with
ErrorMessageandCompletedAtUtc. Cancelled when the request was cancelled before the action request was sent (e.g. shutdown). - A run left in Requested/Running by a process stop is set to Failed with "Interrupted: the application stopped before the action completed" at the next startup (or when read, if the spec prefers).
- The detail page lists the container's most recent action runs (e.g. 20) with action, status, requested/completed time, requested by and message.
RequestedBy: the app has no authentication (guarantee). Recommendation: a fixed value"UI"; do not record IP addresses. A reverse-proxy user header is out of scope.
Security considerations
- No authentication or authorization in the app is a deliberate guarantee and is not changed. Consequence: anyone who can reach the UI can trigger actions on instances with Portainer enabled. README and
docs/ARCHITECTURE.md(Security posture) must say so next to the Portainer settings; enabling Portainer remains the explicit opt-in (defaultfalse). - Actions are triggered only through interactive Blazor event handlers — no GET route, no HTTP API endpoint, no query-string trigger — so a link or cross-site request cannot start one.
- Server-side guard: at most one non-terminal action run per container at a time (checked in the service, not only by disabled buttons — two browser tabs must not start two actions).
- The target is validated server-side against the instance's current snapshot set (
docs/areas/docker-instances.md, Container snapshots); requests for unknown or non-current containers are rejected and recorded as Failed. - The container is targeted by its container ID from the snapshot (same Engine), verified through Portainer; if Portainer does not know that ID (container recreated since the last scan), the run fails with "Container changed since the last scan; run a runtime scan and try again" — no fallback to a name match on another container.
- Credentials never appear in action runs, messages, logs or telemetry; action runs, logs and messages carry action, container name, instance name and HTTP status only.
- Self-targeting: stopping/restarting DockerUpdateGuard's own container or its database container takes the app down mid-action. The confirmation dialog shows an extra warning when the target is the app's own container (recommendation: compare the container ID with the
HOSTNAMEshort ID Docker sets); the interrupted run is reconciled at startup.
Dependencies
- [BUG] TransientHttpRetryHandler retries non-idempotent POSTs, so a Portainer action can run twice #273 — non-idempotent Portainer requests must not be retried, otherwise one confirmed action can run several times. Required before this feature.
- [BUG] Portainer without EndpointId silently uses the first listed environment #279 — no silent pick of the first Portainer environment. Required before this feature.
- [BUG] Portainer AllowInsecureHttp logs no runtime warning while plaintext HTTP is in use #276 — runtime warning for
AllowInsecureHttp. - "Manual tag selection acts as a pin" (separate feature, being filed separately): the manual tag preference on the same page is the natural input for an update action (redeploy/update to the selected tag). This feature does not spec that; v1 update actions re-pull the image reference as deployed. The spec should leave a clear extension point (e.g. an optional target image/tag on the action request) and mention the dependency.
Scenarios and edge cases (for the spec)
- Restart a running container, Portainer reachable → run Succeeded, history shows it, success message.
- Start a stopped container → Succeeded. Engine answers 304 Not Modified (already started/stopped, status in the snapshot was stale) → treated as Succeeded with message "already in the requested state" (today the client treats 304 as failure).
- Portainer answers 401/403 (token revoked) → Failed with the status, no credentials in the message.
- Portainer answers 5xx or the connection breaks after sending → Failed after exactly one attempt ([BUG] TransientHttpRetryHandler retries non-idempotent POSTs, so a Portainer action can run twice #273); message tells the operator the action may or may not have been applied.
- Request times out (
Portainer:RequestTimeoutSeconds) → Failed with timeout message. - Container removed or recreated since the last scan → Failed, rescan hint.
- Portainer disabled, capability check fails, or several environments without
EndpointId→ no buttons, message shown; a forged request is rejected and recorded Failed. - Two tabs confirm actions on the same container at once → the second is rejected with "Another action is in progress".
- Browser closed / circuit disconnected while the action runs → the action continues to completion and its run is finalized (do not tie the action to the circuit's lifetime beyond the request timeout).
- Application shutdown during an action → Cancelled if not yet sent, otherwise left non-terminal and reconciled to Failed (“Interrupted”) at startup.
- Instance removed from configuration → its action runs are deleted with it (existing cascade,
docs/areas/docker-instances.md); Portainer disabled later → runs kept, endpoint link kept (endpoint marked disabled). - RedeployStack for a compose project not created through Portainer ("limited" stack in Portainer) → action not offered or Failed with a clear "stack is not managed by Portainer" message.
- Long resource names (> 200 characters) → truncated safely or rejected; never a database error.
- Action history grows forever → the spec decides on retention (recommendation: keep action runs as audit history, no retention in v1; document it in
docs/areas/scan-engine.md).
Acceptance criteria
-
ContainerActionTypeis the single action contract used byIPortainerClient, the new action service and the UI; the client's string allowlist and the unused actions are removed; existing enum numbers unchanged. -
IPortainerClientreturnsExternalOperationResult<T>for capability and actions (guarantee in.squad/project.md), or the spec records a decision why not. - A new action service (interface + scoped registration in the composition root) validates the request, writes the action run, calls Portainer, and finalizes the run; it never throws for expected failures.
- The runtime container detail page offers only the actions valid for the container's state and Portainer capability, each behind a confirmation dialog, and shows the action history.
- Every requested action produces exactly one action run with the status flow above; stale non-terminal runs are reconciled at startup.
- Server-side guards: one in-flight action per container; target must be in the current snapshot set; target by container ID.
- HTTP 304 on start/stop is reported as Succeeded.
- Unit tests (MSTest, naming per
docs/UNIT_TESTS.md; NSubstitute only for the HTTP boundary): action service (success, failure, 304, not found, concurrent request rejected, not-current snapshot rejected, cancellation, reconciliation),PortainerClientTestsfor the new contract, UI tests for the detail page (RuntimeContainerDetailRenderTests/…PersistentStateTests: buttons per state, confirmation required, history shown, no buttons when not supported), data tests insrc/Tests/DockerUpdateGuard.Data.Testsfor any model change. - Coverage gate passes (≥ 80 % on new/changed lines).
- README (Portainer section: what enabling it allows, no-auth consequence),
docs/ARCHITECTURE.md(Integration clients, UI, Security posture, Data layer if changed),docs/areas/web-ui.md(remove "The UI offers no container actions", add the actions) anddocs/areas/container-actions.mddescribe the new behavior; the Known gaps entries for this feature (currentlycontainer-actions.mdOpen questions items 1 and 2) are removed. - A decision record in
docs/decisions/captures the chosen action set, theRequestedByvalue and the retention choice.
Implementation notes
- Affected components:
src/DockerUpdateGuard/Portainer/*(client, data types, logging), a new action service (e.g.src/DockerUpdateGuard/Portainer/ContainerActionService.cs+ interface),RuntimeContainerDetail.razor(.cs)and its view data insrc/DockerUpdateGuard/UI,ServiceCollectionExtensions.cs, startup reconciliation (a startup step or the existing startup sequence indocs/areas/hosting.md). - Entity model: the existing columns suffice for v1; adding enum values needs no schema change. If the spec adds a column (e.g. a target image for the pin dependency, or a
ContainerId), create a migration named perAGENTS.md(UpdateN, file renamed without timestamp, attribute unchanged). - Portainer APIs to use for update actions (verify against the supported Portainer version and document it): stack redeploy
PUT /api/stacks/{id}?endpointId={eid}withpullImage: true(and/api/stacks/{id}/git/redeployfor Git-based stacks); Swarm service update through the Docker proxyPOST /api/endpoints/{eid}/docker/services/{id}/update?version=…withForceUpdateincremented. These are non-idempotent and rely on [BUG] TransientHttpRetryHandler retries non-idempotent POSTs, so a Portainer action can run twice #273. - Blazor Server pages share a scoped
DbContextper circuit (F-032 (P5) — Shared scoped DbContext across the Blazor circuit; view-service lock does not cover write/scan paths #21); the action service must not hold it across the long Portainer call in a way that conflicts with other page operations — prefer a short-lived context (IDbContextFactory) for writing the run. - Telemetry: add a trace activity for the action (see F-040 (P6) — Critical paths without their own trace spans:
scan.run/Portainer/CVE/persistence never started #83) without credentials. - Must not change: no authentication in the app (guarantee); Portainer stays opt-in; Portainer certificate validation always on; secrets never logged or shown; layering (UI in the host, entities in
.Data).
Related
- [BUG] TransientHttpRetryHandler retries non-idempotent POSTs, so a Portainer action can run twice #273, [BUG] Portainer without EndpointId silently uses the first listed environment #279, [BUG] Portainer AllowInsecureHttp logs no runtime warning while plaintext HTTP is in use #276 (prerequisites / related bugs above)
- F-040 (P6) — Critical paths without their own trace spans:
scan.run/Portainer/CVE/persistence never started #83 (Portainer calls without trace spans), F-032 (P5) — Shared scoped DbContext across the Blazor circuit; view-service lock does not cover write/scan paths #21 (shared scopedDbContextin the Blazor circuit), F-035 (P5) — Timestamps withToLocalTime()in Blazor Server → server timezone instead of user timezone #36 (timestamps shown in server time zone — applies to the action history) - "Manual tag selection acts as a pin" (separate feature, dependency for update actions)
- PR [Docs] Describe the current behavior by area #263 (area documents)
- [Feature] Manual tag selection pins the update target of a runtime container #291 (manual tag selection pins the update target)
Generated by Claude Code
Activity