Files
volt/pkg/deploy/history.go
Karl Clinger 0ebe75b2ca 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 02:08:15 -05:00

187 lines
5.4 KiB
Go

/*
History — Persistent deployment history for Volt.
Stores deployment records as YAML in /var/lib/volt/deployments/.
Each target gets its own history file to keep lookups fast.
Copyright (c) Armored Gates LLC. All rights reserved.
*/
package deploy
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"gopkg.in/yaml.v3"
)
// ── Constants ────────────────────────────────────────────────────────────────
const (
// DefaultHistoryDir is where deployment history files are stored.
DefaultHistoryDir = "/var/lib/volt/deployments"
)
// ── History Entry ────────────────────────────────────────────────────────────
// HistoryEntry records a single deployment operation.
type HistoryEntry struct {
ID string `yaml:"id" json:"id"`
Target string `yaml:"target" json:"target"`
Strategy string `yaml:"strategy" json:"strategy"`
OldRef string `yaml:"old_ref" json:"old_ref"`
NewRef string `yaml:"new_ref" json:"new_ref"`
Status string `yaml:"status" json:"status"` // "complete", "failed", "rolling-back"
StartedAt time.Time `yaml:"started_at" json:"started_at"`
CompletedAt time.Time `yaml:"completed_at" json:"completed_at"`
InstancesUpdated int `yaml:"instances_updated" json:"instances_updated"`
Message string `yaml:"message,omitempty" json:"message,omitempty"`
}
// ── History Store ────────────────────────────────────────────────────────────
// HistoryStore manages deployment history on disk.
type HistoryStore struct {
dir string
mu sync.Mutex
}
// NewHistoryStore creates a history store at the given directory.
func NewHistoryStore(dir string) *HistoryStore {
if dir == "" {
dir = DefaultHistoryDir
}
return &HistoryStore{dir: dir}
}
// Dir returns the history directory path.
func (h *HistoryStore) Dir() string {
return h.dir
}
// historyFile returns the path to the history file for a target.
func (h *HistoryStore) historyFile(target string) string {
// Sanitize the target name for use as a filename.
safe := strings.Map(func(r rune) rune {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
(r >= '0' && r <= '9') || r == '-' || r == '_' {
return r
}
return '_'
}, target)
return filepath.Join(h.dir, safe+".yaml")
}
// Append adds a deployment entry to the target's history file.
func (h *HistoryStore) Append(entry HistoryEntry) error {
h.mu.Lock()
defer h.mu.Unlock()
if err := os.MkdirAll(h.dir, 0755); err != nil {
return fmt.Errorf("history: create dir: %w", err)
}
// Load existing entries.
entries, _ := h.readEntries(entry.Target) // ignore error on first write
// Append and write.
entries = append(entries, entry)
return h.writeEntries(entry.Target, entries)
}
// ListByTarget returns all deployment history for a target, most recent first.
func (h *HistoryStore) ListByTarget(target string) ([]HistoryEntry, error) {
h.mu.Lock()
defer h.mu.Unlock()
entries, err := h.readEntries(target)
if err != nil {
return nil, err
}
// Sort by StartedAt descending (most recent first).
sort.Slice(entries, func(i, j int) bool {
return entries[i].StartedAt.After(entries[j].StartedAt)
})
return entries, nil
}
// ListAll returns all deployment history across all targets, most recent first.
func (h *HistoryStore) ListAll() ([]HistoryEntry, error) {
h.mu.Lock()
defer h.mu.Unlock()
files, err := filepath.Glob(filepath.Join(h.dir, "*.yaml"))
if err != nil {
return nil, fmt.Errorf("history: glob: %w", err)
}
var all []HistoryEntry
for _, f := range files {
data, err := os.ReadFile(f)
if err != nil {
continue
}
var entries []HistoryEntry
if err := yaml.Unmarshal(data, &entries); err != nil {
continue
}
all = append(all, entries...)
}
sort.Slice(all, func(i, j int) bool {
return all[i].StartedAt.After(all[j].StartedAt)
})
return all, nil
}
// readEntries loads entries from the history file for a target.
// Returns empty slice (not error) if file doesn't exist.
func (h *HistoryStore) readEntries(target string) ([]HistoryEntry, error) {
filePath := h.historyFile(target)
data, err := os.ReadFile(filePath)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("history: read %s: %w", filePath, err)
}
var entries []HistoryEntry
if err := yaml.Unmarshal(data, &entries); err != nil {
return nil, fmt.Errorf("history: parse %s: %w", filePath, err)
}
return entries, nil
}
// writeEntries writes entries to the history file for a target.
func (h *HistoryStore) writeEntries(target string, entries []HistoryEntry) error {
filePath := h.historyFile(target)
data, err := yaml.Marshal(entries)
if err != nil {
return fmt.Errorf("history: marshal: %w", err)
}
// Atomic write: tmp + rename.
tmpPath := filePath + ".tmp"
if err := os.WriteFile(tmpPath, data, 0644); err != nil {
return fmt.Errorf("history: write %s: %w", tmpPath, err)
}
if err := os.Rename(tmpPath, filePath); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("history: rename %s: %w", filePath, err)
}
return nil
}