v3.0.0 f19f453

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_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}

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 7

Tags 106

↑↓ moveenter openesc close