/* Webhook — Notification system for Volt events. Sends HTTP webhook notifications when events occur: - Deploy complete/failed - Container crash - Health check failures - Scaling events Supports: - HTTP POST webhooks (JSON payload) - Slack-formatted messages - Email (via configured SMTP) - Custom headers and authentication Configuration stored in /etc/volt/webhooks.yaml Copyright (c) Armored Gates LLC. All rights reserved. */ package webhook import ( "bytes" "encoding/json" "fmt" "net/http" "os" "path/filepath" "strings" "sync" "time" "gopkg.in/yaml.v3" ) // ── Constants ──────────────────────────────────────────────────────────────── const ( DefaultConfigPath = "/etc/volt/webhooks.yaml" DefaultTimeout = 10 * time.Second MaxRetries = 3 ) // ── Event Types ────────────────────────────────────────────────────────────── // EventType defines the types of events that trigger notifications. type EventType string const ( EventDeploy EventType = "deploy" EventDeployFail EventType = "deploy.fail" EventCrash EventType = "crash" EventHealthFail EventType = "health.fail" EventHealthOK EventType = "health.ok" EventScale EventType = "scale" EventRestart EventType = "restart" EventCreate EventType = "create" EventDelete EventType = "delete" ) // ── Webhook Config ─────────────────────────────────────────────────────────── // Hook defines a single webhook endpoint. type Hook struct { Name string `yaml:"name" json:"name"` URL string `yaml:"url" json:"url"` Events []EventType `yaml:"events" json:"events"` Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"` Secret string `yaml:"secret,omitempty" json:"secret,omitempty"` // For HMAC signing Format string `yaml:"format,omitempty" json:"format,omitempty"` // "json" (default) or "slack" Enabled bool `yaml:"enabled" json:"enabled"` } // Config holds all webhook configurations. type Config struct { Hooks []Hook `yaml:"hooks" json:"hooks"` } // ── Notification Payload ───────────────────────────────────────────────────── // Payload is the JSON body sent to webhook endpoints. type Payload struct { Event EventType `json:"event"` Timestamp string `json:"timestamp"` Hostname string `json:"hostname"` Workload string `json:"workload,omitempty"` Message string `json:"message"` Details any `json:"details,omitempty"` } // ── Manager ────────────────────────────────────────────────────────────────── // Manager handles webhook registration and dispatch. type Manager struct { configPath string hooks []Hook mu sync.RWMutex client *http.Client } // NewManager creates a webhook manager. func NewManager(configPath string) *Manager { if configPath == "" { configPath = DefaultConfigPath } return &Manager{ configPath: configPath, client: &http.Client{ Timeout: DefaultTimeout, }, } } // Load reads webhook configurations from disk. func (m *Manager) Load() error { m.mu.Lock() defer m.mu.Unlock() data, err := os.ReadFile(m.configPath) if err != nil { if os.IsNotExist(err) { m.hooks = nil return nil } return fmt.Errorf("webhook: read config: %w", err) } var config Config if err := yaml.Unmarshal(data, &config); err != nil { return fmt.Errorf("webhook: parse config: %w", err) } m.hooks = config.Hooks return nil } // Save writes the current webhook configurations to disk. func (m *Manager) Save() error { m.mu.RLock() config := Config{Hooks: m.hooks} m.mu.RUnlock() data, err := yaml.Marshal(config) if err != nil { return fmt.Errorf("webhook: marshal config: %w", err) } dir := filepath.Dir(m.configPath) if err := os.MkdirAll(dir, 0755); err != nil { return fmt.Errorf("webhook: create dir: %w", err) } return os.WriteFile(m.configPath, data, 0640) } // AddHook registers a new webhook. func (m *Manager) AddHook(hook Hook) error { m.mu.Lock() defer m.mu.Unlock() // Check for duplicate name for _, h := range m.hooks { if h.Name == hook.Name { return fmt.Errorf("webhook: hook %q already exists", hook.Name) } } hook.Enabled = true m.hooks = append(m.hooks, hook) return nil } // RemoveHook removes a webhook by name. func (m *Manager) RemoveHook(name string) error { m.mu.Lock() defer m.mu.Unlock() filtered := make([]Hook, 0, len(m.hooks)) found := false for _, h := range m.hooks { if h.Name == name { found = true continue } filtered = append(filtered, h) } if !found { return fmt.Errorf("webhook: hook %q not found", name) } m.hooks = filtered return nil } // ListHooks returns all configured webhooks. func (m *Manager) ListHooks() []Hook { m.mu.RLock() defer m.mu.RUnlock() result := make([]Hook, len(m.hooks)) copy(result, m.hooks) return result } // Dispatch sends a notification to all hooks subscribed to the given event type. func (m *Manager) Dispatch(event EventType, workload, message string, details any) { m.mu.RLock() hooks := make([]Hook, 0) for _, h := range m.hooks { if !h.Enabled { continue } if hookMatchesEvent(h, event) { hooks = append(hooks, h) } } m.mu.RUnlock() if len(hooks) == 0 { return } hostname, _ := os.Hostname() payload := Payload{ Event: event, Timestamp: time.Now().UTC().Format(time.RFC3339), Hostname: hostname, Workload: workload, Message: message, Details: details, } for _, hook := range hooks { go m.send(hook, payload) } } // ── Internal ───────────────────────────────────────────────────────────────── func hookMatchesEvent(hook Hook, event EventType) bool { for _, e := range hook.Events { if e == event { return true } // Prefix match: "deploy" matches "deploy.fail" if strings.HasPrefix(string(event), string(e)+".") { return true } // Wildcard if e == "*" { return true } } return false } func (m *Manager) send(hook Hook, payload Payload) { var body []byte var contentType string if hook.Format == "slack" { slackMsg := map[string]any{ "text": formatSlackMessage(payload), } body, _ = json.Marshal(slackMsg) contentType = "application/json" } else { body, _ = json.Marshal(payload) contentType = "application/json" } for attempt := 0; attempt < MaxRetries; attempt++ { req, err := http.NewRequest("POST", hook.URL, bytes.NewReader(body)) if err != nil { continue } req.Header.Set("Content-Type", contentType) req.Header.Set("User-Agent", "Volt-Webhook/1.0") for k, v := range hook.Headers { req.Header.Set(k, v) } resp, err := m.client.Do(req) if err != nil { if attempt < MaxRetries-1 { time.Sleep(time.Duration(attempt+1) * 2 * time.Second) continue } fmt.Fprintf(os.Stderr, "webhook: failed to send to %s after %d attempts: %v\n", hook.Name, MaxRetries, err) return } resp.Body.Close() if resp.StatusCode >= 200 && resp.StatusCode < 300 { return // Success } if resp.StatusCode >= 500 && attempt < MaxRetries-1 { time.Sleep(time.Duration(attempt+1) * 2 * time.Second) continue } fmt.Fprintf(os.Stderr, "webhook: %s returned HTTP %d\n", hook.Name, resp.StatusCode) return } } func formatSlackMessage(payload Payload) string { emoji := "ℹ️" switch payload.Event { case EventDeploy: emoji = "🚀" case EventDeployFail: emoji = "❌" case EventCrash: emoji = "💥" case EventHealthFail: emoji = "🏥" case EventHealthOK: emoji = "✅" case EventScale: emoji = "📈" case EventRestart: emoji = "🔄" } msg := fmt.Sprintf("%s *[%s]* %s", emoji, payload.Event, payload.Message) if payload.Workload != "" { msg += fmt.Sprintf("\n• Workload: `%s`", payload.Workload) } msg += fmt.Sprintf("\n• Host: `%s`", payload.Hostname) msg += fmt.Sprintf("\n• Time: %s", payload.Timestamp) return msg }