feat(#19): separate webhook per automation mechanism (update vs heal)
Split the single container-automation webhook URL into two independently
optional URLs — UpdateWebhookURL (fired on update/rollback/update-failed) and
HealWebhookURL (fired on auto-heal restart). The notifier routes each event to
its mechanism's URL by kind; an empty URL silences only that mechanism, so a
user can enable notifications for updates without heal (or vice-versa).
Settings gain both fields (each validated http/https, {{message}} allowed), the
NotificationPanel exposes two labeled inputs, and the golden migration output is
updated. Delivery path (goroutine/recover/timeout, {{message}} GET vs POST,
per-container stack message format) is unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -49,12 +49,31 @@ func newWebhookNotifier(dataStore dataservices.DataStore) webhookNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
// webhookURLForKind selects the configured webhook URL for an event kind: the
|
||||
// update-family events (image update, rollback, update-failed) route to the
|
||||
// update endpoint, and the auto-heal restart routes to the heal endpoint. This
|
||||
// lets a user enable notifications for one mechanism without the other — an
|
||||
// empty URL for a mechanism means "no webhook for that mechanism".
|
||||
func webhookURLForKind(notification portainer.ContainerAutomationNotificationSettings, kind EventKind) string {
|
||||
switch kind {
|
||||
case EventUpdated, EventRollback, EventUpdateFailed:
|
||||
return notification.UpdateWebhookURL
|
||||
case EventHealRestarted:
|
||||
return notification.HealWebhookURL
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
// Notify reads the webhook URL for the event's mechanism (update vs heal) 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. When the URL for the event's
|
||||
// mechanism is empty, the event is skipped and the other mechanism is
|
||||
// unaffected.
|
||||
func (n webhookNotifier) Notify(event Event) {
|
||||
settings, err := n.dataStore.Settings().Settings()
|
||||
if err != nil {
|
||||
@@ -62,7 +81,7 @@ func (n webhookNotifier) Notify(event Event) {
|
||||
return
|
||||
}
|
||||
|
||||
webhookURL := strings.TrimSpace(settings.ContainerAutomation.Notification.WebhookURL)
|
||||
webhookURL := strings.TrimSpace(webhookURLForKind(settings.ContainerAutomation.Notification, event.Kind))
|
||||
if webhookURL == "" {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -12,11 +12,22 @@ import (
|
||||
"github.com/portainer/portainer/api/datastore"
|
||||
)
|
||||
|
||||
// newTestWebhookNotifier builds an initialized test datastore, sets the webhook
|
||||
// URL, and returns a webhookNotifier bound to it.
|
||||
// newTestWebhookNotifier builds an initialized test datastore, sets both the
|
||||
// update and heal webhook URLs to the same value (so the notifier fires for
|
||||
// every event kind), and returns a webhookNotifier bound to it. Use
|
||||
// newTestWebhookNotifierSplit to configure the two URLs independently.
|
||||
func newTestWebhookNotifier(t *testing.T, webhookURL string) (webhookNotifier, *datastore.Store) {
|
||||
t.Helper()
|
||||
|
||||
return newTestWebhookNotifierSplit(t, webhookURL, webhookURL)
|
||||
}
|
||||
|
||||
// newTestWebhookNotifierSplit builds an initialized test datastore with the
|
||||
// auto-update and auto-heal webhook URLs set independently, and returns a
|
||||
// webhookNotifier bound to it.
|
||||
func newTestWebhookNotifierSplit(t *testing.T, updateURL, healURL string) (webhookNotifier, *datastore.Store) {
|
||||
t.Helper()
|
||||
|
||||
_, store := datastore.MustNewTestStore(t, true, false)
|
||||
|
||||
settings, err := store.Settings().Settings()
|
||||
@@ -24,7 +35,8 @@ func newTestWebhookNotifier(t *testing.T, webhookURL string) (webhookNotifier, *
|
||||
t.Fatalf("read settings: %v", err)
|
||||
}
|
||||
|
||||
settings.ContainerAutomation.Notification.WebhookURL = webhookURL
|
||||
settings.ContainerAutomation.Notification.UpdateWebhookURL = updateURL
|
||||
settings.ContainerAutomation.Notification.HealWebhookURL = healURL
|
||||
if err := store.Settings().UpdateSettings(settings); err != nil {
|
||||
t.Fatalf("update settings: %v", err)
|
||||
}
|
||||
@@ -138,6 +150,132 @@ func TestWebhookNotifierEmptyURLNoCall(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// waitForRequest returns the first request seen on ch, or fails after a short
|
||||
// grace period.
|
||||
func waitForRequest(t *testing.T, ch <-chan *http.Request, what string) *http.Request {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case r := <-ch:
|
||||
return r
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatalf("%s was not received", what)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// expectNoRequest asserts nothing arrives on ch within a short grace period.
|
||||
func expectNoRequest(t *testing.T, ch <-chan *http.Request, what string) {
|
||||
t.Helper()
|
||||
|
||||
select {
|
||||
case <-ch:
|
||||
t.Fatalf("%s should not have been called", what)
|
||||
case <-time.After(300 * time.Millisecond):
|
||||
// No call, as expected.
|
||||
}
|
||||
}
|
||||
|
||||
// TestWebhookNotifierUpdateEventRoutesToUpdateURL verifies an update-family event
|
||||
// dispatches to the auto-update URL only; the heal URL is set but never called.
|
||||
func TestWebhookNotifierUpdateEventRoutesToUpdateURL(t *testing.T) {
|
||||
updateReqs := make(chan *http.Request, 1)
|
||||
updateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
updateReqs <- r
|
||||
}))
|
||||
defer updateSrv.Close()
|
||||
|
||||
healReqs := make(chan *http.Request, 1)
|
||||
healSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
healReqs <- r
|
||||
}))
|
||||
defer healSrv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifierSplit(t, updateSrv.URL+"/update", healSrv.URL+"/heal")
|
||||
createEndpoint(t, store, 1, "prod")
|
||||
|
||||
for _, kind := range []EventKind{EventUpdated, EventRollback, EventUpdateFailed} {
|
||||
n.Notify(Event{Kind: kind, EndpointID: 1, ContainerName: "c"})
|
||||
|
||||
r := waitForRequest(t, updateReqs, "update webhook for "+string(kind))
|
||||
if r.URL.Path != "/update" {
|
||||
t.Errorf("kind %s hit %q, want /update", kind, r.URL.Path)
|
||||
}
|
||||
}
|
||||
|
||||
expectNoRequest(t, healReqs, "heal webhook")
|
||||
}
|
||||
|
||||
// TestWebhookNotifierHealEventRoutesToHealURL verifies a heal event dispatches to
|
||||
// the auto-heal URL only; the update URL is set but never called.
|
||||
func TestWebhookNotifierHealEventRoutesToHealURL(t *testing.T) {
|
||||
updateReqs := make(chan *http.Request, 1)
|
||||
updateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
updateReqs <- r
|
||||
}))
|
||||
defer updateSrv.Close()
|
||||
|
||||
healReqs := make(chan *http.Request, 1)
|
||||
healSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
healReqs <- r
|
||||
}))
|
||||
defer healSrv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifierSplit(t, updateSrv.URL+"/update", healSrv.URL+"/heal")
|
||||
createEndpoint(t, store, 1, "prod")
|
||||
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
|
||||
|
||||
r := waitForRequest(t, healReqs, "heal webhook")
|
||||
if r.URL.Path != "/heal" {
|
||||
t.Errorf("heal event hit %q, want /heal", r.URL.Path)
|
||||
}
|
||||
|
||||
expectNoRequest(t, updateReqs, "update webhook")
|
||||
}
|
||||
|
||||
// TestWebhookNotifierEmptyUpdateURLSkipsUpdateOnly verifies that an empty
|
||||
// auto-update URL suppresses update-family events while heal still fires.
|
||||
func TestWebhookNotifierEmptyUpdateURLSkipsUpdateOnly(t *testing.T) {
|
||||
healReqs := make(chan *http.Request, 1)
|
||||
healSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
healReqs <- r
|
||||
}))
|
||||
defer healSrv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifierSplit(t, "", healSrv.URL+"/heal")
|
||||
createEndpoint(t, store, 1, "prod")
|
||||
|
||||
// Update-family event: no URL configured, so nothing is delivered.
|
||||
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerName: "c"})
|
||||
expectNoRequest(t, healReqs, "heal webhook on an update event")
|
||||
|
||||
// Heal event: the heal URL is set, so it still fires.
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
|
||||
waitForRequest(t, healReqs, "heal webhook")
|
||||
}
|
||||
|
||||
// TestWebhookNotifierEmptyHealURLSkipsHealOnly verifies that an empty auto-heal
|
||||
// URL suppresses heal events while update-family events still fire.
|
||||
func TestWebhookNotifierEmptyHealURLSkipsHealOnly(t *testing.T) {
|
||||
updateReqs := make(chan *http.Request, 1)
|
||||
updateSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
updateReqs <- r
|
||||
}))
|
||||
defer updateSrv.Close()
|
||||
|
||||
n, store := newTestWebhookNotifierSplit(t, updateSrv.URL+"/update", "")
|
||||
createEndpoint(t, store, 1, "prod")
|
||||
|
||||
// Heal event: no URL configured, so nothing is delivered.
|
||||
n.Notify(Event{Kind: EventHealRestarted, EndpointID: 1, ContainerName: "nginx"})
|
||||
expectNoRequest(t, updateReqs, "update webhook on a heal event")
|
||||
|
||||
// Update event: the update URL is set, so it still fires.
|
||||
n.Notify(Event{Kind: EventUpdated, EndpointID: 1, ContainerName: "c"})
|
||||
waitForRequest(t, updateReqs, "update webhook")
|
||||
}
|
||||
|
||||
// TestWebhookNotifierFailingEndpointDoesNotBlock verifies that a broken endpoint
|
||||
// neither blocks the caller nor panics.
|
||||
func TestWebhookNotifierFailingEndpointDoesNotBlock(t *testing.T) {
|
||||
|
||||
@@ -73,8 +73,10 @@ 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 = ""
|
||||
// The automation notification webhooks are opt-in per mechanism: both the
|
||||
// auto-update and auto-heal endpoints are empty (disabled) by default.
|
||||
defaultSettings.ContainerAutomation.Notification.UpdateWebhookURL = ""
|
||||
defaultSettings.ContainerAutomation.Notification.HealWebhookURL = ""
|
||||
|
||||
return store.SettingsService.UpdateSettings(defaultSettings)
|
||||
}
|
||||
|
||||
@@ -600,7 +600,8 @@
|
||||
"Scope": "labeled"
|
||||
},
|
||||
"Notification": {
|
||||
"WebhookURL": ""
|
||||
"HealWebhookURL": "",
|
||||
"UpdateWebhookURL": ""
|
||||
}
|
||||
},
|
||||
"Edge": {
|
||||
|
||||
@@ -87,7 +87,8 @@ type containerAutomationSettingsPayload struct {
|
||||
}
|
||||
|
||||
type notificationSettingsPayload struct {
|
||||
WebhookURL *string `example:"https://example.com/notify?msg={{message}}"`
|
||||
UpdateWebhookURL *string `example:"https://example.com/notify?msg={{message}}"`
|
||||
HealWebhookURL *string `example:"https://example.com/notify?msg={{message}}"`
|
||||
}
|
||||
|
||||
type autoHealSettingsPayload struct {
|
||||
@@ -186,15 +187,33 @@ 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")
|
||||
}
|
||||
// Each mechanism's webhook URL is independently optional: validate a URL only
|
||||
// when it is provided and non-empty. The URL may carry the "{{message}}"
|
||||
// placeholder, so we accept any http(s) URL with a host rather than a strict
|
||||
// format check.
|
||||
if err := validateNotificationWebhookURL(notification.UpdateWebhookURL, "auto-update"); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateNotificationWebhookURL(notification.HealWebhookURL, "auto-heal"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateNotificationWebhookURL validates an optional container-automation
|
||||
// notification webhook URL: nil or empty is accepted (the mechanism's webhook is
|
||||
// disabled); otherwise it must be a valid http(s) URL with a host. mechanism
|
||||
// names the automation the URL belongs to for the error message.
|
||||
func validateNotificationWebhookURL(webhookURL *string, mechanism string) error {
|
||||
if webhookURL == nil || *webhookURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
u, err := url.Parse(*webhookURL)
|
||||
if err != nil || (u.Scheme != "http" && u.Scheme != "https") || u.Host == "" {
|
||||
return errors.New("Invalid " + mechanism + " notification webhook URL. Must be a valid http(s) URL")
|
||||
}
|
||||
|
||||
return nil
|
||||
@@ -359,7 +378,8 @@ func (handler *Handler) updateSettings(tx dataservices.DataStoreTx, payload sett
|
||||
if payload.ContainerAutomation != nil && payload.ContainerAutomation.Notification != nil {
|
||||
notification := payload.ContainerAutomation.Notification
|
||||
current := &settings.ContainerAutomation.Notification
|
||||
current.WebhookURL = *cmp.Or(notification.WebhookURL, ¤t.WebhookURL)
|
||||
current.UpdateWebhookURL = *cmp.Or(notification.UpdateWebhookURL, ¤t.UpdateWebhookURL)
|
||||
current.HealWebhookURL = *cmp.Or(notification.HealWebhookURL, ¤t.HealWebhookURL)
|
||||
}
|
||||
|
||||
if err := tx.Settings().UpdateSettings(settings); err != nil {
|
||||
|
||||
@@ -127,10 +127,11 @@ 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.
|
||||
// TestSettingsUpdatePayloadValidateNotificationWebhookURL covers the
|
||||
// per-mechanism container-automation notification webhook URLs (auto-update and
|
||||
// auto-heal): each is independently optional (empty is valid), must be a valid
|
||||
// http(s) URL with a host when set, and accepts the "{{message}}" placeholder.
|
||||
// Each case is exercised against both the update field and the heal field.
|
||||
func TestSettingsUpdatePayloadValidateNotificationWebhookURL(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -147,23 +148,41 @@ func TestSettingsUpdatePayloadValidateNotificationWebhookURL(t *testing.T) {
|
||||
{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),
|
||||
},
|
||||
},
|
||||
}
|
||||
fields := []struct {
|
||||
name string
|
||||
payload func(url string) *notificationSettingsPayload
|
||||
}{
|
||||
{
|
||||
name: "UpdateWebhookURL",
|
||||
payload: func(url string) *notificationSettingsPayload {
|
||||
return ¬ificationSettingsPayload{UpdateWebhookURL: strptr(url)}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "HealWebhookURL",
|
||||
payload: func(url string) *notificationSettingsPayload {
|
||||
return ¬ificationSettingsPayload{HealWebhookURL: strptr(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)
|
||||
}
|
||||
})
|
||||
for _, field := range fields {
|
||||
for _, tc := range cases {
|
||||
t.Run(field.name+"/"+tc.name, func(t *testing.T) {
|
||||
payload := settingsUpdatePayload{
|
||||
ContainerAutomation: &containerAutomationSettingsPayload{
|
||||
Notification: field.payload(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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+16
-9
@@ -1176,16 +1176,23 @@ 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 holds the webhook notification
|
||||
// config for container-automation events, split per mechanism so auto-update
|
||||
// and auto-heal can be wired to different endpoints (or one enabled without
|
||||
// the other): update-family events (image update, rollback, update-failed)
|
||||
// use UpdateWebhookURL, and auto-heal restart uses HealWebhookURL. Each URL
|
||||
// is independently optional, 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"`
|
||||
// UpdateWebhookURL is the HTTP(S) endpoint called on auto-update events
|
||||
// (image update, rollback and update-failed). 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 update notifications.
|
||||
UpdateWebhookURL string `json:"UpdateWebhookURL"`
|
||||
// HealWebhookURL is the HTTP(S) endpoint called on auto-heal restart
|
||||
// events. It follows the same "{{message}}" placeholder / GET-vs-POST
|
||||
// convention as UpdateWebhookURL. Empty disables heal notifications.
|
||||
HealWebhookURL string `json:"HealWebhookURL"`
|
||||
}
|
||||
|
||||
// ContainerAutomationSettings holds native container automation settings
|
||||
|
||||
+53
-12
@@ -17,13 +17,14 @@ function renderComponent() {
|
||||
}
|
||||
|
||||
describe('NotificationPanel', () => {
|
||||
it('renders the webhook URL read from the API', async () => {
|
||||
it('renders both webhook URLs read from the API', async () => {
|
||||
server.use(
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({
|
||||
ContainerAutomation: {
|
||||
Notification: {
|
||||
WebhookURL: 'https://example.com/notify?msg={{message}}',
|
||||
UpdateWebhookURL: 'https://example.com/update?msg={{message}}',
|
||||
HealWebhookURL: 'https://example.com/heal?msg={{message}}',
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -37,26 +38,31 @@ describe('NotificationPanel', () => {
|
||||
).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Webhook URL/i)).toHaveValue(
|
||||
'https://example.com/notify?msg={{message}}'
|
||||
expect(screen.getByLabelText(/Auto-update webhook URL/i)).toHaveValue(
|
||||
'https://example.com/update?msg={{message}}'
|
||||
);
|
||||
expect(screen.getByLabelText(/Auto-heal webhook URL/i)).toHaveValue(
|
||||
'https://example.com/heal?msg={{message}}'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('disables the save button on an invalid URL', async () => {
|
||||
it('disables the save button on an invalid auto-update URL', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({
|
||||
ContainerAutomation: { Notification: { WebhookURL: '' } },
|
||||
ContainerAutomation: {
|
||||
Notification: { UpdateWebhookURL: '', HealWebhookURL: '' },
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const input = await screen.findByLabelText(/Webhook URL/i);
|
||||
const input = await screen.findByLabelText(/Auto-update webhook URL/i);
|
||||
await user.type(input, 'not-a-url');
|
||||
|
||||
await waitFor(() => {
|
||||
@@ -70,14 +76,45 @@ describe('NotificationPanel', () => {
|
||||
).toBeDisabled();
|
||||
});
|
||||
|
||||
it('includes the webhook URL in the saved payload', async () => {
|
||||
it('disables the save button on an invalid auto-heal URL', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
server.use(
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({
|
||||
ContainerAutomation: {
|
||||
Notification: { UpdateWebhookURL: '', HealWebhookURL: '' },
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
renderComponent();
|
||||
|
||||
const input = await screen.findByLabelText(/Auto-heal 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 both webhook URLs in the saved payload', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
let savedPayload: unknown;
|
||||
server.use(
|
||||
http.get('/api/settings', () =>
|
||||
HttpResponse.json({
|
||||
ContainerAutomation: { Notification: { WebhookURL: '' } },
|
||||
ContainerAutomation: {
|
||||
Notification: { UpdateWebhookURL: '', HealWebhookURL: '' },
|
||||
},
|
||||
})
|
||||
),
|
||||
http.put('/api/settings', async ({ request }) => {
|
||||
@@ -88,8 +125,11 @@ describe('NotificationPanel', () => {
|
||||
|
||||
renderComponent();
|
||||
|
||||
const input = await screen.findByLabelText(/Webhook URL/i);
|
||||
await user.type(input, 'https://hook.example/notify');
|
||||
const updateInput = await screen.findByLabelText(/Auto-update webhook URL/i);
|
||||
await user.type(updateInput, 'https://hook.example/update');
|
||||
|
||||
const healInput = screen.getByLabelText(/Auto-heal webhook URL/i);
|
||||
await user.type(healInput, 'https://hook.example/heal');
|
||||
|
||||
const save = screen.getByRole('button', {
|
||||
name: /Save notification settings/i,
|
||||
@@ -101,7 +141,8 @@ describe('NotificationPanel', () => {
|
||||
expect(savedPayload).toEqual({
|
||||
ContainerAutomation: {
|
||||
Notification: {
|
||||
WebhookURL: 'https://hook.example/notify',
|
||||
UpdateWebhookURL: 'https://hook.example/update',
|
||||
HealWebhookURL: 'https://hook.example/heal',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -24,7 +24,8 @@ export function NotificationPanel() {
|
||||
|
||||
const notification = settingsQuery.data.Notification;
|
||||
const initialValues: Values = {
|
||||
webhookUrl: notification?.WebhookURL || '',
|
||||
updateWebhookUrl: notification?.UpdateWebhookURL || '',
|
||||
healWebhookUrl: notification?.HealWebhookURL || '',
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -36,16 +37,19 @@ export function NotificationPanel() {
|
||||
<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{' '}
|
||||
Portainer can call an HTTP URL for container-automation events so you
|
||||
can forward them to chat or a custom endpoint. The two webhooks are
|
||||
configured independently: the auto-update URL is called on image
|
||||
update, rollback and failed-update events, and the auto-heal URL on
|
||||
auto-heal restarts. Set only one to notify on that mechanism alone.
|
||||
For each URL, 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.
|
||||
Leave a URL empty to disable that mechanism. Delivery is best-effort
|
||||
and never blocks or delays an update or heal.
|
||||
</TextTip>
|
||||
</div>
|
||||
|
||||
@@ -67,7 +71,8 @@ export function NotificationPanel() {
|
||||
{
|
||||
ContainerAutomation: {
|
||||
Notification: {
|
||||
WebhookURL: values.webhookUrl,
|
||||
UpdateWebhookURL: values.updateWebhookUrl,
|
||||
HealWebhookURL: values.healWebhookUrl,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -86,16 +91,30 @@ function InnerForm({ isLoading }: { isLoading: boolean }) {
|
||||
return (
|
||||
<Form className="form-horizontal">
|
||||
<FormControl
|
||||
label="Webhook URL"
|
||||
inputId="notification_webhook_url"
|
||||
errors={errors.webhookUrl}
|
||||
label="Auto-update webhook URL"
|
||||
inputId="notification_update_webhook_url"
|
||||
errors={errors.updateWebhookUrl}
|
||||
>
|
||||
<Field
|
||||
as={Input}
|
||||
id="notification_webhook_url"
|
||||
placeholder="e.g. https://example.com/notify?msg={{message}}"
|
||||
name="webhookUrl"
|
||||
data-cy="settings-notificationWebhookUrl"
|
||||
id="notification_update_webhook_url"
|
||||
placeholder="e.g. https://example.com/update?msg={{message}}"
|
||||
name="updateWebhookUrl"
|
||||
data-cy="settings-notificationUpdateWebhookUrl"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Auto-heal webhook URL"
|
||||
inputId="notification_heal_webhook_url"
|
||||
errors={errors.healWebhookUrl}
|
||||
>
|
||||
<Field
|
||||
as={Input}
|
||||
id="notification_heal_webhook_url"
|
||||
placeholder="e.g. https://example.com/heal?msg={{message}}"
|
||||
name="healWebhookUrl"
|
||||
data-cy="settings-notificationHealWebhookUrl"
|
||||
/>
|
||||
</FormControl>
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export interface Values {
|
||||
webhookUrl: string;
|
||||
updateWebhookUrl: string;
|
||||
healWebhookUrl: string;
|
||||
}
|
||||
|
||||
@@ -8,33 +8,48 @@ function validate(values: Values) {
|
||||
.catch((err: { errors: string[] }) => err.errors);
|
||||
}
|
||||
|
||||
const empty: Values = { updateWebhookUrl: '', healWebhookUrl: '' };
|
||||
|
||||
describe('NotificationPanel validation', () => {
|
||||
it('accepts an empty webhook URL (disabled)', async () => {
|
||||
expect(await validate({ webhookUrl: '' })).toBeUndefined();
|
||||
it('accepts both webhook URLs empty (disabled)', async () => {
|
||||
expect(await validate(empty)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('accepts a valid http(s) URL, including the placeholder', async () => {
|
||||
it('accepts a valid http(s) URL, including the placeholder, in each field', async () => {
|
||||
expect(
|
||||
await validate({ webhookUrl: 'https://example.com/notify' })
|
||||
await validate({ ...empty, updateWebhookUrl: 'https://example.com/notify' })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await validate({ webhookUrl: 'http://example.com/notify' })
|
||||
await validate({ ...empty, healWebhookUrl: 'http://example.com/notify' })
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
await validate({
|
||||
webhookUrl: 'https://example.com/notify?msg={{message}}',
|
||||
updateWebhookUrl: 'https://example.com/update?msg={{message}}',
|
||||
healWebhookUrl: 'https://example.com/heal?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(
|
||||
it('rejects a non-http(s) or malformed auto-update URL', async () => {
|
||||
expect(
|
||||
await validate({ ...empty, updateWebhookUrl: 'example.com/notify' })
|
||||
).toContain('Must be a valid http(s) URL');
|
||||
expect(
|
||||
await validate({ ...empty, updateWebhookUrl: 'ftp://example.com' })
|
||||
).toContain('Must be a valid http(s) URL');
|
||||
expect(
|
||||
await validate({ ...empty, updateWebhookUrl: 'not a url' })
|
||||
).toContain('Must be a valid http(s) URL');
|
||||
});
|
||||
|
||||
it('rejects a non-http(s) or malformed auto-heal URL', async () => {
|
||||
expect(
|
||||
await validate({ ...empty, healWebhookUrl: 'example.com/notify' })
|
||||
).toContain('Must be a valid http(s) URL');
|
||||
expect(
|
||||
await validate({ ...empty, healWebhookUrl: 'ftp://example.com' })
|
||||
).toContain('Must be a valid http(s) URL');
|
||||
expect(await validate({ ...empty, healWebhookUrl: 'not a url' })).toContain(
|
||||
'Must be a valid http(s) URL'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -14,17 +14,25 @@ function isHttpUrl(value: string): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
// optionalHttpUrl builds a schema for an independently-optional webhook URL: an
|
||||
// empty value disables that mechanism's webhook, otherwise it must be an http(s)
|
||||
// URL (the "{{message}}" placeholder is allowed).
|
||||
function optionalHttpUrl() {
|
||||
return string()
|
||||
.default('')
|
||||
.test('valid-webhook-url', 'Must be a valid http(s) URL', (value) => {
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return isHttpUrl(value);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}),
|
||||
// Each URL is independently optional: an empty URL disables that webhook.
|
||||
updateWebhookUrl: optionalHttpUrl(),
|
||||
healWebhookUrl: optionalHttpUrl(),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -160,11 +160,15 @@ 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.
|
||||
// NotificationSettings holds the per-mechanism webhooks for container-automation
|
||||
// events, so auto-update and auto-heal can be wired to different endpoints (or
|
||||
// one enabled without the other). UpdateWebhookURL is called on update-family
|
||||
// events (image update, rollback, failed update) and HealWebhookURL on auto-heal
|
||||
// restart. Each URL is independently optional; an empty value disables that
|
||||
// mechanism's webhook.
|
||||
export interface NotificationSettings {
|
||||
WebhookURL: string;
|
||||
UpdateWebhookURL: string;
|
||||
HealWebhookURL: string;
|
||||
}
|
||||
|
||||
export interface ContainerAutomationSettings {
|
||||
|
||||
Reference in New Issue
Block a user