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}