Files
portainer/api/containerautomation/notify_test.go
T
agent_coder eb35e9c47f 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>
2026-07-01 19:31:18 +03:00

91 lines
3.0 KiB
Go

package containerautomation
import (
"errors"
"testing"
portainer "github.com/portainer/portainer/api"
)
// recordingNotifier captures emitted events for assertions in tests.
type recordingNotifier struct {
events []Event
}
func (r *recordingNotifier) Notify(event Event) {
r.events = append(r.events, event)
}
func TestLogNotifierDoesNotPanic(t *testing.T) {
n := logNotifier{}
// Every event kind, including a failure carrying an error, must log without
// panicking and without requiring any optional field.
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerID: "abc", Image: "nginx:latest"})
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, StackID: 7})
n.Notify(Event{Kind: EventRollback, EndpointID: 2, ContainerID: "def", Image: "nginx:1.0"})
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 3, ContainerID: "ghi"})
n.Notify(Event{Kind: EventUpdateFailed, EndpointID: 4, ContainerID: "jkl", Err: errors.New("boom")})
n.Notify(Event{Kind: EventUpdateFailed, EndpointID: 4}) // failure without an error
n.Notify(Event{}) // zero value
}
func TestRecordingNotifierCapturesEvents(t *testing.T) {
r := &recordingNotifier{}
r.Notify(Event{Kind: EventUpdated, EndpointID: 1})
r.Notify(Event{Kind: EventRollback, EndpointID: 1})
if len(r.events) != 2 {
t.Fatalf("captured %d events, want 2", len(r.events))
}
if r.events[0].Kind != EventUpdated || r.events[1].Kind != EventRollback {
t.Errorf("unexpected event kinds: %v, %v", r.events[0].Kind, r.events[1].Kind)
}
}
// 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
endpoint *portainer.Endpoint
want bool
}{
{name: "nil endpoint is not enabled", endpoint: nil, want: false},
{name: "default (zero value) participates", endpoint: &portainer.Endpoint{}, want: true},
{name: "explicitly disabled opts out", endpoint: &portainer.Endpoint{ContainerAutomationDisabled: true}, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := AutomationEnabledForEndpoint(tt.endpoint); got != tt.want {
t.Errorf("AutomationEnabledForEndpoint() = %v, want %v", got, tt.want)
}
})
}
}