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 "os"
6 "path/filepath"
7 "testing"
8
9 "github.com/carlosframework/platform/internal/registry"
10 "github.com/carlosframework/platform/internal/release"
11)
12
13func testRegistry(t *testing.T) *registry.Registry {
14 t.Helper()
15 reg, err := registry.Open(filepath.Join(t.TempDir(), "registry.db"))
16 if err != nil {
17 t.Fatalf("open registry: %v", err)
18 }
19 return reg
20}
21
22func TestSyncWritesFileAndRestartsOnChange(t *testing.T) {
23 s := release.NewDirStore(t.TempDir())
24 ctx := context.Background()
25 reg := testRegistry(t)
26 envDir := t.TempDir()
27
28 priv, pub, err := GenerateKey()
29 if err != nil {
30 t.Fatalf("GenerateKey: %v", err)
31 }
32 privKey, err := ParsePrivateKey(priv, "test")
33 if err != nil {
34 t.Fatalf("ParsePrivateKey: %v", err)
35 }
36 pubKey, err := ParsePublicKey(pub, "test")
37 if err != nil {
38 t.Fatalf("ParsePublicKey: %v", err)
39 }
40 envelope, err := Seal(pubKey, Binding{Account: "bab", App: "keymail"}, []byte("shh"))
41 if err != nil {
42 t.Fatalf("Seal: %v", err)
43 }
44 if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil {
45 t.Fatalf("SetEnv: %v", err)
46 }
47 if err := SetSecret(ctx, s, "bab", "keymail", map[string]string{"SES_SECRET": envelope}); err != nil {
48 t.Fatalf("SetSecret: %v", err)
49 }
50 if err := reg.Upsert(registry.Route{
51 Host: "keymail.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
52 Backing: registry.BackingUnit,
53 }); err != nil {
54 t.Fatalf("Upsert: %v", err)
55 }
56
57 var restarted []string
58 restart := func(host string) error { restarted = append(restarted, host); return nil }
59 if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
60 t.Fatalf("Sync: %v", err)
61 }
62
63 got, err := os.ReadFile(filepath.Join(envDir, "keymail.example.com.env"))
64 if err != nil {
65 t.Fatalf("read rendered env: %v", err)
66 }
67 want := "LOG_LEVEL=debug\nSES_SECRET=shh\nCARLOS_PURGE=header\n"
68 if string(got) != want {
69 t.Fatalf("rendered env = %q, want %q", got, want)
70 }
71 if len(restarted) != 1 || restarted[0] != "keymail.example.com" {
72 t.Fatalf("restarted = %v, want exactly [keymail.example.com]", restarted)
73 }
74
75 // A second pass with nothing changed must not write or restart again.
76 restarted = nil
77 if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
78 t.Fatalf("Sync (second pass): %v", err)
79 }
80 if len(restarted) != 0 {
81 t.Fatalf("second pass restarted %v, want none", restarted)
82 }
83}
84
85func TestSyncSkipsRoutesWithNoConfigEverSet(t *testing.T) {
86 s := release.NewDirStore(t.TempDir())
87 ctx := context.Background()
88 reg := testRegistry(t)
89 envDir := t.TempDir()
90 priv, _, err := GenerateKey()
91 if err != nil {
92 t.Fatalf("GenerateKey: %v", err)
93 }
94 privKey, err := ParsePrivateKey(priv, "test")
95 if err != nil {
96 t.Fatalf("ParsePrivateKey: %v", err)
97 }
98 if err := reg.Upsert(registry.Route{
99 Host: "bare.example.com", Kind: "instance", App: "bareapp", Account: "bab", Status: "ready",
100 }); err != nil {
101 t.Fatalf("Upsert: %v", err)
102 }
103 var restarted []string
104 restart := func(host string) error { restarted = append(restarted, host); return nil }
105 if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
106 t.Fatalf("Sync: %v", err)
107 }
108 if _, err := os.Stat(filepath.Join(envDir, "bare.example.com.env")); !os.IsNotExist(err) {
109 t.Fatalf("expected no env file for a route with no config ever set, stat err = %v", err)
110 }
111 if len(restarted) != 0 {
112 t.Fatalf("restarted = %v, want none", restarted)
113 }
114}
115
116func TestSyncLeavesHandStagedFileAloneWhenNoManifestExists(t *testing.T) {
117 // The migration case: an app whose env file is still hand-staged on the
118 // host, with nothing in the bucket for it yet. Turning this feature on
119 // (setting CARLOS_SECRETS_KEY) must not truncate that file — "no
120 // manifest object at all" is a no-op, not "converge to empty".
121 s := release.NewDirStore(t.TempDir())
122 ctx := context.Background()
123 reg := testRegistry(t)
124 envDir := t.TempDir()
125 priv, _, err := GenerateKey()
126 if err != nil {
127 t.Fatalf("GenerateKey: %v", err)
128 }
129 privKey, err := ParsePrivateKey(priv, "test")
130 if err != nil {
131 t.Fatalf("ParsePrivateKey: %v", err)
132 }
133 if err := reg.Upsert(registry.Route{
134 Host: "legacy.example.com", Kind: "instance", App: "legacyapp", Account: "bab", Status: "ready",
135 }); err != nil {
136 t.Fatalf("Upsert: %v", err)
137 }
138 path := filepath.Join(envDir, "legacy.example.com.env")
139 staged := "DATABASE_URL=postgres://staged\nAPI_TOKEN=hand-written\n"
140 if err := os.WriteFile(path, []byte(staged), 0o600); err != nil {
141 t.Fatalf("stage env file: %v", err)
142 }
143
144 var restarted []string
145 if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
146 restarted = append(restarted, h)
147 return nil
148 }, nil); err != nil {
149 t.Fatalf("Sync: %v", err)
150 }
151
152 got, err := os.ReadFile(path)
153 if err != nil {
154 t.Fatalf("read staged env: %v", err)
155 }
156 if string(got) != staged {
157 t.Fatalf("hand-staged env file = %q, want it untouched (%q)", got, staged)
158 }
159 if len(restarted) != 0 {
160 t.Fatalf("restarted = %v, want none", restarted)
161 }
162}
163
164func TestSyncConvergesToAgentOwnedOnlyAfterEveryVarIsUnset(t *testing.T) {
165 // The mirror of the case above: the manifest object DOES exist, it just
166 // has no vars left. That is a real, operator-intended state and must
167 // converge — every tenant var is dropped and the unit restarted. The
168 // file is not fully emptied: CARLOS_PURGE=header is agent-owned, not a
169 // tenant var, and applyAgentVars appends it unconditionally.
170 s := release.NewDirStore(t.TempDir())
171 ctx := context.Background()
172 reg := testRegistry(t)
173 envDir := t.TempDir()
174 priv, _, err := GenerateKey()
175 if err != nil {
176 t.Fatalf("GenerateKey: %v", err)
177 }
178 privKey, err := ParsePrivateKey(priv, "test")
179 if err != nil {
180 t.Fatalf("ParsePrivateKey: %v", err)
181 }
182 if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil {
183 t.Fatalf("SetEnv: %v", err)
184 }
185 if err := reg.Upsert(registry.Route{
186 Host: "keymail.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
187 Backing: registry.BackingUnit,
188 }); err != nil {
189 t.Fatalf("Upsert: %v", err)
190 }
191 var restarted []string
192 restart := func(h string) error { restarted = append(restarted, h); return nil }
193 if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
194 t.Fatalf("Sync: %v", err)
195 }
196 path := filepath.Join(envDir, "keymail.example.com.env")
197 if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
198 t.Fatalf("after set: env = %q, err = %v; want LOG_LEVEL=debug\\nCARLOS_PURGE=header", got, err)
199 }
200
201 if _, err := Unset(ctx, s, "bab", "keymail", []string{"LOG_LEVEL"}); err != nil {
202 t.Fatalf("Unset: %v", err)
203 }
204 restarted = nil
205 if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
206 t.Fatalf("Sync (after unset): %v", err)
207 }
208 got, err := os.ReadFile(path)
209 if err != nil {
210 t.Fatalf("read env after unset: %v", err)
211 }
212 // Convergence to empty is now "just the agent-owned line" — the agent
213 // still owns CARLOS_PURGE regardless of what the tenant's manifest holds.
214 if string(got) != "CARLOS_PURGE=header\n" {
215 t.Fatalf("after full unset: env = %q, want just CARLOS_PURGE=header", got)
216 }
217 if len(restarted) != 1 || restarted[0] != "keymail.example.com" {
218 t.Fatalf("restarted = %v, want exactly [keymail.example.com]", restarted)
219 }
220}
221
222func TestSyncReapsEnvFilesForHostsWithNoRoute(t *testing.T) {
223 // A decommissioned route's env file may hold decrypted secrets; nothing
224 // else on the box removes it. A host that still has a route row keeps
225 // its file even if it has no config set at all.
226 s := release.NewDirStore(t.TempDir())
227 ctx := context.Background()
228 reg := testRegistry(t)
229 envDir := t.TempDir()
230 priv, _, err := GenerateKey()
231 if err != nil {
232 t.Fatalf("GenerateKey: %v", err)
233 }
234 privKey, err := ParsePrivateKey(priv, "test")
235 if err != nil {
236 t.Fatalf("ParsePrivateKey: %v", err)
237 }
238 if err := reg.Upsert(registry.Route{
239 Host: "live.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
240 }); err != nil {
241 t.Fatalf("Upsert: %v", err)
242 }
243 // A route that exists but is not syncable (mid-provisioning) must also
244 // keep its file — reaping keys off the full route list, not syncable().
245 if err := reg.Upsert(registry.Route{
246 Host: "pending.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "provisioning",
247 }); err != nil {
248 t.Fatalf("Upsert: %v", err)
249 }
250 files := map[string]string{
251 "live.example.com.env": "KEEP=1\n",
252 "pending.example.com.env": "KEEP=2\n",
253 "gone.example.com.env": "SECRET=leftover\n",
254 }
255 for name, body := range files {
256 if err := os.WriteFile(filepath.Join(envDir, name), []byte(body), 0o600); err != nil {
257 t.Fatalf("stage %s: %v", name, err)
258 }
259 }
260
261 if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
262 t.Fatalf("Sync: %v", err)
263 }
264
265 if _, err := os.Stat(filepath.Join(envDir, "gone.example.com.env")); !os.IsNotExist(err) {
266 t.Fatalf("orphaned env file survived Sync, stat err = %v", err)
267 }
268 for _, name := range []string{"live.example.com.env", "pending.example.com.env"} {
269 got, err := os.ReadFile(filepath.Join(envDir, name))
270 if err != nil {
271 t.Fatalf("%s: want it kept, read err = %v", name, err)
272 }
273 if string(got) != files[name] {
274 t.Fatalf("%s = %q, want it untouched (%q)", name, got, files[name])
275 }
276 }
277}
278
279func TestSyncEmptyRegistryLeavesEnvFilesAlone(t *testing.T) {
280 // registry.Open silently creates an empty database when handed a wrong
281 // path or an unmounted /data — "successful read, zero routes" is an
282 // easily reachable state, not proof the box runs nothing. Reaping
283 // against it would wipe every env file on the box as root.
284 s := release.NewDirStore(t.TempDir())
285 ctx := context.Background()
286 reg := testRegistry(t) // empty: no routes ever upserted
287 envDir := t.TempDir()
288 priv, _, err := GenerateKey()
289 if err != nil {
290 t.Fatalf("GenerateKey: %v", err)
291 }
292 privKey, err := ParsePrivateKey(priv, "test")
293 if err != nil {
294 t.Fatalf("ParsePrivateKey: %v", err)
295 }
296 staged := "DATABASE_URL=postgres://staged\n"
297 path := filepath.Join(envDir, "handstaged.example.com.env")
298 if err := os.WriteFile(path, []byte(staged), 0o600); err != nil {
299 t.Fatalf("stage env file: %v", err)
300 }
301
302 if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
303 t.Fatalf("Sync: %v", err)
304 }
305
306 got, err := os.ReadFile(path)
307 if err != nil {
308 t.Fatalf("env file gone after Sync against an empty registry: %v", err)
309 }
310 if string(got) != staged {
311 t.Fatalf("env file = %q, want it untouched (%q)", got, staged)
312 }
313}
314
315func TestSyncSkipsExecBackedRoutes(t *testing.T) {
316 // An exec-backed route (Backing == "", the fleet default) reads
317 // <DataDir>/<host>.env, not /etc/carlos/apps — materializing there
318 // would write a file nothing reads and claim a restart that restarted
319 // nothing. Sync must skip it (with a log line, not silence).
320 s := release.NewDirStore(t.TempDir())
321 ctx := context.Background()
322 reg := testRegistry(t)
323 envDir := t.TempDir()
324 priv, _, err := GenerateKey()
325 if err != nil {
326 t.Fatalf("GenerateKey: %v", err)
327 }
328 privKey, err := ParsePrivateKey(priv, "test")
329 if err != nil {
330 t.Fatalf("ParsePrivateKey: %v", err)
331 }
332 if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil {
333 t.Fatalf("SetEnv: %v", err)
334 }
335 if err := reg.Upsert(registry.Route{
336 Host: "execshape.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
337 }); err != nil {
338 t.Fatalf("Upsert: %v", err)
339 }
340 var restarted []string
341 if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
342 restarted = append(restarted, h)
343 return nil
344 }, nil); err != nil {
345 t.Fatalf("Sync: %v", err)
346 }
347 if _, err := os.Stat(filepath.Join(envDir, "execshape.example.com.env")); !os.IsNotExist(err) {
348 t.Fatalf("expected no env file for an exec-backed route, stat err = %v", err)
349 }
350 if len(restarted) != 0 {
351 t.Fatalf("restarted = %v, want none", restarted)
352 }
353}
354
355func TestSyncReapsStaleTmpFiles(t *testing.T) {
356 // A crash between write and rename leaves <host>.env.tmp holding
357 // decrypted secrets; by the time reap runs, any temp file is stale by
358 // construction and must go — even one whose host still has a route.
359 s := release.NewDirStore(t.TempDir())
360 ctx := context.Background()
361 reg := testRegistry(t)
362 envDir := t.TempDir()
363 priv, _, err := GenerateKey()
364 if err != nil {
365 t.Fatalf("GenerateKey: %v", err)
366 }
367 privKey, err := ParsePrivateKey(priv, "test")
368 if err != nil {
369 t.Fatalf("ParsePrivateKey: %v", err)
370 }
371 if err := reg.Upsert(registry.Route{
372 Host: "live.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
373 Backing: registry.BackingUnit,
374 }); err != nil {
375 t.Fatalf("Upsert: %v", err)
376 }
377 for _, name := range []string{"live.example.com.env.tmp", "gone.example.com.env.tmp"} {
378 if err := os.WriteFile(filepath.Join(envDir, name), []byte("SECRET=leftover\n"), 0o600); err != nil {
379 t.Fatalf("stage %s: %v", name, err)
380 }
381 }
382
383 if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
384 t.Fatalf("Sync: %v", err)
385 }
386
387 for _, name := range []string{"live.example.com.env.tmp", "gone.example.com.env.tmp"} {
388 if _, err := os.Stat(filepath.Join(envDir, name)); !os.IsNotExist(err) {
389 t.Fatalf("%s survived Sync, stat err = %v", name, err)
390 }
391 }
392}
393
394func TestSyncHostRefusesNonBareHostname(t *testing.T) {
395 // A registry row whose Host carries a path separator must never place a
396 // root-written file outside envDir.
397 envDir := filepath.Join(t.TempDir(), "apps")
398 if err := os.Mkdir(envDir, 0o755); err != nil {
399 t.Fatalf("mkdir: %v", err)
400 }
401 m := &Manifest{Schema: 1, App: "evil", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}
402 for _, host := range []string{"../escape", "a/b", "", ".", ".."} {
403 syncHost(registry.Route{Host: host}, m, false, nil, envDir, func(string) error { return nil }, "", nil)
404 }
405 if _, err := os.Stat(filepath.Join(envDir, "..", "escape.env")); !os.IsNotExist(err) {
406 t.Fatalf("a path-escaping host wrote outside envDir, stat err = %v", err)
407 }
408 ents, err := os.ReadDir(envDir)
409 if err != nil {
410 t.Fatalf("read envDir: %v", err)
411 }
412 if len(ents) != 0 {
413 t.Fatalf("envDir = %v, want nothing written for non-bare hostnames", ents)
414 }
415}
416
417func TestSyncSurvivesAMissingEnvDir(t *testing.T) {
418 // A box that has never materialized anything has no env dir; the reap
419 // step must not turn that into an error.
420 s := release.NewDirStore(t.TempDir())
421 ctx := context.Background()
422 reg := testRegistry(t)
423 envDir := filepath.Join(t.TempDir(), "never-created")
424 priv, _, err := GenerateKey()
425 if err != nil {
426 t.Fatalf("GenerateKey: %v", err)
427 }
428 privKey, err := ParsePrivateKey(priv, "test")
429 if err != nil {
430 t.Fatalf("ParsePrivateKey: %v", err)
431 }
432 if err := reg.Upsert(registry.Route{
433 Host: "bare.example.com", Kind: "instance", App: "bareapp", Account: "bab", Status: "ready",
434 }); err != nil {
435 t.Fatalf("Upsert: %v", err)
436 }
437 if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
438 t.Fatalf("Sync with a missing env dir: %v", err)
439 }
440}
441
442func TestSyncRendersEachRoutesBoundEnvironment(t *testing.T) {
443 // One app, three routes of it, two of them bound to different named
444 // environments. Each route's file must be the default bundle overlaid by
445 // ITS OWN environment — no leakage of one route's environment into
446 // another's render (the per-(acct, app) fetch cache must not collapse
447 // routes that resolve differently).
448 s := release.NewDirStore(t.TempDir())
449 ctx := context.Background()
450 reg := testRegistry(t)
451 envDir := t.TempDir()
452 priv, _, err := GenerateKey()
453 if err != nil {
454 t.Fatalf("GenerateKey: %v", err)
455 }
456 privKey, err := ParsePrivateKey(priv, "test")
457 if err != nil {
458 t.Fatalf("ParsePrivateKey: %v", err)
459 }
460 if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "info", "SHARED": "yes"}); err != nil {
461 t.Fatalf("SetEnv: %v", err)
462 }
463 if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
464 "LOG_LEVEL": {Kind: "env", Value: "debug"},
465 "DEMO_ONLY": {Kind: "env", Value: "1"},
466 }, nil); err != nil {
467 t.Fatalf("SaveEnvironment demo: %v", err)
468 }
469 if err := SaveEnvironment(ctx, s, "bab", "keymail", "staging", map[string]Var{
470 "LOG_LEVEL": {Kind: "env", Value: "trace"},
471 "STAGING_ONLY": {Kind: "env", Value: "1"},
472 }, nil); err != nil {
473 t.Fatalf("SaveEnvironment staging: %v", err)
474 }
475 routes := map[string]string{ // host -> bound environment
476 "plain.example.com": "",
477 "demo.example.com": "demo",
478 "staging.example.com": "staging",
479 }
480 for host, env := range routes {
481 if err := reg.Upsert(registry.Route{
482 Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
483 Backing: registry.BackingUnit, Environment: env,
484 }); err != nil {
485 t.Fatalf("Upsert %s: %v", host, err)
486 }
487 }
488
489 var restarted []string
490 restart := func(h string) error { restarted = append(restarted, h); return nil }
491 if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
492 t.Fatalf("Sync: %v", err)
493 }
494
495 want := map[string]string{
496 "plain.example.com": "LOG_LEVEL=info\nSHARED=yes\nCARLOS_PURGE=header\n",
497 "demo.example.com": "DEMO_ONLY=1\nLOG_LEVEL=debug\nSHARED=yes\nCARLOS_PURGE=header\n",
498 "staging.example.com": "LOG_LEVEL=trace\nSHARED=yes\nSTAGING_ONLY=1\nCARLOS_PURGE=header\n",
499 }
500 for host, body := range want {
501 got, err := os.ReadFile(filepath.Join(envDir, host+".env"))
502 if err != nil {
503 t.Fatalf("%s: read rendered env: %v", host, err)
504 }
505 if string(got) != body {
506 t.Fatalf("%s: rendered env = %q, want %q", host, got, body)
507 }
508 }
509 if len(restarted) != 3 {
510 t.Fatalf("restarted = %v, want all three routes", restarted)
511 }
512
513 // Unchanged merged output on a second pass: no rewrite, no restart —
514 // including for the environment-bound routes, whose merge must be stable.
515 before := map[string]os.FileInfo{}
516 for host := range want {
517 fi, err := os.Stat(filepath.Join(envDir, host+".env"))
518 if err != nil {
519 t.Fatalf("%s: stat: %v", host, err)
520 }
521 before[host] = fi
522 }
523 restarted = nil
524 if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
525 t.Fatalf("Sync (second pass): %v", err)
526 }
527 if len(restarted) != 0 {
528 t.Fatalf("second pass restarted %v, want none", restarted)
529 }
530 for host, fi := range before {
531 now, err := os.Stat(filepath.Join(envDir, host+".env"))
532 if err != nil {
533 t.Fatalf("%s: stat after second pass: %v", host, err)
534 }
535 if !now.ModTime().Equal(fi.ModTime()) {
536 t.Fatalf("%s: env file was rewritten on an unchanged pass", host)
537 }
538 }
539}
540
541func TestSyncLeavesEnvFileUntouchedWhenBoundEnvironmentIsMissing(t *testing.T) {
542 // The bundle was deleted out from under a bound route. That route's
543 // resolution FAILS — and a failed resolution must never reach the
544 // render-and-diff path, because an empty render differs from the file on
545 // disk and would truncate it as root and restart the unit. The last-good
546 // file stays byte-identical and untouched; other routes of the same app
547 // still converge.
548 s := release.NewDirStore(t.TempDir())
549 ctx := context.Background()
550 reg := testRegistry(t)
551 envDir := t.TempDir()
552 priv, _, err := GenerateKey()
553 if err != nil {
554 t.Fatalf("GenerateKey: %v", err)
555 }
556 privKey, err := ParsePrivateKey(priv, "test")
557 if err != nil {
558 t.Fatalf("ParsePrivateKey: %v", err)
559 }
560 if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "info"}); err != nil {
561 t.Fatalf("SetEnv: %v", err)
562 }
563 for host, env := range map[string]string{
564 "gone.example.com": "vanished",
565 "ok.example.com": "",
566 } {
567 if err := reg.Upsert(registry.Route{
568 Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
569 Backing: registry.BackingUnit, Environment: env,
570 }); err != nil {
571 t.Fatalf("Upsert %s: %v", host, err)
572 }
573 }
574 lastGood := "LOG_LEVEL=debug\nDB_URL=postgres://last-good\n"
575 path := filepath.Join(envDir, "gone.example.com.env")
576 if err := os.WriteFile(path, []byte(lastGood), 0o600); err != nil {
577 t.Fatalf("stage last-good env file: %v", err)
578 }
579 fi, err := os.Stat(path)
580 if err != nil {
581 t.Fatalf("stat staged file: %v", err)
582 }
583
584 var restarted []string
585 if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
586 restarted = append(restarted, h)
587 return nil
588 }, nil); err != nil {
589 t.Fatalf("Sync: %v", err)
590 }
591
592 got, err := os.ReadFile(path)
593 if err != nil {
594 t.Fatalf("last-good env file gone after Sync: %v", err)
595 }
596 if string(got) != lastGood {
597 t.Fatalf("env file = %q, want it untouched (%q)", got, lastGood)
598 }
599 now, err := os.Stat(path)
600 if err != nil {
601 t.Fatalf("stat after Sync: %v", err)
602 }
603 if !now.ModTime().Equal(fi.ModTime()) {
604 t.Fatalf("env file of a route with a missing environment was rewritten")
605 }
606 if _, err := os.Stat(path + ".tmp"); !os.IsNotExist(err) {
607 t.Fatalf("a temp file was written for a route whose environment is missing, stat err = %v", err)
608 }
609 for _, h := range restarted {
610 if h == "gone.example.com" {
611 t.Fatalf("restarted a route whose bound environment is missing: %v", restarted)
612 }
613 }
614 // The sibling route must be entirely unaffected by the other's failure.
615 sibling, err := os.ReadFile(filepath.Join(envDir, "ok.example.com.env"))
616 if err != nil {
617 t.Fatalf("sibling route not materialized: %v", err)
618 }
619 if string(sibling) != "LOG_LEVEL=info\nCARLOS_PURGE=header\n" {
620 t.Fatalf("sibling env = %q, want LOG_LEVEL=info + CARLOS_PURGE=header", sibling)
621 }
622 if len(restarted) != 1 || restarted[0] != "ok.example.com" {
623 t.Fatalf("restarted = %v, want exactly [ok.example.com]", restarted)
624 }
625}
626
627func TestSyncDoesNotReapEnvFileWhenEnvironmentIsDeleted(t *testing.T) {
628 // Reaping is keyed on ROUTES, never on environments: deleting a bundle
629 // must not make a live route's env file look orphaned.
630 s := release.NewDirStore(t.TempDir())
631 ctx := context.Background()
632 reg := testRegistry(t)
633 envDir := t.TempDir()
634 priv, _, err := GenerateKey()
635 if err != nil {
636 t.Fatalf("GenerateKey: %v", err)
637 }
638 privKey, err := ParsePrivateKey(priv, "test")
639 if err != nil {
640 t.Fatalf("ParsePrivateKey: %v", err)
641 }
642 if err := reg.Upsert(registry.Route{
643 Host: "bound.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
644 Backing: registry.BackingUnit, Environment: "deleted-env",
645 }); err != nil {
646 t.Fatalf("Upsert: %v", err)
647 }
648 path := filepath.Join(envDir, "bound.example.com.env")
649 if err := os.WriteFile(path, []byte("KEEP=1\n"), 0o600); err != nil {
650 t.Fatalf("stage env file: %v", err)
651 }
652 if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
653 t.Fatalf("Sync: %v", err)
654 }
655 got, err := os.ReadFile(path)
656 if err != nil {
657 t.Fatalf("env file reaped after its environment was deleted: %v", err)
658 }
659 if string(got) != "KEEP=1\n" {
660 t.Fatalf("env file = %q, want it untouched", got)
661 }
662}
663
664// Review CRITICAL 1: binding an environment must not be a way to replace a
665// hand-staged production env file. The unbound path is protected by
666// getIfExists ("no config.json at all ⇒ leave the file alone"); a bound route
667// resolves through LoadMerged, which treats a missing default as an empty
668// base — so without this guard, the documented migration step (create a small
669// override bundle, bind the routes) replaces every var the staged file held
670// with just the override's.
671func TestSyncRefusesToReplaceStagedFileWhenAppHasNoDefaultBundle(t *testing.T) {
672 s := release.NewDirStore(t.TempDir())
673 ctx := context.Background()
674 reg := testRegistry(t)
675 envDir := t.TempDir()
676 priv, _, err := GenerateKey()
677 if err != nil {
678 t.Fatalf("GenerateKey: %v", err)
679 }
680 privKey, err := ParsePrivateKey(priv, "test")
681 if err != nil {
682 t.Fatalf("ParsePrivateKey: %v", err)
683 }
684 // An override bundle only — the app has never had a config.json.
685 if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
686 "LOG_LEVEL": {Kind: "env", Value: "debug"},
687 }, nil); err != nil {
688 t.Fatalf("SaveEnvironment: %v", err)
689 }
690 for host, env := range map[string]string{
691 "prod.example.com": "demo",
692 "fresh.example.com": "demo",
693 } {
694 if err := reg.Upsert(registry.Route{
695 Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
696 Backing: registry.BackingUnit, Environment: env,
697 }); err != nil {
698 t.Fatalf("Upsert %s: %v", host, err)
699 }
700 }
701 staged := "DB_URL=postgres://prod\nSTRIPE_KEY=sk_live\nSMTP_HOST=mail\nLOG_LEVEL=info\n"
702 path := filepath.Join(envDir, "prod.example.com.env")
703 if err := os.WriteFile(path, []byte(staged), 0o600); err != nil {
704 t.Fatalf("stage env file: %v", err)
705 }
706 fi, err := os.Stat(path)
707 if err != nil {
708 t.Fatalf("stat staged file: %v", err)
709 }
710
711 var restarted []string
712 if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
713 restarted = append(restarted, h)
714 return nil
715 }, nil); err != nil {
716 t.Fatalf("Sync: %v", err)
717 }
718
719 got, err := os.ReadFile(path)
720 if err != nil {
721 t.Fatalf("staged env file gone after Sync: %v", err)
722 }
723 if string(got) != staged {
724 t.Fatalf("staged env file = %q, want it untouched (%q)", got, staged)
725 }
726 if now, err := os.Stat(path); err != nil || !now.ModTime().Equal(fi.ModTime()) {
727 t.Fatalf("staged env file was rewritten (stat err = %v)", err)
728 }
729 for _, h := range restarted {
730 if h == "prod.example.com" {
731 t.Fatalf("restarted a route whose staged file was left in place: %v", restarted)
732 }
733 }
734 // The route with nothing staged has nothing to lose: environment-only
735 // render proceeds for it, in the same pass.
736 fresh, err := os.ReadFile(filepath.Join(envDir, "fresh.example.com.env"))
737 if err != nil {
738 t.Fatalf("fresh route not materialized: %v", err)
739 }
740 if string(fresh) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
741 t.Fatalf("fresh route env = %q, want LOG_LEVEL=debug + CARLOS_PURGE=header", fresh)
742 }
743 if len(restarted) != 1 || restarted[0] != "fresh.example.com" {
744 t.Fatalf("restarted = %v, want exactly [fresh.example.com]", restarted)
745 }
746}
747
748// Round-2 review, IMPORTANT: the no-default guard must not freeze a route on
749// the file SYNC ITSELF wrote. "A non-empty file exists" is not evidence a
750// human staged it — after pass 1 it is evidence of pass 1 — so the guard is
751// keyed on KEY LOSS instead: it refuses only when the file on disk holds keys
752// the merged render would drop. Same keys with different values, and supersets
753// of what is on disk, converge freely.
754func TestSyncEnvironmentOnlyAppKeepsConvergingAfterItsOwnFirstWrite(t *testing.T) {
755 s := release.NewDirStore(t.TempDir())
756 ctx := context.Background()
757 reg := testRegistry(t)
758 envDir := t.TempDir()
759 priv, _, err := GenerateKey()
760 if err != nil {
761 t.Fatalf("GenerateKey: %v", err)
762 }
763 privKey, err := ParsePrivateKey(priv, "test")
764 if err != nil {
765 t.Fatalf("ParsePrivateKey: %v", err)
766 }
767 // No config.json for this app, ever — only a named bundle.
768 if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
769 "LOG_LEVEL": {Kind: "env", Value: "debug"},
770 }, nil); err != nil {
771 t.Fatalf("SaveEnvironment: %v", err)
772 }
773 if err := reg.Upsert(registry.Route{
774 Host: "fresh.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
775 Backing: registry.BackingUnit, Environment: "demo",
776 }); err != nil {
777 t.Fatalf("Upsert: %v", err)
778 }
779 var restarted []string
780 restart := func(h string) error { restarted = append(restarted, h); return nil }
781 if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
782 t.Fatalf("Sync (pass 1): %v", err)
783 }
784 path := filepath.Join(envDir, "fresh.example.com.env")
785 if got, err := os.ReadFile(path); err != nil || string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
786 t.Fatalf("pass 1: env = %q, err = %v; want LOG_LEVEL=debug + CARLOS_PURGE=header", got, err)
787 }
788
789 // The operator edits the bundle: one value changed, one key added.
790 if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
791 "LOG_LEVEL": {Kind: "env", Value: "trace"},
792 "NEW": {Kind: "env", Value: "1"},
793 }, nil); err != nil {
794 t.Fatalf("SaveEnvironment (edit): %v", err)
795 }
796 restarted = nil
797 if err := Sync(ctx, reg, s, privKey, envDir, restart, nil); err != nil {
798 t.Fatalf("Sync (pass 2): %v", err)
799 }
800 got, err := os.ReadFile(path)
801 if err != nil {
802 t.Fatalf("pass 2: read env: %v", err)
803 }
804 if string(got) != "LOG_LEVEL=trace\nNEW=1\nCARLOS_PURGE=header\n" {
805 t.Fatalf("pass 2: env = %q, want the edited bundle to converge", got)
806 }
807 if len(restarted) != 1 || restarted[0] != "fresh.example.com" {
808 t.Fatalf("pass 2: restarted = %v, want exactly [fresh.example.com]", restarted)
809 }
810}
811
812// A file this pass cannot parse is content whose keys are unknown, so it is
813// impossible to prove nothing would be lost — assume the worst and refuse,
814// same direction as the guard's other cases.
815func TestSyncRefusesToReplaceAnUnparseableFileWhenAppHasNoDefaultBundle(t *testing.T) {
816 s := release.NewDirStore(t.TempDir())
817 ctx := context.Background()
818 reg := testRegistry(t)
819 envDir := t.TempDir()
820 priv, _, err := GenerateKey()
821 if err != nil {
822 t.Fatalf("GenerateKey: %v", err)
823 }
824 privKey, err := ParsePrivateKey(priv, "test")
825 if err != nil {
826 t.Fatalf("ParsePrivateKey: %v", err)
827 }
828 if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
829 "LOG_LEVEL": {Kind: "env", Value: "debug"},
830 }, nil); err != nil {
831 t.Fatalf("SaveEnvironment: %v", err)
832 }
833 if err := reg.Upsert(registry.Route{
834 Host: "odd.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
835 Backing: registry.BackingUnit, Environment: "demo",
836 }); err != nil {
837 t.Fatalf("Upsert: %v", err)
838 }
839 junk := "this file is not KEY=value at all\n"
840 path := filepath.Join(envDir, "odd.example.com.env")
841 if err := os.WriteFile(path, []byte(junk), 0o600); err != nil {
842 t.Fatalf("stage junk env file: %v", err)
843 }
844 var restarted []string
845 if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
846 restarted = append(restarted, h)
847 return nil
848 }, nil); err != nil {
849 t.Fatalf("Sync: %v", err)
850 }
851 got, err := os.ReadFile(path)
852 if err != nil {
853 t.Fatalf("read env: %v", err)
854 }
855 if string(got) != junk {
856 t.Fatalf("env = %q, want an unparseable file left untouched", got)
857 }
858 if len(restarted) != 0 {
859 t.Fatalf("restarted = %v, want none", restarted)
860 }
861}
862
863// Known limitation of the key-loss gate, pinned so it is not a surprise:
864// while an app has NO default bundle, REMOVING a key from its environment
865// bundle looks exactly like the truncation hazard (a key on disk that the
866// render drops) and is refused. The refusal names both escapes — put the
867// remaining config in a default bundle, or delete the file. It self-heals the
868// moment a default bundle exists, which is the migration this guard is for.
869func TestSyncEnvOnlyKeyRemovalIsRefusedUntilADefaultBundleExists(t *testing.T) {
870 s := release.NewDirStore(t.TempDir())
871 ctx := context.Background()
872 reg := testRegistry(t)
873 envDir := t.TempDir()
874 priv, _, err := GenerateKey()
875 if err != nil {
876 t.Fatalf("GenerateKey: %v", err)
877 }
878 privKey, err := ParsePrivateKey(priv, "test")
879 if err != nil {
880 t.Fatalf("ParsePrivateKey: %v", err)
881 }
882 if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
883 "KEEP": {Kind: "env", Value: "1"},
884 "GONE": {Kind: "env", Value: "2"},
885 }, nil); err != nil {
886 t.Fatalf("SaveEnvironment: %v", err)
887 }
888 if err := reg.Upsert(registry.Route{
889 Host: "shrink.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
890 Backing: registry.BackingUnit, Environment: "demo",
891 }); err != nil {
892 t.Fatalf("Upsert: %v", err)
893 }
894 if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
895 t.Fatalf("Sync (pass 1): %v", err)
896 }
897 path := filepath.Join(envDir, "shrink.example.com.env")
898 if _, err := UnsetEnvironment(ctx, s, "bab", "keymail", "demo", []string{"GONE"}); err != nil {
899 t.Fatalf("UnsetEnvironment: %v", err)
900 }
901 if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
902 t.Fatalf("Sync (pass 2): %v", err)
903 }
904 if got, _ := os.ReadFile(path); string(got) != "GONE=2\nKEEP=1\nCARLOS_PURGE=header\n" {
905 t.Fatalf("env = %q, want the removal refused while the app has no default bundle", got)
906 }
907 // The moment a default bundle exists the app is bucket-managed and the
908 // merged render is authoritative — the removal lands, no operator
909 // intervention beyond the import the refusal asked for.
910 if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"KEEP": "1"}); err != nil {
911 t.Fatalf("SetEnv: %v", err)
912 }
913 if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
914 t.Fatalf("Sync (pass 3): %v", err)
915 }
916 if got, _ := os.ReadFile(path); string(got) != "KEEP=1\nCARLOS_PURGE=header\n" {
917 t.Fatalf("env = %q, want the removal to land once a default bundle exists", got)
918 }
919}
920
921func TestSyncMergedRenderIsAuthoritativeOnceADefaultBundleExists(t *testing.T) {
922 // The other side of the guard above: once the app HAS a config.json —
923 // even one with every var unset — the operator has adopted bucket config
924 // for it, and the merged render is authoritative over whatever is on
925 // disk. Anything else would freeze a migrated app's file forever.
926 s := release.NewDirStore(t.TempDir())
927 ctx := context.Background()
928 reg := testRegistry(t)
929 envDir := t.TempDir()
930 priv, _, err := GenerateKey()
931 if err != nil {
932 t.Fatalf("GenerateKey: %v", err)
933 }
934 privKey, err := ParsePrivateKey(priv, "test")
935 if err != nil {
936 t.Fatalf("ParsePrivateKey: %v", err)
937 }
938 if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"SEED": "1"}); err != nil {
939 t.Fatalf("SetEnv: %v", err)
940 }
941 if _, err := Unset(ctx, s, "bab", "keymail", []string{"SEED"}); err != nil {
942 t.Fatalf("Unset: %v", err) // object now exists with zero vars
943 }
944 if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
945 "LOG_LEVEL": {Kind: "env", Value: "debug"},
946 }, nil); err != nil {
947 t.Fatalf("SaveEnvironment: %v", err)
948 }
949 if err := reg.Upsert(registry.Route{
950 Host: "prod.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
951 Backing: registry.BackingUnit, Environment: "demo",
952 }); err != nil {
953 t.Fatalf("Upsert: %v", err)
954 }
955 path := filepath.Join(envDir, "prod.example.com.env")
956 if err := os.WriteFile(path, []byte("DB_URL=postgres://prod\n"), 0o600); err != nil {
957 t.Fatalf("stage env file: %v", err)
958 }
959
960 var restarted []string
961 if err := Sync(ctx, reg, s, privKey, envDir, func(h string) error {
962 restarted = append(restarted, h)
963 return nil
964 }, nil); err != nil {
965 t.Fatalf("Sync: %v", err)
966 }
967
968 got, err := os.ReadFile(path)
969 if err != nil {
970 t.Fatalf("read env: %v", err)
971 }
972 if string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
973 t.Fatalf("env = %q, want the merged render to win once a default bundle exists", got)
974 }
975 if len(restarted) != 1 || restarted[0] != "prod.example.com" {
976 t.Fatalf("restarted = %v, want exactly [prod.example.com]", restarted)
977 }
978}
979
980func TestSyncEnvironmentOnlyAppWithNoDefaultBundle(t *testing.T) {
981 // A named environment does not require a default bundle to exist:
982 // LoadMerged resolves to the environment alone, and Sync must converge it
983 // (the object exists, so this is a configured app, not an unconfigured one).
984 s := release.NewDirStore(t.TempDir())
985 ctx := context.Background()
986 reg := testRegistry(t)
987 envDir := t.TempDir()
988 priv, _, err := GenerateKey()
989 if err != nil {
990 t.Fatalf("GenerateKey: %v", err)
991 }
992 privKey, err := ParsePrivateKey(priv, "test")
993 if err != nil {
994 t.Fatalf("ParsePrivateKey: %v", err)
995 }
996 if err := SaveEnvironment(ctx, s, "bab", "keymail", "demo", map[string]Var{
997 "ONLY": {Kind: "env", Value: "here"},
998 }, nil); err != nil {
999 t.Fatalf("SaveEnvironment: %v", err)
1000 }
1001 for _, host := range []string{"demo.example.com", "emptyfile.example.com"} {
1002 if err := reg.Upsert(registry.Route{
1003 Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
1004 Backing: registry.BackingUnit, Environment: "demo",
1005 }); err != nil {
1006 t.Fatalf("Upsert %s: %v", host, err)
1007 }
1008 }
1009 // An EMPTY file on disk is not staged config — there is nothing in it to
1010 // lose, so the no-default guard (CRITICAL 1) must not freeze it.
1011 if err := os.WriteFile(filepath.Join(envDir, "emptyfile.example.com.env"), nil, 0o600); err != nil {
1012 t.Fatalf("stage empty env file: %v", err)
1013 }
1014 if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
1015 t.Fatalf("Sync: %v", err)
1016 }
1017 for _, host := range []string{"demo.example.com", "emptyfile.example.com"} {
1018 got, err := os.ReadFile(filepath.Join(envDir, host+".env"))
1019 if err != nil {
1020 t.Fatalf("%s: read rendered env: %v", host, err)
1021 }
1022 if string(got) != "ONLY=here\nCARLOS_PURGE=header\n" {
1023 t.Fatalf("%s: rendered env = %q, want ONLY=here + CARLOS_PURGE=header", host, got)
1024 }
1025 }
1026}
1027
1028func TestLostKeysIgnoresAgentOwnedVars(t *testing.T) {
1029 // The sync itself appends CARLOS_PURGE=header to every file it writes
1030 // (applyAgentVars). If lostKeys counts it, every defaultMissing route
1031 // the sync previously converged reads as "about to lose a key" and is
1032 // refused forever — the false-refusal loop.
1033 cur := []byte("A=1\nCARLOS_PURGE=header\n")
1034 m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}
1035 lost, parsed := lostKeys(cur, m)
1036 if !parsed || len(lost) != 0 {
1037 t.Fatalf("lost=%v parsed=%v, want none/true", lost, parsed)
1038 }
1039}
1040
1041// TestSyncHostConvergesAgentOwnedVarWithoutRewrite pins the convergence
1042// property applyAgentVars must not break: syncHost's bytes.Equal
1043// short-circuit (sync.go) has to still hold once every render carries an
1044// appended CARLOS_PURGE=header line. The first pass writes the file
1045// (containing the var) and restarts; an identical second pass must leave the
1046// file's mtime and content alone and must NOT call restart again — proof
1047// that applyAgentVars is deterministic and idempotent, not just that its
1048// output happens to look right once.
1049func TestSyncHostConvergesAgentOwnedVarWithoutRewrite(t *testing.T) {
1050 envDir := t.TempDir()
1051 m := &Manifest{Schema: 1, App: "keymail", Vars: map[string]Var{"A": {Kind: "env", Value: "1"}}}
1052 rt := registry.Route{Host: "steady.example.com", Kind: "instance", App: "keymail", Account: "bab", Status: "ready", Backing: registry.BackingUnit}
1053
1054 var restarted int
1055 restart := func(string) error { restarted++; return nil }
1056
1057 syncHost(rt, m, false, nil, envDir, restart, "", nil)
1058 path := filepath.Join(envDir, "steady.example.com.env")
1059 got, err := os.ReadFile(path)
1060 if err != nil {
1061 t.Fatalf("read env after first sync: %v", err)
1062 }
1063 if string(got) != "A=1\nCARLOS_PURGE=header\n" {
1064 t.Fatalf("env = %q, want A=1 + CARLOS_PURGE=header", got)
1065 }
1066 if restarted != 1 {
1067 t.Fatalf("restarted = %d after first sync, want 1", restarted)
1068 }
1069 fi, err := os.Stat(path)
1070 if err != nil {
1071 t.Fatalf("stat after first sync: %v", err)
1072 }
1073
1074 // Second, identical pass.
1075 syncHost(rt, m, false, nil, envDir, restart, "", nil)
1076 if restarted != 1 {
1077 t.Fatalf("restarted = %d after second identical sync, want still 1 (no re-restart)", restarted)
1078 }
1079 now, err := os.Stat(path)
1080 if err != nil {
1081 t.Fatalf("stat after second sync: %v", err)
1082 }
1083 if !now.ModTime().Equal(fi.ModTime()) {
1084 t.Fatalf("env file was rewritten on an unchanged pass (mtime moved)")
1085 }
1086 got2, err := os.ReadFile(path)
1087 if err != nil {
1088 t.Fatalf("read env after second sync: %v", err)
1089 }
1090 if string(got2) != string(got) {
1091 t.Fatalf("second pass content = %q, want unchanged (%q)", got2, got)
1092 }
1093}
1094
1095func TestSyncMaterializesEveryRouteOfAMultiRouteApp(t *testing.T) {
1096 // One app, two simultaneous routes (e.g. a stable route and a canary
1097 // route are two different Hosts) — the shared config must land in
1098 // both routes' env files, per spec §5's per-route-host materialization.
1099 s := release.NewDirStore(t.TempDir())
1100 ctx := context.Background()
1101 reg := testRegistry(t)
1102 envDir := t.TempDir()
1103 priv, _, err := GenerateKey()
1104 if err != nil {
1105 t.Fatalf("GenerateKey: %v", err)
1106 }
1107 privKey, err := ParsePrivateKey(priv, "test")
1108 if err != nil {
1109 t.Fatalf("ParsePrivateKey: %v", err)
1110 }
1111 if err := SetEnv(ctx, s, "bab", "keymail", map[string]string{"LOG_LEVEL": "debug"}); err != nil {
1112 t.Fatalf("SetEnv: %v", err)
1113 }
1114 for _, host := range []string{"stable.keymail.example.com", "canary.keymail.example.com"} {
1115 if err := reg.Upsert(registry.Route{
1116 Host: host, Kind: "instance", App: "keymail", Account: "bab", Status: "ready",
1117 Backing: registry.BackingUnit,
1118 }); err != nil {
1119 t.Fatalf("Upsert %s: %v", host, err)
1120 }
1121 }
1122 if err := Sync(ctx, reg, s, privKey, envDir, func(string) error { return nil }, nil); err != nil {
1123 t.Fatalf("Sync: %v", err)
1124 }
1125 for _, host := range []string{"stable.keymail.example.com", "canary.keymail.example.com"} {
1126 got, err := os.ReadFile(filepath.Join(envDir, host+".env"))
1127 if err != nil {
1128 t.Fatalf("%s: read rendered env: %v", host, err)
1129 }
1130 if string(got) != "LOG_LEVEL=debug\nCARLOS_PURGE=header\n" {
1131 t.Fatalf("%s: rendered env = %q, want LOG_LEVEL=debug + CARLOS_PURGE=header", host, got)
1132 }
1133 }
1134}
1135