Skip to content

[BUG] Portainer without EndpointId silently uses the first listed environment #279

Closed

Description

@LarsLaskowski

Summary

When DockerUpdateGuard:DockerInstances:<n>:Portainer:EndpointId is not set, PortainerClient resolves the environment by taking the first entry of Portainer's environment list, on every call. On a Portainer server that manages several environments this can be an environment other than the Docker host the instance describes: the capability check reports "connected", and container actions are looked up — and, for a container with the same name, executed — on the wrong host. Without an explicit EndpointId an environment may only be picked when it is unambiguous.

Area

  • docs/areas/container-actions.md (Configuration and mapping, Capability check, Actions)
  • Security area (.squad/project.md): Container actions (PortainerClient)

Current behavior

src/DockerUpdateGuard/Portainer/PortainerClient.cs:74-86:

private static async Task<string?> ResolveEndpointIdAsync(HttpClient client, CancellationToken cancellationToken)
{
    using var response = await client.GetAsync("/api/endpoints", cancellationToken).ConfigureAwait(false);

    if (response.IsSuccessStatusCode == false)
    {
        return null;
    }

    var endpoints = await response.Content.ReadFromJsonAsync<PortainerEndpointItem[]>(cancellationToken: cancellationToken).ConfigureAwait(false);

    return endpoints?.FirstOrDefault()?.Id.ToString();
}

It is used by the capability check (PortainerClient.cs:237-252) and by ExecuteActionAsync (PortainerClient.cs:306-320) whenever EndpointId is empty. A non-success answer of the list request is also reported as "No Portainer endpoint found", hiding the HTTP status.

README (README.md:191): "Explicit Portainer endpoint ID; auto-discovered when omitted".

Scenario: one Portainer manages local (Id 1, the Portainer host itself) and nas (Id 2). DockerUpdateGuard instance nas has Portainer enabled without EndpointId. The capability check says "Portainer connected, endpoint 1"; a restart of container db for instance nas restarts db on local if a container of that name exists there.

Expected behavior

Decision (product manager): no silent pick.

  • With EndpointId configured: unchanged — that environment is used.
  • Without EndpointId:
    • Portainer lists exactly one environment → it is used (as today).
    • Portainer lists more than one environment → the capability check reports configured, actions not supported with a clear message such as "Portainer manages 3 environments; set DockerUpdateGuard:DockerInstances::Portainer:EndpointId to one of: 1 (local), 2 (nas), 5 (edge)". Actions fail before any container lookup with the same message. No environment is picked.
    • Portainer lists no environment → as today, "No Portainer endpoint found".
    • The environment-list request answers non-success → the message names the HTTP status code (instead of "No Portainer endpoint found").
  • The message lists environment IDs and names only (no URLs or secrets); for very large lists it may be truncated (e.g. the first 10 plus "…").

Acceptance criteria

  • Capability check without EndpointId and two listed environments → IsConfigured = true, SupportsActions = false, message names the key and the IDs/names.
  • ExecuteActionAsync without EndpointId and two listed environments → failed with the same message, no container-list and no action request sent.
  • Exactly one listed environment → used, as today.
  • Zero environments → "No Portainer endpoint found", as today.
  • Environment list answering 500 → message contains the status code.
  • Configured EndpointId → environment list not requested at all.
  • Unit tests (MSTest, naming per docs/UNIT_TESTS.md) in PortainerClientTests, e.g. PortainerClientGetCapabilityWithMultipleEnvironmentsAndNoEndpointIdReportsAmbiguous, PortainerClientExecuteActionWithMultipleEnvironmentsAndNoEndpointIdFailsWithoutAction, PortainerClientGetCapabilityWithSingleEnvironmentUsesIt.
  • README (README.md:191) describes the rule (EndpointId required when Portainer manages more than one environment).
  • The area document docs/areas/container-actions.md describes the new behavior and its Known gaps entry for this issue (currently Open questions item 5) is removed.

