Skip to content

Commit 5585018

Browse files
corylanouclaude
andcommitted
feat: implement comprehensive global replica defaults
This feature allows users to define global default settings for replica configurations, eliminating the need to duplicate common settings across multiple database entries. ## What's Changed - **New ReplicaSettings struct**: Contains all configurable replica fields (S3, ABS, SFTP, NATS, Age encryption, timing settings) - **Embedded in Config**: Global defaults can be set at the top level of configuration files - **Embedded in ReplicaConfig**: Individual replicas inherit from global settings but can override any field - **Smart default propagation**: SetDefaults() method merges global settings with replica-specific settings - **Full backward compatibility**: Existing configurations continue to work unchanged - **Comprehensive test coverage**: Tests cover all replica types and override scenarios ## Supported Global Settings - **S3**: access-key-id, secret-access-key, region, endpoint, force-path-style, skip-verify - **ABS**: account-name, account-key - **SFTP**: host, user, password, key-path, concurrent-writes - **NATS**: jwt, seed, creds, nkey, username, token, tls, root-cas, client-cert, client-key, etc. - **Timing**: sync-interval, validation-interval - **Encryption**: age identities and recipients ## Example Configuration Before (verbose with duplication): ```yaml dbs: - path: /db1.sqlite replica: type: s3 access-key-id: AKIA... secret-access-key: xxx... region: us-west-2 endpoint: custom.endpoint.com bucket: bucket1 - path: /db2.sqlite replica: type: s3 access-key-id: AKIA... # duplicated secret-access-key: xxx... # duplicated region: us-west-2 # duplicated endpoint: custom.endpoint.com # duplicated bucket: bucket2 ``` After (clean with global defaults): ```yaml # Global defaults access-key-id: AKIA... secret-access-key: xxx... region: us-west-2 endpoint: custom.endpoint.com dbs: - path: /db1.sqlite replica: type: s3 bucket: bucket1 - path: /db2.sqlite replica: type: s3 bucket: bucket2 ``` Fixes #504 Closes #553 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <[email protected]>
1 parent 98c8630 commit 5585018

File tree

4 files changed

+394
-21
lines changed

4 files changed

