diff --git a/api/containerautomation/autoheal.go b/api/containerautomation/autoheal.go index 42537cf51..dd4d7ad6c 100644 --- a/api/containerautomation/autoheal.go +++ b/api/containerautomation/autoheal.go @@ -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", }) } diff --git a/api/containerautomation/autoupdate.go b/api/containerautomation/autoupdate.go index 4f3a6d2f0..782a67ea7 100644 --- a/api/containerautomation/autoupdate.go +++ b/api/containerautomation/autoupdate.go @@ -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 { diff --git a/api/containerautomation/notify.go b/api/containerautomation/notify.go index 60fff5f70..231fde7f8 100644 --- a/api/containerautomation/notify.go +++ b/api/containerautomation/notify.go @@ -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) + }() + } +} diff --git a/api/containerautomation/notify_test.go b/api/containerautomation/notify_test.go index ada8b0cdd..092b75c71 100644 --- a/api/containerautomation/notify_test.go +++ b/api/containerautomation/notify_test.go @@ -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 diff --git a/api/containerautomation/rollback.go b/api/containerautomation/rollback.go index 0c6c874f8..a1a626eaa 100644 --- a/api/containerautomation/rollback.go +++ b/api/containerautomation/rollback.go @@ -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", }) diff --git a/api/containerautomation/service.go b/api/containerautomation/service.go index 4139256f7..798445b56 100644 --- a/api/containerautomation/service.go +++ b/api/containerautomation/service.go @@ -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), } } diff --git a/api/containerautomation/webhook.go b/api/containerautomation/webhook.go new file mode 100644 index 000000000..a8599c38c --- /dev/null +++ b/api/containerautomation/webhook.go @@ -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 +// "#" 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 "#" 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 +} diff --git a/api/containerautomation/webhook_test.go b/api/containerautomation/webhook_test.go new file mode 100644 index 000000000..4610da2dc --- /dev/null +++ b/api/containerautomation/webhook_test.go @@ -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 "#" 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) + } + } +} diff --git a/api/datastore/init.go b/api/datastore/init.go index 6337fea1a..bb09dc5e4 100644 --- a/api/datastore/init.go +++ b/api/datastore/init.go @@ -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 { diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index 8d6bc033a..af4df8604 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -598,6 +598,9 @@ "RollbackOnFailure": false, "RollbackTimeout": "120s", "Scope": "labeled" + }, + "Notification": { + "WebhookURL": "" } }, "Edge": { diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 2ef722302..1fedde189 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -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, ¤t.RollbackTimeout) } + if payload.ContainerAutomation != nil && payload.ContainerAutomation.Notification != nil { + notification := payload.ContainerAutomation.Notification + current := &settings.ContainerAutomation.Notification + current.WebhookURL = *cmp.Or(notification.WebhookURL, ¤t.WebhookURL) + } + if err := tx.Settings().UpdateSettings(settings); err != nil { return nil, httperror.InternalServerError("Unable to persist settings changes inside the database", err) } diff --git a/api/http/handler/settings/settings_update_test.go b/api/http/handler/settings/settings_update_test.go index e0aa8d8e6..ed7aa19bd 100644 --- a/api/http/handler/settings/settings_update_test.go +++ b/api/http/handler/settings/settings_update_test.go @@ -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: ¬ificationSettingsPayload{ + 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) + } + }) + } +} diff --git a/api/portainer.go b/api/portainer.go index 8d1a6d0ab..1f32fff6c 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -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 diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.test.tsx b/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.test.tsx new file mode 100644 index 000000000..134815b77 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.test.tsx @@ -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(); +} + +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', + }, + }, + }); + }); + }); +}); diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.tsx b/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.tsx new file mode 100644 index 000000000..c8ac76d45 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.tsx @@ -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 ( + + + +
+ + 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{' '} + {'{{message}}'} 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{' '} + Environment | prod / Container [nginx] / Auto-heal: .... + Leave empty to disable. Delivery is best-effort and never blocks or + delays an update or heal. + +
+ + + + +
+
+ ); + + 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(); + + return ( +
+ + + + +
+
+ + Save notification settings + +
+
+
+ ); +} diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/index.ts b/app/react/portainer/settings/SettingsView/NotificationPanel/index.ts new file mode 100644 index 000000000..2ac02fdb9 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/index.ts @@ -0,0 +1 @@ +export { NotificationPanel } from './NotificationPanel'; diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/types.ts b/app/react/portainer/settings/SettingsView/NotificationPanel/types.ts new file mode 100644 index 000000000..296e1a850 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/types.ts @@ -0,0 +1,3 @@ +export interface Values { + webhookUrl: string; +} diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/validation.test.ts b/app/react/portainer/settings/SettingsView/NotificationPanel/validation.test.ts new file mode 100644 index 000000000..ac2b3ce85 --- /dev/null +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/validation.test.ts @@ -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' + ); + }); +}); diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/validation.ts b/app/react/portainer/settings/SettingsView/NotificationPanel/validation.ts new file mode 100644 index 000000000..424c389bc --- /dev/null +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/validation.ts @@ -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 { + 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); + }), + }); +} diff --git a/app/react/portainer/settings/SettingsView/SettingsView.tsx b/app/react/portainer/settings/SettingsView/SettingsView.tsx index acb48338e..257afea1f 100644 --- a/app/react/portainer/settings/SettingsView/SettingsView.tsx +++ b/app/react/portainer/settings/SettingsView/SettingsView.tsx @@ -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() { + + diff --git a/app/react/portainer/settings/types.ts b/app/react/portainer/settings/types.ts index 48f307a6a..cb07f118a 100644 --- a/app/react/portainer/settings/types.ts +++ b/app/react/portainer/settings/types.ts @@ -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 {