Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,16 @@ Edit `ANY_SYNC_BUNDLE_INIT_EXTERNAL_ADDRS` in the compose file before starting.

### Quick Reference

| Variable | Purpose | Required |
| ------------------------------------- | -------------------- | -------- |
| `ANY_SYNC_BUNDLE_INIT_EXTERNAL_ADDRS` | Advertised addresses | Yes |
| `ANY_SYNC_BUNDLE_INIT_MONGO_URI` | MongoDB connection | No |
| `ANY_SYNC_BUNDLE_INIT_REDIS_URI` | Redis connection | No |
| `ANY_SYNC_BUNDLE_INIT_S3_BUCKET` | S3 bucket name | No |
| `ANY_SYNC_BUNDLE_INIT_S3_ENDPOINT` | S3 endpoint URL | No |
| `AWS_ACCESS_KEY_ID` | S3 credentials | No |
| `AWS_SECRET_ACCESS_KEY` | S3 credentials | No |
| Variable | Purpose | Required |
| ------------------------------------- | ------------------------------ | -------- |
| `ANY_SYNC_BUNDLE_INIT_EXTERNAL_ADDRS` | Advertised addresses | Yes |
| `ANY_SYNC_BUNDLE_INIT_MONGO_URI` | MongoDB connection | No |
| `ANY_SYNC_BUNDLE_INIT_REDIS_URI` | Redis connection | No |
| `ANY_SYNC_BUNDLE_INIT_S3_BUCKET` | S3 bucket name | No |
| `ANY_SYNC_BUNDLE_INIT_S3_ENDPOINT` | S3 endpoint URL | No |
| `ANY_SYNC_BUNDLE_INIT_S3_REGION` | S3 region (default: us-east-1) | No |
| `AWS_ACCESS_KEY_ID` | S3 credentials | No |
| `AWS_SECRET_ACCESS_KEY` | S3 credentials | No |

### Configuration Files

Expand Down Expand Up @@ -175,7 +176,16 @@ export AWS_SECRET_ACCESS_KEY="..."
--initial-s3-endpoint "https://s3.us-east-1.amazonaws.com"
```

For MinIO, add `--initial-s3-force-path-style`.
**For MinIO**, add `--initial-s3-force-path-style`. If your MinIO uses a custom
region, also add `--initial-s3-region`:

```sh
./any-sync-bundle start-bundle \
--initial-s3-bucket "my-bucket" \
--initial-s3-endpoint "http://minio:9000" \
--initial-s3-region "custom-region" \
--initial-s3-force-path-style
```

**Docker Compose with MinIO:**

Expand Down Expand Up @@ -220,6 +230,7 @@ All parameters available as binary flags or environment variables. See `./any-sy
| `--initial-redis-uri` | Initial Redis URI for the bundle <br> ‣ Default: `redis://127.0.0.1:6379/` <br> ‣ Environment Variable: `ANY_SYNC_BUNDLE_INIT_REDIS_URI` |
| `--initial-s3-bucket` | S3 bucket name <br> ‣ Environment Variable: `ANY_SYNC_BUNDLE_INIT_S3_BUCKET` |
| `--initial-s3-endpoint` | S3 endpoint URL <br> ‣ Environment Variable: `ANY_SYNC_BUNDLE_INIT_S3_ENDPOINT` |
| `--initial-s3-region` | S3 region for request signing <br> ‣ Default: `us-east-1` <br> ‣ Environment Variable: `ANY_SYNC_BUNDLE_INIT_S3_REGION` |
| `--initial-s3-force-path-style` | Use path-style S3 URLs (required for MinIO) <br> ‣ Default: `false` <br> ‣ Environment Variable: `ANY_SYNC_BUNDLE_INIT_S3_FORCE_PATH_STYLE` |

## Operations
Expand Down
6 changes: 6 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ const (
// Credentials via env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY.
flagStartS3Bucket = "initial-s3-bucket"
flagStartS3Endpoint = "initial-s3-endpoint"
flagStartS3Region = "initial-s3-region"
flagStartS3ForcePathStyle = "initial-s3-force-path-style"
)

