v3.3.0 b164fe9

parse_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 106

↑↓ moveenter openesc close