sorcery-go/pkg/state/state.go

292 lines
10 KiB
Go
Executable File

// Package state provides the ACID-compliant persistence layer for Sorcery-Go.
//
// The state DB (a single bbolt file at /var/lib/sorcery-go/state/state.db)
// holds five logical buckets:
//
// Journal — every cast's intent, status, and checkpoint
// Manifests — file list per spell+variant (post-commit)
// Tablet — interactive y/n answers per spell+option
// Configs — variant hashes per spell+flags
// Index — file-path -> spell+variant (the Gaze reverse index)
//
// If the power cuts out mid-cast, Manager.Recover() walks the Journal and
// resumes the interrupted task at the last checkpoint, instead of starting
// over. This is the "Single Source of Truth" pillar of the migration.
package state
import (
"bytes"
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"time"
bolt "go.etcd.io/bbolt"
)
// SpellState is the lifecycle status of a spell inside the Journal.
type SpellState string
const (
StatePlanned SpellState = "planned"
StateSummoning SpellState = "summoning"
StateUnpacking SpellState = "unpacking"
StateCasting SpellState = "casting"
StateCommitting SpellState = "committing"
StateInstalled SpellState = "installed"
StateFailed SpellState = "failed"
)
// JournalEntry is one record in the Journal bucket.
type JournalEntry struct {
SpellName string `json:"name"`
Variant string `json:"variant"`
Status SpellState `json:"status"`
Checkpoint string `json:"checkpoint"`
TaskID string `json:"task_id"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// ErrNotFound is returned by GetManifest and WhoOwns when no entry exists.
var ErrNotFound = fmt.Errorf("state: not found")
// Manager wraps the bbolt DB. All methods are safe for concurrent use
// because bbolt transactions are serialised internally.
type Manager struct {
db *bolt.DB
}
// Open opens (or creates) the state DB. The parent directory is created
// with mode 0700 so the file is never world-readable.
func Open(path string) (*Manager, error) {
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, fmt.Errorf("state: mkdir: %w", err)
}
db, err := bolt.Open(path, 0600, nil)
if err != nil {
return nil, fmt.Errorf("state: open %s: %w", path, err)
}
err = db.Update(func(tx *bolt.Tx) error {
for _, b := range []string{"Journal", "Manifests", "Tablet", "Configs", "Index"} {
if _, e := tx.CreateBucketIfNotExists([]byte(b)); e != nil {
return e
}
}
return nil
})
if err != nil {
return nil, err
}
return &Manager{db: db}, nil
}
// Close releases the file lock.
func (m *Manager) Close() error {
if m.db == nil {
return nil
}
return m.db.Close()
}
// RecordJournal writes (or updates) a JournalEntry atomically.
func (m *Manager) RecordJournal(entry JournalEntry) error {
if entry.UpdatedAt.IsZero() {
entry.UpdatedAt = time.Now()
}
if entry.StartedAt.IsZero() {
entry.StartedAt = entry.UpdatedAt
}
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Journal"))
data, err := json.Marshal(entry)
if err != nil {
return err
}
key := journalKey(entry.SpellName, entry.Variant)
return b.Put([]byte(key), data)
})
}
// GetJournal fetches a JournalEntry for a spell+variant. Returns nil, nil
// when no entry exists.
func (m *Manager) GetJournal(spell, variant string) (*JournalEntry, error) {
var out *JournalEntry
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Journal"))
v := b.Get([]byte(journalKey(spell, variant)))
if v == nil {
return nil
}
var e JournalEntry
if err := json.Unmarshal(v, &e); err != nil {
return err
}
out = &e
return nil
})
return out, err
}
// Recover scans the Journal for any entry that is not StateInstalled and
// not StateFailed. Returns the list so the caller can resume them.
// Called once at engine startup.
func (m *Manager) Recover() ([]JournalEntry, error) {
var out []JournalEntry
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Journal"))
return b.ForEach(func(k, v []byte) error {
var e JournalEntry
if err := json.Unmarshal(v, &e); err != nil {
log.Printf("state: recover: warning: corrupt journal entry %s: %v", string(k), err)
return nil
}
if e.Status != StateInstalled && e.Status != StateFailed {
out = append(out, e)
}
return nil
})
})
return out, err
}
// SaveTablet persists a y/n answer for one spell+option.
func (m *Manager) SaveTablet(spell, option string, value bool) error {
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Tablet"))
key := fmt.Sprintf("%s:%s", spell, option)
return b.Put([]byte(key), []byte(boolStr(value)))
})
}
// GetTablet reads a previously stored y/n answer. Returns (false, false)
// when no answer exists yet.
func (m *Manager) GetTablet(spell, option string) (bool, bool) {
var found, value bool
_ = m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Tablet"))
v := b.Get([]byte(fmt.Sprintf("%s:%s", spell, option)))
if v != nil {
found = true
value = string(v) == "true"
}
return nil
})
return value, found
}
// ListTablet returns every recorded y/n answer for a spell. Used by the
// `gaze tablet <spell>` command.
func (m *Manager) ListTablet(spell string) map[string]bool {
out := make(map[string]bool)
prefix := []byte(spell + ":")
_ = m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Tablet"))
c := b.Cursor()
for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() {
opt := string(bytes.TrimPrefix(k, prefix))
out[opt] = string(v) == "true"
}
return nil
})
return out
}
// SaveManifest stores the list of files belonging to a spell+variant.
// It also updates the reverse Index so `gaze whereis /usr/bin/wget` is O(1).
func (m *Manager) SaveManifest(spell, variant string, files []string) error {
return m.db.Update(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Manifests"))
data, err := json.Marshal(files)
if err != nil {
return err
}
if err := b.Put([]byte(spell+":"+variant), data); err != nil {
return err
}
// Update reverse index.
idx := tx.Bucket([]byte("Index"))
owner := []byte(spell + ":" + variant)
for _, f := range files {
if err := idx.Put([]byte(f), owner); err != nil {
return err
}
}
return nil
})
}
// GetManifest returns the stored file list for a spell+variant.
// Returns state.ErrNotFound if the manifest does not exist.
func (m *Manager) GetManifest(spell, variant string) ([]string, error) {
var files []string
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Manifests"))
v := b.Get([]byte(spell + ":" + variant))
if v == nil {
return ErrNotFound
}
return json.Unmarshal(v, &files)
})
return files, err
}
// WhoOwns is the reverse lookup used by `gaze whereis /usr/bin/wget`.
// Returns ("spell", "variant", nil) when found.
// Returns ("", "", state.ErrNotFound) when no entry exists.
func (m *Manager) WhoOwns(path string) (string, string, error) {
var spell, variant string
var resultErr error
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Index"))
v := b.Get([]byte(path))
if v == nil {
resultErr = ErrNotFound
return nil
}
parts := bytes.SplitN(v, ':', 2)
if len(parts) == 2 {
spell = string(parts[0])
variant = string(parts[1])
}
return nil
})
if err != nil {
return "", "", fmt.Errorf("state: whoowns %s: %w", path, err)
}
return spell, variant, resultErr
}
// ListInstalled returns every spell+variant that is currently StateInstalled.
// Used by `sorcery gaze` and the WebUI's Grimoire tab.
func (m *Manager) ListInstalled() ([]JournalEntry, error) {
var out []JournalEntry
err := m.db.View(func(tx *bolt.Tx) error {
b := tx.Bucket([]byte("Journal"))
return b.ForEach(func(k, v []byte) error {
var e JournalEntry
if err := json.Unmarshal(v, &e); err != nil {
return nil
}
if e.Status == StateInstalled {
out = append(out, e)
}
return nil
})
})
return out, err
}
func journalKey(spell, variant string) string {
return spell + ":" + variant
}
func boolStr(b bool) string {
if b {
return "true"
}
return "false"
}