559 lines
14 KiB
Go
559 lines
14 KiB
Go
// Package agentstate provides the crash-safe device-local persistence boundary
|
|
// for the shared AgentTask manager state.
|
|
package agentstate
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
|
|
"iop/packages/go/agenttask"
|
|
)
|
|
|
|
const storeSchemaVersion uint32 = 1
|
|
const maxIntegrationRecordPayload = 16 << 20
|
|
|
|
var (
|
|
ErrCorruptState = errors.New("agentstate corrupt state")
|
|
ErrUnsupportedSchema = errors.New("agentstate unsupported store schema")
|
|
)
|
|
|
|
type StateError struct {
|
|
Path string
|
|
Kind error
|
|
Err error
|
|
}
|
|
|
|
func (e *StateError) Error() string {
|
|
return fmt.Sprintf("agentstate: %v at %q: %v", e.Kind, e.Path, e.Err)
|
|
}
|
|
|
|
func (e *StateError) Unwrap() []error {
|
|
return []error{e.Kind, e.Err}
|
|
}
|
|
|
|
type Store struct {
|
|
path string
|
|
lockPath string
|
|
}
|
|
|
|
// IntegrationRecordSnapshot is an immutable view of one checksum-covered
|
|
// integration record and its key-local content revision.
|
|
type IntegrationRecordSnapshot struct {
|
|
Payload []byte
|
|
Revision string
|
|
}
|
|
|
|
// IntegrationRecordUpdate describes one key-local compare-and-swap update.
|
|
// All updates supplied to CompareAndSwapIntegrationRecords commit together.
|
|
type IntegrationRecordUpdate struct {
|
|
Key string
|
|
Expected string
|
|
Payload []byte
|
|
}
|
|
|
|
func NewStore(path string) (*Store, error) {
|
|
if path == "" {
|
|
return nil, fmt.Errorf("agentstate: state path is required")
|
|
}
|
|
clean := filepath.Clean(path)
|
|
if clean == "." {
|
|
return nil, fmt.Errorf("agentstate: state path must name a file")
|
|
}
|
|
return &Store{path: clean, lockPath: clean + ".lock"}, nil
|
|
}
|
|
|
|
func (s *Store) Path() string {
|
|
return s.path
|
|
}
|
|
|
|
// LoadIntegrationRecord returns one opaque, checksum-covered host integration
|
|
// journal together with its key-local content revision.
|
|
func (s *Store) LoadIntegrationRecord(
|
|
ctx context.Context,
|
|
key string,
|
|
) ([]byte, string, bool, error) {
|
|
if err := validateIntegrationKey(key); err != nil {
|
|
return nil, "", false, err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, "", false, err
|
|
}
|
|
var payload []byte
|
|
var found bool
|
|
err := s.withLock(ctx, syscall.LOCK_SH, func() error {
|
|
_, _, records, err := s.readUnlocked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
record, ok := records[key]
|
|
if !ok {
|
|
return nil
|
|
}
|
|
payload = append([]byte(nil), record...)
|
|
found = true
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, "", false, err
|
|
}
|
|
if !found {
|
|
return nil, "", false, nil
|
|
}
|
|
return payload, integrationRecordRevision(payload), true, nil
|
|
}
|
|
|
|
// LoadIntegrationRecords returns cloned, checksum-covered integration records
|
|
// whose keys start with prefix.
|
|
func (s *Store) LoadIntegrationRecords(
|
|
ctx context.Context,
|
|
prefix string,
|
|
) (map[string]IntegrationRecordSnapshot, error) {
|
|
if err := validateIntegrationKey(prefix); err != nil {
|
|
return nil, err
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
snapshots := make(map[string]IntegrationRecordSnapshot)
|
|
err := s.withLock(ctx, syscall.LOCK_SH, func() error {
|
|
_, _, records, err := s.readUnlocked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for key, record := range records {
|
|
if !strings.HasPrefix(key, prefix) {
|
|
continue
|
|
}
|
|
payload := append([]byte(nil), record...)
|
|
snapshots[key] = IntegrationRecordSnapshot{
|
|
Payload: payload,
|
|
Revision: integrationRecordRevision(payload),
|
|
}
|
|
}
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return snapshots, nil
|
|
}
|
|
|
|
// CompareAndSwapIntegrationRecord atomically updates one opaque integration
|
|
// journal while preserving the manager snapshot and every sibling journal.
|
|
func (s *Store) CompareAndSwapIntegrationRecord(
|
|
ctx context.Context,
|
|
key string,
|
|
expected string,
|
|
payload []byte,
|
|
) (string, error) {
|
|
revisions, err := s.CompareAndSwapIntegrationRecords(ctx, []IntegrationRecordUpdate{{
|
|
Key: key,
|
|
Expected: expected,
|
|
Payload: payload,
|
|
}})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return revisions[key], nil
|
|
}
|
|
|
|
// CompareAndSwapIntegrationRecords atomically updates multiple opaque
|
|
// integration records while preserving the manager snapshot and all siblings.
|
|
// Every expected revision is checked before any record is mutated.
|
|
func (s *Store) CompareAndSwapIntegrationRecords(
|
|
ctx context.Context,
|
|
updates []IntegrationRecordUpdate,
|
|
) (map[string]string, error) {
|
|
if len(updates) == 0 {
|
|
return nil, fmt.Errorf("agentstate: at least one integration record update is required")
|
|
}
|
|
prepared := make([]IntegrationRecordUpdate, len(updates))
|
|
seenKeys := make(map[string]struct{}, len(updates))
|
|
for index, update := range updates {
|
|
if err := validateIntegrationKey(update.Key); err != nil {
|
|
return nil, fmt.Errorf("agentstate: integration record update %d: %w", index, err)
|
|
}
|
|
if _, duplicate := seenKeys[update.Key]; duplicate {
|
|
return nil, fmt.Errorf("agentstate: duplicate integration record key %q", update.Key)
|
|
}
|
|
seenKeys[update.Key] = struct{}{}
|
|
if len(update.Payload) == 0 ||
|
|
len(update.Payload) > maxIntegrationRecordPayload ||
|
|
!json.Valid(update.Payload) {
|
|
return nil, fmt.Errorf(
|
|
"agentstate: integration record update %d must contain one bounded JSON value",
|
|
index,
|
|
)
|
|
}
|
|
var decoded any
|
|
if err := decodeOne(update.Payload, &decoded); err != nil {
|
|
return nil, fmt.Errorf(
|
|
"agentstate: decode integration record update %d: %w",
|
|
index,
|
|
err,
|
|
)
|
|
}
|
|
prepared[index] = IntegrationRecordUpdate{
|
|
Key: update.Key,
|
|
Expected: update.Expected,
|
|
Payload: append([]byte(nil), update.Payload...),
|
|
}
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
revisions := make(map[string]string, len(prepared))
|
|
err := s.withLock(ctx, syscall.LOCK_EX, func() error {
|
|
state, current, records, err := s.readUnlocked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, update := range prepared {
|
|
currentRecord, exists := records[update.Key]
|
|
currentRevision := ""
|
|
if exists {
|
|
currentRevision = integrationRecordRevision(currentRecord)
|
|
}
|
|
if currentRevision != update.Expected {
|
|
return agenttask.ErrRevisionConflict
|
|
}
|
|
}
|
|
|
|
changed := false
|
|
if records == nil {
|
|
records = make(map[string]json.RawMessage)
|
|
}
|
|
for _, update := range prepared {
|
|
revisions[update.Key] = integrationRecordRevision(update.Payload)
|
|
if currentRecord, exists := records[update.Key]; exists &&
|
|
bytes.Equal(currentRecord, update.Payload) {
|
|
continue
|
|
}
|
|
records[update.Key] = append(json.RawMessage(nil), update.Payload...)
|
|
changed = true
|
|
}
|
|
if !changed {
|
|
return nil
|
|
}
|
|
if current == ^uint64(0) {
|
|
return fmt.Errorf("agentstate: revision overflow")
|
|
}
|
|
return s.writeUnlocked(current+1, state, records)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return revisions, nil
|
|
}
|
|
|
|
func (s *Store) Load(
|
|
ctx context.Context,
|
|
) (agenttask.ManagerState, agenttask.StateRevision, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return agenttask.ManagerState{}, "", err
|
|
}
|
|
var state agenttask.ManagerState
|
|
var revision uint64
|
|
err := s.withLock(ctx, syscall.LOCK_SH, func() error {
|
|
var err error
|
|
state, revision, _, err = s.readUnlocked()
|
|
return err
|
|
})
|
|
if err != nil {
|
|
return agenttask.ManagerState{}, "", err
|
|
}
|
|
return state, agenttask.StateRevision(strconv.FormatUint(revision, 10)), nil
|
|
}
|
|
|
|
func (s *Store) CompareAndSwap(
|
|
ctx context.Context,
|
|
expected agenttask.StateRevision,
|
|
next agenttask.ManagerState,
|
|
) (agenttask.StateRevision, error) {
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
var committed uint64
|
|
err := s.withLock(ctx, syscall.LOCK_EX, func() error {
|
|
_, current, integrationRecords, err := s.readUnlocked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
currentRevision := agenttask.StateRevision(strconv.FormatUint(current, 10))
|
|
if expected != currentRevision {
|
|
return agenttask.ErrRevisionConflict
|
|
}
|
|
if current == ^uint64(0) {
|
|
return fmt.Errorf("agentstate: revision overflow")
|
|
}
|
|
if next.SchemaVersion != agenttask.StateSchemaVersion {
|
|
return s.stateError(
|
|
ErrUnsupportedSchema,
|
|
fmt.Errorf("manager schema version %d", next.SchemaVersion),
|
|
)
|
|
}
|
|
committed = current + 1
|
|
return s.writeUnlocked(committed, next, integrationRecords)
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return agenttask.StateRevision(strconv.FormatUint(committed, 10)), nil
|
|
}
|
|
|
|
type diskEnvelope struct {
|
|
SchemaVersion uint32 `json:"schema_version"`
|
|
Revision uint64 `json:"revision"`
|
|
Checksum string `json:"checksum"`
|
|
State json.RawMessage `json:"state"`
|
|
IntegrationRecords map[string]json.RawMessage `json:"integration_records,omitempty"`
|
|
}
|
|
|
|
type checksumPayload struct {
|
|
SchemaVersion uint32 `json:"schema_version"`
|
|
Revision uint64 `json:"revision"`
|
|
State json.RawMessage `json:"state"`
|
|
IntegrationRecords map[string]json.RawMessage `json:"integration_records,omitempty"`
|
|
}
|
|
|
|
func (s *Store) readUnlocked() (
|
|
agenttask.ManagerState,
|
|
uint64,
|
|
map[string]json.RawMessage,
|
|
error,
|
|
) {
|
|
payload, err := os.ReadFile(s.path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
return agenttask.ManagerState{SchemaVersion: agenttask.StateSchemaVersion},
|
|
0,
|
|
nil,
|
|
nil
|
|
}
|
|
if err != nil {
|
|
return agenttask.ManagerState{}, 0, nil, err
|
|
}
|
|
var envelope diskEnvelope
|
|
if err := decodeOne(payload, &envelope); err != nil {
|
|
return agenttask.ManagerState{}, 0, nil, s.stateError(
|
|
ErrCorruptState,
|
|
fmt.Errorf("decode envelope: %w", err),
|
|
)
|
|
}
|
|
if envelope.SchemaVersion != storeSchemaVersion {
|
|
return agenttask.ManagerState{}, 0, nil, s.stateError(
|
|
ErrUnsupportedSchema,
|
|
fmt.Errorf("schema version %d", envelope.SchemaVersion),
|
|
)
|
|
}
|
|
expected, err := stateChecksum(
|
|
envelope.SchemaVersion,
|
|
envelope.Revision,
|
|
envelope.State,
|
|
envelope.IntegrationRecords,
|
|
)
|
|
if err != nil {
|
|
return agenttask.ManagerState{}, 0, nil, s.stateError(ErrCorruptState, err)
|
|
}
|
|
if envelope.Checksum == "" || !bytes.Equal(
|
|
[]byte(envelope.Checksum),
|
|
[]byte(expected),
|
|
) {
|
|
return agenttask.ManagerState{}, 0, nil, s.stateError(
|
|
ErrCorruptState,
|
|
fmt.Errorf("checksum mismatch"),
|
|
)
|
|
}
|
|
var state agenttask.ManagerState
|
|
if err := decodeOne(envelope.State, &state); err != nil {
|
|
return agenttask.ManagerState{}, 0, nil, s.stateError(
|
|
ErrCorruptState,
|
|
fmt.Errorf("decode manager state: %w", err),
|
|
)
|
|
}
|
|
for key, record := range envelope.IntegrationRecords {
|
|
if validateIntegrationKey(key) != nil || len(record) == 0 || !json.Valid(record) {
|
|
return agenttask.ManagerState{}, 0, nil, s.stateError(
|
|
ErrCorruptState,
|
|
fmt.Errorf("invalid integration record %q", key),
|
|
)
|
|
}
|
|
}
|
|
return state, envelope.Revision, cloneRawRecords(envelope.IntegrationRecords), nil
|
|
}
|
|
|
|
func (s *Store) writeUnlocked(
|
|
revision uint64,
|
|
state agenttask.ManagerState,
|
|
integrationRecords map[string]json.RawMessage,
|
|
) error {
|
|
statePayload, err := json.Marshal(state)
|
|
if err != nil {
|
|
return fmt.Errorf("agentstate: encode manager state: %w", err)
|
|
}
|
|
checksum, err := stateChecksum(
|
|
storeSchemaVersion,
|
|
revision,
|
|
statePayload,
|
|
integrationRecords,
|
|
)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
envelopePayload, err := json.Marshal(diskEnvelope{
|
|
SchemaVersion: storeSchemaVersion,
|
|
Revision: revision,
|
|
Checksum: checksum,
|
|
State: statePayload,
|
|
IntegrationRecords: integrationRecords,
|
|
})
|
|
if err != nil {
|
|
return fmt.Errorf("agentstate: encode envelope: %w", err)
|
|
}
|
|
envelopePayload = append(envelopePayload, '\n')
|
|
|
|
dir := filepath.Dir(s.path)
|
|
temp, err := os.CreateTemp(dir, "."+filepath.Base(s.path)+".tmp-*")
|
|
if err != nil {
|
|
return err
|
|
}
|
|
tempPath := temp.Name()
|
|
removeTemp := true
|
|
defer func() {
|
|
if removeTemp {
|
|
_ = os.Remove(tempPath)
|
|
}
|
|
}()
|
|
if err := temp.Chmod(0o600); err != nil {
|
|
_ = temp.Close()
|
|
return err
|
|
}
|
|
if _, err := temp.Write(envelopePayload); err != nil {
|
|
_ = temp.Close()
|
|
return err
|
|
}
|
|
if err := temp.Sync(); err != nil {
|
|
_ = temp.Close()
|
|
return err
|
|
}
|
|
if err := temp.Close(); err != nil {
|
|
return err
|
|
}
|
|
if err := os.Rename(tempPath, s.path); err != nil {
|
|
return err
|
|
}
|
|
removeTemp = false
|
|
dirHandle, err := os.Open(dir)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer dirHandle.Close()
|
|
return dirHandle.Sync()
|
|
}
|
|
|
|
func (s *Store) withLock(
|
|
ctx context.Context,
|
|
mode int,
|
|
action func() error,
|
|
) error {
|
|
if err := os.MkdirAll(filepath.Dir(s.path), 0o700); err != nil {
|
|
return err
|
|
}
|
|
lock, err := os.OpenFile(s.lockPath, os.O_CREATE|os.O_RDWR, 0o600)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer lock.Close()
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
if err := syscall.Flock(int(lock.Fd()), mode); err != nil {
|
|
return err
|
|
}
|
|
defer syscall.Flock(int(lock.Fd()), syscall.LOCK_UN) //nolint:errcheck
|
|
if err := ctx.Err(); err != nil {
|
|
return err
|
|
}
|
|
return action()
|
|
}
|
|
|
|
func stateChecksum(
|
|
schemaVersion uint32,
|
|
revision uint64,
|
|
state json.RawMessage,
|
|
integrationRecords ...map[string]json.RawMessage,
|
|
) (string, error) {
|
|
var records map[string]json.RawMessage
|
|
if len(integrationRecords) != 0 {
|
|
records = integrationRecords[0]
|
|
}
|
|
payload, err := json.Marshal(checksumPayload{
|
|
SchemaVersion: schemaVersion,
|
|
Revision: revision,
|
|
State: state,
|
|
IntegrationRecords: records,
|
|
})
|
|
if err != nil {
|
|
return "", fmt.Errorf("agentstate: encode checksum payload: %w", err)
|
|
}
|
|
sum := sha256.Sum256(payload)
|
|
return hex.EncodeToString(sum[:]), nil
|
|
}
|
|
|
|
func decodeOne(payload []byte, target any) error {
|
|
decoder := json.NewDecoder(bytes.NewReader(payload))
|
|
decoder.DisallowUnknownFields()
|
|
if err := decoder.Decode(target); err != nil {
|
|
return err
|
|
}
|
|
var trailing any
|
|
if err := decoder.Decode(&trailing); !errors.Is(err, io.EOF) {
|
|
if err == nil {
|
|
return fmt.Errorf("multiple JSON values")
|
|
}
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func validateIntegrationKey(key string) error {
|
|
if strings.TrimSpace(key) == "" ||
|
|
strings.TrimSpace(key) != key ||
|
|
strings.ContainsAny(key, "\x00\r\n") {
|
|
return fmt.Errorf("agentstate: invalid integration record key")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func integrationRecordRevision(payload []byte) string {
|
|
sum := sha256.Sum256(payload)
|
|
return "sha256:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func cloneRawRecords(
|
|
records map[string]json.RawMessage,
|
|
) map[string]json.RawMessage {
|
|
if records == nil {
|
|
return nil
|
|
}
|
|
copy := make(map[string]json.RawMessage, len(records))
|
|
for key, record := range records {
|
|
copy[key] = append(json.RawMessage(nil), record...)
|
|
}
|
|
return copy
|
|
}
|
|
|
|
func (s *Store) stateError(kind error, err error) error {
|
|
return &StateError{Path: s.path, Kind: kind, Err: err}
|
|
}
|