v3.0.0 f19f453

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

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