292 lines
10 KiB
Go
Executable File
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"
|
|
}
|