eb35e9c47f
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>
91 lines
3.0 KiB
Go
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)
|
|
}
|
|
})
|
|
}
|
|
}
|