1package appmanifest_test
2
3import (
4 "strings"
5 "testing"
6
7 "github.com/stretchr/testify/assert"
8 "github.com/stretchr/testify/require"
9
10 "github.com/bnema/gordon/internal/adapters/in/appmanifest"
11 "github.com/bnema/gordon/internal/domain"
12)
13
14const validWeb = `
15name = "blog"
16
17[env]
18APP_ENV = "production"
19
20[services.web]
21image = "registry.example.com/blog/web:1.4.2"
22
23[services.web.readiness]
24type = "http"
25path = "/healthz"
26
27[[services.web.http]]
28host = "Blog.Example.COM."
29port = 8080
30
31[services.web.secrets]
32DATABASE_URL = "database-url"
33`
34
35func TestParse_ValidWeb(t *testing.T) {
36 spec, warnings, err := appmanifest.Parse([]byte(validWeb), "blog.toml")
37 require.NoError(t, err)
38 assert.Empty(t, warnings)
39 assert.Equal(t, "blog", spec.Name)
40 assert.Equal(t, "production", spec.Env["APP_ENV"])
41 require.Len(t, spec.Services, 1)
42 svc := spec.Services[0]
43 assert.Equal(t, "web", svc.Name)
44 assert.Equal(t, "registry.example.com/blog/web:1.4.2", svc.Image)
45 assert.Equal(t, "blog.example.com", svc.HTTP[0].Host)
46 assert.Equal(t, "auto", svc.HTTP[0].TLS)
47 assert.Equal(t, "http", svc.Readiness.Type)
48}
49
50func TestParse_SharedNetwork(t *testing.T) {
51 doc := `
52name = "media"
53[services.web]
54image = "registry.example.com/media/web:1"
55[[network.shared]]
56network = "backend"
57services = ["web"]
58aliases = ["media-web"]
59`
60
61 spec, warnings, err := appmanifest.Parse([]byte(doc), "media.toml")
62 require.NoError(t, err)
63 assert.Empty(t, warnings)
64 require.Len(t, spec.Networks, 1)
65 assert.Equal(t, domain.AppSharedNetwork{
66 Network: "backend", Services: []string{"web"}, Aliases: []string{"media-web"},
67 }, spec.Networks[0])
68}
69
70func TestParse_FileNameMismatchIsWarning(t *testing.T) {
71 _, warnings, err := appmanifest.Parse([]byte(validWeb), "other.toml")
72 require.NoError(t, err)
73 require.Len(t, warnings, 1)
74 assert.Contains(t, warnings[0], "other.toml")
75}
76
77func TestParse_MissingTagNormalizesToLatest(t *testing.T) {
78 doc := `
79name = "blog"
80[services.web]
81image = "registry.example.com/blog/web"
82[[services.web.http]]
83host = "blog.example.com"
84port = 8080
85`
86 spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
87 require.NoError(t, err)
88 assert.Equal(t, "registry.example.com/blog/web:latest", spec.Services[0].Image)
89}
90
91func TestParse_UnknownFieldsRejected(t *testing.T) {
92 cases := []struct {
93 name string
94 doc string
95 }{
96 {"top level", "name = \"blog\"\nbogus = 1\n[services.web]\nimage = \"img:1\"\n"},
97 {"service", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\nbogus = 1\n"},
98 {"readiness", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.readiness]\nbogus = 1\n"},
99 {"http", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.http]]\nhost = \"blog.example.com\"\nport = 8080\nbogus = 1\n"},
100 }
101 for _, tc := range cases {
102 t.Run(tc.name, func(t *testing.T) {
103 _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
104 require.Error(t, err)
105 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
106 })
107 }
108}
109
110// TestParse_OldSyntaxRejected proves the retired array-of-tables schema
111// and its name field are no longer accepted, with no compatibility shim.
112func TestParse_OldSyntaxRejected(t *testing.T) {
113 cases := []struct {
114 name string
115 doc string
116 wantErr string
117 }{
118 {
119 "legacy service array",
120 "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n",
121 "[[service]] array-of-tables is a retired app shape",
122 },
123 {
124 "legacy plural service array",
125 "name = \"blog\"\n[[services]]\nname = \"web\"\nimage = \"img:1\"\n",
126 "[[services]] array-of-tables is a retired app shape",
127 },
128 {
129 "service name as field",
130 "name = \"blog\"\n[services.web]\nname = \"web\"\nimage = \"img:1\"\n",
131 "unknown TOML fields or tables: services.web.name",
132 },
133 }
134 for _, tc := range cases {
135 t.Run(tc.name, func(t *testing.T) {
136 _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
137 require.Error(t, err)
138 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
139 assert.ErrorContains(t, err, tc.wantErr)
140 })
141 }
142}
143
144// TestParse_RetiredArraySyntaxHintIsActionable proves the rejected
145// [[service]] and [[services]] shapes carry the same stable keyed-schema
146// remedy instead of leaking go-toml decoding wording.
147func TestParse_RetiredArraySyntaxHintIsActionable(t *testing.T) {
148 docs := map[string]string{
149 "singular": "name = \"blog\"\n[[service]]\nname = \"web\"\nimage = \"img:1\"\n",
150 "plural": "name = \"blog\"\n[[services]]\nname = \"web\"\nimage = \"img:1\"\n",
151 }
152 for name, doc := range docs {
153 t.Run(name, func(t *testing.T) {
154 _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
155 require.Error(t, err)
156 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
157 assert.ErrorContains(t, err, "keyed schema")
158 assert.ErrorContains(t, err, "[services.<name>]")
159 assert.ErrorContains(t, err, "[services.web]")
160 })
161 }
162}
163
164// TestParse_NonArrayTablesKeepPlainDiagnostic proves the keyed-schema hint
165// stays reserved for real array-of-tables shapes: a [service] table, a
166// scalar service value, and a string array under services are not array
167// tables, so they keep the plain unknown-field diagnostic instead of
168// being described as [[service]]/[[services]].
169func TestParse_NonArrayTablesKeepPlainDiagnostic(t *testing.T) {
170 cases := []struct {
171 name string
172 doc string
173 wantErr string
174 }{
175 {
176 "service table",
177 "name = \"blog\"\n[service]\nimage = \"img:1\"\n",
178 "unknown TOML fields or tables: service",
179 },
180 {
181 "service scalar",
182 "name = \"blog\"\nservice = \"web\"\n",
183 "unknown TOML fields or tables: service",
184 },
185 {
186 // services is a keyed table, so an array value fails to decode.
187 // Assert only that the diagnostic names the key, never go-toml's
188 // internal wording.
189 "services string array",
190 "name = \"blog\"\nservices = [\"web\"]\n",
191 "services",
192 },
193 }
194 for _, tc := range cases {
195 t.Run(tc.name, func(t *testing.T) {
196 _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
197 require.Error(t, err)
198 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
199 assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(tc.wantErr))
200 assert.NotContains(t, err.Error(), "array-of-tables is a retired app shape")
201 })
202 }
203}
204
205// TestParse_UnknownNestedFieldRejected proves strict decoding still
206// reports the dotted path of an unknown table or field under a service.
207func TestParse_UnknownNestedFieldRejected(t *testing.T) {
208 cases := []struct {
209 name string
210 doc string
211 wantErr string
212 }{
213 {
214 "unknown protocol table",
215 "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.rcon]]\nport = 28016\n",
216 "unknown TOML fields or tables: services.web.rcon",
217 },
218 {
219 "unknown nested table",
220 "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.extra]\nkey = \"value\"\n",
221 "unknown TOML fields or tables: services.web.extra",
222 },
223 }
224 for _, tc := range cases {
225 t.Run(tc.name, func(t *testing.T) {
226 _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
227 require.Error(t, err)
228 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
229 assert.ErrorContains(t, err, tc.wantErr)
230 })
231 }
232}
233
234// TestParse_QuotedDottedServiceKey proves a service name containing a
235// dot must be quoted, decodes as one key, and is usable by nested tables.
236func TestParse_QuotedDottedServiceKey(t *testing.T) {
237 doc := `
238name = "blog"
239[services."web.api"]
240image = "registry.example.com/blog/api:1"
241[[services."web.api".http]]
242host = "api.example.com"
243port = 8080
244`
245 spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
246 require.NoError(t, err)
247 require.Len(t, spec.Services, 1)
248 assert.Equal(t, "web.api", spec.Services[0].Name)
249 require.Len(t, spec.Services[0].HTTP, 1)
250 assert.Equal(t, "api.example.com", spec.Services[0].HTTP[0].Host)
251}
252
253// TestParse_UnquotedDottedKeyRejected proves an unquoted dotted key is
254// read as nested tables, not as one service name, and is rejected.
255func TestParse_UnquotedDottedKeyRejected(t *testing.T) {
256 doc := `
257name = "blog"
258[services.web.api]
259image = "registry.example.com/blog/api:1"
260`
261 _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
262 require.Error(t, err)
263 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
264 assert.ErrorContains(t, err, "unknown TOML fields or tables: services.web.api")
265}
266
267// TestParse_DuplicateServiceTableRejected proves the TOML decoder rejects
268// a repeated [services.<name>] table before domain validation runs.
269func TestParse_DuplicateServiceTableRejected(t *testing.T) {
270 doc := `
271name = "blog"
272[services.web]
273image = "img:1"
274[services.web]
275image = "img:2"
276`
277 _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
278 require.Error(t, err)
279 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
280 assert.ErrorContains(t, err, "already exists")
281}
282
283// TestParse_ServiceOrderDeterministic proves map keys are sorted before
284// domain conversion so spec.Services order never depends on TOML order.
285func TestParse_ServiceOrderDeterministic(t *testing.T) {
286 doc := `
287name = "blog"
288[services.zeta]
289image = "img:1"
290[services.alpha]
291image = "img:2"
292[services.mid]
293image = "img:3"
294`
295 for i := 0; i < 3; i++ {
296 spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
297 require.NoError(t, err)
298 require.Len(t, spec.Services, 3)
299 assert.Equal(t, []string{"alpha", "mid", "zeta"}, []string{
300 spec.Services[0].Name, spec.Services[1].Name, spec.Services[2].Name,
301 })
302 }
303}
304
305func TestParse_ServiceEnvRejected(t *testing.T) {
306 cases := []struct {
307 name string
308 doc string
309 wantErr string
310 }{
311 {
312 "bare service name",
313 "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"bar\"\n",
314 "[services.web.env]",
315 },
316 {
317 "dotted service name is quoted",
318 "name = \"blog\"\n[services.\"web.api\"]\nimage = \"img:1\"\n[services.\"web.api\".env]\nFOO = \"bar\"\n",
319 `[services."web.api".env]`,
320 },
321 }
322 for _, tc := range cases {
323 t.Run(tc.name, func(t *testing.T) {
324 _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
325 require.Error(t, err)
326 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
327 require.ErrorContains(t, err, tc.wantErr)
328 })
329 }
330}
331
332func TestParse_Names(t *testing.T) {
333 cases := []struct {
334 name string
335 doc string
336 wantErr string
337 }{
338 {"bad app", "name = \"Blog!\"\n[services.web]\nimage = \"img:1\"\n", "app name"},
339 {"double dash app", "name = \"a--b\"\n[services.web]\nimage = \"img:1\"\n", "--"},
340 {"reserved app", "name = \"gordon\"\n[services.web]\nimage = \"img:1\"\n", "reserved"},
341 {"no services", "name = \"blog\"\n", "at least one service"},
342 {"dup service", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web]\nimage = \"img:2\"\n", "already exists"},
343 {"dup service case", "name = \"blog\"\n[services.Web]\nimage = \"img:1\"\n", "must match"},
344 {"normalized collision", "name = \"blog\"\n[services.\"a.b\"]\nimage = \"img:1\"\n[services.\"a-b\"]\nimage = \"img:2\"\n", "same runtime identifier"},
345 {"replicas rejected", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\nreplicas = 2\n", "unknown TOML fields or tables: services.web.replicas"},
346 {"volume double dash", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"},
347 {"shared volume", "name = \"blog\"\n[services.a]\nimage = \"img:1\"\n[[services.a.volume]]\nname = \"d\"\npath = \"/data\"\n[services.b]\nimage = \"img:1\"\n[[services.b.volume]]\nname = \"d\"\npath = \"/data\"\n", "claimed by both"},
348 {"env secret key collision", "name = \"blog\"\n[env]\nDB_PASSWORD = \"x\"\n[services.web]\nimage = \"img:1\"\n[services.web.secrets]\nDB_PASSWORD = \"db-password\"\n", "collides with a secret key"},
349 {"secret ref in env", "name = \"blog\"\n[env]\nFOO = \"${pass:x}\"\n[services.web]\nimage = \"img:1\"\n", "secret references"},
350 }
351 for _, tc := range cases {
352 t.Run(tc.name, func(t *testing.T) {
353 _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
354 require.ErrorContains(t, err, tc.wantErr)
355 })
356 }
357}
358
359func TestParse_Readiness(t *testing.T) {
360 cases := []struct {
361 name string
362 doc string
363 wantErr string
364 }{
365 {"bad type", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntype = \"exec\"\n", "none|tcp|http|log"},
366 {"log needs path", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntype = \"log\"\ncontains = \"x\"\n", "path and contains"},
367 {"http needs path", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[services.w.readiness]\ntype = \"http\"\n", "http readiness requires path"},
368 {"udp tcp rejected", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.udp]]\nentrypoint = \"udp\"\nport = 9000\npublish = \"0.0.0.0:9000\"\n[services.w.readiness]\ntype = \"tcp\"\n", "UDP-only"},
369 {"multi tcp needs port", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[[services.w.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"9000\"\n[services.w.readiness]\ntype = \"tcp\"\n", "readiness.port is required"},
370 {"bad port ref", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"b.example.com\"\nport = 8080\n[services.w.readiness]\ntype = \"tcp\"\nport = 9999\n", "matches no declared container port"},
371 {"bad timeout", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntimeout = \"99h\"\n", "timeout must be within"},
372 }
373 for _, tc := range cases {
374 t.Run(tc.name, func(t *testing.T) {
375 _, _, err := appmanifest.Parse([]byte(tc.doc), "b.toml")
376 require.ErrorContains(t, err, tc.wantErr)
377 })
378 }
379}
380
381func TestParse_Interfaces(t *testing.T) {
382 cases := []struct {
383 name string
384 doc string
385 wantErr string
386 }{
387 {"tcp needs entrypoint", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"},
388 {"publish hostname", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.tcp]]\nentrypoint = \"tcp\"\nport = 9000\npublish = \"example.com:9000\"\n", "literal IP"},
389 {"rcon table rejected", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.rcon]]\nentrypoint = \"tcp\"\nport = 28016\npublish = \"0.0.0.0:28016\"\npublic = true\n", "unknown TOML fields or tables: services.w.rcon"},
390 {"bad host", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"},
391 }
392 for _, tc := range cases {
393 t.Run(tc.name, func(t *testing.T) {
394 _, _, err := appmanifest.Parse([]byte(tc.doc), "b.toml")
395 require.ErrorContains(t, err, tc.wantErr)
396 })
397 }
398}
399
400func TestParse_Backups(t *testing.T) {
401 doc := `
402name = "shop"
403[services.api]
404image = "registry.example.com/shop/api:2.0.0"
405[[services.api.http]]
406host = "shop.example.com"
407port = 8080
408[[services.api.database]]
409name = "main"
410type = "postgres"
411schedule = "daily"
412[services.api.backup]
413postgres = ["main"]
414`
415 spec, _, err := appmanifest.Parse([]byte(doc), "shop.toml")
416 require.NoError(t, err)
417 assert.Equal(t, "daily", spec.Services[0].Databases[0].Schedule)
418
419 badRef := strings.Replace(doc, "postgres = [\"main\"]", "postgres = [\"ghost\"]", 1)
420 _, _, err = appmanifest.Parse([]byte(badRef), "shop.toml")
421 require.ErrorContains(t, err, "unknown database")
422
423 badSchedule := strings.Replace(doc, "schedule = \"daily\"", "schedule = \"minutely\"", 1)
424 _, _, err = appmanifest.Parse([]byte(badSchedule), "shop.toml")
425 require.ErrorContains(t, err, "schedule must be")
426
427 badEngine := strings.Replace(doc, "type = \"postgres\"", "type = \"mysql\"", 1)
428 _, _, err = appmanifest.Parse([]byte(badEngine), "shop.toml")
429 require.ErrorContains(t, err, "must be postgres")
430}
431
432func TestParse_GameAndStagingExamples(t *testing.T) {
433 game := `
434name = "rust"
435[services.server]
436image = "registry.example.com/games/rust:2026.09"
437[services.server.readiness]
438type = "log"
439path = "/data/logs/server.log"
440contains = "Server startup complete"
441timeout = "5m"
442[[services.server.udp]]
443entrypoint = "udp"
444port = 28015
445publish = "0.0.0.0:28015"
446[[services.server.tcp]]
447entrypoint = "tcp"
448port = 28016
449publish = "127.0.0.1:28016"
450[[services.server.volume]]
451name = "rust-data"
452path = "/data"
453`
454 spec, _, err := appmanifest.Parse([]byte(game), "rust.toml")
455 require.NoError(t, err)
456 assert.Equal(t, "log", spec.Services[0].Readiness.Type)
457 require.Len(t, spec.Services[0].TCP, 1)
458 assert.Equal(t, 28016, spec.Services[0].TCP[0].Port)
459
460 staging := `
461name = "blog-staging"
462[env]
463APP_ENV = "staging"
464[services.web]
465image = "registry.example.com/blog/web:1.4.2-rc.1"
466[[services.web.http]]
467host = "staging-blog.example.com"
468port = 8080
469`
470 spec, _, err = appmanifest.Parse([]byte(staging), "blog-staging.toml")
471 require.NoError(t, err)
472 assert.Equal(t, "blog-staging", spec.Name)
473}
474
475func TestParse_NoFileEnvSecretReads(t *testing.T) {
476 // Parser input is bytes only; there is no path parameter that could
477 // trigger file, env, or secret reads beyond the manifest input.
478 spec, _, err := appmanifest.Parse([]byte(validWeb), "")
479 require.NoError(t, err)
480 assert.Equal(t, "blog", spec.Name)
481}
482
483func TestParse_Binds(t *testing.T) {
484 doc := `
485name = "blog"
486[services.web]
487image = "registry.example.com/blog/web:1.4.2"
488[[services.web.bind]]
489name = "config"
490path = "/etc/app.conf"
491readonly = true
492[[services.web.bind]]
493name = "data"
494path = "/var/lib/app"
495`
496 spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
497 require.NoError(t, err)
498 require.Len(t, spec.Services[0].Binds, 2)
499 assert.Equal(t, domain.AppBind{Name: "config", Path: "/etc/app.conf", ReadOnly: true}, spec.Services[0].Binds[0])
500 assert.Equal(t, domain.AppBind{Name: "data", Path: "/var/lib/app"}, spec.Services[0].Binds[1])
501}
502
503func TestParse_BindDestinationRejected(t *testing.T) {
504 doc := `
505name = "blog"
506[services.web]
507image = "registry.example.com/blog/web:1.4.2"
508[[services.web.bind]]
509name = "dev"
510path = "/dev"
511`
512 _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
513 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
514 assert.Contains(t, err.Error(), "sensitive container path")
515}
516
517func TestParse_Devices(t *testing.T) {
518 doc := `
519name = "demo"
520[services.worker]
521image = "registry.example.com/demo/worker:1"
522devices = ["test_gpu"]
523`
524 spec, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
525 require.NoError(t, err)
526 require.Len(t, spec.Services, 1)
527 assert.Equal(t, []string{"test_gpu"}, spec.Services[0].Devices)
528}
529
530func TestParse_DevicesWrongType(t *testing.T) {
531 doc := `
532name = "demo"
533[services.worker]
534image = "registry.example.com/demo/worker:1"
535devices = "test_gpu"
536`
537 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
538 require.Error(t, err)
539}
540
541func TestParse_DevicesMalformedRejected(t *testing.T) {
542 doc := `
543name = "demo"
544[services.worker]
545image = "registry.example.com/demo/worker:1"
546devices = ["Bad Name!"]
547`
548 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
549 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
550}
551
552func TestParse_DevicesDuplicateRejected(t *testing.T) {
553 doc := `
554name = "demo"
555[services.worker]
556image = "registry.example.com/demo/worker:1"
557devices = ["test_gpu", "test_gpu"]
558`
559 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
560 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
561 assert.Contains(t, err.Error(), "duplicate device")
562}
563
564func TestParse_UnknownGPUFieldRejected(t *testing.T) {
565 doc := `
566name = "demo"
567[services.worker]
568image = "registry.example.com/demo/worker:1"
569gpus = "all"
570`
571 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
572 require.Error(t, err)
573}
574
575func TestParse_TelemetryLogsPrecedence(t *testing.T) {
576 const manifest = `
577name = "blog"
578
579[telemetry]
580logs = false
581
582[services.web]
583image = "registry.example.com/blog/web:1"
584
585[services.web.telemetry]
586logs = true
587
588[services.db]
589image = "postgres:17"
590`
591 spec, _, err := appmanifest.Parse([]byte(manifest), "blog.toml")
592 require.NoError(t, err)
593 byName := map[string]domain.AppService{}
594 for _, svc := range spec.Services {
595 byName[svc.Name] = svc
596 }
597 assert.False(t, byName["web"].LogExportDisabled, "service override wins")
598 assert.True(t, byName["db"].LogExportDisabled, "service inherits app")
599}
600
601func TestParse_TelemetryLogsDefaultOn(t *testing.T) {
602 spec, _, err := appmanifest.Parse([]byte(validWeb), "blog.toml")
603 require.NoError(t, err)
604 assert.False(t, spec.Services[0].LogExportDisabled)
605}