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
This commit is contained in:
337
pkg/webhook/webhook.go
Normal file
337
pkg/webhook/webhook.go
Normal file
@@ -0,0 +1,337 @@
|
||||
/*
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user