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:
@@ -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",
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+15
-2
@@ -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
|
||||
|
||||
+110
@@ -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>
|
||||
</>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user