mirror of
https://github.com/immich-app/yucca.git
synced 2026-09-30 13:33:00 +08:00
353 lines
12 KiB
Go
353 lines
12 KiB
Go
package storage
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
|
|
"michael/internal/config"
|
|
|
|
"github.com/aws/aws-sdk-go-v2/service/s3/types"
|
|
)
|
|
|
|
// readOnly hides any io.Seeker the underlying reader implements, so the value
|
|
// handed to the S3 SDK is a plain, non-seekable stream — exactly what michael
|
|
// proxies in production (restic's r.Body, further wrapped in an io.TeeReader
|
|
// for hashing). The SDK cannot rewind it to retry.
|
|
type readOnly struct{ r io.Reader }
|
|
|
|
func (ro readOnly) Read(p []byte) (int, error) { return ro.r.Read(p) }
|
|
|
|
func TestIsPreconditionFailed_NilError(t *testing.T) {
|
|
if isPreconditionFailed(nil) {
|
|
t.Error("expected false for nil error")
|
|
}
|
|
}
|
|
|
|
func TestIsPreconditionFailed_GenericError(t *testing.T) {
|
|
err := &genericError{msg: "some error"}
|
|
if isPreconditionFailed(err) {
|
|
t.Error("expected false for generic error")
|
|
}
|
|
}
|
|
|
|
func TestIsPreconditionFailed_412Error(t *testing.T) {
|
|
err := &httpError{statusCode: 412}
|
|
if !isPreconditionFailed(err) {
|
|
t.Error("expected true for 412 error")
|
|
}
|
|
}
|
|
|
|
func TestIsPreconditionFailed_404Error(t *testing.T) {
|
|
err := &httpError{statusCode: 404}
|
|
if isPreconditionFailed(err) {
|
|
t.Error("expected false for 404 error")
|
|
}
|
|
}
|
|
|
|
func TestIsBackendFailure(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil", nil, false},
|
|
{"404 not found", &httpError{statusCode: 404}, false},
|
|
{"403 denied", &httpError{statusCode: 403}, false},
|
|
{"409 conflict", &httpError{statusCode: 409}, false},
|
|
{"412 precondition", &httpError{statusCode: 412}, false},
|
|
{"500 server error", &httpError{statusCode: 500}, true},
|
|
{"503 unavailable", &httpError{statusCode: 503}, true},
|
|
{"transport error (no status)", &genericError{msg: "dial tcp: connection refused"}, true},
|
|
{"wrapped transport error", errors.New("wrapped: unexpected EOF"), true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := isBackendFailure(tc.err); got != tc.want {
|
|
t.Errorf("isBackendFailure(%v) = %v, want %v", tc.err, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestIsNotFound(t *testing.T) {
|
|
cases := []struct {
|
|
name string
|
|
err error
|
|
want bool
|
|
}{
|
|
{"nil", nil, false},
|
|
{"generic error", &genericError{msg: "boom"}, false},
|
|
{"404", &httpError{statusCode: 404}, true},
|
|
{"403", &httpError{statusCode: 403}, false},
|
|
{"500", &httpError{statusCode: 500}, false},
|
|
{"types.NotFound", &types.NotFound{}, true},
|
|
{"types.NoSuchKey", &types.NoSuchKey{}, true},
|
|
}
|
|
for _, tc := range cases {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
if got := IsNotFound(tc.err); got != tc.want {
|
|
t.Errorf("IsNotFound(%v) = %v, want %v", tc.err, got, tc.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListObjects_PaginatesAllPages(t *testing.T) {
|
|
// Restic's REST listing has no pagination, so ListObjects must walk every
|
|
// ListObjectsV2 page — a repo past 1000 keys otherwise gets silently
|
|
// truncated and restic reports the tail packs as missing.
|
|
var tokens []string
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
token := r.URL.Query().Get("continuation-token")
|
|
tokens = append(tokens, token)
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
if token == "" {
|
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<Name>bucket</Name><Prefix>data/</Prefix><KeyCount>2</KeyCount><MaxKeys>2</MaxKeys>
|
|
<IsTruncated>true</IsTruncated><NextContinuationToken>tok-2</NextContinuationToken>
|
|
<Contents><Key>data/aa</Key><Size>1</Size></Contents>
|
|
<Contents><Key>data/bb</Key><Size>2</Size></Contents>
|
|
</ListBucketResult>`))
|
|
return
|
|
}
|
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<Name>bucket</Name><Prefix>data/</Prefix><KeyCount>1</KeyCount><MaxKeys>2</MaxKeys>
|
|
<IsTruncated>false</IsTruncated>
|
|
<Contents><Key>data/cc</Key><Size>3</Size></Contents>
|
|
</ListBucketResult>`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := NewS3StorageForEndpoint(probeConfig(), srv.URL)
|
|
var blobs []BlobInfo
|
|
err := s.ListObjects(context.Background(), "bucket", "data/", func(b BlobInfo) error {
|
|
blobs = append(blobs, b)
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListObjects: %v", err)
|
|
}
|
|
|
|
// Names are full keys; prefix-stripping is the caller's job.
|
|
want := []BlobInfo{{Name: "data/aa", Size: 1}, {Name: "data/bb", Size: 2}, {Name: "data/cc", Size: 3}}
|
|
if len(blobs) != len(want) {
|
|
t.Fatalf("got %d blobs (%v), want %d", len(blobs), blobs, len(want))
|
|
}
|
|
for i, b := range blobs {
|
|
if b != want[i] {
|
|
t.Errorf("blob[%d] = %+v, want %+v", i, b, want[i])
|
|
}
|
|
}
|
|
if len(tokens) != 2 || tokens[0] != "" || tokens[1] != "tok-2" {
|
|
t.Errorf("continuation tokens sent = %q, want [\"\" \"tok-2\"]", tokens)
|
|
}
|
|
}
|
|
|
|
func TestListObjects_EmptyListing(t *testing.T) {
|
|
// An empty listing completes without invoking the callback.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/xml")
|
|
_, _ = w.Write([]byte(`<?xml version="1.0" encoding="UTF-8"?>
|
|
<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
|
|
<Name>bucket</Name><Prefix>data/</Prefix><KeyCount>0</KeyCount><MaxKeys>1000</MaxKeys>
|
|
<IsTruncated>false</IsTruncated>
|
|
</ListBucketResult>`))
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := NewS3StorageForEndpoint(probeConfig(), srv.URL)
|
|
calls := 0
|
|
err := s.ListObjects(context.Background(), "bucket", "data/", func(BlobInfo) error {
|
|
calls++
|
|
return nil
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ListObjects: %v", err)
|
|
}
|
|
if calls != 0 {
|
|
t.Fatalf("expected no callbacks for empty listing, got %d", calls)
|
|
}
|
|
}
|
|
|
|
// TestPutObject_NonSeekableBodyOn503_NoRewindRetry reproduces the production
|
|
// throughput collapse: michael streams restic's non-seekable pack body straight
|
|
// into S3 PutObject, and when the gateway returns a retryable 5xx the SDK's
|
|
// default retryer tries to rewind the body to resend it — which fails with
|
|
// "failed to rewind transport stream for retry, request stream is not seekable"
|
|
// and surfaces as an opaque 500 to restic. Under load this hit ~28% of requests
|
|
// and stalled the whole fleet.
|
|
//
|
|
// The desired behaviour (asserted here) is: no rewind is ever attempted, exactly
|
|
// one upload is made, and the caller gets the clean underlying backend error —
|
|
// which restic retries at the pack level (its own body IS seekable). RED before
|
|
// the s3.go RetryMaxAttempts=1 fix, GREEN after.
|
|
func TestPutObject_NonSeekableBodyOn503_NoRewindRetry(t *testing.T) {
|
|
var puts atomic.Int32
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.Method == http.MethodPut {
|
|
puts.Add(1)
|
|
_, _ = io.Copy(io.Discard, r.Body) // let the client finish sending
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := NewS3StorageForEndpoint(probeConfig(), srv.URL)
|
|
body := readOnly{strings.NewReader("restic-pack-bytes")}
|
|
err := s.PutObject(context.Background(), "bucket", "data/deadbeef", body, int64(len("restic-pack-bytes")), true, "")
|
|
|
|
if err == nil {
|
|
t.Fatal("expected an error from the 503 gateway, got nil")
|
|
}
|
|
// The bug: a retry on the non-seekable body fails to rewind and masks the
|
|
// real backend error.
|
|
if msg := err.Error(); strings.Contains(msg, "rewind") || strings.Contains(msg, "not seekable") {
|
|
t.Fatalf("PutObject attempted a rewind-for-retry on a non-seekable body: %v", err)
|
|
}
|
|
// And with retries off, the gateway must see exactly one upload attempt.
|
|
if n := puts.Load(); n != 1 {
|
|
t.Fatalf("expected exactly 1 upload attempt (no retry on a non-seekable body), got %d", n)
|
|
}
|
|
}
|
|
|
|
func probeConfig() config.Config {
|
|
return config.Config{
|
|
S3AccessKeyID: "test",
|
|
S3SecretAccessKey: "test",
|
|
S3Region: "us-east-1",
|
|
S3ForcePathStyle: true,
|
|
}
|
|
}
|
|
|
|
func TestProbe_HealthyOn404(t *testing.T) {
|
|
// A gateway that answers HeadBucket with 404 is alive: probe must succeed.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := NewS3StorageForEndpoint(probeConfig(), srv.URL)
|
|
if err := s.Probe(context.Background(), "probe-bucket"); err != nil {
|
|
t.Errorf("Probe against 404 gateway: expected healthy, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProbe_HealthyOn200(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusOK)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := NewS3StorageForEndpoint(probeConfig(), srv.URL)
|
|
if err := s.Probe(context.Background(), "probe-bucket"); err != nil {
|
|
t.Errorf("Probe against 200 gateway: expected healthy, got %v", err)
|
|
}
|
|
}
|
|
|
|
func TestProbe_UnhealthyOn503(t *testing.T) {
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusServiceUnavailable)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
s := NewS3StorageForEndpoint(probeConfig(), srv.URL)
|
|
if err := s.Probe(context.Background(), "probe-bucket"); err == nil {
|
|
t.Error("Probe against 503 gateway: expected unhealthy, got nil")
|
|
}
|
|
}
|
|
|
|
func TestProbe_UnhealthyOnConnRefused(t *testing.T) {
|
|
// Start a server then close it so the port refuses connections.
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
|
url := srv.URL
|
|
srv.Close()
|
|
|
|
s := NewS3StorageForEndpoint(probeConfig(), url)
|
|
if err := s.Probe(context.Background(), "probe-bucket"); err == nil {
|
|
t.Error("Probe against down gateway: expected unhealthy, got nil")
|
|
}
|
|
}
|
|
|
|
func TestNewS3StorageWithOptions_PinsDialAndPreservesHost(t *testing.T) {
|
|
// A stand-in gateway. We point DialAddr at it but give the SDK an
|
|
// unresolvable signing host — if the request arrives, the pin worked, and
|
|
// the recorded Host proves signing/Host used the signing endpoint, not the
|
|
// dial IP. This is exactly the HAProxy-replacement behavior.
|
|
var gotHost string
|
|
hit := false
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
hit = true
|
|
gotHost = r.Host
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
|
|
dialAddr := srv.Listener.Addr().String()
|
|
s := NewS3StorageWithOptions(probeConfig(), S3Options{
|
|
Endpoint: "http://s3.signing-host.invalid",
|
|
DialAddr: dialAddr,
|
|
})
|
|
if err := s.Probe(context.Background(), "probe-bucket"); err != nil {
|
|
t.Fatalf("Probe through pinned dial: %v", err)
|
|
}
|
|
if !hit {
|
|
t.Fatal("request never reached the pinned gateway")
|
|
}
|
|
if gotHost != "s3.signing-host.invalid" {
|
|
t.Errorf("Host header = %q, want the signing host (not the dial IP)", gotHost)
|
|
}
|
|
}
|
|
|
|
func TestNewS3StorageWithOptions_TLSSkipVerify(t *testing.T) {
|
|
// Self-signed TLS server (httptest's default cert is not in system roots).
|
|
srv := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
defer srv.Close()
|
|
dialAddr := srv.Listener.Addr().String()
|
|
|
|
// Without skip-verify, the self-signed cert must fail the probe.
|
|
noSkip := NewS3StorageWithOptions(probeConfig(), S3Options{
|
|
Endpoint: "https://s3.signing-host.invalid",
|
|
DialAddr: dialAddr,
|
|
})
|
|
if err := noSkip.Probe(context.Background(), "probe-bucket"); err == nil {
|
|
t.Error("expected TLS verification failure without skip-verify")
|
|
}
|
|
|
|
// With skip-verify, it must succeed (gateway answered 404 = alive).
|
|
skip := NewS3StorageWithOptions(probeConfig(), S3Options{
|
|
Endpoint: "https://s3.signing-host.invalid",
|
|
DialAddr: dialAddr,
|
|
TLSSkipVerify: true,
|
|
})
|
|
if err := skip.Probe(context.Background(), "probe-bucket"); err != nil {
|
|
t.Errorf("Probe with skip-verify against self-signed gateway: %v", err)
|
|
}
|
|
}
|
|
|
|
// test helpers
|
|
|
|
type genericError struct {
|
|
msg string
|
|
}
|
|
|
|
func (e *genericError) Error() string { return e.msg }
|
|
|
|
type httpError struct {
|
|
statusCode int
|
|
}
|
|
|
|
func (e *httpError) Error() string { return "http error" }
|
|
func (e *httpError) HTTPStatusCode() int { return e.statusCode }
|