- GetDesiredStateResponse.deployment_id set: the desired revision is applied as a push with that ID, also in manual mode (ADR 0022). - The reports of the last 20 push deployments are kept in state.json. A resent push with a handled deployment ID is not applied again; its report is delivered again unless it is still queued. - A push already queued with the same deployment ID is not queued twice. - Reports the server refuses for good (InvalidArgument, NotFound, AlreadyExists, FailedPrecondition, OutOfRange) are dropped instead of blocking the report queue. - Tests for polling pushes, resent pushes, mode changes via a new Welcome, persistence and bounds of handled deployments. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
407 lines
13 KiB
Go
407 lines
13 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
|
|
|
|
// maxHandledDeployments bounds the remembered push deployments, and
|
|
// handledMessageLen the message kept with each of them.
|
|
const (
|
|
maxHandledDeployments = 20
|
|
handledMessageLen = 1024
|
|
)
|
|
|
|
// 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"`
|
|
// HandledDeployments are the reports of the most recent push
|
|
// deployments (oldest first, at most maxHandledDeployments). A push
|
|
// with one of these deployment IDs is not applied again: the server
|
|
// resends open pushes after a reconnect and announces them while
|
|
// polling until it has the report (docs/adr/0022-ausrollen-pull-push-m4.md).
|
|
HandledDeployments []DeploymentReport `json:"handled_deployments,omitempty"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
}
|
|
|
|
// handledDeployment returns the remembered report of a push deployment.
|
|
func (st *State) handledDeployment(id string) (DeploymentReport, bool) {
|
|
if id == "" {
|
|
return DeploymentReport{}, false
|
|
}
|
|
i := slices.IndexFunc(st.HandledDeployments, func(d DeploymentReport) bool { return d.DeploymentID == id })
|
|
if i < 0 {
|
|
return DeploymentReport{}, false
|
|
}
|
|
return st.HandledDeployments[i], true
|
|
}
|
|
|
|
// rememberDeployment records the report of a finished push deployment.
|
|
func (st *State) rememberDeployment(r DeploymentReport) {
|
|
if r.DeploymentID == "" {
|
|
return
|
|
}
|
|
r.Message = truncate(r.Message, handledMessageLen)
|
|
handled := slices.DeleteFunc(slices.Clone(st.HandledDeployments), func(d DeploymentReport) bool {
|
|
return d.DeploymentID == r.DeploymentID
|
|
})
|
|
handled = append(handled, r)
|
|
if n := len(handled); n > maxHandledDeployments {
|
|
handled = slices.Clone(handled[n-maxHandledDeployments:])
|
|
}
|
|
st.HandledDeployments = handled
|
|
}
|
|
|
|
// 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:])
|
|
}
|
|
if n := len(st.HandledDeployments); n > maxHandledDeployments {
|
|
st.HandledDeployments = slices.Clone(st.HandledDeployments[n-maxHandledDeployments:])
|
|
}
|
|
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()
|
|
}
|