v3.3.0 b164fe9

parse.go RawHistoryFolder

1// Package appmanifest decodes standalone app TOML files into
2// normalized domain.AppSpec values. It performs SHAPE decoding only
3// (strict unknown fields, type mapping, defaults); all semantic rules
4// live in domain.AppSpec.Validate. It never reads files, environment,
5// or secrets beyond the manifest bytes given to it.
6package appmanifest
7
8import (
9	"bytes"
10	"errors"
11	"fmt"
12	"maps"
13	"slices"
14	"strings"
15	"time"
16
17	"github.com/pelletier/go-toml/v2"
18
19	"github.com/bnema/gordon/internal/domain"
20)
21
22// rawManifest mirrors the frozen TOML schema for strict decoding.
23// Services is keyed by service name: [services.<name>].
24type rawManifest struct {
25	Name      string                `toml:"name"`
26	Env       map[string]string     `toml:"env"`
27	Services  map[string]rawService `toml:"services"`
28	Network   rawNetwork            `toml:"network"`
29	Telemetry rawTelemetry          `toml:"telemetry"`
30}
31
32// rawTelemetry mirrors [telemetry] and [services.<name>.telemetry].
33// A nil Logs means "inherit": service inherits the app, the app
34// inherits the default (export on).
35type rawTelemetry struct {
36	Logs *bool `toml:"logs"`
37}
38
39// rawNetwork mirrors [network] and its [[network.shared]] children.
40type rawNetwork struct {
41	Shared []rawSharedNetwork `toml:"shared"`
42}
43
44// rawService mirrors one [services.<name>] table. The service name is
45// the table key, so there is no name field inside the table.
46type rawService struct {
47	Image     string            `toml:"image"`
48	Command   []string          `toml:"command"`
49	StopGrace string            `toml:"stop_grace"`
50	Env       map[string]string `toml:"env"`
51	Readiness rawReadiness      `toml:"readiness"`
52	HTTP      []rawHTTP         `toml:"http"`
53	TCP       []rawTCP          `toml:"tcp"`
54	UDP       []rawUDP          `toml:"udp"`
55	// Secrets maps env keys to a secret name string or to a table
56	// { name = "...", as = "env"|"file" }; see toDomainSecrets.
57	Secrets map[string]any `toml:"secrets"`
58	Volumes []rawVolume    `toml:"volume"`
59	Binds   []rawBind      `toml:"bind"`
60	// Devices lists logical device names from `devices = [...]`.
61	Devices   []string      `toml:"devices"`
62	Databases []rawDatabase `toml:"database"`
63	Backup    rawBackup     `toml:"backup"`
64	Telemetry rawTelemetry  `toml:"telemetry"`
65}
66
67// rawReadiness mirrors [services.<name>.readiness].
68type rawReadiness struct {
69	Type     string `toml:"type"`
70	Path     string `toml:"path"`
71	Contains string `toml:"contains"`
72	Port     int    `toml:"port"`
73	Timeout  string `toml:"timeout"`
74}
75
76// rawHTTP mirrors [[services.<name>.http]].
77type rawHTTP struct {
78	Host string `toml:"host"`
79	Port int    `toml:"port"`
80	// TLS uses presence tracking because internal interfaces reject every
81	// explicit value, including an empty string.
82	TLS *string `toml:"tls"`
83	// Visibility is empty when unset; the parser normalizes it to public.
84	Visibility string `toml:"visibility"`
85}
86
87// rawTCP mirrors [[services.<name>.tcp]].
88type rawTCP struct {
89	Entrypoint string `toml:"entrypoint"`
90	Port       int    `toml:"port"`
91	Publish    string `toml:"publish"`
92}
93
94// rawUDP mirrors [[services.<name>.udp]].
95type rawUDP struct {
96	Entrypoint string `toml:"entrypoint"`
97	Port       int    `toml:"port"`
98	Publish    string `toml:"publish"`
99}
100
101// rawVolume mirrors [[services.<name>.volume]].
102type rawVolume struct {
103	Name     string `toml:"name"`
104	Path     string `toml:"path"`
105	ReadOnly bool   `toml:"readonly"`
106}
107
108// rawBind mirrors [[services.<name>.bind]].
109type rawBind struct {
110	Name     string `toml:"name"`
111	Path     string `toml:"path"`
112	ReadOnly bool   `toml:"readonly"`
113}
114
115// rawDatabase mirrors [[services.<name>.database]].
116type rawDatabase struct {
117	Name     string `toml:"name"`
118	Type     string `toml:"type"`
119	Schedule string `toml:"schedule"`
120}
121
122// rawBackup mirrors [services.<name>.backup].
123type rawBackup struct {
124	Postgres []string `toml:"postgres"`
125	Volume   []string `toml:"volume"`
126}
127
128// rawSharedNetwork mirrors [[network.shared]].
129type rawSharedNetwork struct {
130	Network  string   `toml:"network"`
131	Services []string `toml:"services"`
132	Aliases  []string `toml:"aliases"`
133}
134
135// Parse decodes manifest TOML bytes into a validated, normalized AppSpec.
136// sourceName is used for mismatch warnings only; the name field is authoritative.
137func Parse(data []byte, sourceName string) (domain.AppSpec, []string, error) {
138	var raw rawManifest
139	decoder := toml.NewDecoder(bytes.NewReader(data)).DisallowUnknownFields()
140	if err := decoder.Decode(&raw); err != nil {
141		if hint := legacyKeyedSchemaHint(data); hint != "" {
142			return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, hint)
143		}
144		return domain.AppSpec{}, nil, fmt.Errorf("%w: %s", domain.ErrInvalidAppSpec, formatDecodeError(err))
145	}
146	spec, err := toDomain(raw)
147	if err != nil {
148		return domain.AppSpec{}, nil, err
149	}
150	var warnings []string
151	if sourceName != "" && sourceName != spec.Name && sourceName != spec.Name+".toml" {
152		warnings = append(warnings, fmt.Sprintf("file name %q does not match app name %q", sourceName, spec.Name))
153	}
154	if err := spec.Validate(); err != nil {
155		return domain.AppSpec{}, warnings, err
156	}
157	return spec, warnings, nil
158}
159
160func formatDecodeError(err error) string {
161	var strictErr *toml.StrictMissingError
162	if !errors.As(err, &strictErr) {
163		return err.Error()
164	}
165	unknown := make([]string, 0, len(strictErr.Errors))
166	for i := range strictErr.Errors {
167		key := strictErr.Errors[i].Key()
168		if len(key) > 0 {
169			unknown = append(unknown, strings.Join(key, "."))
170		}
171	}
172	if len(unknown) == 0 {
173		return strictErr.Error()
174	}
175	return "unknown TOML fields or tables: " + strings.Join(unknown, ", ")
176}
177
178// legacyKeyedSchemaHint recognizes the retired array-of-tables app shapes
179// ([[service]] and [[services]]) and returns a stable, actionable
180// diagnostic. It inspects the loosely decoded document rather than
181// matching go-toml error wording, which is not a stable contract.
182func legacyKeyedSchemaHint(data []byte) string {
183	var doc map[string]any
184	if err := toml.Unmarshal(data, &doc); err != nil {
185		return ""
186	}
187	const fix = "array-of-tables is a retired app shape; use one [services.<name>] table per service (keyed schema), e.g. [services.web]"
188	switch {
189	case isArrayTable(doc["service"]):
190		return "[[service]] " + fix
191	case isArrayTable(doc["services"]):
192		return "[[services]] " + fix
193	default:
194		return ""
195	}
196}
197
198// isArrayTable reports whether a loosely decoded value is the [[name]]
199// TOML shape: a non-empty array whose elements are all tables. Scalar
200// values, string arrays, and keyed tables are not array tables, so they
201// keep the plain unknown-field diagnostic.
202func isArrayTable(v any) bool {
203	tables, ok := v.([]any)
204	if !ok || len(tables) == 0 {
205		return false
206	}
207	for _, table := range tables {
208		if _, ok := table.(map[string]any); !ok {
209			return false
210		}
211	}
212	return true
213}
214
215// toDomain maps raw TOML onto domain types with normalization and defaults.
216func toDomain(raw rawManifest) (domain.AppSpec, error) {
217	spec := domain.AppSpec{
218		Name:     raw.Name,
219		Env:      map[string]string{},
220		Services: make([]domain.AppService, 0, len(raw.Services)),
221		Networks: make([]domain.AppSharedNetwork, 0, len(raw.Network.Shared)),
222	}
223	for key, value := range raw.Env {
224		spec.Env[key] = value
225	}
226	// Iterate the service map in sorted key order so that domain
227	// conversion and every downstream projection are deterministic
228	// regardless of TOML declaration order.
229	for _, name := range slices.Sorted(maps.Keys(raw.Services)) {
230		svc, err := toDomainService(name, raw.Services[name])
231		if err != nil {
232			return domain.AppSpec{}, err
233		}
234		svc.LogExportDisabled = !resolveLogExport(raw.Telemetry.Logs, raw.Services[name].Telemetry.Logs)
235		spec.Services = append(spec.Services, svc)
236	}
237	for _, net := range raw.Network.Shared {
238		spec.Networks = append(spec.Networks, domain.AppSharedNetwork{
239			Network:  net.Network,
240			Services: append([]string(nil), net.Services...),
241			Aliases:  append([]string(nil), net.Aliases...),
242		})
243	}
244	return spec, nil
245}
246
247// resolveLogExport applies the precedence service > app > default (on).
248func resolveLogExport(app, service *bool) bool {
249	if service != nil {
250		return *service
251	}
252	if app != nil {
253		return *app
254	}
255	return true
256}
257
258func toDomainHTTP(service string, h rawHTTP) (domain.AppHTTPInterface, error) {
259	visibility := h.Visibility
260	if visibility == "" {
261		visibility = domain.AppVisibilityPublic
262	}
263	if visibility == domain.AppVisibilityInternal && h.TLS != nil {
264		return domain.AppHTTPInterface{}, fmt.Errorf("%w: service %q internal http port %d must not declare tls", domain.ErrInvalidAppSpec, service, h.Port)
265	}
266	tls := ""
267	if h.TLS != nil {
268		tls = *h.TLS
269	} else if visibility == domain.AppVisibilityPublic {
270		tls = domain.AppTLSAuto
271	}
272	return domain.AppHTTPInterface{
273		Host: domain.CanonicalHTTPHost(h.Host), Port: h.Port, TLS: tls, Visibility: visibility,
274	}, nil
275}
276
277// toDomainService maps one raw service with defaults. serviceName is the
278// [services.<name>] table key and becomes the service identity.
279func toDomainService(serviceName string, raw rawService) (domain.AppService, error) {
280	svc := domain.AppService{
281		Name:    serviceName,
282		Image:   normalizeImage(raw.Image),
283		Command: append([]string(nil), raw.Command...),
284		Secrets: map[string]string{},
285		Backup: domain.AppBackup{
286			Postgres: append([]string(nil), raw.Backup.Postgres...),
287			Volume:   append([]string(nil), raw.Backup.Volume...),
288		},
289	}
290	stopGrace := domain.AppDefaultStopGrace
291	if raw.StopGrace != "" {
292		parsed, err := time.ParseDuration(raw.StopGrace)
293		if err != nil {
294			return domain.AppService{}, fmt.Errorf("%w: service %q stop_grace %q is invalid: %v", domain.ErrInvalidAppSpec, serviceName, raw.StopGrace, err)
295		}
296		stopGrace = parsed
297	}
298	svc.StopGrace = stopGrace
299	readiness, err := toDomainReadiness(serviceName, raw.Readiness)
300	if err != nil {
301		return domain.AppService{}, err
302	}
303	svc.Readiness = readiness
304	for _, h := range raw.HTTP {
305		iface, err := toDomainHTTP(serviceName, h)
306		if err != nil {
307			return domain.AppService{}, err
308		}
309		svc.HTTP = append(svc.HTTP, iface)
310	}
311	for _, t := range raw.TCP {
312		svc.TCP = append(svc.TCP, domain.AppTCPInterface{
313			Entrypoint: t.Entrypoint,
314			Port:       t.Port,
315			Publish:    t.Publish,
316		})
317	}
318	for _, u := range raw.UDP {
319		svc.UDP = append(svc.UDP, domain.AppUDPInterface{
320			Entrypoint: u.Entrypoint,
321			Port:       u.Port,
322			Publish:    u.Publish,
323		})
324	}
325	svc.Env = maps.Clone(raw.Env)
326	secrets, secretFiles, err := toDomainSecrets(serviceName, raw.Secrets)
327	if err != nil {
328		return domain.AppService{}, err
329	}
330	svc.Secrets = secrets
331	svc.SecretFiles = secretFiles
332	for _, v := range raw.Volumes {
333		svc.Volumes = append(svc.Volumes, domain.AppVolume{
334			Name:     v.Name,
335			Path:     v.Path,
336			ReadOnly: v.ReadOnly,
337		})
338	}
339	for _, b := range raw.Binds {
340		svc.Binds = append(svc.Binds, domain.AppBind{
341			Name:     b.Name,
342			Path:     b.Path,
343			ReadOnly: b.ReadOnly,
344		})
345	}
346	// Preserve manifest order; domain equalDevices treats the slice as a
347	// set, so reorder-only input is a no-op in diffs.
348	svc.Devices = append([]string(nil), raw.Devices...)
349	for _, db := range raw.Databases {
350		svc.Databases = append(svc.Databases, domain.AppDatabase{
351			Name:     db.Name,
352			Type:     db.Type,
353			Schedule: db.Schedule,
354		})
355	}
356	return svc, nil
357}
358
359// Secret delivery modes accepted in a secret table's `as` key.
360const (
361	secretAsEnv  = "env"
362	secretAsFile = "file"
363)
364
365// toDomainSecrets maps [services.<name>.secrets]. Each value is either a
366// secret name string (env delivery) or a table with a required `name`
367// and an optional `as` ("env" or "file", default "env"). It returns the
368// env key -> secret name map and the sorted file-delivered keys (nil
369// when none).
370func toDomainSecrets(service string, raw map[string]any) (map[string]string, []string, error) {
371	secrets := make(map[string]string, len(raw))
372	var files []string
373	for _, key := range slices.Sorted(maps.Keys(raw)) {
374		name, asFile, err := toDomainSecret(raw[key])
375		if err != nil {
376			return nil, nil, fmt.Errorf("%w: service %q secret %q: %s", domain.ErrInvalidAppSpec, service, key, err)
377		}
378		secrets[key] = name
379		if asFile {
380			files = append(files, key)
381		}
382	}
383	return secrets, files, nil
384}
385
386// toDomainSecret decodes one secret value and reports whether it is
387// delivered as a file.
388func toDomainSecret(value any) (string, bool, error) {
389	switch v := value.(type) {
390	case string:
391		return v, false, nil
392	case map[string]any:
393		return toDomainSecretTable(v)
394	default:
395		return "", false, errors.New("value must be a secret name string or a { name, as } table")
396	}
397}
398
399func toDomainSecretTable(table map[string]any) (string, bool, error) {
400	for _, key := range slices.Sorted(maps.Keys(table)) {
401		if key != "name" && key != "as" {
402			return "", false, fmt.Errorf("unknown key %q (allowed: name, as)", key)
403		}
404	}
405	name, ok := table["name"].(string)
406	if !ok || name == "" {
407		return "", false, errors.New("name must be a non-empty string")
408	}
409	rawAs, present := table["as"]
410	if !present {
411		return name, false, nil
412	}
413	as, ok := rawAs.(string)
414	if !ok || (as != secretAsEnv && as != secretAsFile) {
415		return "", false, fmt.Errorf("as must be %q or %q", secretAsEnv, secretAsFile)
416	}
417	return name, as == secretAsFile, nil
418}
419
420// toDomainReadiness maps readiness with defaults.
421func toDomainReadiness(service string, raw rawReadiness) (domain.AppReadiness, error) {
422	readiness := domain.AppReadiness{
423		Type:     raw.Type,
424		Path:     raw.Path,
425		Contains: raw.Contains,
426		Port:     raw.Port,
427		Timeout:  domain.AppDefaultReadinessTimeout,
428	}
429	if readiness.Type == "" {
430		readiness.Type = domain.AppReadinessNone
431	}
432	if raw.Timeout != "" {
433		parsed, err := time.ParseDuration(raw.Timeout)
434		if err != nil {
435			return domain.AppReadiness{}, fmt.Errorf("%w: service %q readiness timeout %q is invalid: %v", domain.ErrInvalidAppSpec, service, raw.Timeout, err)
436		}
437		readiness.Timeout = parsed
438	}
439	return readiness, nil
440}
441
442// normalizeImage records a missing tag as explicit :latest.
443func normalizeImage(image string) string {
444	image = strings.TrimSpace(image)
445	if image == "" {
446		return image
447	}
448	if strings.Contains(image, "@") {
449		return image
450	}
451	lastSlash := strings.LastIndex(image, "/")
452	lastColon := strings.LastIndex(image, ":")
453	if lastColon <= lastSlash {
454		return image + ":latest"
455	}
456	return image
457}

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