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}