Implementation notes

  • Change ResolveEndpointIdAsync to return a small result (resolved ID, or a reason: none / ambiguous with the list / HTTP failure) instead of string?; both call sites map it to their result type.
  • PortainerEndpointItem already carries Id and Name (src/DockerUpdateGuard/Portainer/Data/PortainerEndpointItem.cs).
  • Count every listed environment; do not try to guess the matching one by name or URL (the Docker instance BaseUrl and Portainer's environment URL usually differ, e.g. a socket vs. an agent URL). Recommendation: no heuristic matching.
  • Portainer may page /api/endpoints on large installations; requesting without paging parameters returns the full list on current versions. If paging headers are present, treat "more than one page" as ambiguous as well.
  • Startup validation stays unchanged (it cannot know how many environments Portainer has); the check happens at call time.
  • No migration impact. PortainerEndpoint.ExternalEndpointId stores the configured value only; do not start persisting a resolved ID as part of this fix.

Related


Generated by Claude Code

Activity

  1. LarsLaskowski commented on Oct 25, 2026

    @LarsLaskowski
    OwnerAuthor

    Squad working record

    Tier: standard · Security area: Container actions (PortainerClient) · Decision record: 0010 (Accepted) · Follow-up: #330 · Squad lessons: none · Pull request: #331

    Plan (plan.md, final version with challenge and security notes): https://github.com/LarsLaskowski/DockerUpdateGuard/blob/37689cb9cb02985ea430c438485ff0584f8a4880/specs/issue-279/plan.md

    Summary: without EndpointId, an environment is used only when Portainer lists exactly one (counting X-Total-Count, an invalid header counts as unknown); more than one, or an unknown count, fails the capability check and every action before any container lookup with a message naming Portainer:EndpointId of the Docker instance and the environment IDs/names (max 10, sanitized); an empty list keeps "No Portainer endpoint found"; a non-success list answer reports the HTTP status. Challenge: 1 major (the key in the message must not use the instance name, since configuration addresses instances by list position — accepted), 1 minor (accepted). Security: approved in two rounds.

    Log
    Date Step Member Result
    2026-10-25 11:32 1 Intake Orchestrator Issue 279 read: bug (Portainer EndpointId ambiguity); branch claude/dazzling-gauss-szkst4 off main d7e9653
    2026-10-25 11:39 2 Plan Lead DONE, tier standard; security area Container actions; record 0010 proposed (opus/medium · 131,380 tokens · 34 tool uses · 372 s)
    2026-10-25 11:41 2 Plan challenge Devil's Advocate OBJECTIONS 1 major (message uses instance name where config key needs list position), 1 minor (empty body with X-Total-Count>1) (opus/medium · 47,923 tokens · 6 tool uses · 64 s)
    2026-10-25 11:41 3 Plan security review Security APPROVED; 3 non-blocking notes (malformed X-Total-Count fails open; single visible env trusted; #280 must not log Message) (opus/medium · 54,136 tokens · 8 tool uses · 125 s)
    2026-10-25 11:43 2 Plan revise Lead DONE: DA major accepted (relative key + instance name), minor accepted, invalid X-Total-Count fails closed (AC9); follow-up issue for validator key messages (opus/medium · 53,083 tokens · 22 tool uses · 126 s)
    2026-10-25 11:44 3 Plan security review (round 2) Security APPROVED; revision confirmed, earlier notes resolved (opus/medium · 11,404 tokens · 3 tool uses · 29 s · continued)
    2026-10-25 11:44 3 Follow-up Orchestrator Opened follow-up issue #330 (validator key messages)
    2026-10-25 11:44 4 Skeleton Orchestrator Launching squad-dev skeleton
    2026-10-25 11:50 4 Skeleton Dev Skeleton built; build 0 errors, existing tests green (703+21); allowed deviation: ResolveEndpointAsync keeps today's logic; 12 Sonar warnings from unused stubs until implement (sonnet/medium · 89,896 tokens · 11 tool uses · 319 s)
    2026-10-25 11:56 5 Tests first Tester Tests written; confirmed: 11 cases fail on skeleton, 40 pass; 4 deferred tests (AC3, AC4, AC6 x2) pass already (sonnet/medium · 123,393 tokens · 14 tool uses · 349 s)
    2026-10-25 12:02 6 Implement Dev Implemented + docs; build 0 warnings; 718+21 tests green; coverage new/changed 100% (69/69), overall 85.6% (re-run by Orchestrator: PASS); plan test names all present (sonnet/medium · 97,422 tokens · 11 tool uses · 265 s)
    2026-10-25 12:03 6 Coverage pass Tester 4 deferred tests mutation-checked (3 mutations), all detect regressions; no test changes (sonnet/medium · 73,661 tokens · 7 tool uses · 97 s)
    2026-10-25 12:05 7 Code check Orchestrator main not moved; Format check 0 files; Build 0 warnings; tests 718+21 green; Coverage gate PASS (100% new/changed, 85.6% overall); Analyzer gate 0 diagnostics; scope-check PASS; Sonar gate NOT CHECKED (no SONAR_TOKEN), step 11 reads PR via sonar-check.py pr; image build check not needed (no csproj/props/razor/Dockerfile change)
    2026-10-25 12:09 8 Review round 1 Reviewer BLOCKING 1 (verbatim doc: area doc + record 0010 said every non-single case is ambiguous, code returns 'No Portainer endpoint found' for empty list), NON-BLOCKING 1 (no test for empty list + invalid X-Total-Count) (opus/medium · 73,430 tokens · 10 tool uses · 125 s)
    2026-10-25 12:09 8 Verbatim fix Orchestrator Applied reviewer's verbatim doc fix to docs/areas/container-actions.md and docs/decisions/0010 (git diff: only those sentences; Format check, scope-check, decision-check PASS); commit e780f1b
    2026-10-25 12:13 8 Non-blocking fix Tester Added empty-list + invalid X-Total-Count tests (2 tests x 2 rows); mutation-checked, production unchanged (sonnet/medium · 75,100 tokens · 12 tool uses · 87 s)
    2026-10-25 12:15 7 Code check (after non-blocking fix) Orchestrator Format check 0 files; Build 0 warnings; tests 722+21 green; Coverage PASS (100% new/changed, 85.6% overall); Analyzer 0 diagnostics; scope-check PASS; head d4a8e2f
    2026-10-25 12:15 8 Review round 2 (delta) Reviewer APPROVE; both round-1 findings resolved, no new defects (opus/medium · 4,165 tokens · 3 tool uses · 26 s · continued)
    2026-10-25 12:15 9 PR approval Orchestrator Approved by checklist: latest round clean (delta round 2 APPROVE), gates pass on head, main not moved, area docs/README in diff; record 0010 set to Accepted; decision-check PASS
    2026-10-25 12:16 10 Pull request Orchestrator Working record posted to issue #279 comment; specs/issue-279 removed; draft PR opened
    2026-10-25 12:20 11 After the PR Orchestrator PR #331 opened as draft; CI green on head 3123605 (Build/Test/Analyze, CodeQL, SonarCloud Quality Gate passed: 0 new issues, 100% new-code coverage); re-approval by checklist (head unchanged); marked ready
    2026-10-25 12:20 12 Wrap-up Orchestrator no lessons

    Squad run metrics

    Member Model/effort Launches Tokens Tool uses Seconds
    Dev sonnet/medium 2 187,318 22 584
    Devil's Advocate opus/medium 1 47,923 6 64
    Lead opus/medium 2 184,463 56 498
    Reviewer opus/medium 2 (1 continued) 77,595 13 151
    Security opus/medium 2 (1 continued) 65,540 11 154
    Tester sonnet/medium 3 272,154 33 533
    Total 12 (2 continued) 834,993 141 1984
    Member Wall time (s) Not covered by launches (s) Gate runs
    Orchestrator 2,880 1,008 2

    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions