Files
go-sip/internal/agent/upload_failure.go
T

110 lines
3.6 KiB
Go

package agent
import (
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"os"
"path/filepath"
agentv1 "git.ipao.vip/rogee/go-sip/gen/agent/v1"
"git.ipao.vip/rogee/go-sip/internal/contract"
"github.com/google/uuid"
"google.golang.org/protobuf/proto"
)
func validateUploadFailureFact(record UploadAttempt, fact *agentv1.ExecutionFact) error {
if record.State != "attempted" || record.Binding == nil || record.Asset == nil || record.Result.SizeBytes != 0 ||
fact == nil || fact.Binding == nil || fact.Kind != agentv1.FactKind_FACT_KIND_RECORDING_PROGRESS ||
!proto.Equal(record.Binding, fact.Binding) || fact.SourceBootId == "" || fact.SourceSequence == 0 || fact.ObservedAtUnixMs <= 0 {
return errors.New("Mock failure requires a matching uncompleted upload attempt")
}
parsed, err := uuid.Parse(fact.FactId)
if err != nil || parsed.Version() != 4 {
return errors.New("Mock failure fact ID must be UUID v4")
}
failure, err := contract.DecodeLocalMockRecordingFailure(fact.PayloadJson)
if err != nil {
return err
}
if failure.UploadID != record.UploadID || failure.RecordingID != record.Asset.AssetId {
return errors.New("Mock failure payload does not match claimed upload")
}
sum := sha256.Sum256(fact.PayloadJson)
if hex.EncodeToString(sum[:]) != fact.ContentSha256 {
return errors.New("Mock failure payload checksum mismatch")
}
return nil
}
// RecordUploadFailureFact persists a single terminal failure identity before
// any network report. Unknown PUT results never enter this path and cannot be
// replayed as a second upload after restart.
func (s *Spool) RecordUploadFailureFact(id string, fact *agentv1.ExecutionFact) error {
s.mu.Lock()
defer s.mu.Unlock()
record, err := s.LoadUploadAttempt(id)
if err != nil {
return err
}
if err := validateUploadFailureFact(record, fact); err != nil {
return err
}
if record.FailureFact != nil {
if !proto.Equal(record.FailureFact, fact) {
return errors.New("Mock upload failure fact identity changed")
}
return nil
}
record.FailureFact = proto.Clone(fact).(*agentv1.ExecutionFact)
return writeJSONAtomic(filepath.Join(s.uploadAttemptDir(id), "state.json"), record)
}
func (s *Spool) CompleteUploadFailureReport(id, factID string) error {
s.mu.Lock()
defer s.mu.Unlock()
record, err := s.LoadUploadAttempt(id)
if err != nil {
return err
}
if record.FailureFact == nil || record.FailureFact.FactId != factID || record.State != "attempted" {
return errors.New("failure report acknowledgement does not match upload")
}
if record.FailureDelivered {
return nil
}
record.FailureDelivered = true
return writeJSONAtomic(filepath.Join(s.uploadAttemptDir(id), "state.json"), record)
}
// PendingUploadFailures retries only the fact notification. It never requests
// another token, reads the source recording or repeats an uncertain PUT.
func (s *Spool) PendingUploadFailures() ([]UploadAttempt, error) {
entries, err := os.ReadDir(filepath.Join(s.root, ".uploads"))
if errors.Is(err, os.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, err
}
var pending []UploadAttempt
for _, entry := range entries {
if !entry.IsDir() {
return nil, fmt.Errorf("unexpected upload journal entry %q", entry.Name())
}
record, err := s.LoadUploadAttempt(entry.Name())
if err != nil {
return nil, err
}
if record.FailureFact == nil || record.FailureDelivered {
continue
}
if err := validateUploadFailureFact(record, record.FailureFact); err != nil {
return nil, fmt.Errorf("invalid persisted Mock failure for %s: %w", record.UploadID, err)
}
pending = append(pending, record)
}
return pending, nil
}