Expand Down Expand Up @@ -153,6 +154,11 @@ func buildStartFlags() []cli.Flag {
EnvVars: []string{"ANY_SYNC_BUNDLE_INIT_S3_ENDPOINT"},
Usage: "S3 endpoint URL (e.g., https://s3.us-east-1.amazonaws.com). Required if using S3 storage.",
},
&cli.StringFlag{
Name: flagStartS3Region,
EnvVars: []string{"ANY_SYNC_BUNDLE_INIT_S3_REGION"},
Usage: "S3 region for request signing (default: us-east-1). Required for MinIO with custom regions.",
},
&cli.BoolFlag{
Name: flagStartS3ForcePathStyle,
EnvVars: []string{"ANY_SYNC_BUNDLE_INIT_S3_FORCE_PATH_STYLE"},
Expand Down
1 change: 1 addition & 0 deletions cmd/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ func loadOrCreateConfig(cCtx *cli.Context, log logger.CtxLogger) *bundleConfig.C
// S3 configuration (optional) - credentials via AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY env vars
S3Bucket: cCtx.String(flagStartS3Bucket),
S3Endpoint: cCtx.String(flagStartS3Endpoint),
S3Region: cCtx.String(flagStartS3Region),
S3ForcePathStyle: cCtx.Bool(flagStartS3ForcePathStyle),
})
}
Expand Down
1 change: 1 addition & 0 deletions compose.s3.yml
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ services:
ANY_SYNC_BUNDLE_INIT_S3_BUCKET: "anytype-data"
ANY_SYNC_BUNDLE_INIT_S3_ENDPOINT: "http://minio:9000"
ANY_SYNC_BUNDLE_INIT_S3_FORCE_PATH_STYLE: "true"
# ANY_SYNC_BUNDLE_INIT_S3_REGION: "us-east-1" # Only if MinIO uses custom region
# S3 Credentials (via standard AWS env vars)
AWS_ACCESS_KEY_ID: "minioadmin"
AWS_SECRET_ACCESS_KEY: "minioadmin"
14 changes: 11 additions & 3 deletions config/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -63,9 +63,13 @@ type FileNodeConfig struct {
// S3Config configures S3-compatible storage backend for the filenode.
// Supports AWS S3, MinIO, Cloudflare R2, Backblaze B2, etc.
// Credentials are provided via environment variables: AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY.
//
// TODO(bundleFormat:v2): Make Region a required field instead of defaulting to "us-east-1".
// This will force explicit configuration and avoid silent auth failures with non-default regions.
type S3Config struct {
Bucket string `yaml:"bucket"` // S3 bucket name (required)
Endpoint string `yaml:"endpoint"` // S3 endpoint URL (required, e.g., "https://s3.us-east-1.amazonaws.com")
Region string `yaml:"region,omitempty"` // S3 region for SigV4 signing (default: "us-east-1")
ForcePathStyle bool `yaml:"forcePathStyle,omitempty"` // Use path-style URLs (required for MinIO)
}

Expand All @@ -77,7 +81,8 @@ var (

// validateS3Config validates S3 configuration and returns the S3Config if valid.
// It checks that both bucket and endpoint are provided, and warns if credentials are missing.
func validateS3Config(bucket, endpoint string, forcePathStyle bool) (*S3Config, error) {
// Region is optional and defaults to "us-east-1" at conversion time if not provided.
func validateS3Config(bucket, endpoint, region string, forcePathStyle bool) (*S3Config, error) {
if bucket == "" {
return nil, ErrS3BucketRequired
}
Expand All @@ -97,6 +102,7 @@ func validateS3Config(bucket, endpoint string, forcePathStyle bool) (*S3Config,
return &S3Config{
Bucket: bucket,
Endpoint: endpoint,
Region: region,
ForcePathStyle: forcePathStyle,
}, nil
}
Expand Down Expand Up @@ -140,6 +146,7 @@ type CreateOptions struct {
// Credentials via env vars: AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
S3Bucket string
S3Endpoint string
S3Region string // Optional, defaults to "us-east-1" if empty
S3ForcePathStyle bool
}

Expand Down Expand Up @@ -215,15 +222,16 @@ func newBundleConfig(cfg *CreateOptions) *Config {

// Configure S3 storage if S3 flags are provided
if cfg.S3Bucket != "" || cfg.S3Endpoint != "" {
s3Cfg, s3Err := validateS3Config(cfg.S3Bucket, cfg.S3Endpoint, cfg.S3ForcePathStyle)
s3Cfg, s3Err := validateS3Config(cfg.S3Bucket, cfg.S3Endpoint, cfg.S3Region, cfg.S3ForcePathStyle)
if s3Err != nil {
log.Panic("invalid S3 configuration", zap.Error(s3Err))
}
defaultCfg.FileNode.S3 = s3Cfg

log.Info("S3 storage configured",
zap.String("bucket", cfg.S3Bucket),
zap.String("endpoint", cfg.S3Endpoint))
zap.String("endpoint", cfg.S3Endpoint),
zap.String("region", cfg.S3Region))
}

return defaultCfg
Expand Down
24 changes: 17 additions & 7 deletions config/bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,37 +227,47 @@ func TestValidateS3Config_Valid(t *testing.T) {
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")

cfg, err := validateS3Config("my-bucket", "https://s3.amazonaws.com", false)
cfg, err := validateS3Config("my-bucket", "https://s3.amazonaws.com", "", false)
require.NoError(t, err)
assert.Equal(t, "my-bucket", cfg.Bucket)
assert.Equal(t, "https://s3.amazonaws.com", cfg.Endpoint)
assert.Empty(t, cfg.Region, "Region should be empty when not provided")
assert.False(t, cfg.ForcePathStyle)
}

func TestValidateS3Config_WithForcePathStyle(t *testing.T) {
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")

cfg, err := validateS3Config("my-bucket", "http://minio:9000", true)
cfg, err := validateS3Config("my-bucket", "http://minio:9000", "", true)
require.NoError(t, err)
assert.True(t, cfg.ForcePathStyle)
}

func TestValidateS3Config_WithRegion(t *testing.T) {
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")

cfg, err := validateS3Config("my-bucket", "http://minio:9000", "sz-hq", true)
require.NoError(t, err)
assert.Equal(t, "sz-hq", cfg.Region)
}

func TestValidateS3Config_MissingBucket(t *testing.T) {
cfg, err := validateS3Config("", "https://s3.amazonaws.com", false)
cfg, err := validateS3Config("", "https://s3.amazonaws.com", "", false)
assert.Nil(t, cfg)
assert.ErrorIs(t, err, ErrS3BucketRequired)
}

func TestValidateS3Config_MissingEndpoint(t *testing.T) {
cfg, err := validateS3Config("my-bucket", "", false)
cfg, err := validateS3Config("my-bucket", "", "", false)
assert.Nil(t, cfg)
assert.ErrorIs(t, err, ErrS3EndpointRequired)
}

func TestValidateS3Config_MissingBoth(t *testing.T) {
// When both are missing, bucket error should come first
cfg, err := validateS3Config("", "", false)
cfg, err := validateS3Config("", "", "", false)
assert.Nil(t, cfg)
assert.ErrorIs(t, err, ErrS3BucketRequired)
}
Expand All @@ -268,7 +278,7 @@ func TestValidateS3Config_MissingCredentials(t *testing.T) {
t.Setenv("AWS_SECRET_ACCESS_KEY", "")

// Should still succeed but with a warning (tested via logs)
cfg, err := validateS3Config("my-bucket", "https://s3.amazonaws.com", false)
cfg, err := validateS3Config("my-bucket", "https://s3.amazonaws.com", "", false)
require.NoError(t, err)
assert.NotNil(t, cfg)
}
Expand All @@ -279,7 +289,7 @@ func TestValidateS3Config_PartialCredentials(t *testing.T) {
t.Setenv("AWS_SECRET_ACCESS_KEY", "")

// Should still succeed but with a warning
cfg, err := validateS3Config("my-bucket", "https://s3.amazonaws.com", false)
cfg, err := validateS3Config("my-bucket", "https://s3.amazonaws.com", "", false)
require.NoError(t, err)
assert.NotNil(t, cfg)
}
Expand Down
10 changes: 9 additions & 1 deletion config/convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,16 @@ func (bc *Config) convertS3Config() s3store.Config {
accessKey := os.Getenv("AWS_ACCESS_KEY_ID")
secretKey := os.Getenv("AWS_SECRET_ACCESS_KEY")

// Use configured region or default to us-east-1 for backwards compatibility.
// Region is part of the SigV4 credential scope used for request signing.
// TODO(bundleFormat:v2): Remove default, require explicit region.
region := s3Cfg.Region
if region == "" {
region = "us-east-1"
}

return s3store.Config{
Region: "us-east-1", // Placeholder - endpoint URL determines actual region
Region: region,
Bucket: s3Cfg.Bucket,
IndexBucket: s3Cfg.Bucket, // Same bucket - keys don't collide (blocks use CID, index uses prefixed keys)
Endpoint: s3Cfg.Endpoint,
Expand Down
39 changes: 39 additions & 0 deletions config/convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,45 @@ func TestConvertS3Config_ForcePathStyle(t *testing.T) {
assert.Equal(t, "http://minio:9000", s3Cfg.Endpoint)
}

func TestConvertS3Config_CustomRegion(t *testing.T) {
t.Setenv("AWS_ACCESS_KEY_ID", "minio-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "minio-secret")

cfg := &Config{
FileNode: FileNodeConfig{
S3: &S3Config{
Bucket: "local-bucket",
Endpoint: "http://minio:9000",
Region: "sz-hq",
ForcePathStyle: true,
},
},
}

s3Cfg := cfg.convertS3Config()

assert.Equal(t, "sz-hq", s3Cfg.Region)
}

func TestConvertS3Config_EmptyRegionDefaultsToUSEast1(t *testing.T) {
t.Setenv("AWS_ACCESS_KEY_ID", "test-key")
t.Setenv("AWS_SECRET_ACCESS_KEY", "test-secret")

cfg := &Config{
FileNode: FileNodeConfig{
S3: &S3Config{
Bucket: "my-bucket",
Endpoint: "https://s3.amazonaws.com",
Region: "", // Explicitly empty
},
},
}

s3Cfg := cfg.convertS3Config()

assert.Equal(t, "us-east-1", s3Cfg.Region, "Empty region should default to us-east-1")
}

func TestConvertS3Config_MissingCredentials(t *testing.T) {
t.Setenv("AWS_ACCESS_KEY_ID", "")
t.Setenv("AWS_SECRET_ACCESS_KEY", "")
Expand Down
4 changes: 4 additions & 0 deletions integration/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ type BundleConfig struct {
RedisURI string
S3Bucket string
S3Endpoint string
S3Region string
S3AccessKey string
S3SecretKey string
}
Expand Down Expand Up @@ -81,6 +82,9 @@ func StartBundle(ctx context.Context, cfg BundleConfig) (*BundleProcess, error)
"--initial-s3-endpoint", cfg.S3Endpoint,
"--initial-s3-force-path-style",
)
if cfg.S3Region != "" {
args = append(args, "--initial-s3-region", cfg.S3Region)
}
}

cmd := exec.CommandContext(ctx, binaryPath, args...)
Expand Down
38 changes: 29 additions & 9 deletions integration/containers.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,19 @@ type MinIOContainer struct {
Endpoint string
AccessKey string
SecretKey string
Region string
network *testcontainers.DockerNetwork
}

// StartMinIO starts MinIO for S3 testing.
// StartMinIO starts MinIO for S3 testing with default region (us-east-1).
// Uses HTTP health endpoint for readiness check.
func StartMinIO(ctx context.Context) (*MinIOContainer, error) {
return StartMinIOWithRegion(ctx, "")
}

// StartMinIOWithRegion starts MinIO with a custom region for S3 testing.
// If region is empty, MinIO uses its default (us-east-1).
func StartMinIOWithRegion(ctx context.Context, region string) (*MinIOContainer, error) {
accessKey := "minioadmin"
secretKey := "minioadmin"

Expand All @@ -100,17 +107,22 @@ func StartMinIO(ctx context.Context) (*MinIOContainer, error) {
}
networkName := dockerNet.Name

env := map[string]string{
"MINIO_ROOT_USER": accessKey,
"MINIO_ROOT_PASSWORD": secretKey,
}
if region != "" {
env["MINIO_REGION"] = region
}

container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
ContainerRequest: testcontainers.ContainerRequest{
Image: minioImage,
ExposedPorts: []string{"9000/tcp"},
Env: map[string]string{
"MINIO_ROOT_USER": accessKey,
"MINIO_ROOT_PASSWORD": secretKey,
},
Cmd: []string{"server", "/data"},
WaitingFor: wait.ForHTTP("/minio/health/live").WithPort("9000/tcp"),
Networks: []string{networkName},
Env: env,
Cmd: []string{"server", "/data"},
WaitingFor: wait.ForHTTP("/minio/health/live").WithPort("9000/tcp"),
Networks: []string{networkName},
NetworkAliases: map[string][]string{
networkName: {"minio"},
},
Expand Down Expand Up @@ -140,7 +152,14 @@ func StartMinIO(ctx context.Context) (*MinIOContainer, error) {
externalEndpoint := "http://" + net.JoinHostPort(host, port.Port())

// Create bucket using mc (use internal network address)
if bucketErr := createMinioBucket(ctx, networkName, "http://minio:9000", accessKey, secretKey, "anytype-data"); bucketErr != nil {
if bucketErr := createMinioBucket(
ctx,
networkName,
"http://minio:9000",
accessKey,
secretKey,
"anytype-data",
); bucketErr != nil {
_ = container.Terminate(ctx)
_ = dockerNet.Remove(ctx)
return nil, fmt.Errorf("failed to create bucket: %w", bucketErr)
Expand All @@ -151,6 +170,7 @@ func StartMinIO(ctx context.Context) (*MinIOContainer, error) {
Endpoint: externalEndpoint,
AccessKey: accessKey,
SecretKey: secretKey,
Region: region,
network: dockerNet,
}, nil
}
Expand Down
Loading
Loading