Files
DDI/agent/core/store.go
T
beug.k@beugsiud.deandClaude Opus 5.5 c96eb33e58 feat(agent): configuration file and file-based agent state
Agent configuration (agent-<role>.yaml, strict decoding, role section
passed undecoded to the role factory) and the state directory of ADR
0011: identity with interrupted-renewal recovery, verified revisions
under revisions/<n>/, state.json with apply results, lease sequence and
queued deployment reports, atomic writes, pruning and a lock file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-13 19:05:16 +02:00

363 lines
11 KiB
Go

// SPDX-License-Identifier: Apache-2.0
package core
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"time"
"git.beugsiud.de/beug.k/DDI/internal/bundle"
)
// Layout of the state directory (docs/adr/0011-agent-zustand-in-dateien.md).
const (
identityDir = "identity"
revisionsDir = "revisions"
stateFile = "state.json"
lockFile = "agent.lock"
manifestFile = "manifest.json"
signatureFile = "manifest.sig"
tmpPrefix = ".tmp-"
oldPrefix = ".old-"
)
// KeepRevisions is the number of stored revisions kept by Prune (the applied
// and the last good revision are kept in addition).
const KeepRevisions = 10
// maxPendingReports bounds the queue of unsent deployment reports.
const maxPendingReports = 100
// Store manages the agent's state directory. Writes are atomic: files are
// written to a temporary file, synced and renamed; directories are built
// under a temporary name and renamed into place.
type Store struct {
dir string
}
// OpenStore creates the state directory if necessary.
func OpenStore(dir string) (*Store, error) {
if dir == "" {
return nil, errors.New("state directory is not set")
}
if err := os.MkdirAll(filepath.Join(dir, revisionsDir), 0o750); err != nil {
return nil, fmt.Errorf("create state directory: %w", err)
}
return &Store{dir: dir}, nil
}
// Dir returns the state directory.
func (s *Store) Dir() string { return s.dir }
// Lock takes the exclusive lock of the state directory. The returned
// function releases it.
func (s *Store) Lock() (func(), error) {
return lockPath(filepath.Join(s.dir, lockFile))
}
// Trigger says why a revision was applied.
type Trigger string
// Triggers.
const (
TriggerPull Trigger = "pull"
TriggerPush Trigger = "push"
)
// Outcome is the result of applying a revision.
type Outcome string
// Outcomes (see DeploymentOutcome in the protocol).
const (
OutcomeSucceeded Outcome = "succeeded"
OutcomeRejected Outcome = "rejected"
OutcomeRolledBack Outcome = "rolled_back"
OutcomeFailed Outcome = "failed"
)
// DeploymentReport is the outcome of one apply, queued in state.json until
// the source accepted it.
type DeploymentReport struct {
DeploymentID string `json:"deployment_id,omitempty"`
Revision uint64 `json:"revision"`
Trigger Trigger `json:"trigger"`
Outcome Outcome `json:"outcome"`
Message string `json:"message,omitempty"`
ActiveRevision uint64 `json:"active_revision"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at"`
}
// State is persisted in state.json.
type State struct {
// Applied is the revision whose configuration is installed (0 = none).
Applied uint64 `json:"applied_revision"`
// LastGood is the last revision that passed its health check.
LastGood uint64 `json:"last_good_revision"`
// LastUnsuccessful is the highest revision that was rejected, rolled
// back or failed. Pulls never retry it; only a newer revision or an
// explicit push does.
LastUnsuccessful uint64 `json:"last_unsuccessful_revision,omitempty"`
// LastResult is the most recent apply outcome.
LastResult *DeploymentReport `json:"last_result,omitempty"`
// LeaseSequence is the sequence number of the last lease report.
LeaseSequence uint64 `json:"lease_sequence,omitempty"`
// PendingReports are deployment reports not yet accepted by the server.
PendingReports []DeploymentReport `json:"pending_reports,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// LoadState reads state.json; a missing file yields the zero state.
func (s *Store) LoadState() (*State, error) {
var st State
data, err := os.ReadFile(filepath.Join(s.dir, stateFile))
if errors.Is(err, fs.ErrNotExist) {
return &st, nil
}
if err != nil {
return nil, fmt.Errorf("read state: %w", err)
}
if err := json.Unmarshal(data, &st); err != nil {
return nil, fmt.Errorf("decode %s: %w", stateFile, err)
}
return &st, nil
}
// SaveState writes state.json atomically.
func (s *Store) SaveState(st *State) error {
if n := len(st.PendingReports); n > maxPendingReports {
st.PendingReports = slices.Clone(st.PendingReports[n-maxPendingReports:])
}
data, err := json.MarshalIndent(st, "", " ")
if err != nil {
return fmt.Errorf("encode state: %w", err)
}
return writeFileAtomic(filepath.Join(s.dir, stateFile), append(data, '\n'), 0o600)
}
// revisionPath returns the directory of revision n.
func (s *Store) revisionPath(n uint64) string {
return filepath.Join(s.dir, revisionsDir, strconv.FormatUint(n, 10))
}
// reservedArtifact reports whether an artifact path would collide with the
// files the store keeps next to the artifacts.
func reservedArtifact(p string) bool {
return p == manifestFile || p == signatureFile
}
// StoreRevision writes a verified bundle to revisions/<n>/ (manifest,
// signature and the artifacts at their manifest paths) and returns it. An
// existing directory for the same number is replaced.
func (s *Store) StoreRevision(m *bundle.Manifest, manifest, signature []byte, contents map[string][]byte) (Revision, error) {
parent := filepath.Join(s.dir, revisionsDir)
tmp, err := os.MkdirTemp(parent, tmpPrefix)
if err != nil {
return Revision{}, fmt.Errorf("create revision directory: %w", err)
}
defer func() { _ = os.RemoveAll(tmp) }()
if err := writeFileSync(filepath.Join(tmp, manifestFile), manifest, 0o640); err != nil {
return Revision{}, err
}
if err := writeFileSync(filepath.Join(tmp, signatureFile), signature, 0o640); err != nil {
return Revision{}, err
}
dirs := map[string]bool{tmp: true}
for _, f := range m.Files {
if err := bundle.CheckPath(f.Path); err != nil {
return Revision{}, err
}
if reservedArtifact(f.Path) {
return Revision{}, fmt.Errorf("artifact path %s is reserved", f.Path)
}
target := filepath.Join(tmp, filepath.FromSlash(f.Path))
dir := filepath.Dir(target)
if !dirs[dir] {
if err := os.MkdirAll(dir, 0o750); err != nil {
return Revision{}, fmt.Errorf("create directory for %s: %w", f.Path, err)
}
dirs[dir] = true
}
if err := writeFileSync(target, contents[f.Path], fs.FileMode(f.Mode&0o777)); err != nil {
return Revision{}, err
}
}
for d := range dirs {
syncDir(d)
}
final := s.revisionPath(m.Revision)
if err := replaceDir(tmp, final); err != nil {
return Revision{}, err
}
syncDir(parent)
return Revision{Number: m.Revision, Dir: final, Files: slices.Clone(m.Files)}, nil
}
// LoadRevision reads a stored revision. Stored revisions were verified
// before they were written (ADR 0009), so only the manifest is parsed.
func (s *Store) LoadRevision(n uint64) (Revision, error) {
dir := s.revisionPath(n)
data, err := os.ReadFile(filepath.Join(dir, manifestFile)) //nolint:gosec // path built from the state directory
if err != nil {
return Revision{}, fmt.Errorf("load revision %d: %w", n, err)
}
var m bundle.Manifest
if err := json.Unmarshal(data, &m); err != nil {
return Revision{}, fmt.Errorf("load revision %d: %w", n, err)
}
if m.Revision != n {
return Revision{}, fmt.Errorf("load revision %d: manifest is for revision %d", n, m.Revision)
}
return Revision{Number: n, Dir: dir, Files: m.Files}, nil
}
// ListRevisions returns the stored revision numbers in ascending order.
func (s *Store) ListRevisions() ([]uint64, error) {
entries, err := os.ReadDir(filepath.Join(s.dir, revisionsDir))
if err != nil {
return nil, fmt.Errorf("list revisions: %w", err)
}
var out []uint64
for _, e := range entries {
if !e.IsDir() {
continue
}
n, err := strconv.ParseUint(e.Name(), 10, 64)
if err != nil || n == 0 || strconv.FormatUint(n, 10) != e.Name() {
continue
}
out = append(out, n)
}
slices.Sort(out)
return out, nil
}
// Prune keeps the newest keep revisions plus the protected ones, removes the
// others and cleans up leftovers of interrupted writes.
func (s *Store) Prune(keep int, protect ...uint64) error {
parent := filepath.Join(s.dir, revisionsDir)
entries, err := os.ReadDir(parent)
if err != nil {
return fmt.Errorf("prune revisions: %w", err)
}
for _, e := range entries {
if strings.HasPrefix(e.Name(), tmpPrefix) || strings.HasPrefix(e.Name(), oldPrefix) {
_ = os.RemoveAll(filepath.Join(parent, e.Name()))
}
}
revs, err := s.ListRevisions()
if err != nil {
return err
}
var errs []error
for i, n := range revs {
if len(revs)-i <= keep || slices.Contains(protect, n) {
continue
}
if err := os.RemoveAll(s.revisionPath(n)); err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// writeFileSync creates path with data and syncs it.
func writeFileSync(path string, data []byte, perm fs.FileMode) error {
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, perm) //nolint:gosec // path built from the state directory
if err != nil {
return fmt.Errorf("write %s: %w", filepath.Base(path), err)
}
if err := finishFile(f, data, perm); err != nil {
return fmt.Errorf("write %s: %w", filepath.Base(path), err)
}
return nil
}
func finishFile(f *os.File, data []byte, perm fs.FileMode) error {
_, err := f.Write(data)
if err == nil {
err = f.Chmod(perm) // not subject to the umask
if errors.Is(err, errors.ErrUnsupported) {
err = nil
}
}
if err == nil {
err = f.Sync()
}
if cerr := f.Close(); err == nil {
err = cerr
}
return err
}
// writeFileAtomic replaces path with data: temporary file, fsync, rename,
// fsync of the directory.
func writeFileAtomic(target string, data []byte, perm fs.FileMode) error {
dir := filepath.Dir(target)
f, err := os.CreateTemp(dir, tmpPrefix+filepath.Base(target)+"-")
if err != nil {
return fmt.Errorf("write %s: %w", filepath.Base(target), err)
}
tmp := f.Name()
if err := finishFile(f, data, perm); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("write %s: %w", filepath.Base(target), err)
}
if err := os.Rename(tmp, target); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("write %s: %w", filepath.Base(target), err)
}
syncDir(dir)
return nil
}
// replaceDir moves the fully written directory tmp to final. An existing
// final directory is moved aside first and removed afterwards.
func replaceDir(tmp, final string) error {
parent := filepath.Dir(final)
var old string
if _, err := os.Stat(final); err == nil {
old = filepath.Join(parent, oldPrefix+filepath.Base(final)+"-"+randomSuffix())
if err := os.Rename(final, old); err != nil {
return fmt.Errorf("replace %s: %w", filepath.Base(final), err)
}
}
if err := os.Rename(tmp, final); err != nil {
if old != "" {
_ = os.Rename(old, final)
}
return fmt.Errorf("replace %s: %w", filepath.Base(final), err)
}
if old != "" {
_ = os.RemoveAll(old)
}
return nil
}
func randomSuffix() string {
var b [6]byte
_, _ = rand.Read(b[:])
return hex.EncodeToString(b[:])
}
// syncDir flushes directory metadata (renames). Errors are ignored because
// some platforms cannot sync directories; the data itself was synced.
func syncDir(dir string) {
d, err := os.Open(dir) //nolint:gosec // path built from the state directory
if err != nil {
return
}
_ = d.Sync()
_ = d.Close()
}