+394
-21
lines changed
Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,251 @@
1+
package main
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"testing"
7+
"time"
8+
)
9+
10+
func TestGlobalDefaults(t *testing.T) {
11+
// Test comprehensive global defaults functionality
12+
t.Run("GlobalReplicaDefaults", func(t *testing.T) {
13+
filename := filepath.Join(t.TempDir(), "litestream.yml")
14+
syncInterval := "30s"
15+
validationInterval := "1h"
16+
17+
if err := os.WriteFile(filename, []byte(`
18+
# Global defaults for all replicas
19+
access-key-id: GLOBAL_ACCESS_KEY
20+
secret-access-key: GLOBAL_SECRET_KEY
21+
region: us-west-2
22+
endpoint: custom.s3.endpoint.com
23+
sync-interval: `+syncInterval+`
24+
validation-interval: `+validationInterval+`
25+
26+
dbs:
27+
# Database 1: Uses all global defaults
28+
- path: /tmp/db1.sqlite
29+
replica:
30+
type: s3
31+
bucket: my-bucket-1
32+
33+
# Database 2: Overrides some defaults
34+
- path: /tmp/db2.sqlite
35+
replica:
36+
type: s3
37+
bucket: my-bucket-2
38+
region: us-east-1 # Override global region
39+
access-key-id: CUSTOM_KEY # Override global access key
40+
41+
# Database 3: Uses legacy replicas format
42+
- path: /tmp/db3.sqlite
43+
replicas:
44+
- type: s3
45+
bucket: my-bucket-3
46+
# Should inherit all other global settings
47+
`[1:]), 0666); err != nil {
48+
t.Fatal(err)
49+
}
50+
51+
config, err := ReadConfigFile(filename, true)
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
56+
// Test global settings were parsed correctly
57+
if got, want := config.AccessKeyID, "GLOBAL_ACCESS_KEY"; got != want {
58+
t.Errorf("config.AccessKeyID=%v, want %v", got, want)
59+
}
60+
if got, want := config.SecretAccessKey, "GLOBAL_SECRET_KEY"; got != want {
61+
t.Errorf("config.SecretAccessKey=%v, want %v", got, want)
62+
}
63+
if got, want := config.Region, "us-west-2"; got != want {
64+
t.Errorf("config.Region=%v, want %v", got, want)
65+
}
66+
if got, want := config.Endpoint, "custom.s3.endpoint.com"; got != want {
67+
t.Errorf("config.Endpoint=%v, want %v", got, want)
68+
}
69+
70+
// Parse expected intervals
71+
expectedSyncInterval, err := time.ParseDuration(syncInterval)
72+
if err != nil {
73+
t.Fatal(err)
74+
}
75+
expectedValidationInterval, err := time.ParseDuration(validationInterval)
76+
if err != nil {
77+
t.Fatal(err)
78+
}
79+
80+
if config.SyncInterval == nil || *config.SyncInterval != expectedSyncInterval {
81+
t.Errorf("config.SyncInterval=%v, want %v", config.SyncInterval, expectedSyncInterval)
82+
}
83+
if config.ValidationInterval == nil || *config.ValidationInterval != expectedValidationInterval {
84+
t.Errorf("config.ValidationInterval=%v, want %v", config.ValidationInterval, expectedValidationInterval)
85+
}
86+
87+
// Test Database 1: Should inherit all global defaults
88+
db1 := config.DBs[0]
89+
if db1.Replica == nil {
90+
t.Fatal("db1.Replica is nil")
91+
}
92+
replica1 := db1.Replica
93+
94+
if got, want := replica1.AccessKeyID, "GLOBAL_ACCESS_KEY"; got != want {
95+
t.Errorf("replica1.AccessKeyID=%v, want %v", got, want)
96+
}
97+
if got, want := replica1.SecretAccessKey, "GLOBAL_SECRET_KEY"; got != want {
98+
t.Errorf("replica1.SecretAccessKey=%v, want %v", got, want)
99+
}
100+
if got, want := replica1.Region, "us-west-2"; got != want {
101+
t.Errorf("replica1.Region=%v, want %v", got, want)
102+
}
103+
if got, want := replica1.Endpoint, "custom.s3.endpoint.com"; got != want {
104+
t.Errorf("replica1.Endpoint=%v, want %v", got, want)
105+
}
106+
if got, want := replica1.Bucket, "my-bucket-1"; got != want {
107+
t.Errorf("replica1.Bucket=%v, want %v", got, want)
108+
}
109+
if replica1.SyncInterval == nil || *replica1.SyncInterval != expectedSyncInterval {
110+
t.Errorf("replica1.SyncInterval=%v, want %v", replica1.SyncInterval, expectedSyncInterval)
111+
}
112+
if replica1.ValidationInterval == nil || *replica1.ValidationInterval != expectedValidationInterval {
113+
t.Errorf("replica1.ValidationInterval=%v, want %v", replica1.ValidationInterval, expectedValidationInterval)
114+
}
115+
116+
// Test Database 2: Should override some defaults
117+
db2 := config.DBs[1]
118+
if db2.Replica == nil {
119+
t.Fatal("db2.Replica is nil")
120+
}
121+
replica2 := db2.Replica
122+
123+
if got, want := replica2.AccessKeyID, "CUSTOM_KEY"; got != want {
124+
t.Errorf("replica2.AccessKeyID=%v, want %v", got, want)
125+
}
126+
if got, want := replica2.SecretAccessKey, "GLOBAL_SECRET_KEY"; got != want {
127+
t.Errorf("replica2.SecretAccessKey=%v, want %v", got, want)
128+
}
129+
if got, want := replica2.Region, "us-east-1"; got != want {
130+
t.Errorf("replica2.Region=%v, want %v", got, want)
131+
}
132+
if got, want := replica2.Endpoint, "custom.s3.endpoint.com"; got != want {
133+
t.Errorf("replica2.Endpoint=%v, want %v", got, want)
134+
}
135+
if got, want := replica2.Bucket, "my-bucket-2"; got != want {
136+
t.Errorf("replica2.Bucket=%v, want %v", got, want)
137+
}
138+
139+
// Test Database 3: Legacy replicas format should work
140+
db3 := config.DBs[2]
141+
if len(db3.Replicas) != 1 {
142+
t.Fatalf("db3.Replicas length=%v, want 1", len(db3.Replicas))
143+
}
144+
replica3 := db3.Replicas[0]
145+
146+
if got, want := replica3.AccessKeyID, "GLOBAL_ACCESS_KEY"; got != want {
147+
t.Errorf("replica3.AccessKeyID=%v, want %v", got, want)
148+
}
149+
if got, want := replica3.SecretAccessKey, "GLOBAL_SECRET_KEY"; got != want {
150+
t.Errorf("replica3.SecretAccessKey=%v, want %v", got, want)
151+
}
152+
if got, want := replica3.Region, "us-west-2"; got != want {
153+
t.Errorf("replica3.Region=%v, want %v", got, want)
154+
}
155+
if got, want := replica3.Endpoint, "custom.s3.endpoint.com"; got != want {
156+
t.Errorf("replica3.Endpoint=%v, want %v", got, want)
157+
}
158+
if got, want := replica3.Bucket, "my-bucket-3"; got != want {
159+
t.Errorf("replica3.Bucket=%v, want %v", got, want)
160+
}
161+
})
162+
163+
// Test different replica types inherit appropriate defaults
164+
t.Run("MultipleReplicaTypes", func(t *testing.T) {
165+
filename := filepath.Join(t.TempDir(), "litestream.yml")
166+
167+
if err := os.WriteFile(filename, []byte(`
168+
# Global defaults that apply to all supported replica types
169+
access-key-id: GLOBAL_S3_KEY
170+
secret-access-key: GLOBAL_S3_SECRET
171+
region: global-region
172+
endpoint: global.endpoint.com
173+
account-name: global-abs-account
174+
account-key: global-abs-key
175+
host: global.sftp.host
176+
user: global-sftp-user
177+
password: global-sftp-pass
178+
sync-interval: 45s
179+
180+
dbs:
181+
- path: /tmp/s3.sqlite
182+
replica:
183+
type: s3
184+
bucket: s3-bucket
185+
186+
- path: /tmp/abs.sqlite
187+
replica:
188+
type: abs
189+
bucket: abs-container
190+
191+
- path: /tmp/sftp.sqlite
192+
replica:
193+
type: sftp
194+
path: /backup/path
195+
`[1:]), 0666); err != nil {
196+
t.Fatal(err)
197+
}
198+
199+
config, err := ReadConfigFile(filename, true)
200+
if err != nil {
201+
t.Fatal(err)
202+
}
203+
204+
expectedSyncInterval, _ := time.ParseDuration("45s")
205+
206+
// Test S3 replica inherits S3-specific defaults
207+
s3Replica := config.DBs[0].Replica
208+
if got, want := s3Replica.AccessKeyID, "GLOBAL_S3_KEY"; got != want {
209+
t.Errorf("s3Replica.AccessKeyID=%v, want %v", got, want)
210+
}
211+
if got, want := s3Replica.SecretAccessKey, "GLOBAL_S3_SECRET"; got != want {
212+
t.Errorf("s3Replica.SecretAccessKey=%v, want %v", got, want)
213+
}
214+
if got, want := s3Replica.Region, "global-region"; got != want {
215+
t.Errorf("s3Replica.Region=%v, want %v", got, want)
216+
}
217+
if got, want := s3Replica.Endpoint, "global.endpoint.com"; got != want {
218+
t.Errorf("s3Replica.Endpoint=%v, want %v", got, want)
219+
}
220+
if s3Replica.SyncInterval == nil || *s3Replica.SyncInterval != expectedSyncInterval {
221+
t.Errorf("s3Replica.SyncInterval=%v, want %v", s3Replica.SyncInterval, expectedSyncInterval)
222+
}
223+
224+
// Test ABS replica inherits ABS-specific defaults
225+
absReplica := config.DBs[1].Replica
226+
if got, want := absReplica.AccountName, "global-abs-account"; got != want {
227+
t.Errorf("absReplica.AccountName=%v, want %v", got, want)
228+
}
229+
if got, want := absReplica.AccountKey, "global-abs-key"; got != want {
230+
t.Errorf("absReplica.AccountKey=%v, want %v", got, want)
231+
}
232+
if absReplica.SyncInterval == nil || *absReplica.SyncInterval != expectedSyncInterval {
233+
t.Errorf("absReplica.SyncInterval=%v, want %v", absReplica.SyncInterval, expectedSyncInterval)
234+
}
235+
236+
// Test SFTP replica inherits SFTP-specific defaults
237+
sftpReplica := config.DBs[2].Replica
238+
if got, want := sftpReplica.Host, "global.sftp.host"; got != want {
239+
t.Errorf("sftpReplica.Host=%v, want %v", got, want)
240+
}
241+
if got, want := sftpReplica.User, "global-sftp-user"; got != want {
242+
t.Errorf("sftpReplica.User=%v, want %v", got, want)
243+
}
244+
if got, want := sftpReplica.Password, "global-sftp-pass"; got != want {
245+
t.Errorf("sftpReplica.Password=%v, want %v", got, want)
246+
}
247+
if sftpReplica.SyncInterval == nil || *sftpReplica.SyncInterval != expectedSyncInterval {
248+
t.Errorf("sftpReplica.SyncInterval=%v, want %v", sftpReplica.SyncInterval, expectedSyncInterval)
249+
}
250+
})
251+
}

0 commit comments

Comments
 (0)