// 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 ` 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" }