Files
portainer/api/http/handler/webhooks/webhook_container_automation.go
T
claude code agent 400ac05864 fix(#32): webhook URL via shared helper, disabled-state note, CAS-release test, Conflict helper (review round 1)
F1 [WARNING] The frontend built the trigger URL from window.location.origin,
bypassing the shared webhookHelper whose getBaseUrl() honours the reverse-proxy
sub-path (<base href>) and the desktop file:// build. Under a non-root base-href
deploy the copied URL would miss the sub-path prefix. Added
containerAutomationWebhookUrl(token) to webhookHelper (mirroring dockerWebhookUrl)
and used it; the test asserts against the same helper.

F2 [WARNING] The panel showed the live URL + 'POST runs a pass' text even when
auto-update was disabled — contradicting the server, which 409s a valid token
while disabled. WebhookTriggerSection now takes the enabled flag and shows an
orange note ('trigger is inactive... a POST returns 409') when a token exists but
auto-update is off; the token/Regenerate/Clear stay (the token is kept). Tests
cover both the disabled note and its absence when enabled.

F3 [WARNING] The CAS-release of the REAL runUpdatePass was untested (only the
acquire-FAIL path was). A broken release defer would wedge auto-update forever
(poll drops every tick, webhook worker spins) with a green suite. Added
TestRunUpdatePassReleasesLock: a test store with auto-update enabled and no
endpoints runs a pass to completion and asserts the lock is released (and a
second pass re-acquires it).

F4 [low] Swapped raw NewError(StatusConflict) for the httperror.Conflict helper,
matching the rest of the file and webhook_create.go.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 06:30:38 +03:00

66 lines
3.0 KiB
Go

package webhooks
import (
"crypto/subtle"
"errors"
"net/http"
httperror "github.com/portainer/portainer/pkg/libhttp/error"
"github.com/portainer/portainer/pkg/libhttp/request"
)
// @summary Trigger a container auto-update pass
// @description Inbound registry-push webhook: any POST with the configured token
// @description immediately runs a full native container auto-update pass (the same
// @description pass the poll timer runs), so a container updates within seconds of a
// @description registry push instead of waiting for the next poll. The payload is not
// @description parsed — the pass itself compares digests and updates only what is
// @description stale, so it is registry-agnostic (Gitea, GHCR, Docker Hub, ...).
// @description **Access policy**: public (guarded by the secret token)
// @tags webhooks
// @param token path string true "Auto-update webhook token"
// @success 202 "Update pass triggered"
// @failure 404 "Unknown or empty token"
// @failure 409 "Container auto-update is disabled"
// @failure 500 "Server error"
// @router /webhooks/container-automation/{token} [post]
func (handler *Handler) webhookContainerAutomation(w http.ResponseWriter, r *http.Request) *httperror.HandlerError {
// An empty/absent token is reported as such via the error; treat it as an empty
// token so it falls through to the constant-time compare below and yields a 404
// (never a 500), matching how a wrong token is handled.
token, _ := request.RetrieveRouteVariableValue(r, "token")
settings, err := handler.DataStore.Settings().Settings()
if err != nil {
return httperror.InternalServerError("Unable to retrieve the settings from the database", err)
}
autoUpdate := settings.ContainerAutomation.AutoUpdate
// Compare in constant time and treat an empty configured token as "endpoint
// disabled": without this an empty request token would match an empty configured
// token and let anyone trigger a pass. A mismatch (or empty token) is a 404 so the
// endpoint does not reveal whether a token is configured.
expected := autoUpdate.WebhookToken
if expected == "" || subtle.ConstantTimeCompare([]byte(token), []byte(expected)) != 1 {
return httperror.NotFound("Invalid webhook token", errors.New("invalid container-automation webhook token"))
}
// The token is valid but auto-update is turned off: report a conflict rather than
// silently accepting a kick that would never do anything.
if !autoUpdate.Enabled {
return httperror.Conflict("Container auto-update is disabled", errors.New("container auto-update is disabled"))
}
if handler.ContainerAutomationService == nil {
return httperror.InternalServerError("Container automation service is not available", errors.New("container automation service not wired"))
}
// Fire-and-forget: the service coalesces/debounces bursts and runs the pass
// asynchronously, so respond 202 Accepted immediately with no body.
handler.ContainerAutomationService.TriggerUpdate()
w.WriteHeader(http.StatusAccepted)
return nil
}