Implement manifest model and local store
This commit is contained in:
@@ -2,26 +2,173 @@ package manifest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Store is a placeholder interface for manifest persistence.
|
||||
// Store persists manifests to and from durable storage.
|
||||
type Store interface {
|
||||
Load(ctx context.Context, sessionID string) (*Manifest, error)
|
||||
Save(ctx context.Context, m *Manifest) error
|
||||
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 is a placeholder local-filesystem manifest store.
|
||||
type LocalStore struct {
|
||||
RootDir string
|
||||
// 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 returns a not-yet-implemented error in the scaffold.
|
||||
func (s *LocalStore) Load(_ context.Context, _ string) (*Manifest, error) {
|
||||
return nil, fmt.Errorf("manifest local load: not yet implemented")
|
||||
// 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 returns a not-yet-implemented error in the scaffold.
|
||||
func (s *LocalStore) Save(_ context.Context, _ *Manifest) error {
|
||||
return fmt.Errorf("manifest local save: not yet implemented")
|
||||
// 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
|
||||
}
|
||||
|
||||
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 checkContext(ctx context.Context) error {
|
||||
if ctx == nil {
|
||||
return nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user