// 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"
tclog "github.com/testcontainers/testcontainers-go/log"
"github.com/testcontainers/testcontainers-go/wait"
)
// Silence testcontainers' default chatty logging (pull progress, container
// IDs, reaper lifecycle, port mappings). Test output should only show what
// the tests themselves print.
func init() {
tclog.SetDefault(noopLogger{})
}
type noopLogger struct{}
func (noopLogger) Printf(string, ...any) {}
// 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)
}