340 lines
8.3 KiB
Go
340 lines
8.3 KiB
Go
package manifest
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// Store persists manifests to and from durable storage.
|
|
type Store interface {
|
|
Create(ctx context.Context, sessionID string) (*Manifest, error)
|
|
Load(ctx context.Context, path string) (*Manifest, error)
|
|
Save(ctx context.Context, path string, m *Manifest) error
|
|
}
|
|
|
|
// LocalStore stores manifests as JSON on the local filesystem.
|
|
type LocalStore struct{}
|
|
|
|
// Create returns a new in-memory manifest for a session.
|
|
func (s *LocalStore) Create(ctx context.Context, sessionID string) (*Manifest, error) {
|
|
if err := checkContext(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return nil, fmt.Errorf("create manifest: sessionID is required")
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
return New(sessionID, now), nil
|
|
}
|
|
|
|
// Load reads and validates a local JSON manifest from path.
|
|
func (s *LocalStore) Load(ctx context.Context, path string) (*Manifest, error) {
|
|
if err := checkContext(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil, fmt.Errorf("load manifest: path is required")
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load manifest %q: %w", path, err)
|
|
}
|
|
|
|
var m Manifest
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return nil, fmt.Errorf("decode manifest %q: %w", path, err)
|
|
}
|
|
|
|
if err := validateLoadedManifest(&m); err != nil {
|
|
return nil, fmt.Errorf("manifest %q invalid: %w", path, err)
|
|
}
|
|
normalizeManifest(&m)
|
|
|
|
return &m, nil
|
|
}
|
|
|
|
// Save writes the manifest to path atomically via temp file + rename.
|
|
func (s *LocalStore) Save(ctx context.Context, path string, m *Manifest) error {
|
|
if err := checkContext(ctx); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("save manifest: path is required")
|
|
}
|
|
if m == nil {
|
|
return fmt.Errorf("save manifest: manifest is nil")
|
|
}
|
|
if strings.TrimSpace(m.SessionID) == "" {
|
|
return fmt.Errorf("save manifest: session_id is required")
|
|
}
|
|
if m.CreatedAt.IsZero() {
|
|
return fmt.Errorf("save manifest: created_at is required")
|
|
}
|
|
|
|
m.UpdatedAt = time.Now().UTC()
|
|
if m.Stages == nil {
|
|
m.Stages = map[string]*StageRecord{}
|
|
}
|
|
|
|
data, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("save manifest: marshal: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("save manifest: create directory %q: %w", dir, err)
|
|
}
|
|
|
|
tmp, err := os.CreateTemp(dir, ".manifest.json.tmp-*")
|
|
if err != nil {
|
|
return fmt.Errorf("save manifest: create temp file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
removeTmp := true
|
|
defer func() {
|
|
if removeTmp {
|
|
_ = os.Remove(tmpName)
|
|
}
|
|
}()
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("save manifest: write temp file: %w", err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("save manifest: sync temp file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("save manifest: close temp file: %w", err)
|
|
}
|
|
if err := checkContext(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
return fmt.Errorf("save manifest: rename temp file: %w", err)
|
|
}
|
|
removeTmp = false
|
|
|
|
return nil
|
|
}
|
|
|
|
// CreateRun returns a new in-memory run manifest for one invocation.
|
|
func (s *LocalStore) CreateRun(
|
|
ctx context.Context,
|
|
sessionID, campaign, runID string,
|
|
force bool,
|
|
requestedStages []string,
|
|
) (*RunManifest, error) {
|
|
if err := checkContext(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(sessionID) == "" {
|
|
return nil, fmt.Errorf("create run manifest: session_id is required")
|
|
}
|
|
if strings.TrimSpace(runID) == "" {
|
|
return nil, fmt.Errorf("create run manifest: run_id is required")
|
|
}
|
|
|
|
now := time.Now().UTC()
|
|
return NewRun(sessionID, campaign, runID, force, requestedStages, now), nil
|
|
}
|
|
|
|
// LoadRun reads and validates a local JSON run manifest from path.
|
|
func (s *LocalStore) LoadRun(ctx context.Context, path string) (*RunManifest, error) {
|
|
if err := checkContext(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return nil, fmt.Errorf("load run manifest: path is required")
|
|
}
|
|
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("load run manifest %q: %w", path, err)
|
|
}
|
|
|
|
var m RunManifest
|
|
if err := json.Unmarshal(data, &m); err != nil {
|
|
return nil, fmt.Errorf("decode run manifest %q: %w", path, err)
|
|
}
|
|
|
|
if err := validateLoadedRunManifest(&m); err != nil {
|
|
return nil, fmt.Errorf("run manifest %q invalid: %w", path, err)
|
|
}
|
|
normalizeRunManifest(&m)
|
|
|
|
return &m, nil
|
|
}
|
|
|
|
// SaveRun writes the run manifest to path atomically via temp file + rename.
|
|
func (s *LocalStore) SaveRun(ctx context.Context, path string, m *RunManifest) error {
|
|
if err := checkContext(ctx); err != nil {
|
|
return err
|
|
}
|
|
if strings.TrimSpace(path) == "" {
|
|
return fmt.Errorf("save run manifest: path is required")
|
|
}
|
|
if m == nil {
|
|
return fmt.Errorf("save run manifest: manifest is nil")
|
|
}
|
|
if strings.TrimSpace(m.SessionID) == "" {
|
|
return fmt.Errorf("save run manifest: session_id is required")
|
|
}
|
|
if strings.TrimSpace(m.RunID) == "" {
|
|
return fmt.Errorf("save run manifest: run_id is required")
|
|
}
|
|
if m.CreatedAt.IsZero() {
|
|
return fmt.Errorf("save run manifest: created_at is required")
|
|
}
|
|
|
|
m.UpdatedAt = time.Now().UTC()
|
|
if m.Stages == nil {
|
|
m.Stages = map[string]*RunStageRecord{}
|
|
}
|
|
|
|
data, err := json.MarshalIndent(m, "", " ")
|
|
if err != nil {
|
|
return fmt.Errorf("save run manifest: marshal: %w", err)
|
|
}
|
|
data = append(data, '\n')
|
|
|
|
return writeJSONAtomically(ctx, path, ".run-manifest.json.tmp-*", data)
|
|
}
|
|
|
|
func validateLoadedManifest(m *Manifest) error {
|
|
if m == nil {
|
|
return fmt.Errorf("manifest is nil")
|
|
}
|
|
if strings.TrimSpace(m.SessionID) == "" {
|
|
return fmt.Errorf("session_id is required")
|
|
}
|
|
if m.CreatedAt.IsZero() {
|
|
return fmt.Errorf("created_at is required")
|
|
}
|
|
if m.UpdatedAt.IsZero() {
|
|
return fmt.Errorf("updated_at is required")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func normalizeManifest(m *Manifest) {
|
|
if m.Stages == nil {
|
|
m.Stages = map[string]*StageRecord{}
|
|
}
|
|
for name, stage := range m.Stages {
|
|
if stage == nil {
|
|
stage = &StageRecord{Name: name, Status: StatusPending, CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}
|
|
m.Stages[name] = stage
|
|
}
|
|
if stage.Name == "" {
|
|
stage.Name = name
|
|
}
|
|
}
|
|
}
|
|
|
|
func validateLoadedRunManifest(m *RunManifest) error {
|
|
if m == nil {
|
|
return fmt.Errorf("manifest is nil")
|
|
}
|
|
if strings.TrimSpace(m.SessionID) == "" {
|
|
return fmt.Errorf("session_id is required")
|
|
}
|
|
if strings.TrimSpace(m.RunID) == "" {
|
|
return fmt.Errorf("run_id is required")
|
|
}
|
|
if m.CreatedAt.IsZero() {
|
|
return fmt.Errorf("created_at is required")
|
|
}
|
|
if m.UpdatedAt.IsZero() {
|
|
return fmt.Errorf("updated_at is required")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func normalizeRunManifest(m *RunManifest) {
|
|
if m.Stages == nil {
|
|
m.Stages = map[string]*RunStageRecord{}
|
|
}
|
|
for name, stage := range m.Stages {
|
|
if stage == nil {
|
|
stage = &RunStageRecord{
|
|
Name: name,
|
|
Action: RunStageActionRun,
|
|
Status: StatusPending,
|
|
CreatedAt: m.CreatedAt,
|
|
UpdatedAt: m.UpdatedAt,
|
|
}
|
|
m.Stages[name] = stage
|
|
}
|
|
if stage.Name == "" {
|
|
stage.Name = name
|
|
}
|
|
}
|
|
}
|
|
|
|
func writeJSONAtomically(ctx context.Context, path, tempPattern string, data []byte) error {
|
|
dir := filepath.Dir(path)
|
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
|
return fmt.Errorf("create directory %q: %w", dir, err)
|
|
}
|
|
|
|
tmp, err := os.CreateTemp(dir, tempPattern)
|
|
if err != nil {
|
|
return fmt.Errorf("create temp file: %w", err)
|
|
}
|
|
tmpName := tmp.Name()
|
|
removeTmp := true
|
|
defer func() {
|
|
if removeTmp {
|
|
_ = os.Remove(tmpName)
|
|
}
|
|
}()
|
|
|
|
if _, err := tmp.Write(data); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("write temp file: %w", err)
|
|
}
|
|
if err := tmp.Sync(); err != nil {
|
|
_ = tmp.Close()
|
|
return fmt.Errorf("sync temp file: %w", err)
|
|
}
|
|
if err := tmp.Close(); err != nil {
|
|
return fmt.Errorf("close temp file: %w", err)
|
|
}
|
|
if err := checkContext(ctx); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := os.Rename(tmpName, path); err != nil {
|
|
return fmt.Errorf("rename temp file: %w", err)
|
|
}
|
|
removeTmp = false
|
|
|
|
return nil
|
|
}
|
|
|
|
func checkContext(ctx context.Context) error {
|
|
if ctx == nil {
|
|
return nil
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
return nil
|
|
}
|
|
}
|