독립 호스트에서 안전한 작업 실행과 복구를 제공하기 위해 런타임 설정, 정책, 상태 저장소, 워크스페이스 격리 및 AgentTask 오케스트레이션을 확장한다.
451 lines
11 KiB
Go
451 lines
11 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
|
|
|
|
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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) {
|
|
if err := validateIntegrationKey(key); err != nil {
|
|
return "", err
|
|
}
|
|
if len(payload) == 0 || len(payload) > 16<<20 || !json.Valid(payload) {
|
|
return "", fmt.Errorf("agentstate: integration record must be one bounded JSON value")
|
|
}
|
|
var decoded any
|
|
if err := decodeOne(payload, &decoded); err != nil {
|
|
return "", fmt.Errorf("agentstate: decode integration record: %w", err)
|
|
}
|
|
if err := ctx.Err(); err != nil {
|
|
return "", err
|
|
}
|
|
nextRevision := integrationRecordRevision(payload)
|
|
err := s.withLock(ctx, syscall.LOCK_EX, func() error {
|
|
state, current, records, err := s.readUnlocked()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
currentRecord, exists := records[key]
|
|
currentRevision := ""
|
|
if exists {
|
|
currentRevision = integrationRecordRevision(currentRecord)
|
|
}
|
|
if currentRevision != expected {
|
|
return agenttask.ErrRevisionConflict
|
|
}
|
|
if exists && bytes.Equal(currentRecord, payload) {
|
|
return nil
|
|
}
|
|
if current == ^uint64(0) {
|
|
return fmt.Errorf("agentstate: revision overflow")
|
|
}
|
|
if records == nil {
|
|
records = make(map[string]json.RawMessage)
|
|
}
|
|
records[key] = append(json.RawMessage(nil), payload...)
|
|
return s.writeUnlocked(current+1, state, records)
|
|
})
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return nextRevision, 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 strings.TrimSpace(key) == "" || 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}
|
|
}
|