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:
agent_coder
2026-07-01 22:47:25 +03:00
parent 5bb678d3ba
commit 492d3d01b0
13 changed files with 406 additions and 112 deletions
+26 -7
View File
@@ -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
}
+141 -3
View File
@@ -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) {
+4 -2
View File
@@ -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": {
+30 -10
View File
@@ -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, &current.WebhookURL)
current.UpdateWebhookURL = *cmp.Or(notification.UpdateWebhookURL, &current.UpdateWebhookURL)
current.HealWebhookURL = *cmp.Or(notification.HealWebhookURL, &current.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: &notificationSettingsPayload{
WebhookURL: strptr(tc.url),
},
},
}
fields := []struct {
name string
payload func(url string) *notificationSettingsPayload
}{
{
name: "UpdateWebhookURL",
payload: func(url string) *notificationSettingsPayload {
return &notificationSettingsPayload{UpdateWebhookURL: strptr(url)}
},
},
{
name: "HealWebhookURL",
payload: func(url string) *notificationSettingsPayload {
return &notificationSettingsPayload{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
View File
@@ -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
@@ -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(),
});
}
+8 -4
View File
@@ -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 {