Add immutable remote commit reader
This commit is contained in:
187
internal/artifacts/current_state_commit.go
Normal file
187
internal/artifacts/current_state_commit.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package artifacts
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/adapters/storage"
|
||||
"gitea.maximumdirect.net/eric/narratio/internal/manifest"
|
||||
)
|
||||
|
||||
func loadCommittedCurrentState(
|
||||
ctx context.Context,
|
||||
store storage.ObjectStore,
|
||||
sessionPrefix string,
|
||||
pointerKey string,
|
||||
validation CurrentStateValidation,
|
||||
) (*CurrentState, error) {
|
||||
pointerData, err := downloadRemoteObject(ctx, store, pointerKey, "narratio-current-commit-pointer-*.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("download current commit pointer %q: %w", pointerKey, err)
|
||||
}
|
||||
pointer, err := DecodeCurrentCommitPointer(pointerData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := pointer.ValidateForSessionPrefix(sessionPrefix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
commitData, err := readVerifiedRemoteObject(ctx, store, pointer.CommitKey, pointer.CommitSHA256, pointer.CommitSize, pointer.CommitGeneration, "narratio-remote-commit-*.json")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read selected remote commit %q: %w", pointer.CommitKey, err)
|
||||
}
|
||||
commit, err := DecodeRemoteCommitManifest(commitData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := commit.ValidateForSessionPrefix(sessionPrefix); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validatePointerCommitIdentity(pointer, commit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
sessionManifest, ok := commit.Artifact(RemoteArtifactTypeSessionManifest)
|
||||
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")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read committed session manifest %q: %w", sessionManifest.DestinationKey, err)
|
||||
}
|
||||
m, err := decodeCommittedManifest(ctx, manifestData)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateCommitManifestIdentity(commit, m); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
state := &CurrentState{
|
||||
SessionPrefix: sessionPrefix,
|
||||
CurrentRunIDKey: pointerKey,
|
||||
CurrentPointerKey: pointerKey,
|
||||
CurrentManifestKey: sessionManifest.DestinationKey,
|
||||
RunID: commit.RunID,
|
||||
Manifest: m,
|
||||
Commit: commit,
|
||||
Pointer: pointer,
|
||||
}
|
||||
if err := ValidateCurrentStateIdentity(state, validation); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func validatePointerCommitIdentity(pointer *CurrentCommitPointer, commit *RemoteCommitManifest) error {
|
||||
if pointer == nil || commit == nil {
|
||||
return fmt.Errorf("current commit pointer and remote commit are required")
|
||||
}
|
||||
if pointer.Campaign != commit.Campaign || pointer.SessionID != commit.SessionID || pointer.RunID != commit.RunID {
|
||||
return fmt.Errorf("current commit pointer identity does not match selected remote commit")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCommitManifestIdentity(commit *RemoteCommitManifest, m *manifest.Manifest) error {
|
||||
if commit == nil || m == nil {
|
||||
return fmt.Errorf("remote commit and committed session manifest are required")
|
||||
}
|
||||
if commit.Campaign != strings.TrimSpace(m.Campaign) ||
|
||||
commit.SessionID != strings.TrimSpace(m.SessionID) ||
|
||||
commit.RunID != strings.TrimSpace(m.RunID) {
|
||||
return fmt.Errorf("selected remote commit identity does not match committed session manifest")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func readVerifiedRemoteObject(
|
||||
ctx context.Context,
|
||||
store storage.ObjectStore,
|
||||
key string,
|
||||
wantSHA256 string,
|
||||
wantSize int64,
|
||||
wantGeneration string,
|
||||
tempPattern string,
|
||||
) ([]byte, error) {
|
||||
data, err := downloadRemoteObject(ctx, store, key, tempPattern)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if int64(len(data)) != wantSize {
|
||||
return nil, fmt.Errorf("size mismatch: got %d, want %d", len(data), wantSize)
|
||||
}
|
||||
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 {
|
||||
return nil, fmt.Errorf("storage size mismatch: got %d, want %d", info.Size, wantSize)
|
||||
}
|
||||
if strings.TrimSpace(info.ETag) != wantGeneration {
|
||||
return nil, fmt.Errorf("generation mismatch: got %q, want %q", info.ETag, wantGeneration)
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
|
||||
func downloadRemoteObject(ctx context.Context, store storage.ObjectStore, key, tempPattern string) ([]byte, error) {
|
||||
localPath, err := storage.DownloadObjectToTemp(ctx, store, key, tempPattern)
|
||||
if err != nil {
|
||||
return nil, 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
|
||||
}
|
||||
|
||||
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)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("committed session manifest decode failed: %w", err)
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
Reference in New Issue
Block a user