Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 400ac05864 | |||
| b6ba9b46c7 |
@@ -26,20 +26,41 @@ const (
|
||||
)
|
||||
|
||||
// update runs a single auto-update pass over every reachable Docker endpoint.
|
||||
// It is registered with the scheduler and guarded against overlapping ticks by
|
||||
// the Service. Errors are logged per endpoint/container so one failure does not
|
||||
// abort the whole pass; it always returns nil so the scheduler keeps the job.
|
||||
// It is the scheduler entry point (poll timer): it delegates to runUpdatePass and
|
||||
// always returns nil so the scheduler keeps the job regardless of the outcome.
|
||||
func (s *Service) update() error {
|
||||
s.runUpdatePass()
|
||||
return nil
|
||||
}
|
||||
|
||||
// runUpdatePass performs a single auto-update pass over every reachable Docker
|
||||
// endpoint. It is guarded against overlapping runs by the updateRunning CAS: if a
|
||||
// pass (scheduled or webhook-triggered) is already in progress it returns false
|
||||
// WITHOUT running, so the caller can decide what to do (the poll timer drops the
|
||||
// tick; the webhook worker waits and re-runs so a mid-pass kick is not lost).
|
||||
// Errors are logged per endpoint/container so one failure does not abort the whole
|
||||
// pass. Returns true when this call actually acquired the lock and ran the pass.
|
||||
func (s *Service) runUpdatePass() bool {
|
||||
if !s.updateRunning.CompareAndSwap(false, true) {
|
||||
log.Debug().Msg("auto-update: previous run still in progress, skipping tick")
|
||||
return nil
|
||||
return false
|
||||
}
|
||||
defer s.updateRunning.Store(false)
|
||||
|
||||
settings, err := s.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("auto-update: unable to read settings")
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
|
||||
// Defense-in-depth: auto-update may have been disabled during the webhook
|
||||
// debounce/backoff window (the handler's 409 gate and the scheduler removal on
|
||||
// Reload both act earlier, but neither covers a kick already in flight). Re-read
|
||||
// the live flag and no-op if it was turned off. Returns true (lock acquired,
|
||||
// nothing to retry).
|
||||
if !settings.ContainerAutomation.AutoUpdate.Enabled {
|
||||
log.Debug().Msg("auto-update: disabled, skipping pass")
|
||||
return true
|
||||
}
|
||||
|
||||
scope := ScopeLabeled
|
||||
@@ -56,7 +77,7 @@ func (s *Service) update() error {
|
||||
endpoints, err := s.dataStore.Endpoint().Endpoints()
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("auto-update: unable to list environments")
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
|
||||
for i := range endpoints {
|
||||
@@ -84,7 +105,7 @@ func (s *Service) update() error {
|
||||
// pruneRetries), so the loop-guard map cannot grow unbounded.
|
||||
s.pruneRolledBack(time.Now())
|
||||
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
|
||||
// updateOptions carries the per-pass auto-update toggles resolved from settings.
|
||||
|
||||
@@ -29,6 +29,16 @@ const (
|
||||
// It is conservative (hours) to stay within registry rate limits; the image-status cache is
|
||||
// short-lived (keyed by the local imageID), so each poll re-checks the remote digest.
|
||||
defaultPollInterval = 6 * time.Hour
|
||||
// webhookDebounceWindow collapses a burst of inbound registry-push webhooks into a
|
||||
// single update pass. A multi-arch / multi-tag push fires several deliveries in a
|
||||
// row; after the first kick the worker waits this long, draining any further kicks,
|
||||
// so the burst runs one pass instead of one per delivery.
|
||||
webhookDebounceWindow = 10 * time.Second
|
||||
// webhookRetryBackoff is how long the webhook worker waits before re-running a pass
|
||||
// that could not acquire the update lock because a scheduled pass was already in
|
||||
// progress. It keeps the retry from spinning while ensuring a mid-pass kick is not
|
||||
// lost until the next poll.
|
||||
webhookRetryBackoff = 5 * time.Second
|
||||
)
|
||||
|
||||
// Service manages the lifecycle of the auto-heal and auto-update scheduler jobs
|
||||
@@ -54,6 +64,19 @@ type Service struct {
|
||||
// default is logNotifier; the field is the seam external senders plug into.
|
||||
notifier Notifier
|
||||
|
||||
// kickCh signals the webhook worker to run an out-of-band update pass. It has a
|
||||
// buffer of 1 and TriggerUpdate sends non-blockingly, so a burst of concurrent
|
||||
// triggers naturally coalesces into at most one pending kick.
|
||||
kickCh chan struct{}
|
||||
// debounceWindow / retryBackoff are the worker timings, held as fields (defaulted
|
||||
// from the package constants in NewService) so tests can shrink them.
|
||||
debounceWindow time.Duration
|
||||
retryBackoff time.Duration
|
||||
// updatePass is the seam the webhook worker runs; it defaults to runUpdatePass and
|
||||
// is overridable in tests to observe the kick/debounce/retry behaviour without a
|
||||
// live engine. It returns whether the pass acquired the update lock and ran.
|
||||
updatePass func() bool
|
||||
|
||||
mu sync.Mutex
|
||||
healJobID string
|
||||
updateJobID string
|
||||
@@ -98,7 +121,7 @@ func NewService(
|
||||
baseCtx = context.Background()
|
||||
}
|
||||
|
||||
return &Service{
|
||||
s := &Service{
|
||||
baseCtx: baseCtx,
|
||||
scheduler: scheduler,
|
||||
dataStore: dataStore,
|
||||
@@ -109,9 +132,113 @@ func NewService(
|
||||
// The webhook reads the current settings per-event from the datastore, so a
|
||||
// URL change in the UI takes effect without a restart; logNotifier keeps the
|
||||
// existing structured log output unchanged.
|
||||
notifier: multiNotifier{logNotifier{}, newWebhookNotifier(dataStore)},
|
||||
retries: make(map[string]retryState),
|
||||
rolledBack: make(map[string]rolledBackTarget),
|
||||
notifier: multiNotifier{logNotifier{}, newWebhookNotifier(dataStore)},
|
||||
kickCh: make(chan struct{}, 1),
|
||||
debounceWindow: webhookDebounceWindow,
|
||||
retryBackoff: webhookRetryBackoff,
|
||||
retries: make(map[string]retryState),
|
||||
rolledBack: make(map[string]rolledBackTarget),
|
||||
}
|
||||
s.updatePass = s.runUpdatePass
|
||||
|
||||
// The webhook worker lives for the whole application lifetime (bounded by
|
||||
// baseCtx), independently of whether the poll job is scheduled: a kick must be
|
||||
// serviceable even between polls.
|
||||
go s.triggerWorker()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// TriggerUpdate requests an immediate auto-update pass out of band from the poll
|
||||
// timer. It is called by the inbound registry-push webhook handler so a container
|
||||
// updates within seconds of a push instead of waiting up to a full poll interval.
|
||||
// The send is non-blocking onto the buffer-1 kick channel: a burst of concurrent
|
||||
// triggers coalesces into at most one pending kick, and the worker then debounces
|
||||
// the burst and, if a scheduled pass is already running, waits and re-runs so a
|
||||
// kick that lands mid-pass is not lost. Safe to call on a zero-value Service (a
|
||||
// nil kick channel makes it a no-op).
|
||||
func (s *Service) TriggerUpdate() {
|
||||
select {
|
||||
case s.kickCh <- struct{}{}:
|
||||
default:
|
||||
// A kick is already pending (or the channel is nil on a zero-value service):
|
||||
// the coming pass will cover this trigger too, so drop the duplicate.
|
||||
}
|
||||
}
|
||||
|
||||
// triggerWorker services webhook-driven update kicks for the life of baseCtx. It
|
||||
// waits for a kick, debounces the burst, then runs an update pass — re-running
|
||||
// after a short backoff if a scheduled pass currently holds the update lock, so a
|
||||
// kick that arrives mid-pass is not dropped until the next poll.
|
||||
func (s *Service) triggerWorker() {
|
||||
for {
|
||||
// Block until the first kick of a burst (or shutdown).
|
||||
select {
|
||||
case <-s.baseCtx.Done():
|
||||
return
|
||||
case <-s.kickCh:
|
||||
}
|
||||
|
||||
if !s.debounceKicks() {
|
||||
return // shutdown during the debounce window
|
||||
}
|
||||
|
||||
// Run the pass, retrying while a scheduled pass holds the lock. runUpdatePass
|
||||
// returns false only when the CAS is not acquired (another pass in progress);
|
||||
// backing off and retrying guarantees this kick eventually runs a fresh pass
|
||||
// that observes the just-pushed image, rather than being silently dropped.
|
||||
//
|
||||
// runPassRecovered wraps each attempt in a recover(): the poll path runs under
|
||||
// the scheduler's cron.Recover, so a panic there is logged and the daemon
|
||||
// survives. This webhook worker is a bare goroutine, so without its own
|
||||
// recover an unrelated panic in the update pass (reachable by a token holder)
|
||||
// would crash the whole process. Match the poll path's guarantee.
|
||||
for !s.runPassRecovered() {
|
||||
log.Debug().Msg("auto-update: webhook-triggered pass deferred, a scheduled pass is running; will retry")
|
||||
select {
|
||||
case <-s.baseCtx.Done():
|
||||
return
|
||||
case <-time.After(s.retryBackoff):
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runPassRecovered runs one update pass with a recover() so a panic in the pass
|
||||
// cannot crash the process from this bare worker goroutine (the poll path is
|
||||
// already protected by the scheduler's cron.Recover). It returns whatever the
|
||||
// pass returned; on a recovered panic it returns true ("done") so the retry loop
|
||||
// does not spin on a deterministically-panicking pass — the next kick starts
|
||||
// fresh. runUpdatePass releases the updateRunning CAS via defer even on panic, so
|
||||
// no lock is leaked here.
|
||||
func (s *Service) runPassRecovered() (done bool) {
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
log.Error().Interface("panic", r).Msg("auto-update: recovered from panic in webhook-triggered update pass")
|
||||
done = true
|
||||
}
|
||||
}()
|
||||
|
||||
return s.updatePass()
|
||||
}
|
||||
|
||||
// debounceKicks waits a fixed window after the first kick, draining any further
|
||||
// kicks that arrive during it, so a burst of registry deliveries collapses into a
|
||||
// single pass. It returns false if baseCtx is cancelled while waiting.
|
||||
func (s *Service) debounceKicks() bool {
|
||||
timer := time.NewTimer(s.debounceWindow)
|
||||
defer timer.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-s.baseCtx.Done():
|
||||
return false
|
||||
case <-s.kickCh:
|
||||
// Additional kick within the window: coalesce it (fixed window from the
|
||||
// first kick, not reset — a bounded, single collapsed pass).
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
package containerautomation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// newTriggerTestService builds a Service wired only for the webhook trigger worker,
|
||||
// with a stubbed updatePass seam and short timings, and starts the worker on a
|
||||
// cancellable context that is torn down at the end of the test.
|
||||
func newTriggerTestService(t *testing.T, pass func() bool) *Service {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
t.Cleanup(cancel)
|
||||
|
||||
s := &Service{
|
||||
baseCtx: ctx,
|
||||
kickCh: make(chan struct{}, 1),
|
||||
debounceWindow: 40 * time.Millisecond,
|
||||
retryBackoff: 10 * time.Millisecond,
|
||||
updatePass: pass,
|
||||
}
|
||||
go s.triggerWorker()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// TestTriggerUpdateCoalescesBurst verifies that a burst of TriggerUpdate calls
|
||||
// arriving within the debounce window collapses into a single update pass, so a
|
||||
// multi-arch / multi-tag registry push does not run one pass per delivery.
|
||||
func TestTriggerUpdateCoalescesBurst(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
passes := 0
|
||||
s := newTriggerTestService(t, func() bool {
|
||||
mu.Lock()
|
||||
passes++
|
||||
mu.Unlock()
|
||||
return true
|
||||
})
|
||||
|
||||
// A burst of deliveries in a tight loop, all well within the debounce window.
|
||||
for range 10 {
|
||||
s.TriggerUpdate()
|
||||
}
|
||||
|
||||
// Wait out the debounce window (plus margin) and confirm exactly one pass ran.
|
||||
require.Eventually(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return passes == 1
|
||||
}, time.Second, 5*time.Millisecond, "the burst should trigger exactly one pass")
|
||||
|
||||
// Give any erroneously-scheduled extra pass a chance to appear, then re-assert.
|
||||
time.Sleep(120 * time.Millisecond)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
require.Equal(t, 1, passes, "no extra pass should run after the coalesced burst")
|
||||
}
|
||||
|
||||
// TestTriggerUpdateRetriesWhenPassBusy verifies the critical edge case: when a
|
||||
// scheduled pass is already running (runUpdatePass returns false because the
|
||||
// updateRunning CAS is not acquired), the kick is NOT lost — the worker waits and
|
||||
// re-runs the pass after the running one finishes.
|
||||
func TestTriggerUpdateRetriesWhenPassBusy(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var attempts int
|
||||
var ranAfterBusy bool
|
||||
|
||||
s := newTriggerTestService(t, func() bool {
|
||||
mu.Lock()
|
||||
attempts++
|
||||
n := attempts
|
||||
if n > 1 {
|
||||
ranAfterBusy = true
|
||||
}
|
||||
mu.Unlock()
|
||||
|
||||
// First attempt models a scheduled pass holding the lock: report the CAS
|
||||
// miss so the worker must back off and retry.
|
||||
return n > 1
|
||||
})
|
||||
|
||||
s.TriggerUpdate()
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return ranAfterBusy && attempts >= 2
|
||||
}, time.Second, 5*time.Millisecond, "the worker must retry and run a fresh pass after the CAS miss")
|
||||
}
|
||||
|
||||
// TestTriggerUpdateBufferedDuringRunningPass verifies that a kick arriving WHILE a
|
||||
// pass is already running is not lost: the buffer-1 channel holds it, and the
|
||||
// worker reads it on the next loop iteration and runs a second pass. This is the
|
||||
// coalescing path for a push that lands mid-pass (distinct from the CAS-miss retry
|
||||
// path, which models a SCHEDULED pass holding the lock).
|
||||
func TestTriggerUpdateBufferedDuringRunningPass(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var passes int
|
||||
release := make(chan struct{})
|
||||
firstStarted := make(chan struct{})
|
||||
|
||||
s := newTriggerTestService(t, func() bool {
|
||||
mu.Lock()
|
||||
passes++
|
||||
n := passes
|
||||
mu.Unlock()
|
||||
if n == 1 {
|
||||
close(firstStarted) // signal the first pass is in progress
|
||||
<-release // ...and block here until the test releases it
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
s.TriggerUpdate()
|
||||
<-firstStarted // the first pass is now running
|
||||
|
||||
// A second push lands while the first pass is still running. It must be
|
||||
// buffered (not dropped) and processed after the current pass finishes.
|
||||
s.TriggerUpdate()
|
||||
close(release)
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return passes == 2
|
||||
}, time.Second, 5*time.Millisecond, "a kick during a running pass must run a second pass, not be lost")
|
||||
}
|
||||
|
||||
// TestTriggerWorkerRecoversFromPanic verifies the webhook worker survives a panic in
|
||||
// the update pass (poll parity: the poll path is protected by cron.Recover). Without
|
||||
// the recover, a panic in this bare goroutine would crash the whole test binary — so
|
||||
// the fact that a LATER kick still runs a pass proves recovery works.
|
||||
func TestTriggerWorkerRecoversFromPanic(t *testing.T) {
|
||||
var mu sync.Mutex
|
||||
var attempts int
|
||||
|
||||
s := newTriggerTestService(t, func() bool {
|
||||
mu.Lock()
|
||||
attempts++
|
||||
n := attempts
|
||||
mu.Unlock()
|
||||
if n == 1 {
|
||||
panic("boom in the update pass")
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
s.TriggerUpdate() // first pass panics; the worker must survive
|
||||
require.Eventually(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return attempts == 1
|
||||
}, time.Second, 5*time.Millisecond, "the panicking pass must have been attempted")
|
||||
|
||||
// The worker is still alive: a second kick runs a clean pass.
|
||||
s.TriggerUpdate()
|
||||
require.Eventually(t, func() bool {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return attempts >= 2
|
||||
}, time.Second, 5*time.Millisecond, "the worker must survive the panic and run later kicks")
|
||||
}
|
||||
|
||||
// TestRunUpdatePassCASGuard verifies that runUpdatePass reports whether it acquired
|
||||
// the overlap lock: a call while a pass is already in progress returns false without
|
||||
// running, which is what lets the poll timer drop overlapping ticks and the webhook
|
||||
// worker know it must retry rather than silently losing the kick.
|
||||
func TestRunUpdatePassCASGuard(t *testing.T) {
|
||||
s := &Service{baseCtx: context.Background()}
|
||||
|
||||
// Simulate an in-progress pass by holding the CAS as runUpdatePass would.
|
||||
require.True(t, s.updateRunning.CompareAndSwap(false, true))
|
||||
require.False(t, s.runUpdatePass(), "runUpdatePass must return false when a pass is already running")
|
||||
}
|
||||
|
||||
// TestRunUpdatePassReleasesLock pins the CAS-RELEASE contract of the real
|
||||
// runUpdatePass (not the stub): after a pass completes, the updateRunning lock
|
||||
// MUST be released, or every future pass — poll tick AND webhook kick — would be
|
||||
// permanently wedged (the poll drops the tick, the webhook worker spins its retry
|
||||
// loop forever) while the rest of the suite stays green. A test store with
|
||||
// auto-update enabled and no endpoints runs a trivial pass to completion.
|
||||
func TestRunUpdatePassReleasesLock(t *testing.T) {
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
settings, err := store.Settings().Settings()
|
||||
require.NoError(t, err)
|
||||
settings.ContainerAutomation.AutoUpdate.Enabled = true
|
||||
require.NoError(t, store.Settings().UpdateSettings(settings))
|
||||
|
||||
s := &Service{baseCtx: context.Background(), dataStore: store}
|
||||
|
||||
// First pass: acquires the lock and runs to completion (no endpoints → no work).
|
||||
require.True(t, s.runUpdatePass(), "the pass should acquire the lock and run")
|
||||
require.False(t, s.updateRunning.Load(), "the lock MUST be released after the pass")
|
||||
|
||||
// A second pass must be able to acquire the lock again — proves the release,
|
||||
// not just the flag read.
|
||||
require.True(t, s.runUpdatePass(), "a subsequent pass must re-acquire the released lock")
|
||||
require.False(t, s.updateRunning.Load())
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import (
|
||||
"github.com/portainer/portainer/pkg/libhttp/ssrf"
|
||||
"github.com/portainer/portainer/pkg/validate"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/pkg/errors"
|
||||
"github.com/rs/zerolog/log"
|
||||
"golang.org/x/oauth2"
|
||||
@@ -104,6 +105,14 @@ type autoUpdateSettingsPayload struct {
|
||||
Cleanup *bool `example:"false"`
|
||||
RollbackOnFailure *bool `example:"false"`
|
||||
RollbackTimeout *string `example:"120s"`
|
||||
// RegenerateWebhookToken, when true, (re)generates the inbound registry-push
|
||||
// webhook token server-side. The client never supplies the token value itself;
|
||||
// it only requests this action, so the secret is always a fresh server uuid.
|
||||
RegenerateWebhookToken *bool `example:"false"`
|
||||
// ClearWebhookToken, when true, removes the webhook token and thereby disables
|
||||
// the inbound trigger endpoint. RegenerateWebhookToken takes precedence if both
|
||||
// are set.
|
||||
ClearWebhookToken *bool `example:"false"`
|
||||
}
|
||||
|
||||
func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
|
||||
@@ -373,6 +382,21 @@ func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload sett
|
||||
current.Cleanup = *cmp.Or(autoUpdate.Cleanup, ¤t.Cleanup)
|
||||
current.RollbackOnFailure = *cmp.Or(autoUpdate.RollbackOnFailure, ¤t.RollbackOnFailure)
|
||||
current.RollbackTimeout = *cmp.Or(autoUpdate.RollbackTimeout, ¤t.RollbackTimeout)
|
||||
|
||||
// Webhook token actions. The token value is never accepted from the client:
|
||||
// regenerate mints a fresh server-side uuid (enabling/rotating the inbound
|
||||
// trigger endpoint), clear removes it (disabling the endpoint). Regenerate wins
|
||||
// if both are set.
|
||||
switch {
|
||||
case autoUpdate.RegenerateWebhookToken != nil && *autoUpdate.RegenerateWebhookToken:
|
||||
token, err := uuid.NewRandom()
|
||||
if err != nil {
|
||||
return nil, httperror.InternalServerError("Unable to generate a webhook token", err)
|
||||
}
|
||||
current.WebhookToken = token.String()
|
||||
case autoUpdate.ClearWebhookToken != nil && *autoUpdate.ClearWebhookToken:
|
||||
current.WebhookToken = ""
|
||||
}
|
||||
}
|
||||
|
||||
if payload.ContainerAutomation != nil && payload.ContainerAutomation.Notification != nil {
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
portainer "github.com/portainer/portainer/api"
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
"github.com/portainer/portainer/api/dataservices"
|
||||
"github.com/portainer/portainer/api/filesystem"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func boolptr(b bool) *bool { return &b }
|
||||
|
||||
// newWebhookTokenTestHandler builds a settings Handler backed by a fresh test
|
||||
// store and a real (temp-dir) file service, enough to exercise updateSettings.
|
||||
func newWebhookTokenTestHandler(t *testing.T) (*Handler, *datastore.Store) {
|
||||
t.Helper()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
fileService, err := filesystem.NewService(t.TempDir(), "")
|
||||
require.NoError(t, err)
|
||||
|
||||
return &Handler{DataStore: store, FileService: fileService}, store
|
||||
}
|
||||
|
||||
// applyAutoUpdate runs updateSettings inside a transaction with the given
|
||||
// auto-update payload and returns the resulting settings.
|
||||
func applyAutoUpdate(t *testing.T, handler *Handler, store *datastore.Store, autoUpdate *autoUpdateSettingsPayload) *portainer.Settings {
|
||||
t.Helper()
|
||||
|
||||
var settings *portainer.Settings
|
||||
require.NoError(t, store.UpdateTx(func(tx dataservices.DataStoreTx) error {
|
||||
var e error
|
||||
settings, e = handler.updateSettings(tx, settingsUpdatePayload{
|
||||
ContainerAutomation: &containerAutomationSettingsPayload{AutoUpdate: autoUpdate},
|
||||
})
|
||||
return e
|
||||
}))
|
||||
|
||||
return settings
|
||||
}
|
||||
|
||||
// TestUpdateSettingsWebhookTokenRegenerate verifies that RegenerateWebhookToken
|
||||
// mints a fresh server-side uuid, that regenerating again rotates it to a new
|
||||
// value, and that the client-supplied token value is never trusted (there is no
|
||||
// field to supply one).
|
||||
func TestUpdateSettingsWebhookTokenRegenerate(t *testing.T) {
|
||||
handler, store := newWebhookTokenTestHandler(t)
|
||||
|
||||
settings := applyAutoUpdate(t, handler, store, &autoUpdateSettingsPayload{
|
||||
RegenerateWebhookToken: boolptr(true),
|
||||
})
|
||||
first := settings.ContainerAutomation.AutoUpdate.WebhookToken
|
||||
require.NotEmpty(t, first, "regenerate must set a token")
|
||||
_, err := uuid.Parse(first)
|
||||
require.NoError(t, err, "the token must be a valid uuid")
|
||||
|
||||
// Persisted to the store.
|
||||
persisted, err := store.Settings().Settings()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, first, persisted.ContainerAutomation.AutoUpdate.WebhookToken)
|
||||
|
||||
// Regenerating again rotates the token.
|
||||
settings = applyAutoUpdate(t, handler, store, &autoUpdateSettingsPayload{
|
||||
RegenerateWebhookToken: boolptr(true),
|
||||
})
|
||||
second := settings.ContainerAutomation.AutoUpdate.WebhookToken
|
||||
require.NotEmpty(t, second)
|
||||
require.NotEqual(t, first, second, "regenerate must rotate the token")
|
||||
}
|
||||
|
||||
// TestUpdateSettingsWebhookTokenClear verifies that ClearWebhookToken removes the
|
||||
// token (disabling the inbound endpoint).
|
||||
func TestUpdateSettingsWebhookTokenClear(t *testing.T) {
|
||||
handler, store := newWebhookTokenTestHandler(t)
|
||||
|
||||
settings := applyAutoUpdate(t, handler, store, &autoUpdateSettingsPayload{
|
||||
RegenerateWebhookToken: boolptr(true),
|
||||
})
|
||||
require.NotEmpty(t, settings.ContainerAutomation.AutoUpdate.WebhookToken)
|
||||
|
||||
settings = applyAutoUpdate(t, handler, store, &autoUpdateSettingsPayload{
|
||||
ClearWebhookToken: boolptr(true),
|
||||
})
|
||||
require.Empty(t, settings.ContainerAutomation.AutoUpdate.WebhookToken, "clear must remove the token")
|
||||
}
|
||||
|
||||
// TestUpdateSettingsWebhookTokenUntouched verifies that an auto-update save that
|
||||
// does not request a token action leaves the existing token unchanged (so saving
|
||||
// other auto-update fields does not accidentally rotate or drop the endpoint).
|
||||
func TestUpdateSettingsWebhookTokenUntouched(t *testing.T) {
|
||||
handler, store := newWebhookTokenTestHandler(t)
|
||||
|
||||
settings := applyAutoUpdate(t, handler, store, &autoUpdateSettingsPayload{
|
||||
RegenerateWebhookToken: boolptr(true),
|
||||
})
|
||||
token := settings.ContainerAutomation.AutoUpdate.WebhookToken
|
||||
require.NotEmpty(t, token)
|
||||
|
||||
settings = applyAutoUpdate(t, handler, store, &autoUpdateSettingsPayload{
|
||||
Enabled: boolptr(true),
|
||||
})
|
||||
require.Equal(t, token, settings.ContainerAutomation.AutoUpdate.WebhookToken, "a non-action save must not touch the token")
|
||||
}
|
||||
|
||||
// TestUpdateSettingsWebhookTokenRegenerateWins verifies that when both actions are
|
||||
// set, regenerate takes precedence over clear.
|
||||
func TestUpdateSettingsWebhookTokenRegenerateWins(t *testing.T) {
|
||||
handler, store := newWebhookTokenTestHandler(t)
|
||||
|
||||
settings := applyAutoUpdate(t, handler, store, &autoUpdateSettingsPayload{
|
||||
RegenerateWebhookToken: boolptr(true),
|
||||
ClearWebhookToken: boolptr(true),
|
||||
})
|
||||
require.NotEmpty(t, settings.ContainerAutomation.AutoUpdate.WebhookToken, "regenerate must win over clear")
|
||||
}
|
||||
@@ -11,12 +11,25 @@ import (
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
// ContainerAutomationTrigger triggers an immediate container auto-update pass. It
|
||||
// is a minimal interface (mirroring the settings handler's
|
||||
// ContainerAutomationReloader) so the webhooks handler does not depend on the
|
||||
// concrete container-automation service. It is satisfied by
|
||||
// *containerautomation.Service.
|
||||
type ContainerAutomationTrigger interface {
|
||||
TriggerUpdate()
|
||||
}
|
||||
|
||||
// Handler is the HTTP handler used to handle webhook operations.
|
||||
type Handler struct {
|
||||
*mux.Router
|
||||
requestBouncer security.BouncerService
|
||||
DataStore dataservices.DataStore
|
||||
DockerClientFactory *dockerclient.ClientFactory
|
||||
// ContainerAutomationService receives the inbound registry-push kick that
|
||||
// triggers an immediate auto-update pass. It may be nil (the endpoint then
|
||||
// reports the feature as unavailable rather than panicking).
|
||||
ContainerAutomationService ContainerAutomationTrigger
|
||||
}
|
||||
|
||||
// NewHandler creates a handler to manage webhooks operations.
|
||||
@@ -35,6 +48,11 @@ func NewHandler(bouncer security.BouncerService) *Handler {
|
||||
bouncer.AuthenticatedAccess(httperror.LoggerHandler(h.webhookDelete))).Methods(http.MethodDelete)
|
||||
h.Handle("/webhooks/{token}",
|
||||
bouncer.PublicAccess(httperror.LoggerHandler(h.webhookExecute))).Methods(http.MethodPost)
|
||||
// Inbound registry-push trigger for native container auto-update. The extra path
|
||||
// segment ("container-automation") keeps it distinct from the resource webhook
|
||||
// above, which matches a single {token} segment.
|
||||
h.Handle("/webhooks/container-automation/{token}",
|
||||
bouncer.PublicAccess(httperror.LoggerHandler(h.webhookContainerAutomation))).Methods(http.MethodPost)
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
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")
|
||||
})
|
||||
}
|
||||
@@ -299,6 +299,7 @@ func (server *Server) Start(ctx context.Context) error {
|
||||
var webhookHandler = webhooks.NewHandler(requestBouncer)
|
||||
webhookHandler.DataStore = server.DataStore
|
||||
webhookHandler.DockerClientFactory = server.DockerClientFactory
|
||||
webhookHandler.ContainerAutomationService = server.ContainerAutomationService
|
||||
|
||||
server.Handler = &handler.Handler{
|
||||
RoleHandler: roleHandler,
|
||||
|
||||
@@ -1187,6 +1187,15 @@ type (
|
||||
// recreated back on the previous image. Standalone-only (M5).
|
||||
RollbackOnFailure bool `json:"RollbackOnFailure"`
|
||||
RollbackTimeout string `json:"RollbackTimeout" example:"120s"`
|
||||
// WebhookToken is the secret path segment of the inbound registry-push
|
||||
// webhook (POST /api/webhooks/container-automation/{token}). A push to the
|
||||
// registry can call that endpoint to trigger an immediate auto-update pass
|
||||
// instead of waiting for the next poll. It is SERVER-generated only (a uuid,
|
||||
// never accepted from the client); empty disables the endpoint. It is
|
||||
// returned in the admin GET /settings (the UI renders the URL from it) but is
|
||||
// deliberately excluded from GET /settings/public so it never leaks to
|
||||
// non-admins.
|
||||
WebhookToken string `json:"WebhookToken"`
|
||||
}
|
||||
|
||||
// ContainerAutomationNotificationSettings holds the webhook notification
|
||||
|
||||
@@ -14,6 +14,10 @@ export function dockerWebhookUrl(token: string) {
|
||||
return `${baseUrl}${API_ENDPOINT_WEBHOOKS}/${token}`;
|
||||
}
|
||||
|
||||
export function containerAutomationWebhookUrl(token: string) {
|
||||
return `${baseUrl}${API_ENDPOINT_WEBHOOKS}/container-automation/${token}`;
|
||||
}
|
||||
|
||||
export function baseStackWebhookUrl() {
|
||||
return `${baseUrl}${API_ENDPOINT_STACKS}/webhooks`;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { HttpResponse, http } from 'msw';
|
||||
|
||||
import { withTestRouter } from '@/react/test-utils/withRouter';
|
||||
import { withTestQueryProvider } from '@/react/test-utils/withTestQuery';
|
||||
import { withUserProvider } from '@/react/test-utils/withUserProvider';
|
||||
import { server } from '@/setup-tests/server';
|
||||
import { containerAutomationWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||
|
||||
import { AutoUpdatePanel } from './AutoUpdatePanel';
|
||||
|
||||
@@ -113,4 +115,126 @@ describe('AutoUpdatePanel', () => {
|
||||
// Falls back to the default rollback timeout when missing.
|
||||
expect(screen.getByLabelText(/Rollback timeout/i)).toHaveValue('120s');
|
||||
});
|
||||
|
||||
function seedSettings(token?: string, enabled = true) {
|
||||
server.use(
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({
|
||||
ContainerAutomation: {
|
||||
AutoHeal: { Enabled: false, CheckInterval: '30s', Scope: 'labeled' },
|
||||
AutoUpdate: {
|
||||
Enabled: enabled,
|
||||
PollInterval: '6h',
|
||||
Scope: 'labeled',
|
||||
Cleanup: false,
|
||||
RollbackOnFailure: false,
|
||||
RollbackTimeout: '120s',
|
||||
WebhookToken: token,
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
it('renders the trigger webhook URL and Regenerate/Clear when a token exists', async () => {
|
||||
const token = 'abc-123-token';
|
||||
seedSettings(token);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const urlField = await screen.findByLabelText(/Update trigger webhook/i);
|
||||
// The URL is built via the shared helper (sub-path / base-href aware), not a
|
||||
// bare origin — assert against the same helper.
|
||||
await waitFor(() =>
|
||||
expect(urlField).toHaveValue(containerAutomationWebhookUrl(token))
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: /Regenerate/i })
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Clear/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('warns that the trigger is inactive when a token exists but auto-update is disabled', async () => {
|
||||
seedSettings('abc-123-token', false);
|
||||
|
||||
renderComponent();
|
||||
|
||||
// The disabled-state note (a POST would 409) must be shown so the UI does not
|
||||
// contradict the server.
|
||||
expect(
|
||||
await screen.findByText(/trigger is inactive/i)
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does NOT show the inactive note when auto-update is enabled', async () => {
|
||||
seedSettings('abc-123-token', true);
|
||||
|
||||
renderComponent();
|
||||
|
||||
// Wait for the section to render, then confirm the note is absent.
|
||||
await screen.findByLabelText(/Update trigger webhook/i);
|
||||
expect(screen.queryByText(/trigger is inactive/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows Generate and no Clear when no token exists', async () => {
|
||||
seedSettings(undefined);
|
||||
|
||||
renderComponent();
|
||||
|
||||
expect(
|
||||
await screen.findByRole('button', { name: /Generate/i })
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole('button', { name: /Clear/i })
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
const urlField = screen.getByLabelText(/Update trigger webhook/i);
|
||||
expect(urlField).toHaveValue('');
|
||||
});
|
||||
|
||||
it('requests a server-side token regeneration without sending a token value', async () => {
|
||||
seedSettings(undefined);
|
||||
|
||||
let putBody: unknown;
|
||||
server.use(
|
||||
http.put('/api/settings', async ({ request }) => {
|
||||
putBody = await request.json();
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const generate = await screen.findByRole('button', { name: /Generate/i });
|
||||
await userEvent.click(generate);
|
||||
|
||||
await waitFor(() => expect(putBody).toBeDefined());
|
||||
expect(putBody).toEqual({
|
||||
ContainerAutomation: { AutoUpdate: { RegenerateWebhookToken: true } },
|
||||
});
|
||||
});
|
||||
|
||||
it('requests clearing the token', async () => {
|
||||
seedSettings('some-token');
|
||||
|
||||
let putBody: unknown;
|
||||
server.use(
|
||||
http.put('/api/settings', async ({ request }) => {
|
||||
putBody = await request.json();
|
||||
return HttpResponse.json({});
|
||||
})
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const clear = await screen.findByRole('button', { name: /Clear/i });
|
||||
await userEvent.click(clear);
|
||||
|
||||
await waitFor(() => expect(putBody).toBeDefined());
|
||||
expect(putBody).toEqual({
|
||||
ContainerAutomation: { AutoUpdate: { ClearWebhookToken: true } },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,9 +2,10 @@ import { RefreshCw } from 'lucide-react';
|
||||
import { Field, Form, Formik, useFormikContext } from 'formik';
|
||||
|
||||
import { notifySuccess } from '@/portainer/services/notifications';
|
||||
import { containerAutomationWebhookUrl } from '@/portainer/helpers/webhookHelper';
|
||||
|
||||
import { Widget } from '@@/Widget';
|
||||
import { LoadingButton } from '@@/buttons';
|
||||
import { LoadingButton, Button, CopyButton } from '@@/buttons';
|
||||
import { FormControl } from '@@/form-components/FormControl';
|
||||
import { Input, Select } from '@@/form-components/Input';
|
||||
import { SwitchField } from '@@/form-components/SwitchField';
|
||||
@@ -68,6 +69,13 @@ export function AutoUpdatePanel() {
|
||||
>
|
||||
<InnerForm isLoading={mutation.isLoading} />
|
||||
</Formik>
|
||||
|
||||
<hr />
|
||||
|
||||
<WebhookTriggerSection
|
||||
token={autoUpdate.WebhookToken}
|
||||
enabled={autoUpdate.Enabled}
|
||||
/>
|
||||
</Widget.Body>
|
||||
</Widget>
|
||||
);
|
||||
@@ -95,6 +103,121 @@ export function AutoUpdatePanel() {
|
||||
}
|
||||
}
|
||||
|
||||
// WebhookTriggerSection renders the inbound registry-push webhook controls: the
|
||||
// trigger URL (readonly, with a copy button) plus Generate/Regenerate and Clear
|
||||
// actions. The token itself is server-generated — the client only requests an
|
||||
// action — so this section never edits the URL, it just displays and rotates it.
|
||||
function WebhookTriggerSection({
|
||||
token,
|
||||
enabled,
|
||||
}: {
|
||||
token?: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
const mutation = useUpdateSettingsMutation();
|
||||
|
||||
// Build the URL via the shared helper so it honours the reverse-proxy sub-path
|
||||
// (`<base href>`) and the desktop file:// build, rather than a bare origin.
|
||||
const webhookUrl = token ? containerAutomationWebhookUrl(token) : '';
|
||||
|
||||
function regenerate() {
|
||||
mutation.mutate(
|
||||
{ ContainerAutomation: { AutoUpdate: { RegenerateWebhookToken: true } } },
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Update trigger webhook token generated');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function clear() {
|
||||
mutation.mutate(
|
||||
{ ContainerAutomation: { AutoUpdate: { ClearWebhookToken: true } } },
|
||||
{
|
||||
onSuccess() {
|
||||
notifySuccess('Success', 'Update trigger webhook disabled');
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="form-horizontal">
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="blue">
|
||||
Configure this URL as a push/package webhook on your registry (e.g.
|
||||
Gitea "Package" webhook or a GHCR{' '}
|
||||
<code>registry_package</code> webhook, or a{' '}
|
||||
<code>curl -X POST</code> step at the end of your publish workflow).
|
||||
Any POST to it immediately runs a full auto-update pass instead of
|
||||
waiting for the next poll. The token is secret — treat the URL as a
|
||||
credential.
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{token && !enabled && (
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12">
|
||||
<TextTip color="orange">
|
||||
The trigger is inactive while container auto-update is disabled —
|
||||
a POST to this URL returns HTTP 409 and runs no pass. Enable
|
||||
auto-update above to activate it. The token is kept and can still
|
||||
be rotated or cleared here.
|
||||
</TextTip>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormControl label="Update trigger webhook" inputId="autoupdate_webhook_url">
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
id="autoupdate_webhook_url"
|
||||
readOnly
|
||||
value={webhookUrl}
|
||||
placeholder="No webhook token generated"
|
||||
data-cy="settings-autoUpdateWebhookUrl"
|
||||
/>
|
||||
<CopyButton
|
||||
copyText={webhookUrl}
|
||||
data-cy="settings-autoUpdateWebhookCopy"
|
||||
>
|
||||
Copy
|
||||
</CopyButton>
|
||||
</div>
|
||||
</FormControl>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="col-sm-12 flex gap-2">
|
||||
<LoadingButton
|
||||
type="button"
|
||||
color="default"
|
||||
isLoading={mutation.isLoading}
|
||||
loadingText="Saving..."
|
||||
onClick={regenerate}
|
||||
data-cy="settings-autoUpdateWebhookRegenerate"
|
||||
>
|
||||
{token ? 'Regenerate' : 'Generate'}
|
||||
</LoadingButton>
|
||||
{token && (
|
||||
<Button
|
||||
type="button"
|
||||
color="dangerlight"
|
||||
disabled={mutation.isLoading}
|
||||
onClick={clear}
|
||||
data-cy="settings-autoUpdateWebhookClear"
|
||||
>
|
||||
Clear
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InnerForm({ isLoading }: { isLoading: boolean }) {
|
||||
const { values, setFieldValue, isValid, errors } = useFormikContext<Values>();
|
||||
|
||||
|
||||
@@ -36,8 +36,18 @@ export async function getSettings() {
|
||||
type OptionalSettings = Omit<Partial<Settings>, 'Edge' | 'ContainerAutomation'> & {
|
||||
Edge?: Partial<Settings['Edge']>;
|
||||
// ContainerAutomation blocks (AutoHeal / AutoUpdate) are saved independently
|
||||
// by their own settings panels, so each may be sent on its own.
|
||||
ContainerAutomation?: Partial<Settings['ContainerAutomation']>;
|
||||
// by their own settings panels, so each may be sent on its own. AutoUpdate also
|
||||
// accepts the write-only webhook-token actions (regenerate / clear); the token
|
||||
// value itself is never sent from the client, only the requested action.
|
||||
ContainerAutomation?: Omit<
|
||||
Partial<Settings['ContainerAutomation']>,
|
||||
'AutoUpdate'
|
||||
> & {
|
||||
AutoUpdate?: Partial<Settings['ContainerAutomation']['AutoUpdate']> & {
|
||||
RegenerateWebhookToken?: boolean;
|
||||
ClearWebhookToken?: boolean;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
export async function updateSettings(settings: OptionalSettings) {
|
||||
|
||||
@@ -158,6 +158,11 @@ export interface AutoUpdateSettings {
|
||||
// previous image when the new container does not become healthy in time.
|
||||
RollbackOnFailure: boolean;
|
||||
RollbackTimeout: string;
|
||||
// WebhookToken is the secret path segment of the inbound registry-push webhook
|
||||
// (POST /api/webhooks/container-automation/{token}) that triggers an immediate
|
||||
// update pass. Server-generated only; empty/undefined means the endpoint is
|
||||
// disabled. Returned only in the admin GET /settings, never in public settings.
|
||||
WebhookToken?: string;
|
||||
}
|
||||
|
||||
// NotificationSettings holds the per-mechanism webhooks for container-automation
|
||||
|
||||
Reference in New Issue
Block a user