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_ServiceEnv(t *testing.T) {
306 doc := "name = \"blog\"\n[env]\nLOG = \"info\"\n[services.web]\nimage = \"img:1\"\n" +
307 "[services.web.env]\nLOG = \"debug\"\nPORT = \"3000\"\n[services.web.secrets]\nDB = \"db\"\n" +
308 "[services.worker]\nimage = \"img:1\"\n"
309 spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
310 require.NoError(t, err)
311 require.Len(t, spec.Services, 2)
312 assert.Equal(t, map[string]string{"LOG": "info"}, spec.Env)
313 assert.Equal(t, map[string]string{"LOG": "debug", "PORT": "3000"}, spec.Services[0].Env)
314 assert.Empty(t, spec.Services[1].Env)
315}
316
317func TestParse_ServiceEnvInvalid(t *testing.T) {
318 cases := []struct {
319 name string
320 doc string
321 wantErr string
322 }{
323 {
324 "secret reference",
325 "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"${pass:x}\"\n",
326 "secret references",
327 },
328 {
329 "secret key collision",
330 "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nDB = \"x\"\n[services.web.secrets]\nDB = \"db\"\n",
331 "collides with a secret key",
332 },
333 {
334 "newline",
335 "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web.env]\nFOO = \"a\\nb\"\n",
336 "newlines",
337 },
338 }
339 for _, tc := range cases {
340 t.Run(tc.name, func(t *testing.T) {
341 _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
342 require.Error(t, err)
343 assert.ErrorIs(t, err, domain.ErrInvalidAppSpec)
344 require.ErrorContains(t, err, tc.wantErr)
345 })
346 }
347}
348
349func TestParse_Names(t *testing.T) {
350 cases := []struct {
351 name string
352 doc string
353 wantErr string
354 }{
355 {"bad app", "name = \"Blog!\"\n[services.web]\nimage = \"img:1\"\n", "app name"},
356 {"double dash app", "name = \"a--b\"\n[services.web]\nimage = \"img:1\"\n", "--"},
357 {"reserved app", "name = \"gordon\"\n[services.web]\nimage = \"img:1\"\n", "reserved"},
358 {"no services", "name = \"blog\"\n", "at least one service"},
359 {"dup service", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[services.web]\nimage = \"img:2\"\n", "already exists"},
360 {"dup service case", "name = \"blog\"\n[services.Web]\nimage = \"img:1\"\n", "must match"},
361 {"normalized collision", "name = \"blog\"\n[services.\"a.b\"]\nimage = \"img:1\"\n[services.\"a-b\"]\nimage = \"img:2\"\n", "same runtime identifier"},
362 {"replicas rejected", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\nreplicas = 2\n", "unknown TOML fields or tables: services.web.replicas"},
363 {"volume double dash", "name = \"blog\"\n[services.web]\nimage = \"img:1\"\n[[services.web.volume]]\nname = \"a--b\"\npath = \"/data\"\n", "--"},
364 {"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"},
365 {"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"},
366 {"secret ref in env", "name = \"blog\"\n[env]\nFOO = \"${pass:x}\"\n[services.web]\nimage = \"img:1\"\n", "secret references"},
367 }
368 for _, tc := range cases {
369 t.Run(tc.name, func(t *testing.T) {
370 _, _, err := appmanifest.Parse([]byte(tc.doc), "blog.toml")
371 require.ErrorContains(t, err, tc.wantErr)
372 })
373 }
374}
375
376func TestParse_Readiness(t *testing.T) {
377 cases := []struct {
378 name string
379 doc string
380 wantErr string
381 }{
382 {"bad type", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntype = \"exec\"\n", "none|tcp|http|log"},
383 {"log needs path", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntype = \"log\"\ncontains = \"x\"\n", "path and contains"},
384 {"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"},
385 {"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"},
386 {"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"},
387 {"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"},
388 {"bad timeout", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[services.w.readiness]\ntimeout = \"99h\"\n", "timeout must be within"},
389 }
390 for _, tc := range cases {
391 t.Run(tc.name, func(t *testing.T) {
392 _, _, err := appmanifest.Parse([]byte(tc.doc), "b.toml")
393 require.ErrorContains(t, err, tc.wantErr)
394 })
395 }
396}
397
398func TestParse_Interfaces(t *testing.T) {
399 cases := []struct {
400 name string
401 doc string
402 wantErr string
403 }{
404 {"tcp needs entrypoint", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.tcp]]\nport = 9000\npublish = \"9000\"\n", "entrypoint is required"},
405 {"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"},
406 {"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"},
407 {"bad host", "name = \"b\"\n[services.w]\nimage = \"i:1\"\n[[services.w.http]]\nhost = \"localhost\"\nport = 8080\n", "not a valid public hostname"},
408 }
409 for _, tc := range cases {
410 t.Run(tc.name, func(t *testing.T) {
411 _, _, err := appmanifest.Parse([]byte(tc.doc), "b.toml")
412 require.ErrorContains(t, err, tc.wantErr)
413 })
414 }
415}
416
417func TestParse_Backups(t *testing.T) {
418 doc := `
419name = "shop"
420[services.api]
421image = "registry.example.com/shop/api:2.0.0"
422[[services.api.http]]
423host = "shop.example.com"
424port = 8080
425[[services.api.database]]
426name = "main"
427type = "postgres"
428schedule = "daily"
429[services.api.backup]
430postgres = ["main"]
431`
432 spec, _, err := appmanifest.Parse([]byte(doc), "shop.toml")
433 require.NoError(t, err)
434 assert.Equal(t, "daily", spec.Services[0].Databases[0].Schedule)
435
436 badRef := strings.Replace(doc, "postgres = [\"main\"]", "postgres = [\"ghost\"]", 1)
437 _, _, err = appmanifest.Parse([]byte(badRef), "shop.toml")
438 require.ErrorContains(t, err, "unknown database")
439
440 badSchedule := strings.Replace(doc, "schedule = \"daily\"", "schedule = \"minutely\"", 1)
441 _, _, err = appmanifest.Parse([]byte(badSchedule), "shop.toml")
442 require.ErrorContains(t, err, "schedule must be")
443
444 badEngine := strings.Replace(doc, "type = \"postgres\"", "type = \"mysql\"", 1)
445 _, _, err = appmanifest.Parse([]byte(badEngine), "shop.toml")
446 require.ErrorContains(t, err, "must be postgres")
447}
448
449func TestParse_GameAndStagingExamples(t *testing.T) {
450 game := `
451name = "rust"
452[services.server]
453image = "registry.example.com/games/rust:2026.09"
454[services.server.readiness]
455type = "log"
456path = "/data/logs/server.log"
457contains = "Server startup complete"
458timeout = "5m"
459[[services.server.udp]]
460entrypoint = "udp"
461port = 28015
462publish = "0.0.0.0:28015"
463[[services.server.tcp]]
464entrypoint = "tcp"
465port = 28016
466publish = "127.0.0.1:28016"
467[[services.server.volume]]
468name = "rust-data"
469path = "/data"
470`
471 spec, _, err := appmanifest.Parse([]byte(game), "rust.toml")
472 require.NoError(t, err)
473 assert.Equal(t, "log", spec.Services[0].Readiness.Type)
474 require.Len(t, spec.Services[0].TCP, 1)
475 assert.Equal(t, 28016, spec.Services[0].TCP[0].Port)
476
477 staging := `
478name = "blog-staging"
479[env]
480APP_ENV = "staging"
481[services.web]
482image = "registry.example.com/blog/web:1.4.2-rc.1"
483[[services.web.http]]
484host = "staging-blog.example.com"
485port = 8080
486`
487 spec, _, err = appmanifest.Parse([]byte(staging), "blog-staging.toml")
488 require.NoError(t, err)
489 assert.Equal(t, "blog-staging", spec.Name)
490}
491
492func TestParse_NoFileEnvSecretReads(t *testing.T) {
493 // Parser input is bytes only; there is no path parameter that could
494 // trigger file, env, or secret reads beyond the manifest input.
495 spec, _, err := appmanifest.Parse([]byte(validWeb), "")
496 require.NoError(t, err)
497 assert.Equal(t, "blog", spec.Name)
498}
499
500func TestParse_Binds(t *testing.T) {
501 doc := `
502name = "blog"
503[services.web]
504image = "registry.example.com/blog/web:1.4.2"
505[[services.web.bind]]
506name = "config"
507path = "/etc/app.conf"
508readonly = true
509[[services.web.bind]]
510name = "data"
511path = "/var/lib/app"
512`
513 spec, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
514 require.NoError(t, err)
515 require.Len(t, spec.Services[0].Binds, 2)
516 assert.Equal(t, domain.AppBind{Name: "config", Path: "/etc/app.conf", ReadOnly: true}, spec.Services[0].Binds[0])
517 assert.Equal(t, domain.AppBind{Name: "data", Path: "/var/lib/app"}, spec.Services[0].Binds[1])
518}
519
520func TestParse_BindDestinationRejected(t *testing.T) {
521 doc := `
522name = "blog"
523[services.web]
524image = "registry.example.com/blog/web:1.4.2"
525[[services.web.bind]]
526name = "dev"
527path = "/dev"
528`
529 _, _, err := appmanifest.Parse([]byte(doc), "blog.toml")
530 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
531 assert.Contains(t, err.Error(), "sensitive container path")
532}
533
534func TestParse_Devices(t *testing.T) {
535 doc := `
536name = "demo"
537[services.worker]
538image = "registry.example.com/demo/worker:1"
539devices = ["test_gpu"]
540`
541 spec, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
542 require.NoError(t, err)
543 require.Len(t, spec.Services, 1)
544 assert.Equal(t, []string{"test_gpu"}, spec.Services[0].Devices)
545}
546
547func TestParse_DevicesWrongType(t *testing.T) {
548 doc := `
549name = "demo"
550[services.worker]
551image = "registry.example.com/demo/worker:1"
552devices = "test_gpu"
553`
554 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
555 require.Error(t, err)
556}
557
558func TestParse_DevicesMalformedRejected(t *testing.T) {
559 doc := `
560name = "demo"
561[services.worker]
562image = "registry.example.com/demo/worker:1"
563devices = ["Bad Name!"]
564`
565 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
566 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
567}
568
569func TestParse_DevicesDuplicateRejected(t *testing.T) {
570 doc := `
571name = "demo"
572[services.worker]
573image = "registry.example.com/demo/worker:1"
574devices = ["test_gpu", "test_gpu"]
575`
576 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
577 require.ErrorIs(t, err, domain.ErrInvalidAppSpec)
578 assert.Contains(t, err.Error(), "duplicate device")
579}
580
581func TestParse_UnknownGPUFieldRejected(t *testing.T) {
582 doc := `
583name = "demo"
584[services.worker]
585image = "registry.example.com/demo/worker:1"
586gpus = "all"
587`
588 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
589 require.Error(t, err)
590}
591
592func TestParse_TelemetryLogsPrecedence(t *testing.T) {
593 const manifest = `
594name = "blog"
595
596[telemetry]
597logs = false
598
599[services.web]
600image = "registry.example.com/blog/web:1"
601
602[services.web.telemetry]
603logs = true
604
605[services.db]
606image = "postgres:17"
607`
608 spec, _, err := appmanifest.Parse([]byte(manifest), "blog.toml")
609 require.NoError(t, err)
610 byName := map[string]domain.AppService{}
611 for _, svc := range spec.Services {
612 byName[svc.Name] = svc
613 }
614 assert.False(t, byName["web"].LogExportDisabled, "service override wins")
615 assert.True(t, byName["db"].LogExportDisabled, "service inherits app")
616}
617
618func TestParse_TelemetryLogsDefaultOn(t *testing.T) {
619 spec, _, err := appmanifest.Parse([]byte(validWeb), "blog.toml")
620 require.NoError(t, err)
621 assert.False(t, spec.Services[0].LogExportDisabled)
622}
623
624func TestParse_Capabilities(t *testing.T) {
625 doc := `
626name = "demo"
627[services.worker]
628image = "registry.example.com/demo/worker:1"
629capabilities = ["net-admin", "ipc-lock"]
630`
631 spec, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
632 require.NoError(t, err)
633 require.Len(t, spec.Services, 1)
634 assert.Equal(t, []string{"net-admin", "ipc-lock"}, spec.Services[0].Capabilities)
635}
636
637func TestParse_CapabilitiesWrongType(t *testing.T) {
638 doc := `
639name = "demo"
640[services.worker]
641image = "registry.example.com/demo/worker:1"
642capabilities = "net-admin"
643`
644 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
645 require.Error(t, err)
646}
647
648func TestParse_CapabilitiesMalformedAndDuplicateRejected(t *testing.T) {
649 for _, list := range []string{`["Bad Name!"]`, `["NET_ADMIN"]`, `["net-admin", "net-admin"]`} {
650 doc := `
651name = "demo"
652[services.worker]
653image = "registry.example.com/demo/worker:1"
654capabilities = ` + list + `
655`
656 _, _, err := appmanifest.Parse([]byte(doc), "demo.toml")
657 require.ErrorIs(t, err, domain.ErrInvalidAppSpec, list)
658 }
659}