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
338 lines
8.5 KiB
Go
338 lines
8.5 KiB
Go
/*
|
||
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
|
||
}
|