main bac21c6

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

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