Skip to content

Commit 727d2c2

Browse files
committed
feat(templates): expose public app URLs in notification data
1 parent 5e4e085 commit 727d2c2

16 files changed

Lines changed: 283 additions & 101 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ Core settings:
9999
| -------------------- | --------------------------- | ----------- | -------------------------------------------------------------------------- |
100100
| `--listen-address` | `OVERDUE__LISTEN_ADDRESS` | `:8080` | HTTP server listen address. |
101101
| `--route-prefix` | `OVERDUE__ROUTE_PREFIX` | empty | Path prefix to mount the service under. |
102+
| `--public-url` | `OVERDUE__PUBLIC_URL` | empty | Externally reachable base URL used in notification templates. |
102103
| `--check-in-name` | `OVERDUE__CHECK_IN_NAME` | `default` | Name of the check-in monitor used in notifications. |
103104
| `--check-in-path` | `OVERDUE__CHECK_IN_PATH` | `/check-in` | Route used to receive check-ins. |
104105
| `--start-active` | `OVERDUE__START_ACTIVE` | `false` | Activate the monitor at startup instead of waiting for the first check-in. |
@@ -112,6 +113,7 @@ Environment variables use the `OVERDUE__` prefix. Flag names are uppercased and
112113
Dynamic notification flags include the target name:
113114

114115
```text
116+
--public-url -> OVERDUE__PUBLIC_URL
115117
--webhook.ops.url -> OVERDUE__WEBHOOK_OPS_URL
116118
--webhook.ops.method -> OVERDUE__WEBHOOK_OPS_METHOD
117119
--webhook.ops.custom-data -> OVERDUE__WEBHOOK_OPS_CUSTOM_DATA
@@ -158,7 +160,7 @@ Custom templates can be mounted into the container and referenced by path:
158160
-e OVERDUE__WEBHOOK_OPS_TEMPLATE=/etc/overdue/slack.tmpl
159161
```
160162

161-
Webhook templates must render valid JSON. Email templates may render text or HTML.
163+
Webhook templates must render valid JSON. Email templates may render text or HTML. When `--public-url` is configured, templates can use `.App.PublicURL`, `.App.CheckInURL`, and `.App.StatusURL`.
162164

163165
See [docs/templates.md](docs/templates.md) for built-in templates, template data, and helper functions.
164166

@@ -187,3 +189,5 @@ See [docs/api.md](docs/api.md) for response examples and route prefix details.
187189
## License
188190

189191
This project is licensed under the Apache 2.0 License. See the [LICENSE](LICENSE) file for details.
192+
193+

docs/configuration.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ Environment variables use the `OVERDUE__` prefix. Flag names are uppercased and
1010
--expected-every -> OVERDUE__EXPECTED_EVERY
1111
--alerting-delay -> OVERDUE__ALERTING_DELAY
1212
--check-in-name -> OVERDUE__CHECK_IN_NAME
13+
--public-url -> OVERDUE__PUBLIC_URL
1314
--response-details -> OVERDUE__RESPONSE_DETAILS
1415
```
1516

@@ -38,6 +39,7 @@ Dynamic notification flags include the target name:
3839
| -------------------- | --------------------------- | ----------- | -------------------------------------------------------------------------- |
3940
| `--listen-address` | `OVERDUE__LISTEN_ADDRESS` | `:8080` | HTTP server listen address. |
4041
| `--route-prefix` | `OVERDUE__ROUTE_PREFIX` | empty | Path prefix to mount the service under. |
42+
| `--public-url` | `OVERDUE__PUBLIC_URL` | empty | Externally reachable base URL used in notification templates. |
4143
| `--check-in-name` | `OVERDUE__CHECK_IN_NAME` | `default` | Name of the check-in monitor used in notifications. |
4244
| `--check-in-path` | `OVERDUE__CHECK_IN_PATH` | `/check-in` | Route used to receive check-ins. |
4345
| `--expected-every` | `OVERDUE__EXPECTED_EVERY` | required | Maximum time between check-ins. |
@@ -132,6 +134,34 @@ This becomes:
132134
/overdue
133135
```
134136

137+
## Public URL
138+
139+
Use `--public-url` to expose externally reachable links to notification templates. The value may include an ingress or reverse-proxy path prefix. It is not derived from `--listen-address`, because listen addresses such as `:8080` or `0.0.0.0:8080` are usually not public URLs.
140+
141+
```sh
142+
overdue \
143+
--public-url=https://example.com/overdue \
144+
--check-in-path=/check-in \
145+
--expected-every=1m \
146+
--alerting-delay=10s
147+
```
148+
149+
Templates can then use:
150+
151+
```gotemplate
152+
{{ .App.PublicURL }}
153+
{{ .App.CheckInURL }}
154+
{{ .App.StatusURL }}
155+
```
156+
157+
With the example above, those values are:
158+
159+
```text
160+
.App.PublicURL = https://example.com/overdue
161+
.App.CheckInURL = https://example.com/overdue/check-in
162+
.App.StatusURL = https://example.com/overdue/status
163+
```
164+
135165
## Authentication
136166

