Bound remote control object reads
This commit is contained in:
@@ -1,15 +1,24 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxCurrentCommitPointerBytes bounds the mutable commit-selection record.
|
||||
MaxCurrentCommitPointerBytes int64 = 64 << 10
|
||||
// MaxRemoteCommitManifestBytes bounds an immutable commit manifest.
|
||||
MaxRemoteCommitManifestBytes int64 = 4 << 20
|
||||
// MaxRemoteSessionManifestBytes bounds the selected session manifest.
|
||||
MaxRemoteSessionManifestBytes int64 = 8 << 20
|
||||
)
|
||||
|
||||
func loadCommittedCurrentState(
|
||||
ctx context.Context,
|
||||
store storage.ObjectStore,
|
||||
@@ -17,9 +26,9 @@ func loadCommittedCurrentState(
|
||||
pointerKey string,
|
||||
validation CurrentStateValidation,
|
||||
) (*CurrentState, error) {
|
||||
pointerData, err := downloadRemoteObject(ctx, store, pointerKey, "narratio-current-commit-pointer-*.json")
|
||||
_, pointerData, err := readCurrentStateControlObject(ctx, store, pointerKey, "current commit pointer", MaxCurrentCommitPointerBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download current commit pointer %q: %w", pointerKey, err)
|
||||
return nil, err
|
||||
}
|
||||
pointer, err := DecodeCurrentCommitPointer(pointerData)
|
||||
if err != nil {
|
||||
@@ -29,7 +38,7 @@ func loadCommittedCurrentState(
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commitData, err := readVerifiedRemoteObject(ctx, store, pointer.CommitKey, pointer.CommitSHA256, pointer.CommitSize, pointer.CommitGeneration, "narratio-remote-commit-*.json")
|
||||
commitData, err := readVerifiedRemoteObject(ctx, store, pointer.CommitKey, pointer.CommitSHA256, pointer.CommitSize, pointer.CommitGeneration, "remote commit manifest", MaxRemoteCommitManifestBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read selected remote commit %q: %w", pointer.CommitKey, err)
|
||||
}
|
||||
@@ -48,7 +57,7 @@ func loadCommittedCurrentState(
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("remote commit must declare exactly one session manifest artifact")
|
||||
}
|
||||
manifestData, err := readVerifiedRemoteObject(ctx, store, sessionManifest.DestinationKey, sessionManifest.SHA256, sessionManifest.Size, sessionManifest.Generation, "narratio-remote-session-manifest-*.json")
|
||||
manifestData, err := readVerifiedRemoteObject(ctx, store, sessionManifest.DestinationKey, sessionManifest.SHA256, sessionManifest.Size, sessionManifest.Generation, "committed session manifest", MaxRemoteSessionManifestBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read committed session manifest %q: %w", sessionManifest.DestinationKey, err)
|
||||
}
|
||||
@@ -106,9 +115,10 @@ func readVerifiedRemoteObject(
|
||||
wantSHA256 string,
|
||||
wantSize int64,
|
||||
wantGeneration string,
|
||||
tempPattern string,
|
||||
category string,
|
||||
maxBytes int64,
|
||||
) ([]byte, error) {
|
||||
data, err := downloadRemoteObject(ctx, store, key, tempPattern)
|
||||
info, data, err := readCurrentStateControlObject(ctx, store, key, category, maxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -118,11 +128,7 @@ func readVerifiedRemoteObject(
|
||||
if checksum := remoteObjectSHA256(data); checksum != wantSHA256 {
|
||||
return nil, fmt.Errorf("checksum mismatch: got %s, want %s", checksum, wantSHA256)
|
||||
}
|
||||
info, err := remoteObjectInfo(ctx, store, key)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info.Size != wantSize {
|
||||
if info.Size > 0 && info.Size != wantSize {
|
||||
return nil, fmt.Errorf("storage size mismatch: got %d, want %d", info.Size, wantSize)
|
||||
}
|
||||
if strings.TrimSpace(info.ETag) != wantGeneration {
|
||||
@@ -131,56 +137,16 @@ func readVerifiedRemoteObject(
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func downloadRemoteObject(ctx context.Context, store storage.ObjectStore, key, tempPattern string) ([]byte, error) {
|
||||
localPath, err := storage.DownloadObjectToTemp(ctx, store, key, tempPattern)
|
||||
func readCurrentStateControlObject(ctx context.Context, store storage.ObjectStore, key, category string, maxBytes int64) (storage.ObjectInfo, []byte, error) {
|
||||
info, data, err := storage.ReadObjectBounded(ctx, store, key, maxBytes)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return storage.ObjectInfo{}, nil, fmt.Errorf("read %s control object %q with %d-byte limit: %w", category, key, maxBytes, err)
|
||||
}
|
||||
defer func() { _ = os.Remove(localPath) }()
|
||||
data, err := os.ReadFile(localPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read downloaded object %q: %w", key, err)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func remoteObjectInfo(ctx context.Context, store storage.ObjectStore, key string) (storage.ObjectInfo, error) {
|
||||
objects, err := store.List(ctx, key)
|
||||
if err != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("list remote object %q: %w", key, err)
|
||||
}
|
||||
var found *storage.ObjectInfo
|
||||
for _, object := range objects {
|
||||
if object.Key != key {
|
||||
continue
|
||||
}
|
||||
if found != nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("remote object %q is ambiguous", key)
|
||||
}
|
||||
copy := object
|
||||
found = ©
|
||||
}
|
||||
if found == nil {
|
||||
return storage.ObjectInfo{}, fmt.Errorf("remote object %q is missing", key)
|
||||
}
|
||||
return *found, nil
|
||||
return info, data, nil
|
||||
}
|
||||
|
||||
func decodeCommittedManifest(ctx context.Context, data []byte) (*manifest.Manifest, error) {
|
||||
file, err := os.CreateTemp("", "narratio-committed-session-manifest-*.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create committed manifest file: %w", err)
|
||||
}
|
||||
path := file.Name()
|
||||
defer func() { _ = os.Remove(path) }()
|
||||
if _, err := file.Write(data); err != nil {
|
||||
_ = file.Close()
|
||||
return nil, fmt.Errorf("write committed manifest file: %w", err)
|
||||
}
|
||||
if err := file.Close(); err != nil {
|
||||
return nil, fmt.Errorf("close committed manifest file: %w", err)
|
||||
}
|
||||
m, err := (&manifest.LocalStore{}).Load(ctx, path)
|
||||
m, err := (&manifest.LocalStore{}).LoadReader(ctx, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("committed session manifest decode failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -10,6 +12,13 @@ import (
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
const (
|
||||
// MaxLegacyCurrentRunPointerBytes bounds the compatibility run selector.
|
||||
MaxLegacyCurrentRunPointerBytes int64 = 4 << 10
|
||||
// MaxLegacyCurrentManifestBytes bounds the compatibility session manifest.
|
||||
MaxLegacyCurrentManifestBytes int64 = MaxRemoteSessionManifestBytes
|
||||
)
|
||||
|
||||
// This file is temporary compatibility support for sessions published before
|
||||
// immutable current commits. It can be removed after legacy remote state is migrated.
|
||||
|
||||
@@ -48,17 +57,12 @@ func loadLegacyCurrentRunPointer(ctx context.Context, store storage.ObjectStore,
|
||||
return "", fmt.Errorf("current run pointer key is required")
|
||||
}
|
||||
|
||||
exists, err := store.Exists(ctx, key)
|
||||
_, data, err := readCurrentStateControlObject(ctx, store, key, "legacy current run pointer", MaxLegacyCurrentRunPointerBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("check current run pointer %q: %w", key, err)
|
||||
}
|
||||
if !exists {
|
||||
return "", &CurrentRunPointerMissingError{Key: key}
|
||||
}
|
||||
|
||||
data, err := downloadRemoteObject(ctx, store, key, "narratio-legacy-current-run-id-*.txt")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("download current run pointer %q: %w", key, err)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return "", &CurrentRunPointerMissingError{Key: key}
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
runID := strings.TrimSpace(string(data))
|
||||
if runID == "" {
|
||||
@@ -76,21 +80,15 @@ func loadLegacyCurrentManifest(ctx context.Context, store storage.ObjectStore, c
|
||||
return nil, fmt.Errorf("current manifest key is required")
|
||||
}
|
||||
|
||||
exists, err := store.Exists(ctx, key)
|
||||
_, data, err := readCurrentStateControlObject(ctx, store, key, "legacy current manifest", MaxLegacyCurrentManifestBytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("check current manifest %q: %w", key, err)
|
||||
}
|
||||
if !exists {
|
||||
return nil, &CurrentManifestMissingError{Key: key}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return nil, &CurrentManifestMissingError{Key: key}
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
localPath, err := storage.DownloadObjectToTemp(ctx, store, key, "narratio-legacy-current-manifest-*.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download current manifest %q: %w", key, err)
|
||||
}
|
||||
defer func() { _ = os.Remove(localPath) }()
|
||||
|
||||
m, err := (&manifest.LocalStore{}).Load(ctx, localPath)
|
||||
m, err := (&manifest.LocalStore{}).LoadReader(ctx, bytes.NewReader(data))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("current manifest decode failed: %w", err)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -119,6 +120,50 @@ func TestLoadCurrentStateReadsCoherentLegacyPair(t *testing.T) {
|
||||
if state.Commit != nil || state.Pointer != nil {
|
||||
t.Fatalf("legacy current state unexpectedly includes immutable commit data: %#v", state)
|
||||
}
|
||||
if len(store.Downloads) != 0 {
|
||||
t.Fatalf("legacy current-state downloads = %#v, want direct bounded reads", store.Downloads)
|
||||
}
|
||||
wantReads := []string{state.CurrentRunIDKey, state.CurrentManifestKey}
|
||||
if len(store.Reads) != len(wantReads) {
|
||||
t.Fatalf("legacy current-state reads = %#v, want %q", store.Reads, wantReads)
|
||||
}
|
||||
for index, want := range wantReads {
|
||||
if store.Reads[index].Key != want {
|
||||
t.Fatalf("legacy current-state read %d = %q, want %q", index, store.Reads[index].Key, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCurrentStateControlObjectLimitsAcceptExactAndRejectLimitPlusOne(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
category string
|
||||
key string
|
||||
limit int64
|
||||
}{
|
||||
{name: "commit pointer", category: "current commit pointer", key: "current/commit-pointer.json", limit: MaxCurrentCommitPointerBytes},
|
||||
{name: "commit manifest", category: "remote commit manifest", key: "runs/run/commit.json", limit: MaxRemoteCommitManifestBytes},
|
||||
{name: "session manifest", category: "committed session manifest", key: "runs/run/session-manifest.json", limit: MaxRemoteSessionManifestBytes},
|
||||
{name: "legacy run pointer", category: "legacy current run pointer", key: "current/run_id.txt", limit: MaxLegacyCurrentRunPointerBytes},
|
||||
{name: "legacy manifest", category: "legacy current manifest", key: "current/manifest.json", limit: MaxLegacyCurrentManifestBytes},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
store := &storage.FakeBackend{}
|
||||
exact := strings.Repeat("x", int(test.limit))
|
||||
store.SeedObject(storage.FakeObject{Key: test.key, Data: []byte(exact)})
|
||||
_, data, err := readCurrentStateControlObject(context.Background(), store, test.key, test.category, test.limit)
|
||||
if err != nil || string(data) != exact {
|
||||
t.Fatalf("exact-limit read data length=%d error=%v", len(data), err)
|
||||
}
|
||||
|
||||
store.SeedObject(storage.FakeObject{Key: test.key, Data: []byte(exact + "x")})
|
||||
_, _, err = readCurrentStateControlObject(context.Background(), store, test.key, test.category, test.limit)
|
||||
if err == nil || !strings.Contains(err.Error(), test.category) || !strings.Contains(err.Error(), test.key) || !strings.Contains(err.Error(), fmt.Sprint(test.limit)) {
|
||||
t.Fatalf("limit-plus-one error = %v, want category, key, and limit", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCurrentStateRejectsTornLegacyPair(t *testing.T) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package artifacts
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
@@ -67,6 +68,28 @@ func TestLoadCurrentStateReadsVerifiedRemoteCommit(t *testing.T) {
|
||||
if state.Manifest.RunID != commit.RunID {
|
||||
t.Fatalf("manifest run ID = %q, want %q", state.Manifest.RunID, commit.RunID)
|
||||
}
|
||||
if len(store.Downloads) != 0 {
|
||||
t.Fatalf("committed current-state downloads = %#v, want direct bounded reads", store.Downloads)
|
||||
}
|
||||
wantReads := []string{state.CurrentPointerKey, state.Pointer.CommitKey, state.CurrentManifestKey}
|
||||
if len(store.Reads) != len(wantReads) {
|
||||
t.Fatalf("committed current-state reads = %#v, want %q", store.Reads, wantReads)
|
||||
}
|
||||
for index, want := range wantReads {
|
||||
if store.Reads[index].Key != want {
|
||||
t.Fatalf("committed current-state read %d = %q, want %q", index, store.Reads[index].Key, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCurrentStateUsesMetadataFromOpenedObjectVersion(t *testing.T) {
|
||||
store := &storage.FakeBackend{ListErr: errors.New("list must not be used for object verification")}
|
||||
commit := testRemoteCommit(t, "20260519T010203Z-a1b2c3d4")
|
||||
seedRemoteCommit(t, store, testCurrentSessionPrefix(), commit, "commit-generation", "manifest-generation")
|
||||
|
||||
if _, err := LoadCurrentState(context.Background(), store, testCurrentSessionPrefix(), CurrentStateValidation{}); err != nil {
|
||||
t.Fatalf("LoadCurrentState() error = %v, want verification from Read metadata", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCurrentStateRejectsPointerCommitIdentityMismatch(t *testing.T) {
|
||||
|
||||
Reference in New Issue
Block a user