diff --git a/api/containerautomation/webhook.go b/api/containerautomation/webhook.go index 9c36cf550..c5a42b3d3 100644 --- a/api/containerautomation/webhook.go +++ b/api/containerautomation/webhook.go @@ -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 } diff --git a/api/containerautomation/webhook_test.go b/api/containerautomation/webhook_test.go index ba589863b..d19fb3411 100644 --- a/api/containerautomation/webhook_test.go +++ b/api/containerautomation/webhook_test.go @@ -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) { diff --git a/api/datastore/init.go b/api/datastore/init.go index bb09dc5e4..7d018de2c 100644 --- a/api/datastore/init.go +++ b/api/datastore/init.go @@ -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) } diff --git a/api/datastore/test_data/output_24_to_latest.json b/api/datastore/test_data/output_24_to_latest.json index af4df8604..7fdf150a0 100644 --- a/api/datastore/test_data/output_24_to_latest.json +++ b/api/datastore/test_data/output_24_to_latest.json @@ -600,7 +600,8 @@ "Scope": "labeled" }, "Notification": { - "WebhookURL": "" + "HealWebhookURL": "", + "UpdateWebhookURL": "" } }, "Edge": { diff --git a/api/http/handler/settings/settings_update.go b/api/http/handler/settings/settings_update.go index 1fedde189..ba2b7f1a8 100644 --- a/api/http/handler/settings/settings_update.go +++ b/api/http/handler/settings/settings_update.go @@ -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 { diff --git a/api/http/handler/settings/settings_update_test.go b/api/http/handler/settings/settings_update_test.go index ed7aa19bd..1ae46a9b8 100644 --- a/api/http/handler/settings/settings_update_test.go +++ b/api/http/handler/settings/settings_update_test.go @@ -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) + } + }) + } } } diff --git a/api/portainer.go b/api/portainer.go index 1f32fff6c..893eaaf83 100644 --- a/api/portainer.go +++ b/api/portainer.go @@ -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 diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.test.tsx b/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.test.tsx index 134815b77..595c2c136 100644 --- a/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.test.tsx +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.test.tsx @@ -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', }, }, }); diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.tsx b/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.tsx index c8ac76d45..d22310017 100644 --- a/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.tsx +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/NotificationPanel.tsx @@ -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() {
- 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{' '} + 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 {'{{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. + Leave a URL empty to disable that mechanism. Delivery is best-effort + and never blocks or delays an update or heal.
@@ -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 (
+ + + + diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/types.ts b/app/react/portainer/settings/SettingsView/NotificationPanel/types.ts index 296e1a850..cca2f911a 100644 --- a/app/react/portainer/settings/SettingsView/NotificationPanel/types.ts +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/types.ts @@ -1,3 +1,4 @@ export interface Values { - webhookUrl: string; + updateWebhookUrl: string; + healWebhookUrl: string; } diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/validation.test.ts b/app/react/portainer/settings/SettingsView/NotificationPanel/validation.test.ts index ac2b3ce85..ae4bb4372 100644 --- a/app/react/portainer/settings/SettingsView/NotificationPanel/validation.test.ts +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/validation.test.ts @@ -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' ); }); diff --git a/app/react/portainer/settings/SettingsView/NotificationPanel/validation.ts b/app/react/portainer/settings/SettingsView/NotificationPanel/validation.ts index 424c389bc..244ea51f0 100644 --- a/app/react/portainer/settings/SettingsView/NotificationPanel/validation.ts +++ b/app/react/portainer/settings/SettingsView/NotificationPanel/validation.ts @@ -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 { 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(), }); } diff --git a/app/react/portainer/settings/types.ts b/app/react/portainer/settings/types.ts index cb07f118a..d6f939c2b 100644 --- a/app/react/portainer/settings/types.ts +++ b/app/react/portainer/settings/types.ts @@ -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 {