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:
Karl Clinger
2026-03-21 00:30:23 -05:00
commit 81ad0b597c
106 changed files with 35984 additions and 0 deletions

337
pkg/webhook/webhook.go Normal file
View 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
}