Centralize remote current-state loading and preserve caller policy
This commit is contained in:
203
internal/artifacts/current_state.go
Normal file
203
internal/artifacts/current_state.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrCurrentRunPointerMissing = errors.New("current run pointer missing")
|
||||
ErrCurrentManifestMissing = errors.New("current manifest missing")
|
||||
)
|
||||
|
||||
type CurrentRunPointerMissingError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e *CurrentRunPointerMissingError) Error() string {
|
||||
return fmt.Sprintf("%s: %q", ErrCurrentRunPointerMissing, e.Key)
|
||||
}
|
||||
|
||||
func (e *CurrentRunPointerMissingError) Unwrap() error {
|
||||
return ErrCurrentRunPointerMissing
|
||||
}
|
||||
|
||||
type CurrentManifestMissingError struct {
|
||||
Key string
|
||||
}
|
||||
|
||||
func (e *CurrentManifestMissingError) Error() string {
|
||||
return fmt.Sprintf("%s: %q", ErrCurrentManifestMissing, e.Key)
|
||||
}
|
||||
|
||||
func (e *CurrentManifestMissingError) Unwrap() error {
|
||||
return ErrCurrentManifestMissing
|
||||
}
|
||||
|
||||
type CurrentState struct {
|
||||
SessionPrefix string
|
||||
CurrentRunIDKey string
|
||||
CurrentManifestKey string
|
||||
RunID string
|
||||
Manifest *manifest.Manifest
|
||||
}
|
||||
|
||||
type CurrentStateValidation struct {
|
||||
ExpectedCampaign string
|
||||
ExpectedSessionID string
|
||||
ExpectedRunID string
|
||||
ValidateRunID bool
|
||||
}
|
||||
|
||||
func LoadCurrentRunPointer(ctx context.Context, store storage.ObjectStore, currentRunIDKey string) (string, error) {
|
||||
if store == nil {
|
||||
return "", fmt.Errorf("object store is required")
|
||||
}
|
||||
key := strings.TrimSpace(currentRunIDKey)
|
||||
if key == "" {
|
||||
return "", fmt.Errorf("current run pointer key is required")
|
||||
}
|
||||
|
||||
exists, err := store.Exists(ctx, key)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("check current run pointer %q: %w", key, err)
|
||||
}
|
||||
if !exists {
|
||||
return "", &CurrentRunPointerMissingError{Key: key}
|
||||
}
|
||||
|
||||
localPath, err := storage.DownloadObjectToTemp(ctx, store, key, "narratio-current-run-id-*.txt")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download current run pointer %q: %w", key, err)
|
||||
}
|
||||
defer func() { _ = os.Remove(localPath) }()
|
||||
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read downloaded current run pointer %q: %w", key, err)
|
||||
}
|
||||
runID := strings.TrimSpace(string(data))
|
||||
if runID == "" {
|
||||
return "", fmt.Errorf("current run pointer %q is empty", key)
|
||||
}
|
||||
return runID, nil
|
||||
}
|
||||
|
||||
func LoadCurrentManifest(ctx context.Context, store storage.ObjectStore, currentManifestKey string) (*manifest.Manifest, error) {
|
||||
if store == nil {
|
||||
return nil, fmt.Errorf("object store is required")
|
||||
}
|
||||
key := strings.TrimSpace(currentManifestKey)
|
||||
if key == "" {
|
||||
return nil, fmt.Errorf("current manifest key is required")
|
||||
}
|
||||
|
||||
exists, err := store.Exists(ctx, key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check current manifest %q: %w", key, err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, &CurrentManifestMissingError{Key: key}
|
||||
}
|
||||
|
||||
localPath, err := storage.DownloadObjectToTemp(ctx, store, key, "narratio-current-manifest-*.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download current manifest %q: %w", key, err)
|
||||
}
|
||||
defer func() { _ = os.Remove(localPath) }()
|
||||
|
||||
manifestStore := &manifest.LocalStore{}
|
||||
m, err := manifestStore.Load(ctx, localPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current manifest decode failed: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func LoadCurrentState(
|
||||
ctx context.Context,
|
||||
store storage.ObjectStore,
|
||||
sessionPrefix string,
|
||||
validation CurrentStateValidation,
|
||||
) (*CurrentState, error) {
|
||||
prefix := strings.TrimSpace(sessionPrefix)
|
||||
if prefix == "" {
|
||||
return nil, fmt.Errorf("session prefix is required")
|
||||
}
|
||||
currentManifestKey, currentRunIDKey := ResolveCurrentStateKeys(prefix)
|
||||
runID, err := LoadCurrentRunPointer(ctx, store, currentRunIDKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
m, err := LoadCurrentManifest(ctx, store, currentManifestKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := &CurrentState{
|
||||
SessionPrefix: prefix,
|
||||
CurrentRunIDKey: currentRunIDKey,
|
||||
CurrentManifestKey: currentManifestKey,
|
||||
RunID: runID,
|
||||
Manifest: m,
|
||||
}
|
||||
if err := ValidateCurrentStateIdentity(state, validation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func ValidateCurrentStateIdentity(state *CurrentState, validation CurrentStateValidation) error {
|
||||
if state == nil || state.Manifest == nil {
|
||||
return fmt.Errorf("current state with manifest is required")
|
||||
}
|
||||
expectedSessionID := strings.TrimSpace(validation.ExpectedSessionID)
|
||||
expectedCampaign := strings.TrimSpace(validation.ExpectedCampaign)
|
||||
expectedRunID := strings.TrimSpace(validation.ExpectedRunID)
|
||||
manifestSessionID := strings.TrimSpace(state.Manifest.SessionID)
|
||||
manifestCampaign := strings.TrimSpace(state.Manifest.Campaign)
|
||||
manifestRunID := strings.TrimSpace(state.Manifest.RunID)
|
||||
|
||||
if expectedSessionID != "" && manifestSessionID != expectedSessionID {
|
||||
return fmt.Errorf(
|
||||
"current manifest session_id %q does not match expected session_id %q",
|
||||
manifestSessionID,
|
||||
expectedSessionID,
|
||||
)
|
||||
}
|
||||
if expectedCampaign != "" {
|
||||
if manifestCampaign == "" {
|
||||
return fmt.Errorf("current manifest campaign is required")
|
||||
}
|
||||
if manifestCampaign != expectedCampaign {
|
||||
return fmt.Errorf(
|
||||
"current manifest campaign %q does not match expected campaign %q",
|
||||
manifestCampaign,
|
||||
expectedCampaign,
|
||||
)
|
||||
}
|
||||
}
|
||||
if expectedRunID == "" && validation.ValidateRunID {
|
||||
expectedRunID = strings.TrimSpace(state.RunID)
|
||||
}
|
||||
if expectedRunID != "" {
|
||||
if manifestRunID == "" {
|
||||
return fmt.Errorf("current manifest run_id is required")
|
||||
}
|
||||
if manifestRunID != expectedRunID {
|
||||
return fmt.Errorf(
|
||||
"current run pointer %q references run %q but current manifest run_id is %q",
|
||||
state.CurrentRunIDKey,
|
||||
expectedRunID,
|
||||
manifestRunID,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
140
internal/artifacts/current_state_test.go
Normal file
140
internal/artifacts/current_state_test.go
Normal file
@@ -0,0 +1,140 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
)
|
||||
|
||||
func TestLoadCurrentStateMissingRunPointer(t *testing.T) {
|
||||
store := &storage.FakeBackend{}
|
||||
_, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{})
|
||||
if err == nil {
|
||||
t.Fatal("LoadCurrentState() error = nil, want missing run pointer error")
|
||||
}
|
||||
var missing *CurrentRunPointerMissingError
|
||||
if !errors.As(err, &missing) {
|
||||
t.Fatalf("errors.As(err, *CurrentRunPointerMissingError) = false; err=%v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrCurrentRunPointerMissing) {
|
||||
t.Fatalf("errors.Is(err, ErrCurrentRunPointerMissing) = false; err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCurrentStateMissingManifest(t *testing.T) {
|
||||
store := &storage.FakeBackend{}
|
||||
_, _, runIDKey := testCurrentStateKeys()
|
||||
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||
|
||||
_, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{})
|
||||
if err == nil {
|
||||
t.Fatal("LoadCurrentState() error = nil, want missing manifest error")
|
||||
}
|
||||
var missing *CurrentManifestMissingError
|
||||
if !errors.As(err, &missing) {
|
||||
t.Fatalf("errors.As(err, *CurrentManifestMissingError) = false; err=%v", err)
|
||||
}
|
||||
if !errors.Is(err, ErrCurrentManifestMissing) {
|
||||
t.Fatalf("errors.Is(err, ErrCurrentManifestMissing) = false; err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCurrentStateEmptyRunPointerFails(t *testing.T) {
|
||||
store := &storage.FakeBackend{}
|
||||
_, manifestKey, runIDKey := testCurrentStateKeys()
|
||||
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte(" \n\t")})
|
||||
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: testManifestJSON(t, "2026-05-03", "sample-campaign", "20260519T010203Z-a1b2c3d4")})
|
||||
|
||||
_, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{})
|
||||
if err == nil || !strings.Contains(err.Error(), "is empty") {
|
||||
t.Fatalf("error = %v, want empty run pointer failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCurrentStateMalformedManifestFails(t *testing.T) {
|
||||
store := &storage.FakeBackend{}
|
||||
_, manifestKey, runIDKey := testCurrentStateKeys()
|
||||
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: []byte("{invalid json")})
|
||||
|
||||
_, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{})
|
||||
if err == nil || !strings.Contains(err.Error(), "current manifest decode failed") {
|
||||
t.Fatalf("error = %v, want manifest decode failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCurrentStateCampaignMismatchFails(t *testing.T) {
|
||||
store := &storage.FakeBackend{}
|
||||
seedCurrentState(t, store, "2026-05-03", "wrong-campaign", "20260519T010203Z-a1b2c3d4")
|
||||
|
||||
_, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{
|
||||
ExpectedCampaign: "sample-campaign",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match expected campaign") {
|
||||
t.Fatalf("error = %v, want campaign mismatch failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCurrentStateSessionMismatchFails(t *testing.T) {
|
||||
store := &storage.FakeBackend{}
|
||||
seedCurrentState(t, store, "wrong-session", "sample-campaign", "20260519T010203Z-a1b2c3d4")
|
||||
|
||||
_, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{
|
||||
ExpectedSessionID: "2026-05-03",
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "does not match expected session_id") {
|
||||
t.Fatalf("error = %v, want session mismatch failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCurrentStateRunIDMismatchFails(t *testing.T) {
|
||||
store := &storage.FakeBackend{}
|
||||
seedCurrentState(t, store, "2026-05-03", "sample-campaign", "different-run-id")
|
||||
|
||||
_, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{
|
||||
ValidateRunID: true,
|
||||
})
|
||||
if err == nil || !strings.Contains(err.Error(), "current manifest run_id") {
|
||||
t.Fatalf("error = %v, want run mismatch failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func testCurrentSessionPrefix() string {
|
||||
return S3SessionPrefix("dnd", "sample-campaign", "2026-05-03")
|
||||
}
|
||||
|
||||
func testCurrentStateKeys() (sessionPrefix, manifestKey, runIDKey string) {
|
||||
sessionPrefix = testCurrentSessionPrefix()
|
||||
manifestKey, runIDKey = ResolveCurrentStateKeys(sessionPrefix)
|
||||
return sessionPrefix, manifestKey, runIDKey
|
||||
}
|
||||
|
||||
func seedCurrentState(t *testing.T, store *storage.FakeBackend, sessionID, campaign, manifestRunID string) {
|
||||
t.Helper()
|
||||
_, manifestKey, runIDKey := testCurrentStateKeys()
|
||||
store.SeedObject(storage.FakeObject{Key: runIDKey, Data: []byte("20260519T010203Z-a1b2c3d4\n")})
|
||||
store.SeedObject(storage.FakeObject{Key: manifestKey, Data: testManifestJSON(t, sessionID, campaign, manifestRunID)})
|
||||
}
|
||||
|
||||
func testManifestJSON(t *testing.T, sessionID, campaign, runID string) []byte {
|
||||
t.Helper()
|
||||
now := time.Date(2026, 5, 19, 23, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
|
||||
payload := map[string]any{
|
||||
"session_id": sessionID,
|
||||
"campaign": campaign,
|
||||
"run_id": runID,
|
||||
"created_at": now,
|
||||
"updated_at": now,
|
||||
"stages": map[string]any{},
|
||||
}
|
||||
data, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal manifest payload: %v", err)
|
||||
}
|
||||
return append(data, '\n')
|
||||
}
|
||||
Reference in New Issue
Block a user