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

140 lines
4.4 KiB
Go

package agent
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"strings"
)
// RecoverDue discovers failure-only recording pairs after an Agent restart.
// Blocked/corrupt entries remain untouched and are returned as errors while
// other independent entries continue; none can produce a fabricated result.
func (r *RecordingRecovery) RecoverDue(ctx context.Context, grant RecoveryGrant, report RecordingResultReporter) (processedCount int, scanErr error) {
defer func() {
if scanErr != nil {
slog.ErrorContext(ctx, "Recording recovery scan failed", "stage", "recovery_scan_failed", "processed", processedCount, "error", scanErr)
}
}()
slog.InfoContext(ctx, "Recording recovery scan", "stage", "recovery_started")
if strings.TrimSpace(r.Root) == "" {
return 0, errors.New("recording recovery root is required")
}
if err := ctx.Err(); err != nil {
return 0, err
}
if err := os.MkdirAll(r.Root, 0700); err != nil {
return 0, fmt.Errorf("prepare recording recovery root: %w", err)
}
if err := checkRecoveryDirectory(r.Root); err != nil {
return 0, err
}
processed := 0
handled := make(map[string]bool)
var blocked []error
addBlocked := func(dir, stage string, cause error, identity ...any) {
digest := sha256.Sum256([]byte(dir))
attrs := append([]any{"stage", stage, "recovery_digest", fmt.Sprintf("%x", digest[:6]), "error", cause}, identity...)
slog.ErrorContext(ctx, "Recording recovery entry blocked", attrs...)
rel, err := filepath.Rel(r.Root, dir)
if err != nil {
blocked = append(blocked, fmt.Errorf("recording recovery path unavailable: %w", cause))
return
}
sum := sha256.Sum256([]byte(filepath.ToSlash(rel)))
blocked = append(blocked, fmt.Errorf("recording recovery %s: %w", hex.EncodeToString(sum[:6]), cause))
}
walkErr := filepath.WalkDir(r.Root, func(path string, item fs.DirEntry, err error) error {
if err != nil {
if path == r.Root {
return err
}
addBlocked(path, "recovery_walk", err)
if item != nil && item.IsDir() {
return filepath.SkipDir
}
return nil
}
if err := ctx.Err(); err != nil {
return err
}
if item.IsDir() {
if err := checkRecoveryDirectory(path); err != nil {
addBlocked(path, "recovery_directory", err)
return filepath.SkipDir
}
if path == filepath.Join(r.Root, ".results") {
return filepath.SkipDir // result-only files have their own scanner
}
return nil
}
dir := filepath.Dir(path)
switch item.Name() {
case "recording.wav":
if handled[dir] {
return nil
} // info processing may have cleaned this pair
if _, err := os.Stat(filepath.Join(dir, "info.json")); err != nil {
addBlocked(dir, "recovery_validate", fmt.Errorf("%w: original audio has no call information", ErrRecordingRecoveryIncomplete))
}
return nil
case "info.json":
handled[dir] = true
default:
addBlocked(dir, "recovery_validate", fmt.Errorf("%w: unexpected recovery file", ErrRecordingRecoveryIncomplete))
return nil
}
rel, err := filepath.Rel(r.Root, dir)
if err != nil {
addBlocked(dir, "recovery_path", err)
return nil
}
parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) < 2 {
addBlocked(dir, "recovery_validate", ErrRecordingRecoveryIncomplete)
return nil
}
bucket, objectKey := parts[0], strings.Join(parts[1:], "/")
before, err := r.Load(bucket, objectKey)
if err != nil {
addBlocked(dir, "recovery_load", err)
return nil
}
after, err := r.Retry(ctx, bucket, objectKey, grant, report)
if errors.Is(err, ErrRecordingNotDue) {
return nil
}
if err != nil {
addBlocked(dir, "recovery_retry", err, recordingRecoveryAttrs(after)...)
return nil
}
if before.State != "delivered" && after.State == "delivered" {
processed++
}
return nil
})
if len(blocked) > 0 || walkErr != nil {
slog.ErrorContext(ctx, "Recording recovery blocked", "stage", "recovery_blocked", "processed", processed, "blocked", len(blocked), "walk_error", walkErr)
} else {
slog.InfoContext(ctx, "Recording recovery scan complete", "stage", "recovery_completed", "processed", processed)
}
return processed, errors.Join(append(blocked, walkErr)...)
}
func checkRecoveryDirectory(path string) error {
info, err := os.Stat(path)
if err != nil {
return err
}
if !info.IsDir() || info.Mode().Perm()&0077 != 0 {
return fmt.Errorf("recording recovery directory must be private (mode=%#o dir=%t)", info.Mode().Perm(), info.IsDir())
}
return nil
}