b6ba9b46c7
Adds POST /api/webhooks/container-automation/{token} which kicks a full
auto-update pass immediately, instead of waiting up to PollInterval (6h) for the
poll. Registry-agnostic 'general kick': the payload is not parsed; the pass
compares digests and updates only stale containers. Polling stays as a fallback.
- Service.TriggerUpdate(): a buffer-1 non-blocking kick channel + a worker
goroutine on baseCtx that debounces ~10s (collapsing a multi-arch/multi-tag
push burst into one pass). update() is split into runUpdatePass() bool, which
returns false when the updateRunning CAS is not acquired; the worker backs off
and re-runs so a kick landing during a scheduled pass is never lost. The poll
timer keeps calling the same pass.
- Worker panics are recovered (poll parity — the poll path runs under the
scheduler's cron.Recover), so a panic in the pass cannot crash the daemon from
this bare goroutine. runUpdatePass also re-checks AutoUpdate.Enabled as
defense-in-depth against a disable during the debounce window.
- Route via bouncer.PublicAccess (distinct from /webhooks/{token} by segment
count); token compared with subtle.ConstantTimeCompare; empty/mismatched token
-> 404, disabled auto-update -> 409, success -> 202. Token is server-generated
(uuid) via RegenerateWebhookToken / ClearWebhookToken settings actions; never
accepted from the client; excluded from /settings/public (present in admin GET
for the UI).
- Frontend: an AutoUpdatePanel 'Update trigger webhook' section (readonly URL,
copy, Generate/Regenerate, Clear, registry-setup hint).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
105 lines
3.4 KiB
Go
105 lines
3.4 KiB
Go
package webhooks
|
|
|
|
import (
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"github.com/portainer/portainer/api/datastore"
|
|
|
|
"github.com/gorilla/mux"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
// fakeTrigger records how many times the inbound webhook asked for an update pass.
|
|
type fakeTrigger struct{ calls int }
|
|
|
|
func (f *fakeTrigger) TriggerUpdate() { f.calls++ }
|
|
|
|
// setupWebhookAutomationHandler builds a webhooks.Handler backed by a test store
|
|
// whose auto-update settings carry the given token and enabled flag, plus a fake
|
|
// trigger to observe the kick.
|
|
func setupWebhookAutomationHandler(t *testing.T, token string, enabled bool) (*Handler, *fakeTrigger) {
|
|
t.Helper()
|
|
|
|
_, store := datastore.MustNewTestStore(t, true, false)
|
|
|
|
settings, err := store.Settings().Settings()
|
|
require.NoError(t, err)
|
|
settings.ContainerAutomation.AutoUpdate.WebhookToken = token
|
|
settings.ContainerAutomation.AutoUpdate.Enabled = enabled
|
|
require.NoError(t, store.Settings().UpdateSettings(settings))
|
|
|
|
trigger := &fakeTrigger{}
|
|
handler := &Handler{DataStore: store, ContainerAutomationService: trigger}
|
|
|
|
return handler, trigger
|
|
}
|
|
|
|
// callWebhook invokes the handler with the given request-path token wired as the
|
|
// mux route variable, mirroring the router configuration.
|
|
func callWebhook(handler *Handler, requestToken string) (*httptest.ResponseRecorder, int) {
|
|
req := httptest.NewRequest(http.MethodPost, "/webhooks/container-automation/"+requestToken, nil)
|
|
req = mux.SetURLVars(req, map[string]string{"token": requestToken})
|
|
rr := httptest.NewRecorder()
|
|
|
|
herr := handler.webhookContainerAutomation(rr, req)
|
|
if herr != nil {
|
|
return rr, herr.StatusCode
|
|
}
|
|
|
|
return rr, rr.Code
|
|
}
|
|
|
|
func TestWebhookContainerAutomation(t *testing.T) {
|
|
const token = "11111111-2222-3333-4444-555555555555"
|
|
|
|
t.Run("valid token on enabled auto-update triggers a pass and returns 202", func(t *testing.T) {
|
|
handler, trigger := setupWebhookAutomationHandler(t, token, true)
|
|
|
|
rr, status := callWebhook(handler, token)
|
|
|
|
require.Equal(t, http.StatusAccepted, status)
|
|
require.Equal(t, 1, trigger.calls, "a valid kick must trigger exactly one pass")
|
|
require.Empty(t, rr.Body.String(), "202 response has no body")
|
|
})
|
|
|
|
t.Run("wrong token returns 404 and does not trigger", func(t *testing.T) {
|
|
handler, trigger := setupWebhookAutomationHandler(t, token, true)
|
|
|
|
_, status := callWebhook(handler, "wrong-token")
|
|
|
|
require.Equal(t, http.StatusNotFound, status)
|
|
require.Zero(t, trigger.calls)
|
|
})
|
|
|
|
t.Run("empty request token returns 404", func(t *testing.T) {
|
|
handler, trigger := setupWebhookAutomationHandler(t, token, true)
|
|
|
|
_, status := callWebhook(handler, "")
|
|
|
|
require.Equal(t, http.StatusNotFound, status)
|
|
require.Zero(t, trigger.calls)
|
|
})
|
|
|
|
t.Run("empty configured token cannot be matched (endpoint disabled)", func(t *testing.T) {
|
|
// No token configured: even an empty request token must not match, or anyone
|
|
// could trigger a pass.
|
|
handler, trigger := setupWebhookAutomationHandler(t, "", true)
|
|
|
|
_, status := callWebhook(handler, "")
|
|
|
|
require.Equal(t, http.StatusNotFound, status)
|
|
require.Zero(t, trigger.calls)
|
|
})
|
|
|
|
t.Run("valid token but auto-update disabled returns 409", func(t *testing.T) {
|
|
handler, trigger := setupWebhookAutomationHandler(t, token, false)
|
|
|
|
_, status := callWebhook(handler, token)
|
|
|
|
require.Equal(t, http.StatusConflict, status)
|
|
require.Zero(t, trigger.calls, "a disabled endpoint must not trigger a pass")
|
|
})
|
|
}
|