diff --git a/pkg/bootstrap/builder.go b/pkg/bootstrap/builder.go index f3be46b6c..94e5a1542 100644 --- a/pkg/bootstrap/builder.go +++ b/pkg/bootstrap/builder.go @@ -19,7 +19,9 @@ import ( "os" "strconv" "strings" + "time" + "go.probo.inc/probo/pkg/duration" "go.probo.inc/probo/pkg/probod" ) @@ -149,12 +151,12 @@ func (b *Builder) Build() (*probod.FullConfig, error) { }, }, Slack: probod.SlackConfig{ - SenderInterval: b.getEnvIntOrDefault("SLACK_SENDER_INTERVAL", 60), + SenderInterval: b.getEnvDurationOrDefault("SLACK_SENDER_INTERVAL", 60*time.Second), SigningSecret: b.getEnv("CONNECTOR_SLACK_SIGNING_SECRET"), }, Webhook: probod.WebhookConfig{ - SenderInterval: b.getEnvIntOrDefault("WEBHOOK_SENDER_INTERVAL", 5), - CacheTTL: b.getEnvIntOrDefault("WEBHOOK_CACHE_TTL", 86400), + SenderInterval: b.getEnvDurationOrDefault("WEBHOOK_SENDER_INTERVAL", 5*time.Second), + CacheTTL: b.getEnvDurationOrDefault("WEBHOOK_CACHE_TTL", 24*time.Hour), }, }, LLM: probod.LLMSettings{ @@ -451,6 +453,20 @@ func (b *Builder) getEnvIntOrDefault(key string, defaultValue int) int { return defaultValue } +func (b *Builder) getEnvDurationOrDefault(key string, defaultValue time.Duration) duration.Duration { + if value := b.getEnv(key); value != "" { + if intValue, err := strconv.ParseInt(value, 10, 64); err == nil { + return duration.Duration(time.Duration(intValue) * time.Second) + } + + if parsed, err := time.ParseDuration(value); err == nil { + return duration.Duration(parsed) + } + } + + return duration.Duration(defaultValue) +} + func (b *Builder) getEnvFloatOrDefault(key string, defaultValue float64) float64 { if value := b.getEnv(key); value != "" { if floatValue, err := strconv.ParseFloat(value, 64); err == nil { diff --git a/pkg/bootstrap/builder_test.go b/pkg/bootstrap/builder_test.go index 7d9407135..48eef9117 100644 --- a/pkg/bootstrap/builder_test.go +++ b/pkg/bootstrap/builder_test.go @@ -18,9 +18,11 @@ import ( "os" "path/filepath" "testing" + "time" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/duration" "go.probo.inc/probo/pkg/probod" ) @@ -156,10 +158,10 @@ func TestBuilder_Build_Defaults(t *testing.T) { assert.Equal(t, "localhost:1025", cfg.Probod.Notifications.Mailer.SMTP.Addr) assert.False(t, cfg.Probod.Notifications.Mailer.SMTP.TLSRequired) assert.Equal(t, 60, cfg.Probod.Notifications.Mailer.MailerInterval) - assert.Equal(t, 60, cfg.Probod.Notifications.Slack.SenderInterval) + assert.Equal(t, duration.Duration(60*time.Second), cfg.Probod.Notifications.Slack.SenderInterval) assert.Empty(t, cfg.Probod.Notifications.Slack.SigningSecret) - assert.Equal(t, 5, cfg.Probod.Notifications.Webhook.SenderInterval) - assert.Equal(t, 86400, cfg.Probod.Notifications.Webhook.CacheTTL) + assert.Equal(t, duration.Duration(5*time.Second), cfg.Probod.Notifications.Webhook.SenderInterval) + assert.Equal(t, duration.Duration(24*time.Hour), cfg.Probod.Notifications.Webhook.CacheTTL) // LLM config — defaults assert.Equal(t, "openai", cfg.Probod.LLM.Defaults.Provider) @@ -243,8 +245,9 @@ func TestBuilder_Build_CustomValues(t *testing.T) { env["AWS_ENDPOINT"] = "https://s3.example.com" env["AWS_USE_PATH_STYLE"] = "true" // Notifications - env["WEBHOOK_SENDER_INTERVAL"] = "10" - env["WEBHOOK_CACHE_TTL"] = "3600" + env["SLACK_SENDER_INTERVAL"] = "2m" + env["WEBHOOK_SENDER_INTERVAL"] = "10s" + env["WEBHOOK_CACHE_TTL"] = "1h" env["CONNECTOR_SLACK_SIGNING_SECRET"] = "slack-signing-secret" // LLM — providers env["OPENAI_API_KEY"] = "sk-test-key" @@ -318,9 +321,10 @@ func TestBuilder_Build_CustomValues(t *testing.T) { assert.Equal(t, "https://s3.example.com", cfg.Probod.AWS.Endpoint) assert.True(t, cfg.Probod.AWS.UsePathStyle) // Notifications + assert.Equal(t, duration.Duration(2*time.Minute), cfg.Probod.Notifications.Slack.SenderInterval) assert.Equal(t, "slack-signing-secret", cfg.Probod.Notifications.Slack.SigningSecret) - assert.Equal(t, 10, cfg.Probod.Notifications.Webhook.SenderInterval) - assert.Equal(t, 3600, cfg.Probod.Notifications.Webhook.CacheTTL) + assert.Equal(t, duration.Duration(10*time.Second), cfg.Probod.Notifications.Webhook.SenderInterval) + assert.Equal(t, duration.Duration(time.Hour), cfg.Probod.Notifications.Webhook.CacheTTL) // LLM — providers assert.Equal(t, "openai", cfg.Probod.LLM.Providers["openai"].Type) assert.Equal(t, "sk-test-key", cfg.Probod.LLM.Providers["openai"].APIKey) diff --git a/pkg/duration/duration.go b/pkg/duration/duration.go new file mode 100644 index 000000000..94360f868 --- /dev/null +++ b/pkg/duration/duration.go @@ -0,0 +1,52 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package duration + +import ( + "encoding/json" + "fmt" + "time" +) + +type Duration time.Duration + +func (d Duration) MarshalJSON() ([]byte, error) { + return json.Marshal(d.String()) +} + +func (d *Duration) UnmarshalJSON(data []byte) error { + var seconds int64 + if err := json.Unmarshal(data, &seconds); err == nil { + *d = Duration(time.Duration(seconds) * time.Second) + return nil + } + + var value string + if err := json.Unmarshal(data, &value); err == nil { + parsed, err := time.ParseDuration(value) + if err != nil { + return fmt.Errorf("cannot parse duration: %w", err) + } + + *d = Duration(parsed) + return nil + } + + return fmt.Errorf("cannot parse duration from %s", string(data)) +} + +func (d Duration) String() string { + return time.Duration(d).String() +} diff --git a/pkg/duration/duration_test.go b/pkg/duration/duration_test.go new file mode 100644 index 000000000..4eb3a7d15 --- /dev/null +++ b/pkg/duration/duration_test.go @@ -0,0 +1,70 @@ +// Copyright (c) 2026 Probo Inc . +// +// Permission to use, copy, modify, and/or distribute this software for any +// purpose with or without fee is hereby granted, provided that the above +// copyright notice and this permission notice appear in all copies. +// +// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH +// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY +// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, +// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM +// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR +// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR +// PERFORMANCE OF THIS SOFTWARE. + +package duration_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.probo.inc/probo/pkg/duration" +) + +func TestDurationUnmarshalJSONInt(t *testing.T) { + var got duration.Duration + err := json.Unmarshal([]byte(`60`), &got) + require.NoError(t, err) + + assert.Equal(t, duration.Duration(60*time.Second), got) +} + +func TestDurationUnmarshalJSONString(t *testing.T) { + var got duration.Duration + err := json.Unmarshal([]byte(`"5m"`), &got) + require.NoError(t, err) + + assert.Equal(t, duration.Duration(5*time.Minute), got) +} + +func TestDurationMarshalJSONRoundTrip(t *testing.T) { + want := duration.Duration(90 * time.Second) + + data, err := json.Marshal(want) + require.NoError(t, err) + assert.Equal(t, `"1m30s"`, string(data)) + + var got duration.Duration + err = json.Unmarshal(data, &got) + require.NoError(t, err) + assert.Equal(t, want, got) +} + +func TestDurationUnmarshalJSONInvalid(t *testing.T) { + tests := []string{ + `true`, + `1.5`, + `"later"`, + } + + for _, tt := range tests { + t.Run(tt, func(t *testing.T) { + var got duration.Duration + err := json.Unmarshal([]byte(tt), &got) + require.Error(t, err) + }) + } +} diff --git a/pkg/probod/notifications_config.go b/pkg/probod/notifications_config.go index b71e379dc..a93cb3196 100644 --- a/pkg/probod/notifications_config.go +++ b/pkg/probod/notifications_config.go @@ -14,6 +14,8 @@ package probod +import "go.probo.inc/probo/pkg/duration" + type NotificationsConfig struct { Mailer MailerConfig `json:"mailer"` Slack SlackConfig `json:"slack"` @@ -21,6 +23,6 @@ type NotificationsConfig struct { } type WebhookConfig struct { - SenderInterval int `json:"sender-interval"` - CacheTTL int `json:"cache-ttl"` + SenderInterval duration.Duration `json:"sender-interval"` + CacheTTL duration.Duration `json:"cache-ttl"` } diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go index 5c5dbcc67..23e39be8b 100644 --- a/pkg/probod/probod.go +++ b/pkg/probod/probod.go @@ -31,6 +31,7 @@ import ( "go.probo.inc/probo/packages/emails" pemutil "go.probo.inc/probo/pkg/crypto/pem" + "go.probo.inc/probo/pkg/duration" "github.com/aws/aws-sdk-go-v2/service/s3" proxyproto "github.com/pires/go-proxyproto" @@ -195,11 +196,11 @@ func New() *Implm { }, }, Slack: SlackConfig{ - SenderInterval: 60, + SenderInterval: duration.Duration(60 * time.Second), }, Webhook: WebhookConfig{ - SenderInterval: 5, - CacheTTL: 86400, + SenderInterval: duration.Duration(5 * time.Second), + CacheTTL: duration.Duration(24 * time.Hour), }, }, CustomDomains: CustomDomainsConfig{ @@ -589,7 +590,7 @@ func (impl *Implm) Run( slackSenderCtx, stopSlackSender := context.WithCancel(context.Background()) slackSender := slack.NewSender(pgClient, l.Named("slack-sender"), encryptionKey, slack.Config{ - Interval: time.Duration(impl.cfg.Notifications.Slack.SenderInterval) * time.Second, + Interval: time.Duration(impl.cfg.Notifications.Slack.SenderInterval), }) wg.Go( func() { @@ -601,8 +602,8 @@ func (impl *Implm) Run( webhookSenderCtx, stopWebhookSender := context.WithCancel(context.Background()) webhookSender := webhook.NewSender(pgClient, l.Named("webhook-sender"), webhook.Config{ - Interval: time.Duration(impl.cfg.Notifications.Webhook.SenderInterval) * time.Second, - CacheTTL: time.Duration(impl.cfg.Notifications.Webhook.CacheTTL) * time.Second, + Interval: time.Duration(impl.cfg.Notifications.Webhook.SenderInterval), + CacheTTL: time.Duration(impl.cfg.Notifications.Webhook.CacheTTL), EncryptionKey: encryptionKey, }) wg.Go( diff --git a/pkg/probod/slack_config.go b/pkg/probod/slack_config.go index c697a3423..12428e4e6 100644 --- a/pkg/probod/slack_config.go +++ b/pkg/probod/slack_config.go @@ -14,7 +14,9 @@ package probod +import "go.probo.inc/probo/pkg/duration" + type SlackConfig struct { - SenderInterval int `json:"sender-interval"` - SigningSecret string `json:"signing-secret"` + SenderInterval duration.Duration `json:"sender-interval"` + SigningSecret string `json:"signing-secret"` }