Files
ntfy-discord/internal/config/config.go
Billy D. 1c1a9cc35f
Some checks failed
CI / Lint (push) Successful in 58s
CI / Test (push) Successful in 1m15s
CI / Release (push) Successful in 6s
CI / Docker Build & Push (push) Failing after 23s
CI / Notify (push) Successful in 1s
fix: add golangci-lint config and fix all lint errors
- Add .golangci.yml with v2 config (errcheck, govet, staticcheck, misspell, etc.)
- Fix 32 errcheck issues across config, discord, ntfy, server packages
- Fix misspelling: cancelled → canceled
- Fix staticcheck: use append(slice...) instead of loop
- Fix staticcheck: remove empty error branch
- Use t.Setenv instead of os.Setenv/Unsetenv in tests
- Update CI workflow: add lint job, release tagging, ntfy notifications
2026-02-14 09:15:01 -05:00

234 lines
5.7 KiB
Go

package config
import (
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"git.daviestechlabs.io/daviestechlabs/ntfy-discord/internal/vault"
)
// Config holds the application configuration
type Config struct {
NtfyURL string
NtfyTopics []string
SecretsPath string
HTTPPort string
// Vault configuration
VaultEnabled bool
vaultClient *vault.Client
mu sync.RWMutex
webhookURL string
}
// Load creates a new Config from environment variables
func Load(ctx context.Context) (*Config, error) {
cfg := &Config{
NtfyURL: getEnv("NTFY_URL", "http://ntfy.observability.svc.cluster.local"),
SecretsPath: getEnv("SECRETS_PATH", ""),
HTTPPort: getEnv("HTTP_PORT", "8080"),
VaultEnabled: getEnv("VAULT_ENABLED", "false") == "true",
}
// Parse topics
topics := getEnv("NTFY_TOPICS", "")
if topics != "" {
cfg.NtfyTopics = strings.Split(topics, ",")
for i := range cfg.NtfyTopics {
cfg.NtfyTopics[i] = strings.TrimSpace(cfg.NtfyTopics[i])
}
}
// Try Vault first if enabled
if cfg.VaultEnabled {
if err := cfg.initVault(ctx); err != nil {
slog.Warn("vault init failed, falling back to file/env", "error", err)
} else {
// Load webhook from Vault
if webhookURL, err := cfg.vaultClient.GetSecret(ctx, "webhook-url"); err == nil {
cfg.mu.Lock()
cfg.webhookURL = webhookURL
cfg.mu.Unlock()
slog.Info("loaded webhook URL from vault")
} else {
slog.Warn("failed to get webhook from vault", "error", err)
}
}
}
// Fall back to file-based secret
if cfg.webhookURL == "" && cfg.SecretsPath != "" {
if err := cfg.loadWebhookFromSecret(); err != nil {
slog.Warn("failed to load webhook from secret, trying env", "error", err)
}
}
// Fall back to environment variable
if cfg.webhookURL == "" {
cfg.webhookURL = getEnv("DISCORD_WEBHOOK_URL", "")
}
if cfg.webhookURL == "" {
slog.Warn("no Discord webhook URL configured")
}
slog.Info("config loaded",
"ntfy_url", cfg.NtfyURL,
"topics", cfg.NtfyTopics,
"secrets_path", cfg.SecretsPath,
"vault_enabled", cfg.VaultEnabled,
)
return cfg, nil
}
// initVault initializes the Vault client
func (c *Config) initVault(ctx context.Context) error {
vaultCfg := vault.Config{
Address: getEnv("VAULT_ADDR", "http://vault.vault.svc.cluster.local:8200"),
AuthMethod: getEnv("VAULT_AUTH_METHOD", "kubernetes"),
Role: getEnv("VAULT_ROLE", "ntfy-discord"),
MountPath: getEnv("VAULT_MOUNT_PATH", "secret"),
SecretPath: getEnv("VAULT_SECRET_PATH", "ntfy-discord"),
TokenPath: getEnv("VAULT_TOKEN_PATH", ""),
}
client, err := vault.NewClient(vaultCfg)
if err != nil {
return err
}
c.vaultClient = client
return nil
}
// WebhookURL returns the current webhook URL (thread-safe)
func (c *Config) WebhookURL() string {
c.mu.RLock()
defer c.mu.RUnlock()
return c.webhookURL
}
// loadWebhookFromSecret reads the webhook URL from the mounted secret
func (c *Config) loadWebhookFromSecret() error {
path := filepath.Join(c.SecretsPath, "webhook-url")
data, err := os.ReadFile(path)
if err != nil {
return err
}
c.mu.Lock()
c.webhookURL = strings.TrimSpace(string(data))
c.mu.Unlock()
slog.Debug("loaded webhook URL from secret")
return nil
}
// WatchSecrets watches the secrets directory for changes and reloads
func (c *Config) WatchSecrets(ctx context.Context) {
// Start Vault watcher if enabled
if c.vaultClient != nil {
go c.watchVaultSecrets(ctx)
}
// Start file watcher if secrets path is set
if c.SecretsPath != "" {
go c.watchFileSecrets(ctx)
}
}
// watchVaultSecrets periodically refreshes secrets from Vault
func (c *Config) watchVaultSecrets(ctx context.Context) {
interval := 30 * time.Second
ticker := time.NewTicker(interval)
defer ticker.Stop()
slog.Info("watching vault secrets", "interval", interval)
for {
select {
case <-ticker.C:
if webhookURL, err := c.vaultClient.GetSecret(ctx, "webhook-url"); err == nil {
c.mu.Lock()
if c.webhookURL != webhookURL {
c.webhookURL = webhookURL
slog.Info("webhook URL updated from vault")
}
c.mu.Unlock()
} else {
slog.Error("failed to refresh webhook from vault", "error", err)
}
case <-ctx.Done():
return
}
}
}
// watchFileSecrets watches the secrets directory for changes
func (c *Config) watchFileSecrets(ctx context.Context) {
watcher, err := fsnotify.NewWatcher()
if err != nil {
slog.Error("failed to create fsnotify watcher", "error", err)
return
}
defer func() { _ = watcher.Close() }()
// Watch the secrets directory
// Kubernetes updates secrets by changing the symlink, so watch the parent
if err := watcher.Add(c.SecretsPath); err != nil {
slog.Error("failed to watch secrets path", "error", err, "path", c.SecretsPath)
return
}
slog.Info("watching secrets for changes", "path", c.SecretsPath)
for {
select {
case event, ok := <-watcher.Events:
if !ok {
return
}
// Kubernetes updates secrets via symlink, which triggers Create events
if event.Has(fsnotify.Create) || event.Has(fsnotify.Write) {
slog.Info("secret changed, reloading", "event", event.Name)
if err := c.loadWebhookFromSecret(); err != nil {
slog.Error("failed to reload webhook from secret", "error", err)
} else {
slog.Info("webhook URL reloaded successfully")
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
slog.Error("fsnotify error", "error", err)
case <-ctx.Done():
return
}
}
}
// Close cleans up resources
func (c *Config) Close() error {
if c.vaultClient != nil {
return c.vaultClient.Close()
}
return nil
}
func getEnv(key, defaultVal string) string {
if val := os.Getenv(key); val != "" {
return val
}
return defaultVal
}