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}