137167
Set `--auth-token` to require bearer-token auth for `/check-in` and `/status`.
@@ -187,3 +217,5 @@ Use it through the environment when possible:
187217
```sh
188218
export OVERDUE__AUTH_TOKEN="$(openssl rand -hex 32)"
189219
```
220+
221+

docs/notifications.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ And this creates an email target named `primary`:
2323

2424
If no notification targets are configured, Overdue still runs and records status, but sends no notifications.
2525

26+
Set `--public-url` when notification templates should include externally reachable Overdue links. The value is exposed as `.App.PublicURL`; `.App.CheckInURL` and `.App.StatusURL` are derived from it.
27+
2628
## Environment variables
2729

2830
Dynamic notification flags include the target name.
@@ -269,3 +271,5 @@ For fan-out delivery, successful targets are not called again on the next retry.
269271
Retry backoff starts at `1s` and is capped at `1m`.
270272

271273
Both alerting and resolved notifications keep their `.NotificationID` across retries.
274+
275+

docs/templates.md

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,11 @@ Notification templates receive a check-in lifecycle event.
6363
| `.Resolved` | bool | `true` for resolved notifications. |
6464
| `.Title` | string | Rendered notification title. Available in body and subject templates. |
6565
| `.Text` | string | Rendered notification text. Available in body templates. |
66+
| `.App` | struct | Application link data derived from `--public-url`. Empty when no public URL is configured. |
6667
| `.CustomData` | map[string]string | Target-local custom data from `--webhook.<name>.custom-data` or `--email.<name>.custom-data`. |
6768

69+
`.App` contains `.App.PublicURL`, `.App.CheckInURL`, and `.App.StatusURL`. `--public-url` is the externally reachable base URL and may include a path prefix. `.App.CheckInURL` appends the configured check-in path, and `.App.StatusURL` appends `/status`.
70+
6871
Custom data keys that are valid Go template identifiers can be read with dot notation, such as `.CustomData.channel`. Other keys can be read with `index`, such as `{{ index .CustomData "team-name" }}`.
6972

7073
Example:
@@ -74,7 +77,8 @@ Example:
7477
7578
Check-in: {{ .CheckInName }}
7679
Status: {{ .Status }}
77-
Channel: {{ .CustomData.channel | default "#alertmanager" | withPrefix "#" }}
80+
Channel: {{ .CustomData.channel | default "alertmanager" | withPrefix "#" }}
81+
Status URL: {{ .App.StatusURL }}
7882
Expected by: {{ .ExpectedBy.Format "2006-01-02 15:04:05 MST" }}
7983
Alerting at: {{ .AlertingAt.Format "2006-01-02 15:04:05 MST" }}
8084
```
@@ -100,7 +104,7 @@ The helpers can also be called without pipelines:
100104

101105
```gotemplate
102106
{{ json .Text }}
103-
{{ when "Resolved at" "Notified at" .Resolved }}
107+
{{ when .Resolved "Resolved at" "Notified at" }}
104108
{{ default "unknown" .CheckInName }}
105109
{{ withPrefix "#" .CustomData.channel }}
106110
{{ withSuffix "/" .CustomData.path }}
@@ -135,7 +139,10 @@ For Slack channel rendering, combine `default`, `withPrefix`, and `json`:
135139

136140
```gotemplate
137141
{
138-
"channel": {{ .CustomData.channel | default "#alertmanager" | withPrefix "#" | json }},
139-
"text": {{ .Text | json }}
142+
"channel": {{ .CustomData.channel | default "alertmanager" | withPrefix "#" | json }},
143+
"text": {{ .Text | json }},
144+
"status_url": {{ .App.StatusURL | json }}
140145
}
141146
```
147+
148+

