Files
volt/pkg/webhook/webhook.go
Karl Clinger 81ad0b597c Volt CLI: source-available under AGPSL v5.0
Complete infrastructure platform CLI:
- Container runtime (systemd-nspawn)
- VoltVisor VMs (Neutron Stardust / QEMU)
- Stellarium CAS (content-addressed storage)
- ORAS Registry
- GitOps integration
- Landlock LSM security
- Compose orchestration
- Mesh networking

Copyright (c) Armored Gates LLC. All rights reserved.
Licensed under AGPSL v5.0
2026-03-21 00:31:12 -05:00

338 lines
8.5 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/*
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
}