ai-code-battle/cmd/acb-worker/b2.go
jedarden 09fced7dfe fix(worker,index-builder): use us-east-1 region for S3-compatible endpoints
The AWS SDK requires a valid AWS region name even when using custom
S3-compatible endpoints (ARMOR/B2). Using "auto" as the region causes
an error: "Invalid region: region was not a valid DNS name."

This fixes the replay upload pipeline which was failing with the
invalid region error. Replays should now upload successfully to B2
via the ARMOR proxy.

Related to ai-code-battle-o43: Replay viewer verification task.
2026-04-25 11:07:08 -04:00

124 lines
3.2 KiB
Go

// B2 client for uploading replays to Backblaze B2 (cold archive)
package main
import (
"bytes"
"context"
"fmt"
"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"
)
// B2Client handles B2 bucket operations (S3-compatible).
type B2Client struct {
client *s3.Client
bucket string
endpoint string
}
// NewB2Client creates a new B2 client.
func NewB2Client(cfg *Config) *B2Client {
// Load AWS config with B2 credentials
// For S3-compatible endpoints (ARMOR/B2), use us-east-1 as a placeholder region
// The actual endpoint is overridden via BaseEndpoint in the S3 client
awsCfg, err := config.LoadDefaultConfig(context.TODO(),
config.WithCredentialsProvider(credentials.NewStaticCredentialsProvider(
cfg.B2AccessKey,
cfg.B2SecretKey,
"",
)),
config.WithRegion("us-east-1"),
)
if err != nil {
panic(fmt.Sprintf("failed to load AWS config: %v", err))
}
// Create S3 client with custom endpoint (ARMOR proxy wrapping B2)
client := s3.NewFromConfig(awsCfg, func(o *s3.Options) {
o.BaseEndpoint = aws.String(cfg.B2Endpoint)
o.UsePathStyle = true // Required for ARMOR/B2 S3-compatible API
})
return &B2Client{
client: client,
bucket: cfg.B2Bucket,
endpoint: cfg.B2Endpoint,
}
}
// Upload uploads data to B2. Pass contentEncoding="" for uncompressed objects.
func (c *B2Client) Upload(ctx context.Context, key string, data []byte, contentType string, contentEncoding string) error {
input := &s3.PutObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
Body: bytes.NewReader(data),
ContentType: aws.String(contentType),
CacheControl: aws.String("public, max-age=31536000, immutable"),
}
if contentEncoding != "" {
input.ContentEncoding = aws.String(contentEncoding)
}
_, err := c.client.PutObject(ctx, input)
return err
}
// Download downloads data from B2.
func (c *B2Client) Download(ctx context.Context, key string) ([]byte, error) {
resp, err := c.client.GetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
})
if err != nil {
return nil, err
}
defer resp.Body.Close()
buf := new(bytes.Buffer)
if _, err := buf.ReadFrom(resp.Body); err != nil {
return nil, err
}
return buf.Bytes(), nil
}
// Delete deletes an object from B2.
func (c *B2Client) Delete(ctx context.Context, key string) error {
_, err := c.client.DeleteObject(ctx, &s3.DeleteObjectInput{
Bucket: aws.String(c.bucket),
Key: aws.String(key),
})
return err
}
// List lists objects with a prefix.
func (c *B2Client) List(ctx context.Context, prefix string) ([]string, error) {
var keys []string
paginator := s3.NewListObjectsV2Paginator(c.client, &s3.ListObjectsV2Input{
Bucket: aws.String(c.bucket),
Prefix: aws.String(prefix),
})
for paginator.HasMorePages() {
page, err := paginator.NextPage(ctx)
if err != nil {
return nil, err
}
for _, obj := range page.Contents {
if obj.Key != nil {
keys = append(keys, *obj.Key)
}
}
}
return keys, nil
}
// Endpoint returns the B2 endpoint URL.
func (c *B2Client) Endpoint() string {
return c.endpoint
}