From f9f458122f465ed5e74d09affac855b324718324 Mon Sep 17 00:00:00 2001 From: Alec Thomas Date: Mon, 30 Mar 2026 10:18:47 +1100 Subject: [PATCH] refactor: extract shared S3 client config into global config block Pull S3 connection config (endpoint, region, SSL settings) and minio client construction out of the S3 cache backend into a new s3client package, following the same pattern as gitclone.Config/ManagerProvider. - Add internal/s3client with Config, ClientProvider, and NewClient - Config is an optional HCL block in GlobalConfig - ClientProvider lazily constructs a singleton *minio.Client - NewClient verifies connectivity via ListBuckets up front - S3Config retains only cache-specific fields (bucket, TTL, upload tuning) - RegisterS3/NewS3 accept a ClientProvider parameter - Move minitest to s3client/s3clienttest with deterministic per-package bucket names derived from runtime caller inspection, cleaning stale objects up front and via t.Cleanup Co-authored-by: Claude Code --- cmd/cachewd/main.go | 9 +- internal/cache/s3.go | 91 +++------- internal/cache/s3_test.go | 36 ++-- internal/metadatadb/s3_test.go | 14 +- internal/minitest/minitest.go | 116 ------------- internal/s3client/s3client.go | 110 ++++++++++++ .../s3client/s3clienttest/s3clienttest.go | 161 ++++++++++++++++++ 7 files changed, 327 insertions(+), 210 deletions(-) delete mode 100644 internal/minitest/minitest.go create mode 100644 internal/s3client/s3client.go create mode 100644 internal/s3client/s3clienttest/s3clienttest.go diff --git a/cmd/cachewd/main.go b/cmd/cachewd/main.go index 26d8625f..ab45f36c 100644 --- a/cmd/cachewd/main.go +++ b/cmd/cachewd/main.go @@ -29,6 +29,7 @@ import ( "github.com/block/cachew/internal/metrics" "github.com/block/cachew/internal/opa" "github.com/block/cachew/internal/reaper" + "github.com/block/cachew/internal/s3client" "github.com/block/cachew/internal/strategy" "github.com/block/cachew/internal/strategy/git" "github.com/block/cachew/internal/strategy/gomod" @@ -42,6 +43,7 @@ type GlobalConfig struct { LoggingConfig logging.Config `hcl:"log,block"` MetricsConfig metrics.Config `hcl:"metrics,block"` GitCloneConfig gitclone.Config `hcl:"git-clone,block"` + S3Config s3client.Config `hcl:"s3,block,optional"` GithubAppConfigs []githubapp.Config `hcl:"github-app,block,optional"` OPAConfig opa.Config `hcl:"opa,block"` } @@ -75,10 +77,11 @@ func main() { managerProvider := gitclone.NewManagerProvider(ctx, globalConfig.GitCloneConfig, func() (gitclone.CredentialProvider, error) { return tokenManagerProvider() }) + s3ClientProvider := s3client.NewClientProvider(ctx, globalConfig.S3Config) schedulerProvider := jobscheduler.NewProvider(ctx, globalConfig.SchedulerConfig) - cr, sr := newRegistries(schedulerProvider, managerProvider, tokenManagerProvider) + cr, sr := newRegistries(schedulerProvider, managerProvider, tokenManagerProvider, s3ClientProvider) // Commands switch { //nolint:gocritic @@ -111,11 +114,11 @@ func main() { kctx.FatalIfErrorf(err) } -func newRegistries(scheduler jobscheduler.Provider, cloneManagerProvider gitclone.ManagerProvider, tokenManagerProvider githubapp.TokenManagerProvider) (*cache.Registry, *strategy.Registry) { +func newRegistries(scheduler jobscheduler.Provider, cloneManagerProvider gitclone.ManagerProvider, tokenManagerProvider githubapp.TokenManagerProvider, s3ClientProvider s3client.ClientProvider) (*cache.Registry, *strategy.Registry) { cr := cache.NewRegistry() cache.RegisterMemory(cr) cache.RegisterDisk(cr) - cache.RegisterS3(cr) + cache.RegisterS3(cr, s3ClientProvider) sr := strategy.NewRegistry() strategy.RegisterAPIV1(sr) diff --git a/internal/cache/s3.go b/internal/cache/s3.go index 7c9a4308..b314c29c 100644 --- a/internal/cache/s3.go +++ b/internal/cache/s3.go @@ -3,7 +3,6 @@ package cache import ( "bufio" "context" - "crypto/tls" "encoding/json" "fmt" "io" @@ -17,26 +16,29 @@ import ( "github.com/alecthomas/errors" "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" "github.com/block/cachew/internal/logging" + "github.com/block/cachew/internal/s3client" ) -func RegisterS3(r *Registry) { +// RegisterS3 registers the S3 cache backend. The clientProvider supplies the +// shared minio client constructed from the global s3 config block. +func RegisterS3(r *Registry, clientProvider s3client.ClientProvider) { Register( r, "s3", "Caches objects in S3", - NewS3, + func(ctx context.Context, config S3Config) (*S3, error) { + return NewS3(ctx, config, clientProvider) + }, ) } +// S3Config contains cache-specific S3 settings. Connection parameters +// (endpoint, region, SSL, credentials) are provided by the global +// s3client.Config block and shared via the s3client.ClientProvider. type S3Config struct { Bucket string `hcl:"bucket" help:"S3 bucket name."` - Endpoint string `hcl:"endpoint,optional" help:"S3 endpoint URL (e.g., s3.amazonaws.com or localhost:9000)." default:"s3.amazonaws.com"` - Region string `hcl:"region,optional" help:"S3 region (defaults to us-west-2)." default:"us-west-2"` - UseSSL bool `hcl:"use-ssl,optional" help:"Use SSL for S3 connections (defaults to true)." default:"true"` - SkipSSLVerify bool `hcl:"skip-ssl-verify,optional" help:"Skip SSL certificate verification (defaults to false)." default:"false"` MaxTTL time.Duration `hcl:"max-ttl,optional" help:"Maximum time-to-live for entries in the S3 cache (defaults to 1 hour)." default:"1h"` UploadConcurrency uint `hcl:"upload-concurrency,optional" help:"Number of concurrent workers for multi-part uploads (0 = use all CPU cores, defaults to 1)." default:"1"` UploadPartSizeMB uint `hcl:"upload-part-size-mb,optional" help:"Size of each part for multi-part uploads in megabytes (defaults to 16MB, minimum 5MB)." default:"16"` @@ -51,19 +53,16 @@ type S3 struct { var _ Cache = (*S3)(nil) -// NewS3 creates a new S3-based cache instance using the minio SDK. +// NewS3 creates a new S3-based cache instance. // -// config.Endpoint and config.Bucket MUST be set. -// -// The standard AWS credential chain is used for authentication, which includes: -// 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) -// 2. AWS credentials file (~/.aws/credentials) -// 3. IAM role from EC2 instance metadata or ECS container credentials +// The minio client is obtained from the shared clientProvider, which is +// constructed once from the global s3 configuration block. Cache-specific +// settings (bucket, TTL, upload tuning) come from the per-instance S3Config. // // This [Cache] implementation stores cache entries in an S3-compatible object storage service. // Metadata (headers and expiration time) are stored as object user metadata. The implementation // uses the lightweight minio-go SDK to reduce overhead compared to the AWS SDK. -func NewS3(ctx context.Context, config S3Config) (*S3, error) { +func NewS3(ctx context.Context, config S3Config, clientProvider s3client.ClientProvider) (*S3, error) { // Set defaults and validate configuration if config.UploadConcurrency == 0 { // #nosec G115 -- n is guaranteed >= 1. I was unable to satisfy all linters. @@ -74,61 +73,15 @@ func NewS3(ctx context.Context, config S3Config) (*S3, error) { return nil, errors.New("upload-part-size-mb must be at least 5MB (S3 minimum part size)") } - logging.FromContext(ctx).InfoContext(ctx, "Constructing S3 cache", "endpoint", config.Endpoint, "bucket", config.Bucket, - "region", config.Region, "use-ssl", config.UseSSL, "max-ttl", config.MaxTTL, - "upload-concurrency", config.UploadConcurrency, "upload-part-size-mb", config.UploadPartSizeMB) - - // Create default transport for credential chain - defaultTransport, err := minio.DefaultTransport(config.UseSSL) + client, err := clientProvider() if err != nil { - return nil, errors.Errorf("failed to create default transport: %w", err) - } - - // Apply SSL verification settings if needed - var transport http.RoundTripper - if config.SkipSSLVerify { - // Clone the default transport and disable SSL verification - customTransport := defaultTransport.Clone() - if customTransport.TLSClientConfig == nil { - customTransport.TLSClientConfig = &tls.Config{ - MinVersion: tls.VersionTLS12, - } - } else { - customTransport.TLSClientConfig.MinVersion = tls.VersionTLS12 - } - customTransport.TLSClientConfig.InsecureSkipVerify = true - transport = customTransport - defaultTransport = customTransport + return nil, errors.Errorf("failed to obtain shared S3 client: %w", err) } - // Use AWS credential chain - creds := credentials.NewChainCredentials( - []credentials.Provider{ - &credentials.EnvAWS{}, // Check AWS environment variables - &credentials.FileAWSCredentials{}, // Check ~/.aws/credentials - &credentials.IAM{ - Client: &http.Client{ - Transport: defaultTransport, - }, - }, // Check EC2 instance metadata or ECS container credentials - }) - - // Create minio client options - options := &minio.Options{ - Creds: creds, - Secure: config.UseSSL, - Region: config.Region, - } - - // Only set custom transport if needed (for SkipSSLVerify) - if transport != nil { - options.Transport = transport - } - - client, err := minio.New(config.Endpoint, options) - if err != nil { - return nil, errors.Errorf("failed to create minio client: %w", err) - } + logging.FromContext(ctx).InfoContext(ctx, "Constructing S3 cache", + "endpoint", client.EndpointURL(), "bucket", config.Bucket, + "max-ttl", config.MaxTTL, + "upload-concurrency", config.UploadConcurrency, "upload-part-size-mb", config.UploadPartSizeMB) // Verify bucket exists exists, err := client.BucketExists(ctx, config.Bucket) @@ -147,7 +100,7 @@ func NewS3(ctx context.Context, config S3Config) (*S3, error) { } func (s *S3) String() string { - return fmt.Sprintf("s3:%s/%s", s.config.Endpoint, s.config.Bucket) + return fmt.Sprintf("s3:%s/%s", s.client.EndpointURL().Host, s.config.Bucket) } func (s *S3) Close() error { diff --git a/internal/cache/s3_test.go b/internal/cache/s3_test.go index 16a39fcb..e49e70ed 100644 --- a/internal/cache/s3_test.go +++ b/internal/cache/s3_test.go @@ -11,26 +11,30 @@ import ( "github.com/block/cachew/internal/cache" "github.com/block/cachew/internal/cache/cachetest" "github.com/block/cachew/internal/logging" - "github.com/block/cachew/internal/minitest" + "github.com/block/cachew/internal/s3client" + "github.com/block/cachew/internal/s3client/s3clienttest" ) // TestS3Cache tests the S3 cache implementation using MinIO in Docker. func TestS3Cache(t *testing.T) { - minitest.Start(t) + bucket := s3clienttest.Start(t) cachetest.Suite(t, func(t *testing.T) cache.Cache { + s3clienttest.CleanBucket(t, bucket) _, ctx := logging.Configure(t.Context(), logging.Config{Level: slog.LevelDebug}) - minitest.CleanBucket(t) + clientProvider := s3client.NewClientProvider(ctx, s3client.Config{ + Endpoint: s3clienttest.Addr, + Region: "", + UseSSL: false, + SkipSSLVerify: false, + }) c, err := cache.NewS3(ctx, cache.S3Config{ - Endpoint: minitest.Addr, - Bucket: minitest.Bucket, - Region: "", - UseSSL: false, + Bucket: bucket, MaxTTL: 100 * time.Millisecond, UploadPartSizeMB: 16, - }) + }, clientProvider) assert.NoError(t, err) return c }) @@ -41,20 +45,22 @@ func TestS3CacheSoak(t *testing.T) { t.Skip("Skipping soak test; set SOAK_TEST=1 to run") } - minitest.Start(t) + bucket := s3clienttest.Start(t) _, ctx := logging.Configure(t.Context(), logging.Config{Level: slog.LevelError}) - minitest.CleanBucket(t) + clientProvider := s3client.NewClientProvider(ctx, s3client.Config{ + Endpoint: s3clienttest.Addr, + Region: "", + UseSSL: false, + SkipSSLVerify: false, + }) c, err := cache.NewS3(ctx, cache.S3Config{ - Endpoint: minitest.Addr, - Bucket: minitest.Bucket, - Region: "", - UseSSL: false, + Bucket: bucket, MaxTTL: 10 * time.Minute, UploadPartSizeMB: 16, - }) + }, clientProvider) assert.NoError(t, err) defer c.Close() diff --git a/internal/metadatadb/s3_test.go b/internal/metadatadb/s3_test.go index d988f447..9cf27690 100644 --- a/internal/metadatadb/s3_test.go +++ b/internal/metadatadb/s3_test.go @@ -6,17 +6,17 @@ import ( "github.com/block/cachew/internal/metadatadb" "github.com/block/cachew/internal/metadatadb/metadatadbtest" - "github.com/block/cachew/internal/minitest" + "github.com/block/cachew/internal/s3client/s3clienttest" ) func TestS3Backend(t *testing.T) { - minitest.Start(t) + bucket := s3clienttest.Start(t) metadatadbtest.Suite(t, func(t *testing.T) metadatadb.Backend { t.Helper() return metadatadb.NewS3Backend(metadatadb.S3BackendConfig{ - Client: minitest.Client(t), - Bucket: minitest.Bucket, + Client: s3clienttest.Client(t), + Bucket: bucket, Prefix: "_meta-" + t.Name(), LockTTL: 5 * time.Second, }) @@ -24,11 +24,11 @@ func TestS3Backend(t *testing.T) { } func TestS3BackendSoak(t *testing.T) { - minitest.Start(t) + bucket := s3clienttest.Start(t) metadatadbtest.Soak(t, metadatadb.NewS3Backend(metadatadb.S3BackendConfig{ - Client: minitest.Client(t), - Bucket: minitest.Bucket, + Client: s3clienttest.Client(t), + Bucket: bucket, Prefix: "_meta-soak", LockTTL: 5 * time.Second, }), metadatadbtest.SoakConfig{ diff --git a/internal/minitest/minitest.go b/internal/minitest/minitest.go deleted file mode 100644 index e4a8defb..00000000 --- a/internal/minitest/minitest.go +++ /dev/null @@ -1,116 +0,0 @@ -// Package minitest provides a reusable MinIO test server via Docker. -package minitest - -import ( - "os/exec" - "strings" - "testing" - "time" - - "github.com/alecthomas/assert/v2" - "github.com/alecthomas/errors" - "github.com/minio/minio-go/v7" - "github.com/minio/minio-go/v7/pkg/credentials" -) - -const ( - containerName = "minio-test" - Port = "19000" - Addr = "localhost:" + Port - Username = "minioadmin" - Password = "minioadmin" - Bucket = "test-bucket" -) - -// Start ensures a shared MinIO container is running, creating it if needed. -// The container persists across tests and packages. -func Start(t *testing.T) { - t.Helper() - - t.Setenv("AWS_ACCESS_KEY_ID", Username) - t.Setenv("AWS_SECRET_ACCESS_KEY", Password) - - // If it's already up and healthy, nothing to do. - if isHealthy(t) { - return - } - - // Try to start — if the container already exists (from another parallel - // package), docker run fails but that's fine, we just wait for it. - cmd := exec.CommandContext(t.Context(), "docker", "run", "-d", - "--name", containerName, - "-p", Port+":9000", - "-e", "MINIO_ROOT_USER="+Username, - "-e", "MINIO_ROOT_PASSWORD="+Password, - "minio/minio", "server", "/data", - ) - if output, err := cmd.CombinedOutput(); err != nil { - // Only fatal if the container doesn't exist at all. - if !strings.Contains(string(output), "already in use") { - t.Fatalf("failed to start minio container: %v\n%s", err, output) - } - } - - waitForReady(t) - createBucket(t) -} - -func isHealthy(t *testing.T) bool { - t.Helper() - client := Client(t) - _, err := client.ListBuckets(t.Context()) - return err == nil -} - -// Client returns a minio client connected to the test server. -func Client(t *testing.T) *minio.Client { - t.Helper() - client, err := minio.New(Addr, &minio.Options{ - Creds: credentials.NewStaticV4(Username, Password, ""), - Secure: false, - }) - assert.NoError(t, err) - return client -} - -// CleanBucket removes all objects from the test bucket. -func CleanBucket(t *testing.T) { - t.Helper() - client := Client(t) - for obj := range client.ListObjects(t.Context(), Bucket, minio.ListObjectsOptions{Recursive: true}) { - if obj.Err != nil { - continue - } - if err := client.RemoveObject(t.Context(), Bucket, obj.Key, minio.RemoveObjectOptions{}); err != nil { - t.Logf("failed to remove object %s: %v", obj.Key, err) - } - } -} - -func waitForReady(t *testing.T) { - t.Helper() - client := Client(t) - timeout := time.After(30 * time.Second) - ticker := time.NewTicker(100 * time.Millisecond) - defer ticker.Stop() - for { - select { - case <-timeout: - t.Fatal(errors.New("timed out waiting for minio to start")) - case <-ticker.C: - if _, err := client.ListBuckets(t.Context()); err == nil { - return - } - } - } -} - -func createBucket(t *testing.T) { - t.Helper() - client := Client(t) - exists, err := client.BucketExists(t.Context(), Bucket) - assert.NoError(t, err) - if !exists { - assert.NoError(t, client.MakeBucket(t.Context(), Bucket, minio.MakeBucketOptions{})) - } -} diff --git a/internal/s3client/s3client.go b/internal/s3client/s3client.go new file mode 100644 index 00000000..6d103231 --- /dev/null +++ b/internal/s3client/s3client.go @@ -0,0 +1,110 @@ +// Package s3client provides shared S3 connection configuration and minio client construction. +// +// A single Config block is declared in the global configuration and a +// ClientProvider lazily constructs a singleton *minio.Client that can be +// shared across multiple consumers (cache backends, strategies, etc.). +package s3client + +import ( + "context" + "crypto/tls" + "net/http" + "sync" + + "github.com/alecthomas/errors" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" + + "github.com/block/cachew/internal/logging" +) + +// Config holds S3 connection parameters that are shared across all consumers. +// It is intended to be embedded as a global HCL block (e.g. `hcl:"s3,block"`). +type Config struct { + Endpoint string `hcl:"endpoint,optional" help:"S3 endpoint URL (e.g., s3.amazonaws.com or localhost:9000)." default:"s3.amazonaws.com"` + Region string `hcl:"region,optional" help:"S3 region." default:"us-west-2"` + UseSSL bool `hcl:"use-ssl,optional" help:"Use SSL for S3 connections." default:"true"` + SkipSSLVerify bool `hcl:"skip-ssl-verify,optional" help:"Skip SSL certificate verification." default:"false"` +} + +// ClientProvider is a function that lazily creates a singleton *minio.Client. +type ClientProvider func() (*minio.Client, error) + +// NewClientProvider returns a ClientProvider that will construct the minio +// client at most once using the supplied Config. +func NewClientProvider(ctx context.Context, config Config) ClientProvider { + return sync.OnceValues(func() (*minio.Client, error) { + return NewClient(ctx, config) + }) +} + +// NewClient constructs a *minio.Client from the given Config. +// The standard AWS credential chain is used: +// 1. Environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN) +// 2. AWS credentials file (~/.aws/credentials) +// 3. IAM role from EC2 instance metadata or ECS container credentials +func NewClient(ctx context.Context, config Config) (*minio.Client, error) { + logging.FromContext(ctx).InfoContext(ctx, "Constructing shared S3 client", + "endpoint", config.Endpoint, + "region", config.Region, + "use-ssl", config.UseSSL, + "skip-ssl-verify", config.SkipSSLVerify, + ) + + // Create default transport for credential chain + defaultTransport, err := minio.DefaultTransport(config.UseSSL) + if err != nil { + return nil, errors.Errorf("failed to create default transport: %w", err) + } + + // Apply SSL verification settings if needed + var transport http.RoundTripper + if config.SkipSSLVerify { + customTransport := defaultTransport.Clone() + if customTransport.TLSClientConfig == nil { + customTransport.TLSClientConfig = &tls.Config{ + MinVersion: tls.VersionTLS12, + } + } else { + customTransport.TLSClientConfig.MinVersion = tls.VersionTLS12 + } + customTransport.TLSClientConfig.InsecureSkipVerify = true + transport = customTransport + defaultTransport = customTransport + } + + // Use AWS credential chain + creds := credentials.NewChainCredentials( + []credentials.Provider{ + &credentials.EnvAWS{}, + &credentials.FileAWSCredentials{}, + &credentials.IAM{ + Client: &http.Client{ + Transport: defaultTransport, + }, + }, + }) + + options := &minio.Options{ + Creds: creds, + Secure: config.UseSSL, + Region: config.Region, + } + + if transport != nil { + options.Transport = transport + } + + mc, err := minio.New(config.Endpoint, options) + if err != nil { + return nil, errors.Errorf("failed to create minio client: %w", err) + } + + // Verify connectivity and credentials up front so misconfiguration is + // caught at startup rather than on the first cache request. + if _, err := mc.ListBuckets(ctx); err != nil { + return nil, errors.Errorf("failed to connect to S3 endpoint %s: %w", config.Endpoint, err) + } + + return mc, nil +} diff --git a/internal/s3client/s3clienttest/s3clienttest.go b/internal/s3client/s3clienttest/s3clienttest.go new file mode 100644 index 00000000..866696b6 --- /dev/null +++ b/internal/s3client/s3clienttest/s3clienttest.go @@ -0,0 +1,161 @@ +// Package s3clienttest provides a reusable MinIO test server via Docker. +package s3clienttest + +import ( + "os/exec" + "regexp" + "runtime" + "strings" + "testing" + "time" + + "github.com/alecthomas/assert/v2" + "github.com/alecthomas/errors" + "github.com/minio/minio-go/v7" + "github.com/minio/minio-go/v7/pkg/credentials" +) + +const ( + containerName = "minio-test" + Port = "19000" + Addr = "localhost:" + Port + Username = "minioadmin" + Password = "minioadmin" +) + +var bucketNameRe = regexp.MustCompile(`[^a-z0-9-]`) + +// Start ensures a shared MinIO container is running, creates a +// deterministic bucket for this test, and returns its name. The bucket +// name is derived from the calling package and t.Name() so that +// different test packages cannot collide even when run in parallel. +// +// Any stale objects left over from a previous (possibly crashed) run +// are removed up front, and t.Cleanup removes objects when the test +// finishes normally. +func Start(t *testing.T) string { + t.Helper() + + t.Setenv("AWS_ACCESS_KEY_ID", Username) + t.Setenv("AWS_SECRET_ACCESS_KEY", Password) + + if !isHealthy(t) { + startContainer(t) + waitForReady(t) + } + + bucket := bucketName(t) + client := Client(t) + + // Ensure the bucket exists, creating it if necessary. + exists, err := client.BucketExists(t.Context(), bucket) + assert.NoError(t, err) + if !exists { + assert.NoError(t, client.MakeBucket(t.Context(), bucket, minio.MakeBucketOptions{})) + } + + // Remove any stale objects from a previous (possibly crashed) run. + CleanBucket(t, bucket) + t.Cleanup(func() { CleanBucket(t, bucket) }) + + return bucket +} + +const modulePrefix = "github.com/block/cachew/" + +// bucketName returns a deterministic, S3-legal bucket name that +// incorporates the calling package path and the test name. This +// ensures uniqueness across packages even though t.Name() alone does +// not include the package. +func bucketName(t *testing.T) string { + t.Helper() + + // Walk up the call stack past this package to find the caller's function. + pkg := "unknown" + var pcs [10]uintptr + n := runtime.Callers(1, pcs[:]) + frames := runtime.CallersFrames(pcs[:n]) + for { + frame, more := frames.Next() + if !strings.Contains(frame.Function, "s3clienttest") { + // frame.Function is e.g. "github.com/block/cachew/internal/cache_test.TestS3Cache" + fn := strings.TrimPrefix(frame.Function, modulePrefix) + if idx := strings.LastIndex(fn, "."); idx >= 0 { + pkg = fn[:idx] + } + break + } + if !more { + break + } + } + + raw := pkg + "-" + t.Name() + return bucketNameRe.ReplaceAllString(strings.ToLower(raw), "-") +} + +// CleanBucket removes all objects from the given bucket. +func CleanBucket(t *testing.T, bucket string) { + t.Helper() + client := Client(t) + for obj := range client.ListObjects(t.Context(), bucket, minio.ListObjectsOptions{Recursive: true}) { + if obj.Err != nil { + continue + } + if err := client.RemoveObject(t.Context(), bucket, obj.Key, minio.RemoveObjectOptions{}); err != nil { + t.Logf("failed to remove object %s: %v", obj.Key, err) + } + } +} + +// Client returns a minio client connected to the test server. +func Client(t *testing.T) *minio.Client { + t.Helper() + client, err := minio.New(Addr, &minio.Options{ + Creds: credentials.NewStaticV4(Username, Password, ""), + Secure: false, + }) + assert.NoError(t, err) + return client +} + +func startContainer(t *testing.T) { + t.Helper() + cmd := exec.CommandContext(t.Context(), "docker", "run", "-d", + "--name", containerName, + "-p", Port+":9000", + "-e", "MINIO_ROOT_USER="+Username, + "-e", "MINIO_ROOT_PASSWORD="+Password, + "minio/minio", "server", "/data", + ) + if output, err := cmd.CombinedOutput(); err != nil { + if !strings.Contains(string(output), "already in use") { + t.Fatalf("failed to start minio container: %v\n%s", err, output) + } + } +} + +func isHealthy(t *testing.T) bool { + t.Helper() + client := Client(t) + _, err := client.ListBuckets(t.Context()) + return err == nil +} + +func waitForReady(t *testing.T) { + t.Helper() + client := Client(t) + timeout := time.After(30 * time.Second) + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + for { + select { + case <-timeout: + t.Fatal(errors.New("timed out waiting for minio to start")) + case <-ticker.C: + if _, err := client.ListBuckets(t.Context()); err == nil { + return + } + } + } +}