internal/app/run.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@ func Run(
4646
"check-in receiver configured",
4747
"listenAddr", flags.ListenAddr,
4848
"routePrefix", flags.RoutePrefix,
49+
"publicURL", flags.PublicURL,
4950
"checkInName", flags.CheckInName,
5051
"checkInPath", flags.CheckInPath,
5152
"expectedEvery", flags.ExpectedEvery.String(),

internal/flag/flag.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/containeroo/httputils"
1414
"github.com/containeroo/overdue/internal/logging"
1515
"github.com/containeroo/overdue/internal/notification/notifier"
16+
"github.com/containeroo/overdue/internal/notification/render"
1617
"github.com/containeroo/overdue/internal/notification/targets"
1718
"github.com/containeroo/tinyflags"
1819
)
@@ -26,6 +27,7 @@ const (
2627
type Config struct {
2728
ListenAddr string
2829
RoutePrefix string
30+
PublicURL string
2931
CheckInName string
3032
CheckInPath string
3133
ExpectedEvery time.Duration
@@ -62,6 +64,26 @@ func ParseArgs(args []string, version string) (Config, error) {
6264
Placeholder("PATH").
6365
Value()
6466

67+
tf.StringVar(&cfg.PublicURL, "public-url", "", "Externally reachable base URL used in notification templates").
68+
Finalize(func(s string) string {
69+
return strings.TrimRight(strings.TrimSpace(s), "/")
70+
}).
71+
FinalizeDefaultValue().
72+
Placeholder("URL").
73+
Validate(func(raw string) error {
74+
if strings.TrimSpace(raw) == "" {
75+
return nil
76+
}
77+
78+
u, err := url.Parse(raw)
79+
if err != nil || u.Scheme == "" || u.Host == "" {
80+
return errors.New("public-url must be a valid absolute URL")
81+
}
82+
83+
return nil
84+
}).
85+
Value()
86+
6587
tf.StringVar(&cfg.CheckInName, "check-in-name", "default", "Name of the check-in monitor used in notifications").
6688
Finalize(strings.TrimSpace).
6789
Placeholder("NAME").
@@ -279,6 +301,7 @@ func ParseArgs(args []string, version string) (Config, error) {
279301
if err != nil {
280302
return Config{}, err
281303
}
304+
notifyConfig.App = render.NewAppData(version, cfg.PublicURL, cfg.CheckInPath)
282305

283306
cfg.ListenAddr = (*listenAddr).String()
284307
cfg.Notify = notifyConfig

internal/flag/flag_test.go

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,35 @@ func TestParseArgs(t *testing.T) {
5151
assert.Equal(t, logging.LogFormatText, cfg.LogFormat)
5252
})
5353

54+
t.Run("builds app template data from public url", func(t *testing.T) {
55+
t.Parallel()
56+
57+
cfg, err := ParseArgs([]string{
58+
"--expected-every=10s",
59+
"--alerting-delay=2s",
60+
"--public-url=https://overdue.example.test/overdue/",
61+
"--check-in-path=custom-check-in/",
62+
}, "dev")
63+
64+
require.NoError(t, err)
65+
assert.Equal(t, "https://overdue.example.test/overdue", cfg.PublicURL)
66+
assert.Equal(t, "https://overdue.example.test/overdue", cfg.Notify.App.PublicURL)
67+
assert.Equal(t, "https://overdue.example.test/overdue/custom-check-in", cfg.Notify.App.CheckInURL)
68+
assert.Equal(t, "https://overdue.example.test/overdue/status", cfg.Notify.App.StatusURL)
69+
})
70+
71+
t.Run("rejects invalid public url", func(t *testing.T) {
72+
t.Parallel()
73+
74+
_, err := ParseArgs([]string{
75+
"--expected-every=10s",
76+
"--alerting-delay=2s",
77+
"--public-url=not-a-url",
78+
}, "dev")
79+
80+
require.Error(t, err)
81+
})
82+
5483
t.Run("reads startup and response detail flags", func(t *testing.T) {
5584
t.Parallel()
5685

internal/flag/notify_test.go

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,6 @@ func TestNotifyConfigFromDynamicGroups(t *testing.T) {
157157
require.Error(t, err)
158158
assert.Contains(t, err.Error(), `invalid "--email.ops.headers"`)
159159
})
160-
161160
}
162161

163162
func TestWebhookConfigsFromDynamicGroupDefaults(t *testing.T) {
@@ -324,15 +323,6 @@ func notifyTestFlagSet(t *testing.T, args []string) *tinyflags.FlagSet {
324323
return fs
325324
}
326325

327-
func notifyTestContentTemplates(prefix string) render.ContentTemplates {
328-
return render.ContentTemplates{
329-
Title: prefix + " title",
330-
ResolvedTitle: prefix + " resolved title",
331-
Text: prefix + " text",
332-
ResolvedText: prefix + " resolved text",
333-
}
334-
}
335-
336326
func notifyTestDefaultContentTemplates() render.ContentTemplates {
337327
return render.ContentTemplates{
338328
Title: `[OVERDUE] Event Notification`,

internal/notification/notifier/builder.go

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/containeroo/overdue/internal/monitor"
1010
"github.com/containeroo/overdue/internal/notification/delivery"
1111
"github.com/containeroo/overdue/internal/notification/dispatch"
12+
"github.com/containeroo/overdue/internal/notification/render"
1213
"github.com/containeroo/overdue/internal/notification/targets"
1314
)
1415

@@ -20,13 +21,13 @@ func New(templateFS fs.FS, cfg Config, logger *slog.Logger) (delivery.Notifier,
2021

2122
notifiers := make([]delivery.Notifier, 0, len(cfg.Webhooks)+len(cfg.Emails))
2223

23-
webhooks, err := buildWebhookNotifiers(templateFS, cfg.Webhooks, logger)
24+
webhooks, err := buildWebhookNotifiers(templateFS, cfg.App, cfg.Webhooks, logger)
2425
if err != nil {
2526
return nil, err
2627
}
2728
notifiers = append(notifiers, webhooks...)
2829

29-
emails, err := buildEmailNotifiers(templateFS, cfg.Emails, logger)
30+
emails, err := buildEmailNotifiers(templateFS, cfg.App, cfg.Emails, logger)
3031
if err != nil {
3132
return nil, err
3233
}
@@ -60,10 +61,10 @@ func ValidateRuntimeTemplates(
6061

6162
// validateTemplates renders configured notification templates with representative events.
6263
func validateTemplates(templateFS fs.FS, cfg Config, alertingEvent, resolvedEvent monitor.Event) error {
63-
if err := validateWebhookTemplates(templateFS, cfg.Webhooks, alertingEvent, resolvedEvent); err != nil {
64+
if err := validateWebhookTemplates(templateFS, cfg.App, cfg.Webhooks, alertingEvent, resolvedEvent); err != nil {
6465
return err
6566
}
66-
if err := validateEmailTemplates(templateFS, cfg.Emails, alertingEvent, resolvedEvent); err != nil {
67+
if err := validateEmailTemplates(templateFS, cfg.App, cfg.Emails, alertingEvent, resolvedEvent); err != nil {
6768
return err
6869
}
6970
return nil
@@ -101,8 +102,9 @@ func templateValidationEvents(
101102
}
102103

103104
// validateWebhookTemplates renders all webhook templates for configured webhook targets.
104-
func validateWebhookTemplates(templateFS fs.FS, configs []targets.WebhookConfig, alertingEvent, resolvedEvent monitor.Event) error {
105+
func validateWebhookTemplates(templateFS fs.FS, app render.AppData, configs []targets.WebhookConfig, alertingEvent, resolvedEvent monitor.Event) error {
105106
for _, cfg := range configs {
107+
cfg.ContentTemplates.App = app
106108
renderer, err := targets.NewWebhookRenderer(templateFS, cfg.Template, cfg.ContentTemplates)
107109
if err != nil {
108110
return err
@@ -115,8 +117,9 @@ func validateWebhookTemplates(templateFS fs.FS, configs []targets.WebhookConfig,
115117
}
116118

117119
// validateEmailTemplates renders all email body and subject templates for configured email targets.
118-
func validateEmailTemplates(templateFS fs.FS, configs []targets.EmailConfig, alertingEvent, resolvedEvent monitor.Event) error {
120+
func validateEmailTemplates(templateFS fs.FS, app render.AppData, configs []targets.EmailConfig, alertingEvent, resolvedEvent monitor.Event) error {
119121
for _, cfg := range configs {
122+
cfg.ContentTemplates.App = app
120123
renderer, err := targets.NewEmailRenderer(
121124
templateFS,
122125
cfg.Template,
@@ -137,12 +140,14 @@ func validateEmailTemplates(templateFS fs.FS, configs []targets.EmailConfig, ale
137140
// buildWebhookNotifiers builds webhook notifiers from typed webhook configuration.
138141
func buildWebhookNotifiers(
139142
templateFS fs.FS,
143+
app render.AppData,
140144
configs []targets.WebhookConfig,
141145
logger *slog.Logger,
142146
) (notifiers []delivery.Notifier, err error) {
143147
notifiers = make([]delivery.Notifier, 0, len(configs))
144148

145149
for _, cfg := range configs {
150+
cfg.ContentTemplates.App = app
146151
renderer, err := targets.NewWebhookRenderer(templateFS, cfg.Template, cfg.ContentTemplates)
147152
if err != nil {
148153
return nil, err
@@ -161,12 +166,14 @@ func buildWebhookNotifiers(
161166
// buildEmailNotifiers builds email notifiers from typed email configuration.
162167
func buildEmailNotifiers(
163168
templateFS fs.FS,
169+
app render.AppData,
164170
configs []targets.EmailConfig,
165171
logger *slog.Logger,
166172
) (notifiers []delivery.Notifier, err error) {
167173
notifiers = make([]delivery.Notifier, 0, len(configs))
168174

169175
for _, cfg := range configs {
176+
cfg.ContentTemplates.App = app
170177
renderer, err := targets.NewEmailRenderer(
171178
templateFS,
172179
cfg.Template,

0 commit comments

Comments
 (0)