110 lines
3.6 KiB
Go
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
|
|
}
|