feat(automation): configurable webhook notifier for automation events

Add an opt-in webhook notification for container-automation events (image
update, rollback, update-failed, auto-heal restart), plugging into the existing
Notifier seam in notify.go.

- Settings: new ContainerAutomation.Notification.WebhookURL (shared across
  update + heal), persisted and validated in the settings update handler
  (optional; http/https only; accepts the {{message}} placeholder).
- webhookNotifier reads the current URL from the datastore per event (UI changes
  take effect without a restart). If the URL contains {{message}} it substitutes
  the URL-encoded message and issues a GET; otherwise it POSTs the message as the
  body. Delivery, the env/stack name lookups, and any panic run in a goroutine
  under recover() with a 10s timeout — strictly best-effort, never blocks or
  crashes the automation daemon. multiNotifier fans events to logNotifier +
  webhook and isolates a panic in any one notifier.
- Message format (maintainer's spec):
    Environment | <env>
    Stack [<name>]            (Container [<name>] for non-stack events)
    Update [<name>]: <old> -> <new>
  Auto-heal: 'Auto-heal: restarted unhealthy container'.
- New NotificationPanel in settings to configure the URL.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
agent_coder
2026-07-01 19:31:18 +03:00
parent 6aecdfbe46
commit eb35e9c47f
21 changed files with 970 additions and 17 deletions
+1 -1
View File
@@ -183,7 +183,7 @@ func (s *Service) healEndpoint(endpoint *portainer.Endpoint, scope string) {
log.Info().Str("container_id", c.ID).Int("endpoint_id", endpointID).Int("attempt", newState.attempts).
Msg("auto-heal: restarted unhealthy container")
s.notifier.Notify(Event{
Kind: EventHealRestarted, EndpointID: endpointID, ContainerID: c.ID,
Kind: EventHealRestarted, EndpointID: endpointID, ContainerID: c.ID, ContainerName: containerName(c.Names),
Message: "restarted unhealthy container",
})
}
+4 -3
View File
@@ -316,7 +316,7 @@ func (s *Service) updateStandalone(cli *dockerclient.Client, endpoint *portainer
log.Warn().Err(err).Str("container_id", c.ID).Int("endpoint_id", endpointID).
Msg("auto-update: failed to recreate standalone container")
s.notifier.Notify(Event{
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: c.ID,
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: c.ID, ContainerName: c.Name,
Message: "failed to recreate standalone container", Err: err,
})
return
@@ -352,8 +352,9 @@ func (s *Service) updateStandalone(cli *dockerclient.Client, endpoint *portainer
// Emit "updated" now: either there was no gate (emitted right after recreate,
// as before), or the gate confirmed the new container is healthy.
s.notifier.Notify(Event{
Kind: EventUpdated, EndpointID: endpointID, ContainerID: newContainer.ID,
Image: newImage, Message: "updated standalone container",
Kind: EventUpdated, EndpointID: endpointID, ContainerID: newContainer.ID, ContainerName: c.Name,
Image: newImage, OldDigest: oldImageID, NewDigest: newContainer.Image,
Message: "updated standalone container",
})
if opts.cleanup && newContainer != nil && newContainer.Image != oldImageID {
+35 -3
View File
@@ -25,9 +25,18 @@ type Event struct {
Kind EventKind
EndpointID int
ContainerID string
StackID int
Image string
Message string
// ContainerName is the human-readable container name (no leading slash), used
// by the webhook message. It may be empty for events keyed only by ID.
ContainerName string
StackID int
Image string
// OldDigest and NewDigest carry the pre/post image identities for an update
// (image IDs, e.g. "sha256:59b9..."). They are threaded from the update call
// site where they are known and left empty otherwise; the webhook notifier
// short-forms them into the "old → new" part of the message.
OldDigest string
NewDigest string
Message string
// Err carries the underlying error for failure events; nil otherwise.
Err error
}
@@ -73,3 +82,26 @@ func (logNotifier) Notify(event Event) {
entry.Msg("container automation: " + message)
}
// multiNotifier fans an event out to several notifiers in order. It is how the
// service composes the always-on logNotifier with the optional webhookNotifier
// without either implementation having to know about the other. Each notifier is
// itself non-blocking, so multiNotifier stays safe on the daemon hot path.
type multiNotifier []Notifier
// Notify forwards the event to every wrapped notifier. Each call is isolated by
// a recover() so one misbehaving notifier can neither abort the others nor let a
// panic reach the daemon hot path; logNotifier is kept first and unchanged.
func (m multiNotifier) Notify(event Event) {
for _, n := range m {
func() {
defer func() {
if r := recover(); r != nil {
log.Warn().Interface("panic", r).Msg("container automation: recovered from panic in notifier")
}
}()
n.Notify(event)
}()
}
}
+26
View File
@@ -43,6 +43,32 @@ func TestRecordingNotifierCapturesEvents(t *testing.T) {
}
}
// panicNotifier always panics, standing in for a misbehaving notifier.
type panicNotifier struct{}
func (panicNotifier) Notify(Event) {
panic("boom")
}
// TestMultiNotifierIsolatesPanics verifies a panicking notifier neither aborts
// the sibling notifiers nor lets the panic reach the caller.
func TestMultiNotifierIsolatesPanics(t *testing.T) {
before := &recordingNotifier{}
after := &recordingNotifier{}
m := multiNotifier{before, panicNotifier{}, after}
// Must not panic even though a wrapped notifier does.
m.Notify(Event{Kind: EventUpdated, EndpointID: 1})
if len(before.events) != 1 {
t.Errorf("notifier before the panicking one got %d events, want 1", len(before.events))
}
if len(after.events) != 1 {
t.Errorf("notifier after the panicking one got %d events, want 1 (panic must not abort the loop)", len(after.events))
}
}
func TestAutomationEnabledForEndpoint(t *testing.T) {
tests := []struct {
name string
+3 -3
View File
@@ -355,7 +355,7 @@ func (s *Service) rollback(cli *dockerclient.Client, endpoint *portainer.Endpoin
log.Error().Err(err).Str("image_id", oldImageID).Str("image", originalRef).Int("endpoint_id", endpointID).
Msg("auto-update: rollback failed to re-tag the previous image, leaving the unhealthy container in place")
s.notifier.Notify(Event{
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: newContainerID,
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: newContainerID, ContainerName: containerName,
Image: originalRef, Message: "rollback failed: could not re-tag previous image", Err: err,
})
@@ -366,7 +366,7 @@ func (s *Service) rollback(cli *dockerclient.Client, endpoint *portainer.Endpoin
log.Error().Err(err).Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
Msg("auto-update: rollback recreate failed, leaving the unhealthy container in place")
s.notifier.Notify(Event{
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: newContainerID,
Kind: EventUpdateFailed, EndpointID: endpointID, ContainerID: newContainerID, ContainerName: containerName,
Image: originalRef, Message: "rollback failed: could not recreate on previous image", Err: err,
})
@@ -376,7 +376,7 @@ func (s *Service) rollback(cli *dockerclient.Client, endpoint *portainer.Endpoin
log.Warn().Str("container_id", newContainerID).Str("image", originalRef).Int("endpoint_id", endpointID).
Msg("auto-update: rolled back to the previous image after a failed update")
s.notifier.Notify(Event{
Kind: EventRollback, EndpointID: endpointID, ContainerID: newContainerID,
Kind: EventRollback, EndpointID: endpointID, ContainerID: newContainerID, ContainerName: containerName,
Image: originalRef, Message: "rolled back to previous image after failed health check",
})
+7 -3
View File
@@ -107,9 +107,13 @@ func NewService(
digestClient: images.NewClientWithRegistry(images.NewRegistryClient(dataStore), clientFactory),
containerService: containerService,
stackDeployer: stackDeployer,
notifier: logNotifier{},
retries: make(map[string]retryState),
rolledBack: make(map[string]rolledBackTarget),
// Compose the always-on log notifier with the optional webhook notifier.
// 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),
}
}
+237
View File
@@ -0,0 +1,237 @@
package containerautomation
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/dataservices"
"github.com/rs/zerolog/log"
)
const (
// webhookMessagePlaceholder is the token replaced in the configured webhook
// URL with the URL-encoded event message. When present, the notifier issues a
// GET on the substituted URL ("message in the address"); when absent, it POSTs
// the plain-text message as the request body.
webhookMessagePlaceholder = "{{message}}"
// webhookTimeout bounds each webhook HTTP call so a slow or unresponsive
// endpoint cannot pile up goroutines. The call already runs off the hot path.
webhookTimeout = 10 * time.Second
// shortDigestLen is how many leading hex characters of an image digest the
// message keeps (matches the maintainer's example, e.g. "59b94983c73a").
shortDigestLen = 12
)
// webhookNotifier delivers container-automation events to a user-configured HTTP
// endpoint. It reads the current webhook URL from the datastore on every event
// so a settings change takes effect without a restart, formats a human-readable
// message, and performs the HTTP call in a background goroutine so a slow or
// broken endpoint never delays or fails the daemon hot path.
type webhookNotifier struct {
dataStore dataservices.DataStore
client *http.Client
}
// newWebhookNotifier builds a webhookNotifier bound to the datastore. The HTTP
// client carries the per-call timeout so a request cannot hang indefinitely.
func newWebhookNotifier(dataStore dataservices.DataStore) webhookNotifier {
return webhookNotifier{
dataStore: dataStore,
client: &http.Client{Timeout: webhookTimeout},
}
}
// Notify reads the current webhook URL and, when set, dispatches the event in a
// background goroutine. Only the settings read and the empty-URL short-circuit
// run synchronously (they decide whether to spawn at all); message formatting —
// which itself reads Endpoint()/Stack() from the datastore — and the HTTP call
// both happen off the daemon hot path, under a single recover(). It never blocks
// the caller and never returns an error: the webhook is strictly best-effort.
func (n webhookNotifier) Notify(event Event) {
settings, err := n.dataStore.Settings().Settings()
if err != nil {
log.Warn().Err(err).Msg("container automation webhook: unable to read settings, skipping notification")
return
}
webhookURL := strings.TrimSpace(settings.ContainerAutomation.Notification.WebhookURL)
if webhookURL == "" {
return
}
// Best-effort delivery: never block or fail the caller (the update/heal hot
// path). Everything below — the env/stack datastore reads in formatMessage and
// the bounded HTTP call — runs in its own goroutine, and any panic there is
// recovered so it can never crash the daemon.
go func() {
defer func() {
if r := recover(); r != nil {
log.Warn().Interface("panic", r).Msg("container automation webhook: recovered from panic during delivery")
}
}()
message := n.formatMessage(settings, event)
n.deliver(webhookURL, message)
}()
}
// deliver performs the HTTP call for a single event. It is always invoked from
// the Notify goroutine (which recovers any panic), so a broken endpoint can
// never block or crash the daemon.
func (n webhookNotifier) deliver(webhookURL, message string) {
ctx, cancel := context.WithTimeout(context.Background(), webhookTimeout)
defer cancel()
var (
req *http.Request
err error
)
if strings.Contains(webhookURL, webhookMessagePlaceholder) {
// Substitution mode: replace the placeholder with the URL-encoded message
// and GET the resulting address (the maintainer's "message in the URL").
target := strings.ReplaceAll(webhookURL, webhookMessagePlaceholder, url.QueryEscape(message))
req, err = http.NewRequestWithContext(ctx, http.MethodGet, target, nil)
} else {
// No placeholder: POST the plain-text message as the body, useful for
// generic POST-style webhooks.
req, err = http.NewRequestWithContext(ctx, http.MethodPost, webhookURL, strings.NewReader(message))
if err == nil {
req.Header.Set("Content-Type", "text/plain; charset=utf-8")
}
}
if err != nil {
log.Warn().Err(err).Msg("container automation webhook: unable to build request")
return
}
resp, err := n.client.Do(req)
if err != nil {
log.Warn().Err(err).Msg("container automation webhook: delivery failed")
return
}
defer resp.Body.Close()
if resp.StatusCode >= http.StatusBadRequest {
log.Warn().Int("status", resp.StatusCode).Msg("container automation webhook: endpoint returned an error status")
}
}
// formatMessage builds the human-readable message for an event. It resolves the
// environment name from the endpoint and the stack name from the stack via the
// datastore, mirroring the maintainer's example:
//
// Environment | nebula.lc
// Stack [cache-demo]
// Update [esphome]: 59b94983c73a → 2231ca5d676d
//
// The context line is the stack for stack-scoped events, otherwise the container;
// the action line is adapted per event kind (update / rollback / update-failed /
// auto-heal restart). Auto-heal renders as:
//
// Environment | nebula.lc
// Container [nginx]
// Auto-heal: restarted unhealthy container
func (n webhookNotifier) formatMessage(settings *portainer.Settings, event Event) string {
lines := []string{"Environment | " + n.environmentName(event.EndpointID)}
// Context line: the stack for stack-scoped events, otherwise the container.
switch {
case event.StackID != 0:
lines = append(lines, fmt.Sprintf("Stack [%s]", n.stackName(event.StackID)))
case event.ContainerName != "":
lines = append(lines, fmt.Sprintf("Container [%s]", event.ContainerName))
}
// Subject for the action line: the container name when known, else the stack
// name, else a short container id.
subject := event.ContainerName
if subject == "" && event.StackID != 0 {
subject = n.stackName(event.StackID)
}
if subject == "" {
subject = shortDigest(event.ContainerID)
}
switch event.Kind {
case EventUpdated:
if event.OldDigest != "" && event.NewDigest != "" {
lines = append(lines, fmt.Sprintf("Update [%s]: %s → %s", subject, shortDigest(event.OldDigest), shortDigest(event.NewDigest)))
} else {
lines = append(lines, fmt.Sprintf("Update [%s]: image updated", subject))
}
case EventRollback:
lines = append(lines, fmt.Sprintf("Rollback [%s]: rolled back to previous image after failed health check", subject))
case EventUpdateFailed:
line := fmt.Sprintf("Update failed [%s]", subject)
if event.Message != "" {
line += ": " + event.Message
}
if event.Err != nil {
line += fmt.Sprintf(" (%s)", event.Err)
}
lines = append(lines, line)
case EventHealRestarted:
lines = append(lines, "Auto-heal: restarted unhealthy container")
default:
if event.Message != "" {
lines = append(lines, event.Message)
}
}
return strings.Join(lines, "\n")
}
// environmentName resolves an endpoint id to its display name, degrading to a
// "#<id>" placeholder when the endpoint cannot be read (deleted, or a zero id).
func (n webhookNotifier) environmentName(endpointID int) string {
if endpointID == 0 {
return "unknown"
}
endpoint, err := n.dataStore.Endpoint().Endpoint(portainer.EndpointID(endpointID))
if err != nil || endpoint == nil {
return fmt.Sprintf("#%d", endpointID)
}
return endpoint.Name
}
// stackName resolves a stack id to its name, degrading to a "#<id>" placeholder
// when the stack cannot be read.
func (n webhookNotifier) stackName(stackID int) string {
stack, err := n.dataStore.Stack().Read(portainer.StackID(stackID))
if err != nil || stack == nil {
return fmt.Sprintf("#%d", stackID)
}
return stack.Name
}
// shortDigest trims an image id/digest to a short, human-friendly hex form
// (shortDigestLen chars), matching the maintainer's example. It drops a leading
// "sha256:" algorithm prefix so "sha256:59b94983c73a..." -> "59b94983c73a".
func shortDigest(s string) string {
if s == "" {
return ""
}
if i := strings.LastIndex(s, "sha256:"); i >= 0 {
s = s[i+len("sha256:"):]
}
if len(s) > shortDigestLen {
return s[:shortDigestLen]
}
return s
}
+256
View File
@@ -0,0 +1,256 @@
package containerautomation
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
portainer "github.com/portainer/portainer/api"
"github.com/portainer/portainer/api/datastore"
)
// newTestWebhookNotifier builds an initialized test datastore, sets the webhook
// URL, and returns a webhookNotifier bound to it.
func newTestWebhookNotifier(t *testing.T, webhookURL string) (webhookNotifier, *datastore.Store) {
t.Helper()
_, store := datastore.MustNewTestStore(t, true, false)
settings, err := store.Settings().Settings()
if err != nil {
t.Fatalf("read settings: %v", err)
}
settings.ContainerAutomation.Notification.WebhookURL = webhookURL
if err := store.Settings().UpdateSettings(settings); err != nil {
t.Fatalf("update settings: %v", err)
}
return newWebhookNotifier(store), store
}
func createEndpoint(t *testing.T, store *datastore.Store, id int, name string) {
t.Helper()
if err := store.Endpoint().Create(&portainer.Endpoint{ID: portainer.EndpointID(id), Name: name}); err != nil {
t.Fatalf("create endpoint: %v", err)
}
}
func createStack(t *testing.T, store *datastore.Store, id int, name string) {
t.Helper()
if err := store.Stack().Create(&portainer.Stack{ID: portainer.StackID(id), Name: name}); err != nil {
t.Fatalf("create stack: %v", err)
}
}
// TestWebhookNotifierGETPlaceholder verifies the placeholder is replaced with the
// URL-encoded message and the URL is fetched with GET.
func TestWebhookNotifierGETPlaceholder(t *testing.T) {
reqs := make(chan *http.Request, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
reqs <- r
}))
defer srv.Close()
n, store := newTestWebhookNotifier(t, srv.URL+"/hook?msg="+webhookMessagePlaceholder)
createEndpoint(t, store, 1, "prod")
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
select {
case r := <-reqs:
if r.Method != http.MethodGet {
t.Errorf("method = %s, want GET", r.Method)
}
got := r.URL.Query().Get("msg")
want := "Environment | prod\nContainer [nginx]\nAuto-heal: restarted unhealthy container"
if got != want {
t.Errorf("decoded msg = %q, want %q", got, want)
}
// The raw query must be URL-encoded: no literal spaces/newlines on the wire.
if strings.ContainsAny(r.URL.RawQuery, " \n") {
t.Errorf("raw query is not URL-encoded: %q", r.URL.RawQuery)
}
case <-time.After(2 * time.Second):
t.Fatal("webhook GET was not received")
}
}
// TestWebhookNotifierPOSTFallback verifies that a URL without the placeholder is
// POSTed with the plain-text message as the body.
func TestWebhookNotifierPOSTFallback(t *testing.T) {
type captured struct {
method string
body string
}
ch := make(chan captured, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
b, _ := io.ReadAll(r.Body)
ch <- captured{method: r.Method, body: string(b)}
}))
defer srv.Close()
n, store := newTestWebhookNotifier(t, srv.URL+"/hook")
createEndpoint(t, store, 2, "staging")
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 2, ContainerName: "api"})
select {
case c := <-ch:
if c.method != http.MethodPost {
t.Errorf("method = %s, want POST", c.method)
}
want := "Environment | staging\nContainer [api]\nAuto-heal: restarted unhealthy container"
if c.body != want {
t.Errorf("body = %q, want %q", c.body, want)
}
case <-time.After(2 * time.Second):
t.Fatal("webhook POST was not received")
}
}
// TestWebhookNotifierEmptyURLNoCall verifies no HTTP call is made when the URL is
// empty.
func TestWebhookNotifierEmptyURLNoCall(t *testing.T) {
called := make(chan struct{}, 1)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
called <- struct{}{}
}))
defer srv.Close()
n, _ := newTestWebhookNotifier(t, "")
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "x"})
select {
case <-called:
t.Fatal("webhook should not be called when the URL is empty")
case <-time.After(300 * time.Millisecond):
// No call, as expected.
}
}
// TestWebhookNotifierFailingEndpointDoesNotBlock verifies that a broken endpoint
// neither blocks the caller nor panics.
func TestWebhookNotifierFailingEndpointDoesNotBlock(t *testing.T) {
// Start then immediately close a server so its address refuses connections.
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
deadURL := srv.URL
srv.Close()
n, store := newTestWebhookNotifier(t, deadURL+"/hook?msg="+webhookMessagePlaceholder)
createEndpoint(t, store, 1, "prod")
done := make(chan struct{})
go func() {
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerName: "c"})
close(done)
}()
select {
case <-done:
// Notify returned promptly despite the failing endpoint.
case <-time.After(1 * time.Second):
t.Fatal("Notify blocked on a failing endpoint")
}
// Give the background delivery goroutine time to hit the error path; it must
// log-and-return, never panic.
time.Sleep(200 * time.Millisecond)
}
// TestFormatMessageStandaloneUpdate covers the maintainer's update format for a
// standalone container, with the old->new short digests.
func TestFormatMessageStandaloneUpdate(t *testing.T) {
n, store := newTestWebhookNotifier(t, "unused")
createEndpoint(t, store, 1, "nebula.lc")
settings, _ := store.Settings().Settings()
msg := n.formatMessage(settings, Event{
Kind: EventUpdated, EndpointID: 1, ContainerName: "esphome",
OldDigest: "sha256:59b94983c73aabcd", NewDigest: "sha256:2231ca5d676dabcd",
})
want := "Environment | nebula.lc\nContainer [esphome]\nUpdate [esphome]: 59b94983c73a → 2231ca5d676d"
if msg != want {
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
}
}
// TestFormatMessageStackUpdate covers a stack-scoped update (no per-container
// digests): the context line is the stack name.
func TestFormatMessageStackUpdate(t *testing.T) {
n, store := newTestWebhookNotifier(t, "unused")
createEndpoint(t, store, 1, "nebula.lc")
createStack(t, store, 7, "cache-demo")
settings, _ := store.Settings().Settings()
msg := n.formatMessage(settings, Event{
Kind: EventUpdated, EndpointID: 1, StackID: 7,
})
want := "Environment | nebula.lc\nStack [cache-demo]\nUpdate [cache-demo]: image updated"
if msg != want {
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
}
}
// TestFormatMessageAutoHeal covers the auto-heal message design.
func TestFormatMessageAutoHeal(t *testing.T) {
n, store := newTestWebhookNotifier(t, "unused")
createEndpoint(t, store, 3, "prod")
settings, _ := store.Settings().Settings()
msg := n.formatMessage(settings, Event{
Kind: EventHealRestarted, EndpointID: 3, ContainerName: "nginx",
})
want := "Environment | prod\nContainer [nginx]\nAuto-heal: restarted unhealthy container"
if msg != want {
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
}
}
// TestFormatMessageUnknownEndpoint verifies the "#<id>" fallback when the
// endpoint cannot be resolved.
func TestFormatMessageUnknownEndpoint(t *testing.T) {
n, store := newTestWebhookNotifier(t, "unused")
settings, _ := store.Settings().Settings()
msg := n.formatMessage(settings, Event{
Kind: EventHealRestarted, EndpointID: 99, ContainerName: "ghost",
})
want := "Environment | #99\nContainer [ghost]\nAuto-heal: restarted unhealthy container"
if msg != want {
t.Errorf("got:\n%q\nwant:\n%q", msg, want)
}
}
// TestShortDigest covers digest short-forming.
func TestShortDigest(t *testing.T) {
cases := map[string]string{
"sha256:59b94983c73a1122334455": "59b94983c73a",
"59b94983c73a1122334455": "59b94983c73a",
"short": "short",
"": "",
}
for in, want := range cases {
if got := shortDigest(in); got != want {
t.Errorf("shortDigest(%q) = %q, want %q", in, got, want)
}
}
}
+3
View File
@@ -73,6 +73,9 @@ func (store *Store) checkOrCreateDefaultSettings() error {
defaultSettings.ContainerAutomation.AutoUpdate.RollbackOnFailure = false
defaultSettings.ContainerAutomation.AutoUpdate.RollbackTimeout = "120s"
// The shared automation notification webhook is opt-in: empty by default.
defaultSettings.ContainerAutomation.Notification.WebhookURL = ""
return store.SettingsService.UpdateSettings(defaultSettings)
}
if err != nil {
@@ -598,6 +598,9 @@
"RollbackOnFailure": false,
"RollbackTimeout": "120s",
"Scope": "labeled"
},
"Notification": {
"WebhookURL": ""
}
},
"Edge": {
+27 -2
View File
@@ -3,6 +3,7 @@ package settings
import (
"cmp"
"net/http"
"net/url"
"strings"
"time"
@@ -80,8 +81,13 @@ type settingsUpdatePayload struct {
}
type containerAutomationSettingsPayload struct {
AutoHeal *autoHealSettingsPayload
AutoUpdate *autoUpdateSettingsPayload
AutoHeal *autoHealSettingsPayload
AutoUpdate *autoUpdateSettingsPayload
Notification *notificationSettingsPayload
}
type notificationSettingsPayload struct {
WebhookURL *string `example:"https://example.com/notify?msg={{message}}"`
}
type autoHealSettingsPayload struct {
@@ -178,6 +184,19 @@ func (payload *settingsUpdatePayload) Validate(r *http.Request) error {
}
}
if payload.ContainerAutomation != nil && payload.ContainerAutomation.Notification != nil {
notification := payload.ContainerAutomation.Notification
// Optional field: only validate when a non-empty URL is provided. The URL may
// carry the "{{message}}" placeholder, so we accept any http(s) URL with a
// host rather than a strict format check.
if notification.WebhookURL != nil && *notification.WebhookURL != "" {
u, err := url.Parse(*notification.WebhookURL)
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
return errors.New("Invalid notification webhook URL. Must be a valid http(s) URL")
}
}
}
return nil
}
@@ -337,6 +356,12 @@ func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload sett
current.RollbackTimeout = *cmp.Or(autoUpdate.RollbackTimeout, &current.RollbackTimeout)
}
if payload.ContainerAutomation != nil && payload.ContainerAutomation.Notification != nil {
notification := payload.ContainerAutomation.Notification
current := &settings.ContainerAutomation.Notification
current.WebhookURL = *cmp.Or(notification.WebhookURL, &current.WebhookURL)
}
if err := tx.Settings().UpdateSettings(settings); err != nil {
return nil, httperror.InternalServerError("Unable to persist settings changes inside the database", err)
}
@@ -126,3 +126,44 @@ func TestSettingsUpdatePayloadValidateRollbackTimeout(t *testing.T) {
})
}
}
// TestSettingsUpdatePayloadValidateNotificationWebhookURL covers the shared
// container-automation notification webhook URL: it is optional (empty is valid),
// must be a valid http(s) URL with a host when set, and accepts the
// "{{message}}" placeholder.
func TestSettingsUpdatePayloadValidateNotificationWebhookURL(t *testing.T) {
cases := []struct {
name string
url string
wantErr bool
}{
{name: "empty is allowed (disabled)", url: "", wantErr: false},
{name: "plain https URL is allowed", url: "https://example.com/notify", wantErr: false},
{name: "http URL is allowed", url: "http://example.com/notify", wantErr: false},
{name: "URL with placeholder is allowed", url: "https://example.com/notify?msg={{message}}", wantErr: false},
{name: "missing scheme is rejected", url: "example.com/notify", wantErr: true},
{name: "non-http scheme is rejected", url: "ftp://example.com/notify", wantErr: true},
{name: "missing host is rejected", url: "https://", wantErr: true},
{name: "garbage is rejected", url: "not a url", wantErr: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
payload := settingsUpdatePayload{
ContainerAutomation: &containerAutomationSettingsPayload{
Notification: &notificationSettingsPayload{
WebhookURL: strptr(tc.url),
},
},
}
err := payload.Validate(httptest.NewRequest("PUT", "/settings", nil))
if tc.wantErr && err == nil {
t.Errorf("Validate(%q) = nil, want error", tc.url)
}
if !tc.wantErr && err != nil {
t.Errorf("Validate(%q) = %v, want nil", tc.url, err)
}
})
}
}
+15 -2
View File
@@ -1176,11 +1176,24 @@ type (
RollbackTimeout string `json:"RollbackTimeout" example:"120s"`
}
// ContainerAutomationNotificationSettings holds the shared webhook
// notification config for container-automation events (image update,
// rollback, update-failed and auto-heal restart). A single webhook is used
// across every automation event, as requested by the maintainer.
ContainerAutomationNotificationSettings struct {
// WebhookURL is the HTTP(S) endpoint called on each automation event.
// When it contains the "{{message}}" placeholder, the URL-encoded event
// message is substituted in and the URL is fetched with GET; otherwise the
// plain-text message is POSTed as the request body. Empty disables it.
WebhookURL string `json:"WebhookURL"`
}
// ContainerAutomationSettings holds native container automation settings
// (auto-heal and auto-update).
ContainerAutomationSettings struct {
AutoHeal ContainerAutoHealSettings `json:"AutoHeal"`
AutoUpdate ContainerAutoUpdateSettings `json:"AutoUpdate"`
AutoHeal ContainerAutoHealSettings `json:"AutoHeal"`
AutoUpdate ContainerAutoUpdateSettings `json:"AutoUpdate"`
Notification ContainerAutomationNotificationSettings `json:"Notification"`
}
// Settings represents the application settings
@@ -0,0 +1,110 @@
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 { NotificationPanel } from './NotificationPanel';
function renderComponent() {
const Wrapped = withTestQueryProvider(
withUserProvider(withTestRouter(NotificationPanel))
);
return render(<Wrapped />);
}
describe('NotificationPanel', () => {
it('renders the webhook URL read from the API', async () => {
server.use(
http.get('/api/settings', () =>
HttpResponse.json({
ContainerAutomation: {
Notification: {
WebhookURL: 'https://example.com/notify?msg={{message}}',
},
},
})
)
);
renderComponent();
expect(
await screen.findByText('Container automation notifications')
).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByLabelText(/Webhook URL/i)).toHaveValue(
'https://example.com/notify?msg={{message}}'
);
});
});
it('disables the save button on an invalid URL', async () => {
const user = userEvent.setup();
server.use(
http.get('/api/settings', () =>
HttpResponse.json({
ContainerAutomation: { Notification: { WebhookURL: '' } },
})
)
);
renderComponent();
const input = await screen.findByLabelText(/Webhook URL/i);
await user.type(input, 'not-a-url');
await waitFor(() => {
expect(
screen.getByText('Must be a valid http(s) URL')
).toBeInTheDocument();
});
expect(
screen.getByRole('button', { name: /Save notification settings/i })
).toBeDisabled();
});
it('includes the webhook URL in the saved payload', async () => {
const user = userEvent.setup();
let savedPayload: unknown;
server.use(
http.get('/api/settings', () =>
HttpResponse.json({
ContainerAutomation: { Notification: { WebhookURL: '' } },
})
),
http.put('/api/settings', async ({ request }) => {
savedPayload = await request.json();
return HttpResponse.json({});
})
);
renderComponent();
const input = await screen.findByLabelText(/Webhook URL/i);
await user.type(input, 'https://hook.example/notify');
const save = screen.getByRole('button', {
name: /Save notification settings/i,
});
await waitFor(() => expect(save).toBeEnabled());
await user.click(save);
await waitFor(() => {
expect(savedPayload).toEqual({
ContainerAutomation: {
Notification: {
WebhookURL: 'https://hook.example/notify',
},
},
});
});
});
});
@@ -0,0 +1,116 @@
import { Webhook } from 'lucide-react';
import { Field, Form, Formik, useFormikContext } from 'formik';
import { notifySuccess } from '@/portainer/services/notifications';
import { Widget } from '@@/Widget';
import { LoadingButton } from '@@/buttons';
import { FormControl } from '@@/form-components/FormControl';
import { Input } from '@@/form-components/Input';
import { TextTip } from '@@/Tip/TextTip';
import { useSettings, useUpdateSettingsMutation } from '../../queries';
import { Values } from './types';
import { validation } from './validation';
export function NotificationPanel() {
const settingsQuery = useSettings((settings) => settings.ContainerAutomation);
const mutation = useUpdateSettingsMutation();
if (!settingsQuery.data) {
return null;
}
const notification = settingsQuery.data.Notification;
const initialValues: Values = {
webhookUrl: notification?.WebhookURL || '',
};
return (
<Widget>
<Widget.Title
icon={Webhook}
title="Container automation notifications"
/>
<Widget.Body>
<div className="mb-3">
<TextTip color="blue">
When set, Portainer calls this HTTP URL for every container-automation
event (image update, rollback, failed update and auto-heal restart) so
you can forward them to chat or a custom endpoint. Include the{' '}
<code>{'{{message}}'}</code> placeholder to have the URL-encoded event
message substituted into the address (the URL is then fetched with
GET); when the placeholder is absent, the plain-text message is POSTed
as the request body instead. The message looks like{' '}
<code>Environment | prod / Container [nginx] / Auto-heal: ...</code>.
Leave empty to disable. Delivery is best-effort and never blocks or
delays an update or heal.
</TextTip>
</div>
<Formik
initialValues={initialValues}
onSubmit={handleSubmit}
validationSchema={validation}
validateOnMount
enableReinitialize
>
<InnerForm isLoading={mutation.isLoading} />
</Formik>
</Widget.Body>
</Widget>
);
function handleSubmit(values: Values) {
mutation.mutate(
{
ContainerAutomation: {
Notification: {
WebhookURL: values.webhookUrl,
},
},
},
{
onSuccess() {
notifySuccess('Success', 'Notification settings updated');
},
}
);
}
}
function InnerForm({ isLoading }: { isLoading: boolean }) {
const { isValid, errors } = useFormikContext<Values>();
return (
<Form className="form-horizontal">
<FormControl
label="Webhook URL"
inputId="notification_webhook_url"
errors={errors.webhookUrl}
>
<Field
as={Input}
id="notification_webhook_url"
placeholder="e.g. https://example.com/notify?msg={{message}}"
name="webhookUrl"
data-cy="settings-notificationWebhookUrl"
/>
</FormControl>
<div className="form-group">
<div className="col-sm-12">
<LoadingButton
isLoading={isLoading}
disabled={!isValid}
data-cy="settings-saveNotificationButton"
loadingText="Saving..."
>
Save notification settings
</LoadingButton>
</div>
</div>
</Form>
);
}
@@ -0,0 +1 @@
export { NotificationPanel } from './NotificationPanel';
@@ -0,0 +1,3 @@
export interface Values {
webhookUrl: string;
}
@@ -0,0 +1,41 @@
import { validation } from './validation';
import { Values } from './types';
function validate(values: Values) {
return validation()
.validate(values, { abortEarly: false })
.then(() => undefined)
.catch((err: { errors: string[] }) => err.errors);
}
describe('NotificationPanel validation', () => {
it('accepts an empty webhook URL (disabled)', async () => {
expect(await validate({ webhookUrl: '' })).toBeUndefined();
});
it('accepts a valid http(s) URL, including the placeholder', async () => {
expect(
await validate({ webhookUrl: 'https://example.com/notify' })
).toBeUndefined();
expect(
await validate({ webhookUrl: 'http://example.com/notify' })
).toBeUndefined();
expect(
await validate({
webhookUrl: 'https://example.com/notify?msg={{message}}',
})
).toBeUndefined();
});
it('rejects a non-http(s) or malformed URL', async () => {
expect(await validate({ webhookUrl: 'example.com/notify' })).toContain(
'Must be a valid http(s) URL'
);
expect(await validate({ webhookUrl: 'ftp://example.com' })).toContain(
'Must be a valid http(s) URL'
);
expect(await validate({ webhookUrl: 'not a url' })).toContain(
'Must be a valid http(s) URL'
);
});
});
@@ -0,0 +1,30 @@
import { SchemaOf, object, string } from 'yup';
import { Values } from './types';
// isHttpUrl reports whether a value parses as an http(s) URL. The webhook URL
// may carry the "{{message}}" placeholder, so we only check the scheme rather
// than a strict format, mirroring the backend validation.
function isHttpUrl(value: string): boolean {
try {
const url = new URL(value);
return url.protocol === 'http:' || url.protocol === 'https:';
} catch {
return false;
}
}
export function validation(): SchemaOf<Values> {
return object({
// Optional field: an empty URL disables the webhook.
webhookUrl: string()
.default('')
.test('valid-webhook-url', 'Must be a valid http(s) URL', (value) => {
if (!value) {
return true;
}
return isHttpUrl(value);
}),
});
}
@@ -16,6 +16,7 @@ import { BackupSettingsPanel } from './BackupSettingsView';
import { HelmCertPanel } from './HelmCertPanel';
import { HiddenContainersPanel } from './HiddenContainersPanel/HiddenContainersPanel';
import { KubeSettingsPanel } from './KubeSettingsPanel';
import { NotificationPanel } from './NotificationPanel';
import { SSLSettingsPanelWrapper } from './SSLSettingsPanel/SSLSettingsPanel';
import { ExperimentalFeatures } from './ExperimentalFeatures';
@@ -60,6 +61,8 @@ export function SettingsView() {
<AutoUpdatePanel />
<NotificationPanel />
<BackupSettingsPanel />
</div>
</>
+8
View File
@@ -160,9 +160,17 @@ export interface AutoUpdateSettings {
RollbackTimeout: string;
}
// NotificationSettings holds the shared webhook called on every
// container-automation event (image update, rollback, failed update, auto-heal
// restart). An empty WebhookURL disables it.
export interface NotificationSettings {
WebhookURL: string;
}
export interface ContainerAutomationSettings {
AutoHeal: AutoHealSettings;
AutoUpdate: AutoUpdateSettings;
Notification: NotificationSettings;
}
export interface GlobalDeploymentOptions {