diff --git a/packages/shared/go.mod b/packages/shared/go.mod index 1842f7e6fd..1c28095772 100644 --- a/packages/shared/go.mod +++ b/packages/shared/go.mod @@ -12,6 +12,7 @@ require ( github.com/RoaringBitmap/roaring/v2 v2.18.0 github.com/aws/aws-sdk-go-v2 v1.41.6 github.com/aws/aws-sdk-go-v2/config v1.32.12 + github.com/aws/aws-sdk-go-v2/credentials v1.19.12 github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.20.12 github.com/aws/aws-sdk-go-v2/service/ecr v1.44.0 github.com/aws/aws-sdk-go-v2/service/s3 v1.100.0 @@ -98,7 +99,6 @@ require ( github.com/apapsch/go-jsonmerge/v2 v2.0.0 // indirect github.com/armon/go-metrics v0.4.1 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.9 // indirect - github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.20 // indirect github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.22 // indirect github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.22 // indirect diff --git a/packages/shared/pkg/storage/gcp_multipart_test.go b/packages/shared/pkg/storage/gcp_multipart_test.go index 5866abc830..58450a960d 100644 --- a/packages/shared/pkg/storage/gcp_multipart_test.go +++ b/packages/shared/pkg/storage/gcp_multipart_test.go @@ -1,6 +1,7 @@ package storage import ( + "bytes" "crypto/md5" "crypto/sha256" "encoding/base64" @@ -13,12 +14,15 @@ import ( "net/http/httptest" "os" "path/filepath" + "slices" "strings" "sync" "sync/atomic" "testing" "time" + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/service/s3" "github.com/hashicorp/go-retryablehttp" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1194,3 +1198,298 @@ func TestGCPCompleteRetriesTruncatedBody(t *testing.T) { require.Error(t, err, "a truncated complete response must fail, not commit") require.Equal(t, int32(3), attempts.Load(), "a failed body read must be retried to the budget") } + +// gcsXMLBackend starts MinIO and opens its bucket for anonymous access so the +// GCS XML MultipartUploader (bearer-token auth, unverifiable by MinIO) can +// upload without request signing. +func gcsXMLBackend(t *testing.T) *s3TestBackend { + t.Helper() + + backend := startMinioBackend(t) + + policy := fmt.Sprintf(`{ + "Version": "2012-10-17", + "Statement": [{ + "Effect": "Allow", + "Principal": {"AWS": ["*"]}, + "Action": ["s3:*"], + "Resource": ["arn:aws:s3:::%s", "arn:aws:s3:::%s/*"] + }] + }`, backend.bucket, backend.bucket) + + _, err := backend.newClient(t, nil).PutBucketPolicy(t.Context(), &s3.PutBucketPolicyInput{ + Bucket: aws.String(backend.bucket), + Policy: aws.String(policy), + }) + require.NoError(t, err, "allow anonymous access on minio bucket") + + return backend +} + +// authStrippingTransport removes the Authorization header (the uploader's +// bearer token) so requests reach MinIO as anonymous — MinIO cannot validate +// Google OAuth tokens. Nothing else is adapted: in particular, MinIO's 411 +// rejection of chunked uploads guards the uploader's explicit Content-Length +// (via multiSliceReader.Len) against regressions. +type authStrippingTransport struct { + inner http.RoundTripper +} + +func (a authStrippingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + clone.Header.Del("Authorization") + + return a.inner.RoundTrip(clone) +} + +// gcsXMLUploader builds a MultipartUploader pointed at the MinIO backend, +// bypassing NewMultipartUploaderWithRetryConfig (which requires real Google +// credentials and hardcodes the production URL). transport is optional and +// sits between the retryable client and the network (e.g. fault injection). +func gcsXMLUploader(t *testing.T, backend *s3TestBackend, key string, metadata ObjectMetadata, transport http.RoundTripper) *MultipartUploader { + t.Helper() + + if transport == nil { + transport = http.DefaultTransport + } + + rc := retryablehttp.NewClient() + rc.RetryMax = 3 + rc.RetryWaitMin = 10 * time.Millisecond + rc.RetryWaitMax = 50 * time.Millisecond + rc.Logger = nil + rc.HTTPClient = &http.Client{Transport: authStrippingTransport{inner: transport}} + + return &MultipartUploader{ + bucketName: backend.bucket, + objectName: key, + token: "test-token", // stripped by authStrippingTransport + client: rc, + retryConfig: DefaultRetryConfig(), + metadata: metadata, + baseURL: backend.endpoint + "/" + backend.bucket, + } +} + +// anonymousGet fetches an object's raw bytes via unauthenticated HTTP. +func anonymousGet(t *testing.T, backend *s3TestBackend, key string) []byte { + t.Helper() + + req, err := http.NewRequestWithContext(t.Context(), http.MethodGet, backend.endpoint+"/"+backend.bucket+"/"+key, nil) + require.NoError(t, err) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + require.Equal(t, http.StatusOK, resp.StatusCode) + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + return body +} + +// TestGCSXMLPartUploaderContract drives MultipartUploader directly against +// MinIO's XML multipart implementation: out-of-order part numbers, a +// multi-slice part body, Content-MD5 validation by a real server, and +// ordered reassembly on Complete. +func TestGCSXMLPartUploaderContract(t *testing.T) { + t.Parallel() + + backend := gcsXMLBackend(t) + key := testKey("gcs-part-contract") + up := gcsXMLUploader(t, backend, key, nil, nil) + + // Part 1 (non-final) must be >= 5 MiB; two slices exercise the + // multi-slice hashing/streaming path. Part 2 (final) is tiny. + part1a := bytes.Repeat([]byte{0xC3}, 3*megabyte) + part1b := bytes.Repeat([]byte{0xD4}, 2*megabyte+512) + part2 := []byte("gcs-final-part") + + require.NoError(t, up.Start(t.Context())) + // Upload out of order: final part first. + require.NoError(t, up.UploadPart(t.Context(), 2, part2)) + require.NoError(t, up.UploadPart(t.Context(), 1, part1a, part1b)) + require.NoError(t, up.Complete(t.Context())) + require.NoError(t, up.Close()) + + want := slices.Concat(part1a, part1b, part2) + got := anonymousGet(t, backend, key) + require.Equal(t, sha256.Sum256(want), sha256.Sum256(got), + "parts must reassemble in part-number order, not upload order") +} + +// TestGCSXMLCompressedRoundTrip runs the production compressed upload path +// (storeFileCompressed) with the GCS XML uploader against MinIO, then +// verifies the stored blob decompresses back to the original. +func TestGCSXMLCompressedRoundTrip(t *testing.T) { + t.Parallel() + + codecs := []struct { + codec CompressionType + level int + }{ + {CompressionZstd, 2}, + {CompressionLZ4, 0}, + } + + for _, tc := range codecs { + t.Run(tc.codec.String(), func(t *testing.T) { + t.Parallel() + + const dataSize = 32 * megabyte + data := generateSemiRandomData(dataSize) + inputPath := writeTempFile(t, data) + + backend := gcsXMLBackend(t) + key := testKey("gcs-compressed-" + tc.codec.String()) + + cfg := CompressConfig{ + Enabled: true, + Type: tc.codec.String(), + Level: tc.level, + FrameSizeKB: 2 * 1024, + MinPartSizeMB: 5, + FrameEncodeWorkers: 4, + EncoderConcurrency: 1, + } + + fullFT, checksum, err := storeFileCompressed(t.Context(), inputPath, cfg, 4, PutOptions{}, + func(metadata ObjectMetadata) (partUploader, error) { + return gcsXMLUploader(t, backend, key, metadata, nil), nil + }) + require.NoError(t, err) + require.Equal(t, sha256.Sum256(data), checksum) + + ft := fullFT.Table() + require.Equal(t, dataSize/(2*megabyte), ft.NumFrames()) + require.Equal(t, int64(dataSize), ft.UncompressedSize()) + + blob := anonymousGet(t, backend, key) + require.Equal(t, ft.CompressedSize(), int64(len(blob))) + + decompressed, err := decompressAll(ft, blob) + require.NoError(t, err) + require.Equal(t, sha256.Sum256(data), sha256.Sum256(decompressed), + "stored blob must decompress back to the original") + }) + } +} + +// TestGCSXMLCompressedEmptyFile verifies the single empty part shipped for +// zero-byte inputs completes against a real XML multipart implementation. +func TestGCSXMLCompressedEmptyFile(t *testing.T) { + t.Parallel() + + inputPath := writeTempFile(t, nil) + backend := gcsXMLBackend(t) + key := testKey("gcs-compressed-empty") + + fullFT, checksum, err := storeFileCompressed(t.Context(), inputPath, testCompressConfig(), 4, PutOptions{}, + func(metadata ObjectMetadata) (partUploader, error) { + return gcsXMLUploader(t, backend, key, metadata, nil), nil + }) + require.NoError(t, err) + require.Equal(t, sha256.Sum256(nil), checksum) + require.Equal(t, 0, fullFT.Table().NumFrames()) + require.Empty(t, anonymousGet(t, backend, key)) +} + +// TestGCSXMLRetryOnTransientFailure fails the first attempt of every request +// (initiate, each part, complete) with an injected 500 and verifies +// retryablehttp's retry with ReaderFunc body replay: the whole upload +// succeeds, retried parts re-send byte-identical bodies, and the stored blob +// decompresses cleanly. +func TestGCSXMLRetryOnTransientFailure(t *testing.T) { + t.Parallel() + + const dataSize = 32 * megabyte + data := generateSemiRandomData(dataSize) + inputPath := writeTempFile(t, data) + + backend := gcsXMLBackend(t) + key := testKey("gcs-retry-transient") + ft := newFaultInjectingTransport() + + cfg := CompressConfig{ + Enabled: true, + Type: CompressionLZ4.String(), + FrameSizeKB: 2 * 1024, + MinPartSizeMB: 5, + FrameEncodeWorkers: 4, + EncoderConcurrency: 1, + } + + fullFT, checksum, err := storeFileCompressed(t.Context(), inputPath, cfg, 4, PutOptions{}, + func(metadata ObjectMetadata) (partUploader, error) { + return gcsXMLUploader(t, backend, key, metadata, ft), nil + }) + require.NoError(t, err, "upload must survive one injected 500 per request") + require.Equal(t, sha256.Sum256(data), checksum) + + blob := anonymousGet(t, backend, key) + decompressed, err := decompressAll(fullFT.Table(), blob) + require.NoError(t, err) + require.Equal(t, sha256.Sum256(data), sha256.Sum256(decompressed), + "stored blob after injected faults must decompress to the original") + + ft.mu.Lock() + defer ft.mu.Unlock() + + require.NotEmpty(t, ft.partBodySizes) + require.Greater(t, ft.injected, len(ft.partBodySizes), + "should have injected faults beyond part uploads (initiate/complete)") + t.Logf("injected %d faults across %d distinct requests (%d parts)", + ft.injected, len(ft.seen), len(ft.partBodySizes)) + + for part, sizes := range ft.partBodySizes { + require.Len(t, sizes, 2, "part %s: expected exactly one failed and one successful attempt", part) + require.Positive(t, sizes[0], "part %s: injected attempt consumed no body", part) + require.Equal(t, sizes[0], sizes[1], + "part %s: retry sent %d bytes but first attempt sent %d — ReaderFunc body replay is broken", + part, sizes[1], sizes[0]) + } +} + +// TestGCSXMLUploadFileInParallel exercises the uncompressed >=50MB multipart +// path (UploadFileInParallel) against MinIO: fixed 50 MB chunks uploaded +// concurrently, with the overlapped checksum hasher. +func TestGCSXMLUploadFileInParallel(t *testing.T) { + t.Parallel() + + const dataSize = 120 * megabyte // 3 parts at the fixed 50 MB chunk size + data := generateSemiRandomData(dataSize) + inputPath := writeTempFile(t, data) + + backend := gcsXMLBackend(t) + key := testKey("gcs-parallel-upload") + up := gcsXMLUploader(t, backend, key, nil, nil) + + hasher := sha256.New() + n, err := up.UploadFileInParallel(t.Context(), inputPath, 4, hasher) + require.NoError(t, err) + require.Equal(t, int64(dataSize), n) + require.Equal(t, sha256.Sum256(data), [32]byte(hasher.Sum(nil)), + "overlapped hasher must checksum the whole file") + + got := anonymousGet(t, backend, key) + require.Equal(t, sha256.Sum256(data), sha256.Sum256(got)) +} + +// TestGCPUploadFileInParallelEmptyFile verifies the empty-file path ships its +// single zero-byte part with an explicit Content-Length rather than chunked +// transfer encoding, which S3-compatible XML backends (MinIO here) reject with +// 411 MissingContentLength. +func TestGCPUploadFileInParallelEmptyFile(t *testing.T) { + t.Parallel() + + backend := gcsXMLBackend(t) + key := testKey("gcp-parallel-empty") + up := gcsXMLUploader(t, backend, key, nil, nil) + + inputPath := writeTempFile(t, nil) + n, err := up.UploadFileInParallel(t.Context(), inputPath, 2, nil) + require.NoError(t, err, "empty file must upload without a 411 chunked-encoding rejection") + require.Zero(t, n) + require.Empty(t, anonymousGet(t, backend, key)) +} diff --git a/packages/shared/pkg/storage/storage_google_test.go b/packages/shared/pkg/storage/storage_google_test.go new file mode 100644 index 0000000000..592e4f6bb8 --- /dev/null +++ b/packages/shared/pkg/storage/storage_google_test.go @@ -0,0 +1,182 @@ +package storage + +// Tests exercising gcpObject (reads, Put, Size/metadata) against a +// fake-gcs-server container over the GCS JSON API. Note production uses the +// gRPC client (NewGCP); tests use the HTTP client — no emulator speaks the +// GCS gRPC protocol. The XML multipart upload side (MultipartUploader) is +// covered in gcp_multipart_test.go against MinIO. + +import ( + "bytes" + "context" + "crypto/sha256" + "fmt" + "net/http" + "testing" + + gcs "cloud.google.com/go/storage" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" + "google.golang.org/api/option" +) + +const fakeGCSImage = "fsouza/fake-gcs-server:1.54.0" + +// emulatorRedirectTransport sends every request to the emulator's host while +// keeping the originally targeted host in the Host header, so fake-gcs-server +// can route both JSON API and XML-style (host-based) requests correctly. +type emulatorRedirectTransport struct { + emulatorHost string + inner http.RoundTripper +} + +func (e emulatorRedirectTransport) RoundTrip(req *http.Request) (*http.Response, error) { + clone := req.Clone(req.Context()) + if clone.Host == "" { + clone.Host = clone.URL.Host + } + clone.URL.Scheme = "http" + clone.URL.Host = e.emulatorHost + + return e.inner.RoundTrip(clone) +} + +// startFakeGCSBackend starts a fake-gcs-server container, creates a bucket, +// and returns a gcpStorage wired to it over the JSON API. +func startFakeGCSBackend(t *testing.T) *gcpStorage { + t.Helper() + + container, err := testcontainers.GenericContainer(t.Context(), testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: fakeGCSImage, + Cmd: []string{"-scheme", "http", "-backend", "memory"}, + ExposedPorts: []string{"4443/tcp"}, + WaitingFor: wait.ForListeningPort("4443/tcp"), + }, + Started: true, + }) + require.NoError(t, err, "start fake-gcs-server container") + + t.Cleanup(func() { + if err := container.Terminate(context.Background()); err != nil { + t.Logf("cleanup: failed to terminate fake-gcs-server container: %v", err) + } + }) + + host, err := container.Host(t.Context()) + require.NoError(t, err) + port, err := container.MappedPort(t.Context(), "4443") + require.NoError(t, err) + + // fake-gcs-server routes XML-style downloads (used by NewRangeReader) by + // Host header, so redirect connections to the emulator while preserving + // the original storage.googleapis.com host — the approach fake-gcs-server + // recommends for the Go client. + httpClient := &http.Client{Transport: emulatorRedirectTransport{ + emulatorHost: fmt.Sprintf("%s:%s", host, port.Port()), + inner: http.DefaultTransport, + }} + + client, err := gcs.NewClient(t.Context(), option.WithHTTPClient(httpClient)) + require.NoError(t, err) + t.Cleanup(func() { _ = client.Close() }) + + const bucketName = "gcs-test-bucket" + require.NoError(t, client.Bucket(bucketName).Create(t.Context(), "test-project", nil)) + + return &gcpStorage{ + client: client, + bucket: client.Bucket(bucketName), + } +} + +// TestGCSObjectReadPath covers the gcpObject side against fake-gcs-server: +// Put with metadata, Size() preferring the uncompressed-size metadata, and +// OpenRangeReader frame-by-frame decompression of a compressed blob. +func TestGCSObjectReadPath(t *testing.T) { + t.Parallel() + + storage := startFakeGCSBackend(t) + + const dataSize = 8 * megabyte + data := generateSemiRandomData(dataSize) + + // Compress in memory with the shared pipeline, then store the assembled + // blob the way the multipart path would have laid it out. + up := &memPartUploader{} + fullFT, checksum, err := compressStream(t.Context(), bytes.NewReader(data), defaultCfg(CompressionZstd, 4, 2*megabyte), up, 4, nil) + require.NoError(t, err) + require.Equal(t, sha256.Sum256(data), checksum) + ft := fullFT.Table() + + seekable, err := storage.OpenSeekable(t.Context(), testKey("gcs-read-compressed")) + require.NoError(t, err) + obj, ok := seekable.(*gcpObject) + require.True(t, ok) + + metadata := ObjectMetadata{}.WithUncompressedSize(int64(dataSize)) + require.NoError(t, obj.Put(t.Context(), up.Assemble(), WithMetadata(metadata))) + + // Size() must come from the uncompressed-size metadata, not the + // (smaller) compressed object size. + size, err := obj.Size(t.Context()) + require.NoError(t, err) + require.Equal(t, int64(dataSize), size) + + stored, err := obj.Metadata(t.Context()) + require.NoError(t, err) + uncompressed, ok := stored.UncompressedSize() + require.True(t, ok) + require.Equal(t, int64(dataSize), uncompressed) + + // Read everything back through frame-aligned range reads. + var got bytes.Buffer + for off := int64(0); off < int64(dataSize); { + rr, src, err := obj.OpenRangeReader(t.Context(), off, 0, ft) + require.NoError(t, err) + require.Equal(t, SourceGCS, src) + + n, err := got.ReadFrom(rr) + require.NoError(t, err) + require.Positive(t, n) + _, err = rr.Close(t.Context()) + require.NoError(t, err) + + off += n + } + require.Equal(t, sha256.Sum256(data), sha256.Sum256(got.Bytes()), + "read-back data differs from original") +} + +// TestGCSObjectStoreFileSmallUncompressed covers gcpObject.StoreFile's +// small-file path (single-shot Put, no multipart, no credentials needed) +// against fake-gcs-server. +func TestGCSObjectStoreFileSmallUncompressed(t *testing.T) { + t.Parallel() + + storage := startFakeGCSBackend(t) + + const dataSize = 4 * megabyte // < gcpMultipartUploadChunkSize -> Put path + data := generateSemiRandomData(dataSize) + inputPath := writeTempFile(t, data) + + seekable, err := storage.OpenSeekable(t.Context(), testKey("gcs-storefile-small")) + require.NoError(t, err) + obj, ok := seekable.(*gcpObject) + require.True(t, ok) + + fullFT, checksum, err := obj.StoreFile(t.Context(), inputPath, WithChecksumSHA256()) + require.NoError(t, err) + require.Nil(t, fullFT, "uncompressed uploads have no frame table") + require.Equal(t, sha256.Sum256(data), checksum) + + size, err := obj.Size(t.Context()) + require.NoError(t, err) + require.Equal(t, int64(dataSize), size) + + var got bytes.Buffer + _, err = obj.WriteTo(t.Context(), &got) + require.NoError(t, err) + require.Equal(t, sha256.Sum256(data), sha256.Sum256(got.Bytes())) +} diff --git a/packages/shared/pkg/storage/storage_testcontainers_test.go b/packages/shared/pkg/storage/storage_testcontainers_test.go new file mode 100644 index 0000000000..02e0a91d80 --- /dev/null +++ b/packages/shared/pkg/storage/storage_testcontainers_test.go @@ -0,0 +1,231 @@ +package storage + +// Shared testcontainer scaffolding for the storage server-backed tests +// (MinIO / fake-gcs-server). Split out from #3113 so the GCS server tests and +// the AWS server tests can share it. + +import ( + "context" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + "github.com/aws/aws-sdk-go-v2/service/s3" + "github.com/stretchr/testify/require" + "github.com/testcontainers/testcontainers-go" + "github.com/testcontainers/testcontainers-go/wait" +) + +const minioImage = "minio/minio:RELEASE.2025-09-07T16-13-09Z" + +// s3TestBackend describes where the tests run: a real AWS bucket (endpoint +// empty) or an S3-compatible container endpoint. +type s3TestBackend struct { + bucket string + endpoint string +} + +// startMinioBackend starts a per-test MinIO container and creates a bucket in +// it (same pattern as redis_utils.SetupInstance — Docker required, torn down +// via t.Cleanup). Also used by the GCS XML multipart tests, which run against +// MinIO because it implements the S3/GCS XML multipart dialect. +func startMinioBackend(t *testing.T) *s3TestBackend { + t.Helper() + + container, err := testcontainers.GenericContainer(t.Context(), testcontainers.GenericContainerRequest{ + ContainerRequest: testcontainers.ContainerRequest{ + Image: minioImage, + Cmd: []string{"server", "/data"}, + ExposedPorts: []string{"9000/tcp"}, + WaitingFor: wait.ForHTTP("/minio/health/live").WithPort("9000/tcp"), + }, + Started: true, + }) + require.NoError(t, err, "start minio container") + + t.Cleanup(func() { + if err := container.Terminate(context.Background()); err != nil { + t.Logf("cleanup: failed to terminate minio container: %v", err) + } + }) + + host, err := container.Host(t.Context()) + require.NoError(t, err) + port, err := container.MappedPort(t.Context(), "9000") + require.NoError(t, err) + + backend := &s3TestBackend{ + bucket: "s3-test-bucket", + endpoint: fmt.Sprintf("http://%s:%s", host, port.Port()), + } + + _, err = backend.newClient(t, nil).CreateBucket(t.Context(), + &s3.CreateBucketInput{Bucket: aws.String(backend.bucket)}) + require.NoError(t, err, "create bucket") + + return backend +} + +// newClient builds an S3 client for the backend. httpClient is optional and +// lets tests install a fault-injecting transport; optFns lets them tune +// client options such as the retryer. +func (b *s3TestBackend) newClient(t *testing.T, httpClient *http.Client, optFns ...func(*s3.Options)) *s3.Client { + t.Helper() + + if b.endpoint != "" { + cfg := aws.Config{ + Credentials: credentials.NewStaticCredentialsProvider("minioadmin", "minioadmin", ""), + Region: "us-east-1", + } + if httpClient != nil { + cfg.HTTPClient = httpClient + } + + return s3.NewFromConfig(cfg, append([]func(*s3.Options){func(o *s3.Options) { + o.BaseEndpoint = aws.String(b.endpoint) + o.UsePathStyle = true + }}, optFns...)...) + } + + var opts []func(*config.LoadOptions) error + if httpClient != nil { + opts = append(opts, config.WithHTTPClient(httpClient)) + } + cfg, err := config.LoadDefaultConfig(t.Context(), opts...) + require.NoError(t, err) + + return s3.NewFromConfig(cfg, optFns...) +} + +func testKey(name string) string { + return fmt.Sprintf("s3-test/%d/%s", time.Now().UnixNano(), name) +} + +func writeTempFile(t *testing.T, data []byte) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "input") + require.NoError(t, os.WriteFile(path, data, 0o644)) + + return path +} + +func testCompressConfig() CompressConfig { + return CompressConfig{Enabled: true, Type: CompressionLZ4.String(), FrameSizeKB: 1, MinPartSizeMB: 1, FrameEncodeWorkers: 1} +} + +// countingReadCloser counts bytes read through it and reports the total to +// onClose when the body is closed. The count is read in Close (after the +// transport is done with the body) rather than after RoundTrip returns, which +// would race the transport's still-running body reads. +type countingReadCloser struct { + rc io.ReadCloser + n atomic.Int64 + onClose func(int64) +} + +func (c *countingReadCloser) Read(p []byte) (int, error) { + n, err := c.rc.Read(p) + c.n.Add(int64(n)) + + return n, err +} + +func (c *countingReadCloser) Close() error { + err := c.rc.Close() + if c.onClose != nil { + c.onClose(c.n.Load()) + } + + return err +} + +// faultInjectingTransport sits below the AWS SDK (after signing): the first +// attempt of every distinct S3 request consumes the full request body and +// gets a synthetic 500 InternalError, forcing the SDK's real retry machinery +// — including body rewind via Seek — before the retry passes through to real +// S3. Per-part body sizes are recorded per attempt so a broken rewind (short +// or empty re-send) is directly observable. +type faultInjectingTransport struct { + inner http.RoundTripper + + mu sync.Mutex + seen map[string]bool + injected int + partBodySizes map[string][]int64 // partNumber -> body bytes per attempt +} + +func newFaultInjectingTransport() *faultInjectingTransport { + return &faultInjectingTransport{ + inner: http.DefaultTransport, + seen: make(map[string]bool), + partBodySizes: make(map[string][]int64), + } +} + +func (f *faultInjectingTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Distinct request identity: retries repeat all of these exactly. + key := req.Method + " " + req.URL.Path + "?" + req.URL.RawQuery + " range=" + req.Header.Get("Range") + partNum := req.URL.Query().Get("partNumber") + + f.mu.Lock() + first := !f.seen[key] + f.seen[key] = true + f.mu.Unlock() + + if first { + var consumed int64 + if req.Body != nil { + consumed, _ = io.Copy(io.Discard, req.Body) + req.Body.Close() + } + + f.mu.Lock() + f.injected++ + if partNum != "" { + f.partBodySizes[partNum] = append(f.partBodySizes[partNum], consumed) + } + f.mu.Unlock() + + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Status: "500 Internal Server Error", + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{"Content-Type": []string{"application/xml"}}, + Body: io.NopCloser(strings.NewReader( + `InternalErrorinjected fault`)), + Request: req, + }, nil + } + + if partNum != "" && req.Body != nil { + counter := &countingReadCloser{ + rc: req.Body, + // Record on Close, not after RoundTrip returns: the transport may + // still be reading/closing the body when RoundTrip returns. + onClose: func(n int64) { + f.mu.Lock() + f.partBodySizes[partNum] = append(f.partBodySizes[partNum], n) + f.mu.Unlock() + }, + } + clone := req.Clone(req.Context()) + clone.Body = counter + + return f.inner.RoundTrip(clone) + } + + return f.inner.RoundTrip(req) +} diff --git a/tests/integration/go.sum b/tests/integration/go.sum index d74ff28083..6d5a50e2c2 100644 --- a/tests/integration/go.sum +++ b/tests/integration/go.sum @@ -22,6 +22,10 @@ cloud.google.com/go/trace v1.11.7 h1:kDNDX8JkaAG3R2nq1lIdkb7FCSi1rCmsEtKVsty7p+U cloud.google.com/go/trace v1.11.7/go.mod h1:TNn9d5V3fQVf6s4SCveVMIBS2LJUqo73GACmq/Tky0s= connectrpc.com/connect v1.18.1 h1:PAg7CjSAGvscaf6YZKUefjoih5Z/qYkyaTrBW8xvYPw= connectrpc.com/connect v1.18.1/go.mod h1:0292hj1rnx8oFrStN7cB4jjVBeqs+Yx5yDIC2prWDO8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg= +github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E= github.com/ClickHouse/ch-go v0.71.0 h1:bUdZ/EZj/LcVHsMqaRUP2holqygrPWQKeMjc6nZoyRM= github.com/ClickHouse/ch-go v0.71.0/go.mod h1:NwbNc+7jaqfY58dmdDUbG4Jl22vThgx1cYjBw0vtgXw= github.com/ClickHouse/clickhouse-go/v2 v2.46.0 h1:s3eRy+hYmu5uzotB6ZhDofgHu8kDgGN/fpmjxRkqSpk= @@ -101,6 +105,8 @@ github.com/bitfield/gotestdox v0.2.2/go.mod h1:D+gwtS0urjBrzguAkTM2wodsTQYFHdpx8 github.com/bits-and-blooms/bitset v1.24.5 h1:654xBVHc23gJMAgOTkPNoCVfiRxuIOAUnAZFtopqJ4w= github.com/bits-and-blooms/bitset v1.24.5/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= +github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= +github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= @@ -110,22 +116,40 @@ github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5P github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5 h1:6xNmx7iTtyBRev0+D/Tv1FZd4SCg8axKApyNyRsAt/w= github.com/cncf/xds/go v0.0.0-20251210132809-ee656c7534f5/go.mod h1:KdCmV+x/BuvyMxRnYBlmVaq4OLiKW6iRQfvC62cvdkI= +github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= +github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M= +github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE= +github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk= +github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I= +github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo= +github.com/containerd/platforms v0.2.1 h1:zvwtM3rz2YHPQsF2CHYM8+KtB5dvhISiXh5ZpSBQv6A= +github.com/containerd/platforms v0.2.1/go.mod h1:XHCb+2/hzowdiut9rkudds9bE5yJ7npe7dG/wG+uFPw= github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= +github.com/cpuguy83/dockercfg v0.3.2 h1:DlJTyZGBDlXqUZ2Dk2Q3xHs/FtnooJJVaad2S9GKorA= +github.com/cpuguy83/dockercfg v0.3.2/go.mod h1:sugsbF4//dDlL/i+S+rtpIWp+5h0BHJHfjj5/jFyUJc= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dchest/uniuri v1.2.0 h1:koIcOUdrTIivZgSLhHQvKgqdWZq5d7KdMEWF1Ud6+5g= github.com/dchest/uniuri v1.2.0/go.mod h1:fSzm4SLHzNZvWLvWJew423PhAzkpNQYq+uNLq4kxhkY= +github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk= +github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E= github.com/dlclark/regexp2 v1.12.0 h1:0j4c5qQmnC6XOWNjP3PIXURXN2gWx76rd3KvgdPkCz8= github.com/dlclark/regexp2 v1.12.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/dnephin/pflag v1.0.7 h1:oxONGlWxhmUct0YzKTgrpQv9AUA1wtPBn7zuSjJqptk= github.com/dnephin/pflag v1.0.7/go.mod h1:uxE91IoWURlOiTUIA8Mq5ZZkAv3dPUfZNaT80Zm7OQE= +github.com/docker/go-connections v0.7.0 h1:6SsRfJddP22WMrCkj19x9WKjEDTB+ahsdiGYf0mN39c= +github.com/docker/go-connections v0.7.0/go.mod h1:no1qkHdjq7kLMGUXYAduOhYPSJxxvgWBh7ogVvptn3Q= +github.com/docker/go-units v0.5.0 h1:69rxXcBk27SvSaaxTtLh/8llcHD8vYHT7WSdRZ/jvr4= +github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDDbaIK4Dk= github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA= github.com/envoyproxy/go-control-plane v0.14.0/go.mod h1:NcS5X47pLl/hfqxU70yPwL9ZMkUlwlKxtAohpi2wBEU= github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ= @@ -159,6 +183,8 @@ github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= +github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/go-openapi/jsonpointer v0.22.5 h1:8on/0Yp4uTb9f4XvTrM2+1CPrV05QPZXu+rvu2o9jcA= github.com/go-openapi/jsonpointer v0.22.5/go.mod h1:gyUR3sCvGSWchA2sUBJGluYMbe1zazrYWIkWPjjMUY0= github.com/go-openapi/swag/jsonname v0.25.5 h1:8p150i44rv/Drip4vWI3kGi9+4W9TdI3US3uUYSFhSo= @@ -260,6 +286,10 @@ github.com/launchdarkly/go-test-helpers/v3 v3.1.0 h1:E3bxJMzMoA+cJSF3xxtk2/chr1z github.com/launchdarkly/go-test-helpers/v3 v3.1.0/go.mod h1:Ake5+hZFS/DmIGKx/cizhn5W9pGA7pplcR7xCxWiLIo= github.com/lib/pq v1.11.2 h1:x6gxUeu39V0BHZiugWe8LXZYZ+Utk7hSJGThs8sdzfs= github.com/lib/pq v1.11.2/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA= +github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0= +github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= +github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= +github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mailru/easyjson v0.9.1 h1:LbtsOm5WAswyWbvTEOqhypdPeZzHavpZx96/n553mR8= github.com/mailru/easyjson v0.9.1/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= @@ -275,6 +305,24 @@ github.com/mdlayher/socket v0.5.1 h1:VZaqt6RkGkt2OE9l3GcC6nZkqD3xKeQLyfleW/uBcos github.com/mdlayher/socket v0.5.1/go.mod h1:TjPLHI1UgwEv5J1B5q0zTZq12A/6H7nKmtTanQE37IQ= github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY= github.com/mfridman/interpolate v0.0.2/go.mod h1:p+7uk6oE07mpE/Ik1b8EckO0O4ZXiGAfshKBWLUM9Xg= +github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= +github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= +github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= +github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= +github.com/moby/moby/api v1.55.0 h1:2/sexvQyqIWS8pRSCFddBfpW2qE7vR7FCL+vN8pxwMc= +github.com/moby/moby/api v1.55.0/go.mod h1:+RQ6wluLwtYaTd1WnPLykIDPekkuyD/ROWQClE83pzs= +github.com/moby/moby/client v0.5.0 h1:5XhyPk2fuOWf6RlSFa3MkIIgDZkF25xToXW8Q/BH7cc= +github.com/moby/moby/client v0.5.0/go.mod h1:rcVpF8ncl9vo5gaIBdol6CnbEtSj1uxMvEV/UrykF/s= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= +github.com/moby/sys/sequential v0.6.0/go.mod h1:uyv8EUTrca5PnDsdMGXhZe6CCe8U/UiTWd+lL+7b/Ko= +github.com/moby/sys/user v0.4.0 h1:jhcMKit7SA80hivmFJcbB1vqmw//wU61Zdui2eQXuMs= +github.com/moby/sys/user v0.4.0/go.mod h1:bG+tYYYJgaMtRKgEmuueC0hJEAZWwtIbZTB+85uoHjs= +github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g= +github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28= +github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ= +github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= github.com/mschoch/smat v0.2.0 h1:8imxQsjDm8yFEAVBe7azKmKSgzSkZXDuKkSq9374khM= @@ -308,6 +356,10 @@ github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAl github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A= github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k= +github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U= +github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= +github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= +github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/orcaman/concurrent-map/v2 v2.0.1 h1:jOJ5Pg2w1oeB6PeDurIYf6k9PQ+aTITr/6lP/L/zp6c= github.com/orcaman/concurrent-map/v2 v2.0.1/go.mod h1:9Eq3TG2oBe5FirmYWQfYO5iH1q0Jv47PLaNK++uCdOM= github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc= @@ -324,6 +376,8 @@ github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1 github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= +github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/pressly/goose/v3 v3.27.2 h1:FjKNzcmMdGrQlSIu5alMSmakQtJFBgtw+A0bb1p/LC8= github.com/pressly/goose/v3 v3.27.2/go.mod h1:qWW+/8dkVtJYjJrbIpwD5xxnEJTUKvxkQ9JKQp9LaIM= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= @@ -342,9 +396,14 @@ github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sethvargo/go-retry v0.3.0 h1:EEt31A35QhrcRZtrYFDTBg91cqZVnFL2navjDrah2SE= github.com/sethvargo/go-retry v0.3.0/go.mod h1:mNX17F0C/HguQMyMyJxcnU471gOZGxCLyYaFyAZraas= +github.com/shirou/gopsutil v3.21.11+incompatible h1:+1+c1VGhc88SSonWP6foOcLhvnKlUeu/erjjvaPEYiI= +github.com/shirou/gopsutil/v4 v4.26.3 h1:2ESdQt90yU3oXF/CdOlRCJxrP+Am1aBYubTMTfxJ1qc= +github.com/shirou/gopsutil/v4 v4.26.3/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/speakeasy-api/jsonpath v0.6.3 h1:c+QPwzAOdrWvzycuc9HFsIZcxKIaWcNpC+xhOW9rJxU= github.com/speakeasy-api/jsonpath v0.6.3/go.mod h1:2cXloNuQ+RSXi5HTRaeBh7JEmjRXTiaKpFTdZiL7URI= github.com/speakeasy-api/openapi v1.19.2 h1:md90tE71/M8jS3cuRlsuWP5Aed4xoG5PSRvXeZgCv/M= @@ -368,6 +427,12 @@ github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/testcontainers/testcontainers-go v0.42.0 h1:He3IhTzTZOygSXLJPMX7n44XtK+qhjat1nI9cneBbUY= +github.com/testcontainers/testcontainers-go v0.42.0/go.mod h1:vZjdY1YmUA1qEForxOIOazfsrdyORJAbhi0bp8plN30= +github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= +github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= +github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= +github.com/tklauser/numcpus v0.11.0/go.mod h1:z+LwcLq54uWZTX0u/bGobaV34u6V7KNlTZejzM6/3MQ= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= @@ -382,6 +447,8 @@ github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZ github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k= +github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= +github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/bridges/otelzap v0.14.0 h1:2nKw2ZXZOC0N8RBsBbYwGwfKR7kJWzzyCZ6QfUGW/es=