/* 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 }