carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package appconfig
2
3import (
4 "context"
5 "crypto/ecdh"
6 "errors"
7 "strings"
8 "testing"
9 "time"
10
11 "github.com/carlosframework/platform/internal/registry"
12 "github.com/carlosframework/platform/internal/relay"
13 "github.com/carlosframework/platform/internal/release"
14)
15
16// syncRelayTestKeys generates and parses a config-crypto keypair, the same
17// shape TestSyncWritesFileAndRestartsOnChange (sync_test.go) uses.
18func syncRelayTestKeys(t *testing.T) (*ecdh.PrivateKey, *ecdh.PublicKey) {
19 t.Helper()
20 privB64, pubB64, err := GenerateKey()
21 if err != nil {
22 t.Fatalf("GenerateKey: %v", err)
23 }
24 priv, err := ParsePrivateKey(privB64, "test")
25 if err != nil {
26 t.Fatalf("ParsePrivateKey: %v", err)
27 }
28 pub, err := ParsePublicKey(pubB64, "test")
29 if err != nil {
30 t.Fatalf("ParsePublicKey: %v", err)
31 }
32 return priv, pub
33}
34
35// sealRelayConfig builds CARLOS_RELAY's value with relay.BuildConfig (the
36// same helper relayConfigValue, internal/console/relayops.go, calls) and
37// seals it into acct/app's config bundle, returning the suffixes
38// BuildConfig issued so tests can compare against what a sync pass's ack
39// names back.
40func sealRelayConfig(t *testing.T, ctx context.Context, s release.Store, pub *ecdh.PublicKey, acct, app string, rec *relay.Record, masters []relay.RegionMaster) map[string]string {
41 t.Helper()
42 value, suffixes, err := relay.BuildConfig(acct, app, rec, masters, func(region string) []string {
43 return []string{"turn:" + region + ".relay.example.com:3478"}
44 })
45 if err != nil {
46 t.Fatalf("relay.BuildConfig: %v", err)
47 }
48 envelope, err := Seal(pub, Binding{Account: acct, App: app}, []byte(value))
49 if err != nil {
50 t.Fatalf("Seal: %v", err)
51 }
52 if err := SetSecret(ctx, s, acct, app, map[string]string{"CARLOS_RELAY": envelope}); err != nil {
53 t.Fatalf("SetSecret: %v", err)
54 }
55 return suffixes
56}
57
58func TestSyncWritesConfigAckNamingRelaySuffixes(t *testing.T) {
59 s := release.NewDirStore(t.TempDir())
60 ctx := context.Background()
61 reg := testRegistry(t)
62 envDir := t.TempDir()
63
64 priv, pub := syncRelayTestKeys(t)
65
66 rec := &relay.Record{Enabled: true, Incarnation: "inc00001", Regions: []string{"eu-west-1"}}
67 masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}}
68 wantSuffixes := sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters)
69 if wantSuffixes["eu-west-1"] == "" {
70 t.Fatalf("sealRelayConfig issued no suffix for eu-west-1: %v", wantSuffixes)
71 }
72
73 if err := reg.Upsert(registry.Route{
74 Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready",
75 Backing: registry.BackingUnit,
76 }); err != nil {
77 t.Fatalf("Upsert: %v", err)
78 }
79
80 var acks []ConfigAck
81 ack := func(a ConfigAck) { acks = append(acks, a) }
82 restart := func(host string) error { return nil }
83 if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil {
84 t.Fatalf("Sync: %v", err)
85 }
86
87 if len(acks) != 1 {
88 t.Fatalf("acks = %d, want 1: %+v", len(acks), acks)
89 }
90 got := acks[0]
91 if got.Host != "meet.example.com" {
92 t.Fatalf("ack.Host = %q, want meet.example.com", got.Host)
93 }
94 if got.Shape != "unit" {
95 t.Fatalf("ack.Shape = %q, want unit", got.Shape)
96 }
97 if !got.Restarted {
98 t.Fatalf("ack.Restarted = false, want true")
99 }
100 if got.RestartError != "" {
101 t.Fatalf("ack.RestartError = %q, want empty", got.RestartError)
102 }
103 if got.RelaySuffixes["eu-west-1"] != wantSuffixes["eu-west-1"] {
104 t.Fatalf("ack.RelaySuffixes[eu-west-1] = %q, want %q", got.RelaySuffixes["eu-west-1"], wantSuffixes["eu-west-1"])
105 }
106 // The identity is account:app:incarnation:kid (spec §5) — a kid alone
107 // would not distinguish a re-enable, so the whole thing must be there.
108 if !strings.Contains(got.RelaySuffixes["eu-west-1"], "abc:meet:inc00001:k1") {
109 t.Fatalf("ack.RelaySuffixes[eu-west-1] = %q, want it to name abc:meet:inc00001:k1", got.RelaySuffixes["eu-west-1"])
110 }
111}
112
113func TestSyncAckReportsRestartFailure(t *testing.T) {
114 s := release.NewDirStore(t.TempDir())
115 ctx := context.Background()
116 reg := testRegistry(t)
117 envDir := t.TempDir()
118
119 priv, pub := syncRelayTestKeys(t)
120 rec := &relay.Record{Enabled: true, Incarnation: "inc00002", Regions: []string{"eu-west-1"}}
121 masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}}
122 sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters)
123
124 if err := reg.Upsert(registry.Route{
125 Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready",
126 Backing: registry.BackingUnit,
127 }); err != nil {
128 t.Fatalf("Upsert: %v", err)
129 }
130
131 var acks []ConfigAck
132 ack := func(a ConfigAck) { acks = append(acks, a) }
133 restartErr := errors.New("systemctl try-restart carlos-app@meet.example.com: exit status 1")
134 restart := func(host string) error { return restartErr }
135 if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil {
136 t.Fatalf("Sync: %v", err)
137 }
138
139 if len(acks) != 1 {
140 t.Fatalf("acks = %d, want 1: %+v", len(acks), acks)
141 }
142 got := acks[0]
143 if got.Restarted {
144 t.Fatalf("ack.Restarted = true, want false on a failed restart")
145 }
146 if got.RestartError != restartErr.Error() {
147 t.Fatalf("ack.RestartError = %q, want %q", got.RestartError, restartErr.Error())
148 }
149}
150
151func TestSyncAckWithoutRelayHasNilSuffixes(t *testing.T) {
152 s := release.NewDirStore(t.TempDir())
153 ctx := context.Background()
154 reg := testRegistry(t)
155 envDir := t.TempDir()
156
157 priv, _ := syncRelayTestKeys(t)
158 if err := SetEnv(ctx, s, "abc", "meet", map[string]string{"LOG_LEVEL": "debug"}); err != nil {
159 t.Fatalf("SetEnv: %v", err)
160 }
161 if err := reg.Upsert(registry.Route{
162 Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready",
163 Backing: registry.BackingUnit,
164 }); err != nil {
165 t.Fatalf("Upsert: %v", err)
166 }
167
168 var acks []ConfigAck
169 ack := func(a ConfigAck) { acks = append(acks, a) }
170 restart := func(host string) error { return nil }
171 if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil {
172 t.Fatalf("Sync: %v", err)
173 }
174
175 if len(acks) != 1 {
176 t.Fatalf("acks = %d, want 1: %+v", len(acks), acks)
177 }
178 if acks[0].RelaySuffixes != nil {
179 t.Fatalf("ack.RelaySuffixes = %+v, want nil (no CARLOS_RELAY entry)", acks[0].RelaySuffixes)
180 }
181}
182
183// TestSyncAckHibernatingRouteReportsNoRestart is spec §5's "hibernating,
184// delivered on wake" case: try-restart on an inactive unit exits 0 having
185// restarted nothing (sync.go's own long-standing comment on the restart
186// call), so a hibernating route's ack must not read as an ordinary
187// Restarted:true adoption — it has to say the config landed but nothing
188// woke to run it.
189func TestSyncAckHibernatingRouteReportsNoRestart(t *testing.T) {
190 s := release.NewDirStore(t.TempDir())
191 ctx := context.Background()
192 reg := testRegistry(t)
193 envDir := t.TempDir()
194
195 priv, pub := syncRelayTestKeys(t)
196 rec := &relay.Record{Enabled: true, Incarnation: "inc00005", Regions: []string{"eu-west-1"}}
197 masters := []relay.RegionMaster{{Region: "eu-west-1", Kid: "k1", Master: []byte("0123456789abcdef0123456789abcdef")}}
198 sealRelayConfig(t, ctx, s, pub, "abc", "meet", rec, masters)
199
200 if err := reg.Upsert(registry.Route{
201 Host: "meet.example.com", Kind: "instance", App: "meet", Account: "abc", Status: "ready",
202 Backing: registry.BackingUnit, Hibernate: true,
203 }); err != nil {
204 t.Fatalf("Upsert: %v", err)
205 }
206
207 var acks []ConfigAck
208 ack := func(a ConfigAck) { acks = append(acks, a) }
209 // try-restart on a hibernating (inactive) unit exits 0 having done
210 // nothing — the no-op restart callers everywhere in this package rely
211 // on returning nil for.
212 restart := func(host string) error { return nil }
213 if err := Sync(ctx, reg, s, priv, envDir, restart, ack); err != nil {
214 t.Fatalf("Sync: %v", err)
215 }
216
217 if len(acks) != 1 {
218 t.Fatalf("acks = %d, want 1: %+v", len(acks), acks)
219 }
220 got := acks[0]
221 if !got.Hibernating {
222 t.Fatalf("ack.Hibernating = false, want true")
223 }
224 if got.Restarted {
225 t.Fatalf("ack.Restarted = true, want false — the route was asleep, nothing actually restarted")
226 }
227 if got.RestartError != "" {
228 t.Fatalf("ack.RestartError = %q, want empty", got.RestartError)
229 }
230}
231
232// The ack says which version of the instance layer the agent rendered
233// (design §6.5). The console holds the etag its own write returned and
234// compares the two, which is decidable in a way a map of delivered key
235// names is not: an old agent and a new agent rendering an overlay that sets
236// nothing look identical by key, and not at all by etag.
237func TestConfigAckNamesTheInstanceOverlayItRendered(t *testing.T) {
238 ctx := context.Background()
239 s := release.NewDirStore(t.TempDir())
240 reg := testRegistry(t)
241 envDir := t.TempDir()
242 priv, _ := syncRelayTestKeys(t)
243
244 if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
245 t.Fatalf("SetEnv: %v", err)
246 }
247 _, want, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
248 map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil)
249 if err != nil {
250 t.Fatalf("ApplyInstance: %v", err)
251 }
252 for _, host := range []string{"book.example.com", "cart.example.com"} {
253 if err := reg.Upsert(registry.Route{
254 Host: host, Kind: "instance", App: "titogo", Account: "bab", Status: "ready",
255 Backing: registry.BackingUnit,
256 }); err != nil {
257 t.Fatalf("Upsert %s: %v", host, err)
258 }
259 }
260 acks := map[string]ConfigAck{}
261 if err := Sync(ctx, reg, s, priv, envDir, func(string) error { return nil },
262 func(a ConfigAck) { acks[a.Host] = a }); err != nil {
263 t.Fatalf("Sync: %v", err)
264 }
265 book, ok := acks["book.example.com"]
266 if !ok {
267 t.Fatal("no ack for the overlaid route")
268 }
269 if book.Schema != ConfigAckSchema {
270 t.Fatalf("Schema = %d, want %d — absence is how an older agent is recognised", book.Schema, ConfigAckSchema)
271 }
272 if book.InstanceConfigETag != want {
273 t.Fatalf("InstanceConfigETag = %q, want the etag the write returned %q", book.InstanceConfigETag, want)
274 }
275 cart, ok := acks["cart.example.com"]
276 if !ok {
277 t.Fatal("no ack for the sibling")
278 }
279 if cart.Schema != ConfigAckSchema || cart.InstanceConfigETag != "" {
280 t.Fatalf("sibling ack = %+v, want the schema and an empty etag: it has no overlay to render", cart)
281 }
282}
283
284// The same statement from the exec half, which is the half an instance
285// sender's box actually runs (design §6.4's delivery check).
286func TestSyncExecConfigAckNamesTheInstanceOverlayItRendered(t *testing.T) {
287 ctx, s, reg, dd, privB64, _ := execKit(t)
288 priv, err := ParsePrivateKey(privB64, "test")
289 if err != nil {
290 t.Fatalf("ParsePrivateKey: %v", err)
291 }
292 if err := SetEnv(ctx, s, "bab", "titogo", map[string]string{"APP_WIDE": "yes"}); err != nil {
293 t.Fatalf("SetEnv: %v", err)
294 }
295 _, want, err := ApplyInstance(ctx, s, "bab", "titogo", "book.example.com",
296 map[string]Var{"TENANT": {Kind: "env", Value: "book"}}, nil)
297 if err != nil {
298 t.Fatalf("ApplyInstance: %v", err)
299 }
300 if err := reg.Upsert(registry.Route{
301 Host: "book.example.com", Kind: "instance", App: "titogo", Account: "bab",
302 Status: "ready", Socket: "/run/carlos/book.sock",
303 }); err != nil {
304 t.Fatalf("Upsert: %v", err)
305 }
306 var got []ConfigAck
307 if err := SyncExec(ctx, reg, s, priv, dd, func(string) error { return nil }, "",
308 func(a ConfigAck) { got = append(got, a) }); err != nil {
309 t.Fatalf("SyncExec: %v", err)
310 }
311 if len(got) != 1 {
312 t.Fatalf("acks = %+v, want exactly one", got)
313 }
314 if got[0].Schema != ConfigAckSchema || got[0].InstanceConfigETag != want {
315 t.Fatalf("ack = %+v, want schema %d and etag %q", got[0], ConfigAckSchema, want)
316 }
317}
318
319// The four answers, and they are four rather than two on purpose. "This
320// agent is too old to render the layer" and "this agent rendered an older
321// version of it" call for different sentences from whoever asks: one is a
322// box to roll, the other is a pass to wait for.
323func TestInstanceConfigDeliveryVerdicts(t *testing.T) {
324 cases := []struct {
325 name string
326 ack *ConfigAck
327 want string
328 got string
329 }{
330 {"not yet acked", nil, "etag-1", InstanceConfigNotYetDelivered},
331 {"agent older than the layer", &ConfigAck{Schema: 0, InstanceConfigETag: ""}, "etag-1", InstanceConfigUnknownAgent},
332 {"rendered this overlay", &ConfigAck{Schema: ConfigAckSchema, InstanceConfigETag: "etag-1"}, "etag-1", InstanceConfigDelivered},
333 {"rendered an older one", &ConfigAck{Schema: ConfigAckSchema, InstanceConfigETag: "etag-0"}, "etag-1", InstanceConfigStale},
334 {"no overlay, and none expected", &ConfigAck{Schema: ConfigAckSchema}, "", InstanceConfigDelivered},
335 {"no overlay, but one was written", &ConfigAck{Schema: ConfigAckSchema}, "etag-1", InstanceConfigStale},
336 }
337 for _, c := range cases {
338 if got := InstanceConfigDelivery(c.ack, c.want); got != c.got {
339 t.Errorf("%s: InstanceConfigDelivery = %q, want %q", c.name, got, c.got)
340 }
341 }
342}
343
344// One route can have acks from more than one box across a migration, so the
345// reader takes the freshest and ignores every other host's.
346func TestLatestConfigAckPicksTheFreshestForTheHost(t *testing.T) {
347 ctx := context.Background()
348 s := release.NewDirStore(t.TempDir())
349 older := ConfigAck{Schema: ConfigAckSchema, Box: "box-1", Host: "book.example.com", Shape: "unit",
350 At: time.Date(2026, 9, 15, 10, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-0"}
351 newer := ConfigAck{Schema: ConfigAckSchema, Box: "box-2", Host: "book.example.com", Shape: "unit",
352 At: time.Date(2026, 9, 15, 12, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-1"}
353 other := ConfigAck{Schema: ConfigAckSchema, Box: "box-2", Host: "cart.example.com", Shape: "unit",
354 At: time.Date(2026, 9, 15, 13, 0, 0, 0, time.UTC), InstanceConfigETag: "etag-9"}
355 for _, a := range []ConfigAck{older, newer, other} {
356 if err := WriteConfigAck(ctx, s, a); err != nil {
357 t.Fatalf("WriteConfigAck %s/%s: %v", a.Box, a.Host, err)
358 }
359 }
360 got, err := LatestConfigAck(ctx, s, "book.example.com")
361 if err != nil || got == nil {
362 t.Fatalf("LatestConfigAck = %+v %v", got, err)
363 }
364 if got.Box != "box-2" || got.InstanceConfigETag != "etag-1" {
365 t.Fatalf("LatestConfigAck = %+v, want box-2's etag-1", got)
366 }
367 if v := InstanceConfigDelivery(got, "etag-1"); v != InstanceConfigDelivered {
368 t.Fatalf("delivery = %q, want %q", v, InstanceConfigDelivered)
369 }
370
371 none, err := LatestConfigAck(ctx, s, "desk.example.com")
372 if err != nil || none != nil {
373 t.Fatalf("LatestConfigAck for a host with no ack = %+v %v, want (nil, nil)", none, err)
374 }
375 if v := InstanceConfigDelivery(none, "etag-1"); v != InstanceConfigNotYetDelivered {
376 t.Fatalf("delivery = %q, want %q", v, InstanceConfigNotYetDelivered)
377 }
378}
379