// Package garage starts a single-node Garage container for end-to-end // tests. Garage v2.3+ exposes `server --single-node --default-bucket` // which auto-creates the bucket + access key from env vars, so the // usual six-step CLI bootstrap (status / layout assign / layout apply / // bucket create / key new / bucket allow) becomes a no-op. // // Requires Docker on the host. Each call to Start spins up a fresh // container with random credentials and binds host ports automatically; // t.Cleanup tears it down. package garage import ( "bytes" "context" "crypto/rand" "encoding/base64" "encoding/hex" "fmt" "testing" "time" "github.com/testcontainers/testcontainers-go" "github.com/testcontainers/testcontainers-go/wait" ) // Image we pull. Pin to a known-good version so test runs are reproducible. const Image = "dxflrs/garage:v2.3.0" // Garage holds the connection details for the running container. type Garage struct { Endpoint string // http://host:port — feed to cacher --endpoint / config endpoint AccessKey string // GK… — feed to CACHER_S3_KEY_ID SecretKey string // hex — feed to CACHER_S3_SECRET Bucket string // pre-created bucket the access key owns Region string // always "garage" for this image } // Start brings up a Garage container and waits for the S3 API to be // responding. Container is automatically terminated via t.Cleanup. func Start(t *testing.T) *Garage { t.Helper() ctx := context.Background() g := &Garage{ AccessKey: "GK" + randHex(16), SecretKey: randHex(32), Bucket: "test-bucket", Region: "garage", } cfg := minimalTOML(randHex(32), randBase64(32)) req := testcontainers.ContainerRequest{ Image: Image, ExposedPorts: []string{"3900/tcp"}, Cmd: []string{"/garage", "server", "--single-node", "--default-bucket"}, Env: map[string]string{ "GARAGE_DEFAULT_ACCESS_KEY": g.AccessKey, "GARAGE_DEFAULT_SECRET_KEY": g.SecretKey, "GARAGE_DEFAULT_BUCKET": g.Bucket, }, Files: []testcontainers.ContainerFile{ { Reader: bytes.NewReader([]byte(cfg)), ContainerFilePath: "/etc/garage.toml", FileMode: 0o644, }, }, // Combine port-open + log-line wait so we don't race the S3 server // starting up after the RPC port is bound. WaitingFor: wait.ForAll( wait.ForListeningPort("3900/tcp").WithStartupTimeout(30*time.Second), wait.ForLog("S3 API server listening").WithStartupTimeout(30*time.Second), ), } c, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{ ContainerRequest: req, Started: true, }) if err != nil { t.Fatalf("garage container start: %v", err) } t.Cleanup(func() { if err := c.Terminate(context.Background()); err != nil { t.Logf("garage container terminate: %v", err) } }) host, err := c.Host(ctx) if err != nil { t.Fatalf("container host: %v", err) } port, err := c.MappedPort(ctx, "3900/tcp") if err != nil { t.Fatalf("container port: %v", err) } g.Endpoint = fmt.Sprintf("http://%s:%s", host, port.Port()) return g } // minimalTOML returns the smallest garage.toml that works for a single- // node test instance: in-memory metadata under /tmp (lost on container // stop, which is what we want), replication_factor=1, S3 region "garage", // random secrets for rpc + admin so the container can't conflict with // neighbours. func minimalTOML(rpcSecret, adminToken string) string { return fmt.Sprintf(` metadata_dir = "/tmp/meta" data_dir = "/tmp/data" db_engine = "sqlite" replication_factor = 1 rpc_bind_addr = "[::]:3901" rpc_public_addr = "127.0.0.1:3901" rpc_secret = %q [s3_api] s3_region = "garage" api_bind_addr = "[::]:3900" root_domain = ".s3.garage.localhost" [s3_web] bind_addr = "[::]:3902" root_domain = ".web.garage.localhost" index = "index.html" [admin] api_bind_addr = "[::]:3903" admin_token = %q `, rpcSecret, adminToken) } func randHex(n int) string { b := make([]byte, n) _, _ = rand.Read(b) return hex.EncodeToString(b) } func randBase64(n int) string { b := make([]byte, n) _, _ = rand.Read(b) return base64.StdEncoding.EncodeToString(b) }