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:
186
pkg/deploy/history.go
Normal file
186
pkg/deploy/history.go
Normal file
@@ -0,0 +1,186 @@
|
||||
/*
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user