1package domain
2
3import (
4 "fmt"
5 "net"
6 "path"
7 "reflect"
8 "regexp"
9 "slices"
10 "sort"
11 "strconv"
12 "strings"
13 "time"
14)
15
16// App manifest validation is owned by the domain.
17// The parser adapter (internal/adapters/in/appmanifest) decodes TOML
18// into these types; all semantic rules live here, not in the parser.
19
20var (
21 appNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
22 serviceNamePattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9_.\-]{0,61}[a-z0-9])?$`)
23 dnsLabelPattern = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
24)
25
26// Reserved app names that collide with installation identity.
27var reservedAppNames = map[string]struct{}{
28 "gordon": {},
29 "registry": {},
30 "admin": {},
31 "localhost": {},
32}
33
34// App readiness types. HTTP is new in v3.
35const (
36 AppReadinessNone = "none"
37 AppReadinessTCP = "tcp"
38 AppReadinessHTTP = "http"
39 AppReadinessLog = "log"
40)
41
42// App TLS modes for HTTP interfaces.
43const (
44 AppTLSAuto = "auto"
45 AppTLSAlways = "always"
46 AppTLSNever = "never"
47)
48
49// App visibility modes for HTTP interfaces. public keeps the historical
50// host/TLS plane; internal is reachable only from the app private network.
51const (
52 AppVisibilityPublic = "public"
53 AppVisibilityInternal = "internal"
54)
55
56// App database engines. Only postgres in v3.
57const (
58 AppDBPostgres = "postgres"
59)
60
61// Backup schedules reuse the BackupSchedule vocabulary.
62var appBackupSchedules = map[string]struct{}{
63 "hourly": {},
64 "daily": {},
65 "weekly": {},
66 "monthly": {},
67}
68
69// App size/time bounds frozen by the contract.
70const (
71 // MaxAppEnvValueLen caps a single [env] value.
72 MaxAppEnvValueLen = 64 * 1024
73 // AppDefaultReadinessTimeout applies when timeout is unset.
74 AppDefaultReadinessTimeout = 30 * time.Second
75 // AppMinReadinessTimeout bounds readiness timeout below.
76 AppMinReadinessTimeout = time.Second
77 // AppMaxReadinessTimeout bounds readiness timeout above.
78 AppMaxReadinessTimeout = 10 * time.Minute
79 // AppDefaultStopGrace applies when stop_grace is unset.
80 AppDefaultStopGrace = 30 * time.Second
81 // AppMaxStopGrace caps stop_grace.
82 AppMaxStopGrace = 5 * time.Minute
83)
84
85// AppSpec is the normalized form of one app manifest file.
86type AppSpec struct {
87 Name string
88 Env map[string]string
89 Services []AppService
90 Networks []AppSharedNetwork
91}
92
93// AppService is one explicitly named image-backed service.
94// v3 runs exactly one container per service: no replicas field.
95// RCON is ordinary TCP: use TCP interfaces, no special RCON kind.
96type AppService struct {
97 Name string
98 Image string
99 Command []string
100 StopGrace time.Duration
101 Readiness AppReadiness
102 HTTP []AppHTTPInterface
103 TCP []AppTCPInterface
104 UDP []AppUDPInterface
105 Secrets map[string]string
106 Volumes []AppVolume
107 Binds []AppBind
108 // Devices lists logical device names granted by administrative
109 // [app_devices] policy. Names resolve to CDI device IDs at activation
110 // time; the logical names (not host resolution) persist in revisions.
111 Devices []string
112 Databases []AppDatabase
113 Backup AppBackup
114}
115
116// AppReadiness is the explicit readiness check for a service.
117type AppReadiness struct {
118 Type string
119 Path string
120 Contains string
121 Port int
122 Timeout time.Duration
123}
124
125// AppHTTPInterface is one HTTP service interface. Visibility is explicit
126// interface state: public HTTP keeps the historical host/TLS behavior,
127// internal HTTP declares neither host nor tls and is reachable only from
128// the app private network.
129type AppHTTPInterface struct {
130 Host string
131 Port int
132 TLS string
133 // Visibility is public or internal. The zero value normalizes to
134 // public when read or projected, so manifests and stored state
135 // written before visibility existed keep their meaning.
136 Visibility string
137}
138
139// EffectiveVisibility normalizes absent visibility to public.
140func (h AppHTTPInterface) EffectiveVisibility() string {
141 if h.Visibility == "" {
142 return AppVisibilityPublic
143 }
144 return h.Visibility
145}
146
147// IsPublic reports whether the interface is served on the public plane.
148func (h AppHTTPInterface) IsPublic() bool {
149 return h.EffectiveVisibility() == AppVisibilityPublic
150}
151
152// IsInternal reports whether the interface is reachable only from the
153// app private network.
154func (h AppHTTPInterface) IsInternal() bool {
155 return h.EffectiveVisibility() == AppVisibilityInternal
156}
157
158// IsPublicHTTP reports whether a service declares at least one
159// effective-public HTTP interface.
160func (s AppService) IsPublicHTTP() bool {
161 for _, h := range s.HTTP {
162 if h.IsPublic() {
163 return true
164 }
165 }
166 return false
167}
168
169// InternallyOnlyPort reports whether port is declared by internal HTTP
170// interfaces and by no externally backed HTTP/TCP interface. Such a port
171// is reached over the private network only and must never gain a host
172// publication, including through readiness metadata.
173func (s AppService) InternallyOnlyPort(port int) bool {
174 internal := false
175 for _, h := range s.HTTP {
176 if h.Port != port {
177 continue
178 }
179 if !h.IsInternal() {
180 return false
181 }
182 internal = true
183 }
184 for _, t := range s.TCP {
185 if t.Port == port {
186 return false
187 }
188 }
189 return internal
190}
191
192// AppTCPInterface is one TCP service interface.
193type AppTCPInterface struct {
194 Entrypoint string
195 Port int
196 Publish string
197}
198
199// AppUDPInterface is one UDP service interface.
200type AppUDPInterface struct {
201 Entrypoint string
202 Port int
203 Publish string
204}
205
206// AppVolume is one named volume mount.
207type AppVolume struct {
208 Name string
209 Path string
210 ReadOnly bool
211}
212
213// AppBind is one named read-only host bind mount.
214// Name is the stable identity reused across deploys; Path is the
215// container destination.
216type AppBind struct {
217 Name string
218 Path string
219 ReadOnly bool
220}
221
222// AppDatabase is one explicit database declaration.
223type AppDatabase struct {
224 Name string
225 Type string
226 Schedule string
227}
228
229// AppBackup declares backup targets by reference.
230type AppBackup struct {
231 Postgres []string
232 Volume []string
233}
234
235// AppSharedNetwork is one shared-network membership declaration.
236type AppSharedNetwork struct {
237 Network string
238 Services []string
239 Aliases []string
240}
241
242// ValidateAppName checks the app identity rules.
243func ValidateAppName(name string) error {
244 if !appNamePattern.MatchString(name) {
245 return fmt.Errorf("%w: app name %q must be a DNS label (lowercase alphanumerics and hyphens, max 63)", ErrInvalidAppSpec, name)
246 }
247 if strings.Contains(name, "--") {
248 return fmt.Errorf("%w: app name %q must not contain -- (reserved separator)", ErrInvalidAppSpec, name)
249 }
250 if _, reserved := reservedAppNames[strings.ToLower(name)]; reserved {
251 return fmt.Errorf("%w: app name %q is reserved", ErrInvalidAppSpec, name)
252 }
253 return nil
254}
255
256// ValidateServiceName checks the service identity rules.
257func ValidateServiceName(name string) error {
258 if !serviceNamePattern.MatchString(name) {
259 return fmt.Errorf("%w: service name %q must match [a-z0-9_.-], max 63", ErrInvalidAppSpec, name)
260 }
261 return nil
262}
263
264// ValidateVolumeName checks volume names (service charset plus -- ban).
265func ValidateVolumeName(name string) error {
266 if err := ValidateServiceName(name); err != nil {
267 return err
268 }
269 if strings.Contains(name, "--") {
270 return fmt.Errorf("%w: volume name %q must not contain -- (reserved separator)", ErrInvalidAppSpec, name)
271 }
272 return nil
273}
274
275// ValidateSecretName checks service-local secret names.
276func ValidateSecretName(name string) error {
277 return ValidateServiceName(name)
278}
279
280// ValidateBindName checks the stable bind identity (service charset plus -- ban).
281func ValidateBindName(name string) error {
282 if !serviceNamePattern.MatchString(name) {
283 return fmt.Errorf("%w: bind name %q must match [a-z0-9_.-], max 63", ErrInvalidAppSpec, name)
284 }
285 if strings.Contains(name, "--") {
286 return fmt.Errorf("%w: bind name %q must not contain -- (reserved separator)", ErrInvalidAppSpec, name)
287 }
288 return nil
289}
290
291// sensitiveBindDestinations lists container paths a manifest bind must never
292// shadow. Server policy may refuse more, but these are always sensitive.
293var sensitiveBindDestinations = map[string]struct{}{
294 "/": {},
295 "/proc": {},
296 "/sys": {},
297 "/dev": {},
298 "/boot": {},
299}
300
301// IsSensitiveBindDestination reports whether dest is a reserved container path
302// or lies below one. Mounting a child such as /proc/self is as dangerous as
303// shadowing the reserved root itself.
304func IsSensitiveBindDestination(dest string) bool {
305 if dest == "/" {
306 return true
307 }
308 for reserved := range sensitiveBindDestinations {
309 if reserved != "/" && (dest == reserved || strings.HasPrefix(dest, reserved+"/")) {
310 return true
311 }
312 }
313 return false
314}
315
316// ValidateBindDestination checks one bind destination: absolute, clean,
317// normalized, and never a sensitive container path. It is pure and reusable
318// by manifest validation and by server-side source policy.
319func ValidateBindDestination(dest string) error {
320 if dest == "" {
321 return fmt.Errorf("%w: bind destination must not be empty", ErrInvalidAppSpec)
322 }
323 if !path.IsAbs(dest) {
324 return fmt.Errorf("%w: bind destination %q must be absolute", ErrInvalidAppSpec, dest)
325 }
326 if path.Clean(dest) != dest {
327 return fmt.Errorf("%w: bind destination %q must be a normalized clean path", ErrInvalidAppSpec, dest)
328 }
329 if IsSensitiveBindDestination(dest) {
330 return fmt.Errorf("%w: bind destination %q is a sensitive container path", ErrInvalidAppSpec, dest)
331 }
332 return nil
333}
334
335// NormalizeServiceName applies the runtime-identifier normalization.
336func NormalizeServiceName(name string) string {
337 return strings.NewReplacer(".", "-", "_", "-", "/", "-").Replace(name)
338}
339
340// LogicalServiceIdentity returns the stable gordon-<app>--<service> identity.
341func LogicalServiceIdentity(app, service string) string {
342 return "gordon-" + app + "--" + NormalizeServiceName(service)
343}
344
345// RuntimeVolumeName returns the generated gordon-<app>--<service>--vol--<name>.
346func RuntimeVolumeName(app, service, volume string) string {
347 return LogicalServiceIdentity(app, service) + "--vol--" + volume
348}
349
350// AppSecretPath returns the pass path gordon/apps/<app>/<service>/<name>.
351// Prefer AppSecretPathForID when the stable internal UUID is known:
352// the UUID-keyed path survives name reuse without implicit adoption.
353func AppSecretPath(app, service, name string) string {
354 return AppSecretPathForID(app, app, service, name)
355}
356
357// AppSecretPathForID returns the pass path for one secret. id is the
358// stable internal UUID; app is the public name used as a fallback when
359// id is empty (legacy records written before UUID assignment). New
360// writes must always pass the record ID so a removed app's secrets are
361// never implicitly adopted by a new app reusing the name.
362func AppSecretPathForID(id, app, service, name string) string {
363 key := id
364 if key == "" {
365 key = app
366 }
367 return "gordon/apps/" + key + "/" + NormalizeServiceName(service) + "/" + name
368}
369
370// CanonicalHTTPHost lowercases and strips a trailing dot.
371func CanonicalHTTPHost(host string) string {
372 host = strings.ToLower(strings.TrimSpace(host))
373 return strings.TrimSuffix(host, ".")
374}
375
376// ParsePublish splits a publish bind into host and port.
377// Acceptable forms: "IP:port" or "port". Hostnames are rejected.
378func ParsePublish(publish string) (string, int, error) {
379 publish = strings.TrimSpace(publish)
380 if publish == "" {
381 return "", 0, fmt.Errorf("%w: publish must not be empty", ErrInvalidAppSpec)
382 }
383 host := ""
384 portText := publish
385 if h, p, err := net.SplitHostPort(publish); err == nil {
386 host = h
387 portText = p
388 }
389 if host != "" && net.ParseIP(host) == nil {
390 return "", 0, fmt.Errorf("%w: publish host %q must be a literal IP, not a hostname", ErrInvalidAppSpec, host)
391 }
392 port, err := strconv.Atoi(portText)
393 if err != nil || port < 1 || port > 65535 {
394 return "", 0, fmt.Errorf("%w: publish port %q must be 1-65535", ErrInvalidAppSpec, portText)
395 }
396 return host, port, nil
397}
398
399// Validate checks the full normalized spec.
400func (s AppSpec) Validate() error {
401 return s.validateFull()
402}
403
404// CheckEnvSecretCollisions re-validates [env]/secret key disjointness on a
405// stored spec. Deployment preflight calls it defensively before any
406// workload mutation; overlapping keys are invalid with no precedence.
407func (s AppSpec) CheckEnvSecretCollisions() error {
408 return checkEnvSecretKeyDisjoint(s)
409}
410
411// validateFull runs the full normalized validation.
412func (s AppSpec) validateFull() error {
413 if err := ValidateAppName(s.Name); err != nil {
414 return err
415 }
416 if len(s.Services) == 0 {
417 return fmt.Errorf("%w: app %q must declare at least one service", ErrInvalidAppSpec, s.Name)
418 }
419 if err := s.validateEnv(); err != nil {
420 return err
421 }
422 if err := s.validateServices(); err != nil {
423 return err
424 }
425 if err := checkEnvSecretKeyDisjoint(s); err != nil {
426 return err
427 }
428 if err := checkVolumeOwnership(s); err != nil {
429 return err
430 }
431 for i := range s.Networks {
432 if err := s.Networks[i].validate(s); err != nil {
433 return err
434 }
435 }
436 return nil
437}
438
439// validateEnv checks app-wide public environment values.
440func (s AppSpec) validateEnv() error {
441 for key, value := range s.Env {
442 if err := ValidateEnvKey(key); err != nil {
443 return fmt.Errorf("%w: env key %q: %v", ErrInvalidAppSpec, key, err)
444 }
445 if strings.Contains(value, "\n") {
446 return fmt.Errorf("%w: env value for %q must not contain newlines", ErrInvalidAppSpec, key)
447 }
448 if len(value) == 0 || len(value) > MaxAppEnvValueLen {
449 return fmt.Errorf("%w: env value for %q must be 1-%d bytes", ErrInvalidAppSpec, key, MaxAppEnvValueLen)
450 }
451 if ContainsSecretReference(value) {
452 return fmt.Errorf("%w: env value for %q must not contain secret references", ErrInvalidAppSpec, key)
453 }
454 }
455 return nil
456}
457
458// validateServices checks service identity uniqueness and delegates per-service rules.
459func (s AppSpec) validateServices() error {
460 seenServices := map[string]struct{}{}
461 seenNormalized := map[string]string{}
462 for i := range s.Services {
463 svc := &s.Services[i]
464 if err := ValidateServiceName(svc.Name); err != nil {
465 return err
466 }
467 if _, ok := seenServices[svc.Name]; ok {
468 return fmt.Errorf("%w: duplicate service name %q", ErrInvalidAppSpec, svc.Name)
469 }
470 seenServices[svc.Name] = struct{}{}
471 normalized := NormalizeServiceName(svc.Name)
472 if prev, ok := seenNormalized[normalized]; ok {
473 return fmt.Errorf("%w: service name %q normalizes to the same runtime identifier as %q", ErrInvalidAppSpec, svc.Name, prev)
474 }
475 seenNormalized[normalized] = svc.Name
476 if err := svc.validate(); err != nil {
477 return err
478 }
479 }
480 return nil
481}
482
483// checkEnvSecretKeyDisjoint enforces [env] keys disjoint from secret map keys.
484func checkEnvSecretKeyDisjoint(s AppSpec) error {
485 for key := range s.Env {
486 for _, svc := range s.Services {
487 if _, ok := svc.Secrets[key]; ok {
488 return fmt.Errorf("%w: env key %q collides with a secret key in service %q", ErrInvalidAppSpec, key, svc.Name)
489 }
490 }
491 }
492 return nil
493}
494
495// checkVolumeOwnership enforces one claimant per volume name.
496func checkVolumeOwnership(s AppSpec) error {
497 claimants := map[string]string{}
498 for _, svc := range s.Services {
499 for _, vol := range svc.Volumes {
500 if prev, ok := claimants[vol.Name]; ok {
501 return fmt.Errorf("%w: volume %q claimed by both %q and %q", ErrInvalidAppSpec, vol.Name, prev, svc.Name)
502 }
503 claimants[vol.Name] = svc.Name
504 }
505 }
506 return nil
507}
508
509// validate checks one service.
510func (s *AppService) validate() error {
511 if strings.TrimSpace(s.Image) == "" {
512 return fmt.Errorf("%w: service %q requires an image", ErrInvalidAppSpec, s.Name)
513 }
514 if s.StopGrace <= 0 || s.StopGrace > AppMaxStopGrace {
515 return fmt.Errorf("%w: service %q stop_grace must be within (0, 5m]", ErrInvalidAppSpec, s.Name)
516 }
517 if err := s.Readiness.validate(s); err != nil {
518 return err
519 }
520 if err := s.validateInterfaces(); err != nil {
521 return err
522 }
523 if err := s.validateSecrets(); err != nil {
524 return err
525 }
526 volumes, err := s.validateVolumes()
527 if err != nil {
528 return err
529 }
530 if err := s.validateBinds(); err != nil {
531 return err
532 }
533 if err := s.validateDevices(); err != nil {
534 return err
535 }
536 if err := s.validateDatabases(volumes); err != nil {
537 return err
538 }
539 return nil
540}
541
542// validateInterfaces checks HTTP/TCP/UDP entries and their port ownership.
543func (s *AppService) validateInterfaces() error {
544 for i := range s.HTTP {
545 if err := s.HTTP[i].validate(s.Name); err != nil {
546 return err
547 }
548 }
549 for i := range s.TCP {
550 if err := s.TCP[i].validate(s.Name); err != nil {
551 return err
552 }
553 }
554 for i := range s.UDP {
555 if err := s.UDP[i].validate(s.Name); err != nil {
556 return err
557 }
558 }
559 return s.validateInterfacePorts()
560}
561
562// validateInterfacePorts rejects one container port claimed by both an
563// internal HTTP interface and an externally backed HTTP/TCP interface:
564// publication is socket-level, so publishing the port would expose the
565// internal listener beyond the private network. Internal ports must also
566// be declared at most once, since they carry no host to tell them apart.
567func (s *AppService) validateInterfacePorts() error {
568 external := map[int]string{}
569 for _, h := range s.HTTP {
570 if h.IsPublic() {
571 external[h.Port] = "http"
572 }
573 }
574 for _, t := range s.TCP {
575 external[t.Port] = "tcp"
576 }
577 seenInternal := map[int]struct{}{}
578 for _, h := range s.HTTP {
579 if !h.IsInternal() {
580 continue
581 }
582 if kind, ok := external[h.Port]; ok {
583 return fmt.Errorf("%w: service %q internal http port %d is also declared by an externally backed %s interface", ErrInvalidAppSpec, s.Name, h.Port, kind)
584 }
585 if _, ok := seenInternal[h.Port]; ok {
586 return fmt.Errorf("%w: service %q internal http port %d is declared more than once", ErrInvalidAppSpec, s.Name, h.Port)
587 }
588 seenInternal[h.Port] = struct{}{}
589 }
590 return nil
591}
592
593// validateSecrets checks the ENV-key to secret-name map.
594func (s *AppService) validateSecrets() error {
595 for envKey, secretName := range s.Secrets {
596 if err := ValidateEnvKey(envKey); err != nil {
597 return fmt.Errorf("%w: service %q secret env key %q: %v", ErrInvalidAppSpec, s.Name, envKey, err)
598 }
599 if err := ValidateSecretName(secretName); err != nil {
600 return fmt.Errorf("%w: service %q secret name %q: %v", ErrInvalidAppSpec, s.Name, secretName, err)
601 }
602 }
603 return nil
604}
605
606// validateVolumes checks volume declarations and returns known names.
607func (s *AppService) validateVolumes() (map[string]struct{}, error) {
608 seenVolumes := map[string]struct{}{}
609 for i := range s.Volumes {
610 vol := &s.Volumes[i]
611 if err := ValidateVolumeName(vol.Name); err != nil {
612 return nil, fmt.Errorf("%w: service %q: %v", ErrInvalidAppSpec, s.Name, err)
613 }
614 if _, ok := seenVolumes[vol.Name]; ok {
615 return nil, fmt.Errorf("%w: service %q duplicate volume %q", ErrInvalidAppSpec, s.Name, vol.Name)
616 }
617 seenVolumes[vol.Name] = struct{}{}
618 if !path.IsAbs(vol.Path) || path.Clean(vol.Path) != vol.Path {
619 return nil, fmt.Errorf("%w: service %q volume %q path must be absolute and normalized", ErrInvalidAppSpec, s.Name, vol.Name)
620 }
621 }
622 return seenVolumes, nil
623}
624
625// validateBinds checks bind names, destinations, duplicates, and volume collisions.
626func (s *AppService) validateBinds() error {
627 volumePaths := make(map[string]struct{}, len(s.Volumes))
628 for _, vol := range s.Volumes {
629 volumePaths[vol.Path] = struct{}{}
630 }
631 seenNames := map[string]struct{}{}
632 seenDests := map[string]struct{}{}
633 for i := range s.Binds {
634 bind := &s.Binds[i]
635 if err := ValidateBindName(bind.Name); err != nil {
636 return fmt.Errorf("%w: service %q: %v", ErrInvalidAppSpec, s.Name, err)
637 }
638 if _, ok := seenNames[bind.Name]; ok {
639 return fmt.Errorf("%w: service %q duplicate bind name %q", ErrInvalidAppSpec, s.Name, bind.Name)
640 }
641 seenNames[bind.Name] = struct{}{}
642 if err := ValidateBindDestination(bind.Path); err != nil {
643 return fmt.Errorf("%w: service %q bind %q: %v", ErrInvalidAppSpec, s.Name, bind.Name, err)
644 }
645 if _, ok := seenDests[bind.Path]; ok {
646 return fmt.Errorf("%w: service %q duplicate bind destination %q", ErrInvalidAppSpec, s.Name, bind.Path)
647 }
648 seenDests[bind.Path] = struct{}{}
649 if _, ok := volumePaths[bind.Path]; ok {
650 return fmt.Errorf("%w: service %q bind destination %q collides with a declared volume", ErrInvalidAppSpec, s.Name, bind.Path)
651 }
652 }
653 return nil
654}
655
656// validateDevices checks logical device names and duplicates. Authorization
657// against administrative policy happens at apply/deploy time, not here:
658// the manifest shape stays valid while policy may refuse to serve it.
659func (s *AppService) validateDevices() error {
660 seen := map[string]struct{}{}
661 for _, name := range s.Devices {
662 if err := ValidateDeviceName(name); err != nil {
663 return fmt.Errorf("%w: service %q: %v", ErrInvalidAppSpec, s.Name, err)
664 }
665 if _, ok := seen[name]; ok {
666 return fmt.Errorf("%w: service %q duplicate device %q", ErrInvalidAppSpec, s.Name, name)
667 }
668 seen[name] = struct{}{}
669 }
670 return nil
671}
672
673// validateDatabases checks database declarations and backup references.
674func (s *AppService) validateDatabases(seenVolumes map[string]struct{}) error {
675 seenDBs := map[string]struct{}{}
676 for i := range s.Databases {
677 db := &s.Databases[i]
678 if strings.TrimSpace(db.Name) == "" {
679 return fmt.Errorf("%w: service %q database name must not be empty", ErrInvalidAppSpec, s.Name)
680 }
681 if _, ok := seenDBs[db.Name]; ok {
682 return fmt.Errorf("%w: service %q duplicate database %q", ErrInvalidAppSpec, s.Name, db.Name)
683 }
684 seenDBs[db.Name] = struct{}{}
685 if db.Type != AppDBPostgres {
686 return fmt.Errorf("%w: service %q database %q type must be postgres", ErrInvalidAppSpec, s.Name, db.Name)
687 }
688 if _, ok := appBackupSchedules[db.Schedule]; !ok {
689 return fmt.Errorf("%w: service %q database %q schedule must be hourly|daily|weekly|monthly", ErrInvalidAppSpec, s.Name, db.Name)
690 }
691 }
692 for _, ref := range s.Backup.Postgres {
693 if _, ok := seenDBs[ref]; !ok {
694 return fmt.Errorf("%w: service %q backup references unknown database %q", ErrInvalidAppSpec, s.Name, ref)
695 }
696 }
697 for _, ref := range s.Backup.Volume {
698 if _, ok := seenVolumes[ref]; !ok {
699 return fmt.Errorf("%w: service %q backup references unknown volume %q", ErrInvalidAppSpec, s.Name, ref)
700 }
701 }
702 return nil
703}
704
705// validate checks readiness rules including UDP restrictions and port selection.
706func (r AppReadiness) validate(svc *AppService) error {
707 if err := r.validateType(svc); err != nil {
708 return err
709 }
710 if r.Timeout < AppMinReadinessTimeout || r.Timeout > AppMaxReadinessTimeout {
711 return fmt.Errorf("%w: service %q readiness timeout must be within [1s, 10m]", ErrInvalidAppSpec, svc.Name)
712 }
713 return nil
714}
715
716// validateType checks the type-specific readiness constraints.
717func (r AppReadiness) validateType(svc *AppService) error {
718 switch r.Type {
719 case "", AppReadinessNone:
720 if r.Path != "" || r.Contains != "" || r.Port != 0 {
721 return fmt.Errorf("%w: service %q readiness none takes no path/contains/port", ErrInvalidAppSpec, svc.Name)
722 }
723 return nil
724 case AppReadinessTCP, AppReadinessHTTP:
725 return r.validateL4(svc)
726 case AppReadinessLog:
727 if strings.TrimSpace(r.Path) == "" || strings.TrimSpace(r.Contains) == "" {
728 return fmt.Errorf("%w: service %q log readiness requires path and contains", ErrInvalidAppSpec, svc.Name)
729 }
730 return nil
731 default:
732 return fmt.Errorf("%w: service %q readiness type must be none|tcp|http|log", ErrInvalidAppSpec, svc.Name)
733 }
734}
735
736// validateL4 checks TCP/HTTP readiness including UDP exclusion and port selection.
737func (r AppReadiness) validateL4(svc *AppService) error {
738 if hasUDPOnly(svc) {
739 return fmt.Errorf("%w: service %q with UDP-only interfaces must use none or log readiness", ErrInvalidAppSpec, svc.Name)
740 }
741 if r.Port == 0 && readinessPortRequired(svc) {
742 return fmt.Errorf("%w: service %q interfaces do not select one readiness port, readiness.port is required", ErrInvalidAppSpec, svc.Name)
743 }
744 if r.Port != 0 && !hasTCPContainerPort(svc, r.Port) {
745 return fmt.Errorf("%w: service %q readiness.port %d matches no declared container port", ErrInvalidAppSpec, svc.Name, r.Port)
746 }
747 if r.Type == AppReadinessHTTP {
748 if err := ValidateReadinessPath(r.Path); err != nil {
749 return fmt.Errorf("%w: service %q: %s", ErrInvalidAppSpec, svc.Name, err)
750 }
751 }
752 return nil
753}
754
755// ValidateReadinessPath requires an origin-form request path: a single
756// leading slash, no scheme or authority, and no control characters. The
757// path is appended to an immutable loopback URL, so rejecting everything
758// that could be read as a different authority keeps the probe on the
759// declared backend. Surrounding whitespace is rejected rather than
760// trimmed: the stored path is used verbatim, and a silently trimmed probe
761// would target a different path than the manifest declares.
762func ValidateReadinessPath(path string) error {
763 if strings.TrimSpace(path) == "" {
764 return fmt.Errorf("http readiness requires path")
765 }
766 if path != strings.TrimSpace(path) {
767 return fmt.Errorf("http readiness path must not have leading or trailing whitespace")
768 }
769 if !strings.HasPrefix(path, "/") || strings.HasPrefix(path, "//") {
770 return fmt.Errorf("http readiness path must be an origin-form path beginning with one slash")
771 }
772 for _, r := range path {
773 if r < 0x20 || r == 0x7f {
774 return fmt.Errorf("http readiness path must not contain control characters")
775 }
776 }
777 return nil
778}
779
780// hasUDPOnly reports services with UDP interfaces and no TCP/HTTP interface.
781func hasUDPOnly(svc *AppService) bool {
782 if len(svc.UDP) == 0 {
783 return false
784 }
785 return len(svc.HTTP) == 0 && len(svc.TCP) == 0
786}
787
788// readinessPortRequired reports whether an omitted readiness.port leaves
789// the probe target ambiguous. One effective-public HTTP port stays
790// selectable even when internal HTTP interfaces are also declared, and a
791// lone TCP-capable interface selects itself. Several public HTTP ports, any
792// mix of TCP and HTTP, or several TCP interfaces must name it explicitly.
793func readinessPortRequired(svc *AppService) bool {
794 if _, ok := singleEffectivePublicHTTPPort(svc); ok {
795 return false
796 }
797 return countTCPInterfaces(svc) > 1
798}
799
800// singleEffectivePublicHTTPPort returns the only distinct effective-public
801// HTTP container port when the service declares at least one and no TCP
802// interface. Internal HTTP interfaces never contribute: they are not
803// reachable through a host publication, so they cannot make the public
804// backend ambiguous.
805func singleEffectivePublicHTTPPort(svc *AppService) (int, bool) {
806 if len(svc.TCP) > 0 {
807 return 0, false
808 }
809 port := 0
810 for _, h := range svc.HTTP {
811 if !h.IsPublic() {
812 continue
813 }
814 if port != 0 && port != h.Port {
815 return 0, false
816 }
817 port = h.Port
818 }
819 if port == 0 {
820 return 0, false
821 }
822 return port, true
823}
824
825// countTCPInterfaces counts http/tcp container ports.
826func countTCPInterfaces(svc *AppService) int {
827 return len(svc.HTTP) + len(svc.TCP)
828}
829
830// hasTCPContainerPort checks readiness.port against declared ports.
831func hasTCPContainerPort(svc *AppService, port int) bool {
832 for _, h := range svc.HTTP {
833 if h.Port == port {
834 return true
835 }
836 }
837 for _, t := range svc.TCP {
838 if t.Port == port {
839 return true
840 }
841 }
842 return false
843}
844
845// validate checks one HTTP interface against its visibility. Public
846// interfaces keep the historical host/TLS rules; internal interfaces are
847// private-network only and must not claim a host or a TLS mode.
848func (h AppHTTPInterface) validate(service string) error {
849 switch h.EffectiveVisibility() {
850 case AppVisibilityPublic:
851 if h.Host == "" {
852 return fmt.Errorf("%w: service %q http host is required", ErrInvalidAppSpec, service)
853 }
854 if _, ok := CanonicalRouteDomain(h.Host); !ok {
855 return fmt.Errorf("%w: service %q http host %q is not a valid public hostname", ErrInvalidAppSpec, service, h.Host)
856 }
857 if h.Port < 1 || h.Port > 65535 {
858 return fmt.Errorf("%w: service %q http port must be 1-65535", ErrInvalidAppSpec, service)
859 }
860 switch h.TLS {
861 case AppTLSAuto, AppTLSAlways, AppTLSNever:
862 default:
863 return fmt.Errorf("%w: service %q http tls must be auto|always|never", ErrInvalidAppSpec, service)
864 }
865 case AppVisibilityInternal:
866 if h.Host != "" {
867 return fmt.Errorf("%w: service %q internal http interface must not declare host", ErrInvalidAppSpec, service)
868 }
869 if h.TLS != "" {
870 return fmt.Errorf("%w: service %q internal http interface must not declare tls", ErrInvalidAppSpec, service)
871 }
872 if h.Port < 1 || h.Port > 65535 {
873 return fmt.Errorf("%w: service %q http port must be 1-65535", ErrInvalidAppSpec, service)
874 }
875 default:
876 return fmt.Errorf("%w: service %q http visibility must be public|internal", ErrInvalidAppSpec, service)
877 }
878 return nil
879}
880
881// validate checks one TCP interface.
882func (t AppTCPInterface) validate(service string) error {
883 if strings.TrimSpace(t.Entrypoint) == "" {
884 return fmt.Errorf("%w: service %q tcp entrypoint is required", ErrInvalidAppSpec, service)
885 }
886 if t.Port < 1 || t.Port > 65535 {
887 return fmt.Errorf("%w: service %q tcp port must be 1-65535", ErrInvalidAppSpec, service)
888 }
889 if _, _, err := ParsePublish(t.Publish); err != nil {
890 return fmt.Errorf("%w: service %q tcp: %v", ErrInvalidAppSpec, service, err)
891 }
892 return nil
893}
894
895// validate checks one UDP interface.
896func (u AppUDPInterface) validate(service string) error {
897 if strings.TrimSpace(u.Entrypoint) == "" {
898 return fmt.Errorf("%w: service %q udp entrypoint is required", ErrInvalidAppSpec, service)
899 }
900 if u.Port < 1 || u.Port > 65535 {
901 return fmt.Errorf("%w: service %q udp port must be 1-65535", ErrInvalidAppSpec, service)
902 }
903 if _, _, err := ParsePublish(u.Publish); err != nil {
904 return fmt.Errorf("%w: service %q udp: %v", ErrInvalidAppSpec, service, err)
905 }
906 return nil
907}
908
909// validate checks one shared-network declaration.
910func (n AppSharedNetwork) validate(s AppSpec) error {
911 if strings.TrimSpace(n.Network) == "" {
912 return fmt.Errorf("%w: shared network name must not be empty", ErrInvalidAppSpec)
913 }
914 if len(n.Services) == 0 {
915 return fmt.Errorf("%w: shared network %q must list at least one service", ErrInvalidAppSpec, n.Network)
916 }
917 seen := map[string]struct{}{}
918 for _, name := range n.Services {
919 if _, ok := seen[name]; ok {
920 return fmt.Errorf("%w: shared network %q duplicate service %q", ErrInvalidAppSpec, n.Network, name)
921 }
922 seen[name] = struct{}{}
923 found := false
924 for _, svc := range s.Services {
925 if svc.Name == name {
926 found = true
927 break
928 }
929 }
930 if !found {
931 return fmt.Errorf("%w: shared network %q references unknown service %q", ErrInvalidAppSpec, n.Network, name)
932 }
933 }
934 for _, alias := range n.Aliases {
935 if !dnsLabelPattern.MatchString(alias) {
936 return fmt.Errorf("%w: shared network %q alias %q must be a DNS label", ErrInvalidAppSpec, n.Network, alias)
937 }
938 }
939 return nil
940}
941
942// AppDiff describes normalized differences between desired and effective specs.
943type AppDiff struct {
944 Added []string
945 Removed []string
946 Changed []string
947}
948
949// DiffAppSpec returns a deterministic, stably sorted diff.
950// It carries no secret values: only secret PATHS appear.
951func DiffAppSpec(desired, effective AppSpec) AppDiff {
952 diff := AppDiff{}
953 desiredServices := map[string]AppService{}
954 for _, svc := range desired.Services {
955 desiredServices[svc.Name] = svc
956 }
957 effectiveServices := map[string]AppService{}
958 for _, svc := range effective.Services {
959 effectiveServices[svc.Name] = svc
960 }
961 for name := range desiredServices {
962 if _, ok := effectiveServices[name]; !ok {
963 diff.Added = append(diff.Added, "service/"+name)
964 }
965 }
966 for name := range effectiveServices {
967 if _, ok := desiredServices[name]; !ok {
968 diff.Removed = append(diff.Removed, "service/"+name)
969 }
970 }
971 for name, desiredSvc := range desiredServices {
972 effectiveSvc, ok := effectiveServices[name]
973 if !ok {
974 continue
975 }
976 diff.Changed = append(diff.Changed, diffService(name, desiredSvc, effectiveSvc)...)
977 }
978 if !equalStringMaps(desired.Env, effective.Env) {
979 diff.Changed = append(diff.Changed, "env")
980 }
981 diff.Changed = append(diff.Changed, diffNetworks(desired.Networks, effective.Networks)...)
982 sort.Strings(diff.Added)
983 sort.Strings(diff.Removed)
984 sort.Strings(diff.Changed)
985 return diff
986}
987
988func diffNetworks(desired, effective []AppSharedNetwork) []string {
989 desiredByName := make(map[string]AppSharedNetwork, len(desired))
990 for _, network := range desired {
991 desiredByName[network.Network] = network
992 }
993 effectiveByName := make(map[string]AppSharedNetwork, len(effective))
994 for _, network := range effective {
995 effectiveByName[network.Network] = network
996 }
997
998 var changed []string
999 for name, network := range desiredByName {
1000 previous, ok := effectiveByName[name]
1001 switch {
1002 case !ok:
1003 changed = append(changed, "network/"+name+"/added")
1004 case !equalStringSets(network.Services, previous.Services):
1005 changed = append(changed, "network/"+name+"/services")
1006 case !equalStringSets(network.Aliases, previous.Aliases):
1007 changed = append(changed, "network/"+name+"/aliases")
1008 }
1009 }
1010 for name := range effectiveByName {
1011 if _, ok := desiredByName[name]; !ok {
1012 changed = append(changed, "network/"+name+"/removed")
1013 }
1014 }
1015 return changed
1016}
1017
1018func equalStringSets(a, b []string) bool {
1019 if len(a) != len(b) {
1020 return false
1021 }
1022 counts := make(map[string]int, len(a))
1023 for _, value := range a {
1024 counts[value]++
1025 }
1026 for _, value := range b {
1027 counts[value]--
1028 if counts[value] < 0 {
1029 return false
1030 }
1031 }
1032 return true
1033}
1034
1035// diffService compares two services field by field.
1036func diffService(name string, desired, effective AppService) []string {
1037 var changed []string
1038 if desired.Image != effective.Image {
1039 changed = append(changed, "service/"+name+"/image")
1040 }
1041 if strings.Join(desired.Command, "\x00") != strings.Join(effective.Command, "\x00") {
1042 changed = append(changed, "service/"+name+"/command")
1043 }
1044 if desired.StopGrace != effective.StopGrace {
1045 changed = append(changed, "service/"+name+"/stop_grace")
1046 }
1047 if desired.Readiness != effective.Readiness {
1048 changed = append(changed, "service/"+name+"/readiness")
1049 }
1050 if len(desired.HTTP) != len(effective.HTTP) || len(desired.TCP) != len(effective.TCP) ||
1051 len(desired.UDP) != len(effective.UDP) {
1052 changed = append(changed, "service/"+name+"/interfaces")
1053 } else if interfacesChanged(desired, effective) {
1054 changed = append(changed, "service/"+name+"/interfaces")
1055 }
1056 if !equalStringMaps(desired.Secrets, effective.Secrets) {
1057 for path := range secretPathChanges(desired, effective) {
1058 changed = append(changed, path)
1059 }
1060 }
1061 if !equalVolumes(desired.Volumes, effective.Volumes) {
1062 changed = append(changed, "service/"+name+"/volumes")
1063 }
1064 if !equalBinds(desired.Binds, effective.Binds) {
1065 changed = append(changed, "service/"+name+"/binds")
1066 }
1067 changed = appendDeviceChanges(changed, name, desired.Devices, effective.Devices)
1068 if !reflect.DeepEqual(desired.Databases, effective.Databases) {
1069 changed = append(changed, "service/"+name+"/databases")
1070 }
1071 if !reflect.DeepEqual(desired.Backup, effective.Backup) {
1072 changed = append(changed, "service/"+name+"/backup")
1073 }
1074 return changed
1075}
1076
1077// secretPathChanges lists secret PATH changes (never values).
1078func secretPathChanges(desired, effective AppService) map[string]struct{} {
1079 paths := map[string]struct{}{}
1080 for envKey, secretName := range desired.Secrets {
1081 if effective.Secrets[envKey] != secretName {
1082 paths["service/"+desired.Name+"/secret/"+secretName] = struct{}{}
1083 }
1084 }
1085 for envKey, secretName := range effective.Secrets {
1086 if desired.Secrets[envKey] != secretName {
1087 paths["service/"+effective.Name+"/secret/"+secretName] = struct{}{}
1088 }
1089 }
1090 return paths
1091}
1092
1093// interfacesChanged compares interface slices.
1094func interfacesChanged(desired, effective AppService) bool {
1095 for i := range desired.HTTP {
1096 if !httpInterfacesEqual(desired.HTTP[i], effective.HTTP[i]) {
1097 return true
1098 }
1099 }
1100 for i := range desired.TCP {
1101 if desired.TCP[i] != effective.TCP[i] {
1102 return true
1103 }
1104 }
1105 for i := range desired.UDP {
1106 if desired.UDP[i] != effective.UDP[i] {
1107 return true
1108 }
1109 }
1110 return false
1111}
1112
1113// httpInterfacesEqual compares two HTTP interfaces with visibility
1114// normalized. State written before the field existed reads as the zero
1115// value and must equal an explicit public, while a real public/internal
1116// flip is still a change.
1117func httpInterfacesEqual(a, b AppHTTPInterface) bool {
1118 if a.EffectiveVisibility() != b.EffectiveVisibility() {
1119 return false
1120 }
1121 a.Visibility, b.Visibility = "", ""
1122 return a == b
1123}
1124
1125// equalStringMaps compares string maps.
1126func equalStringMaps(a, b map[string]string) bool {
1127 if len(a) != len(b) {
1128 return false
1129 }
1130 for k, v := range a {
1131 if b[k] != v {
1132 return false
1133 }
1134 }
1135 return true
1136}
1137
1138// equalVolumes compares volume slices by value.
1139func equalVolumes(a, b []AppVolume) bool {
1140 if len(a) != len(b) {
1141 return false
1142 }
1143 for i := range a {
1144 if a[i] != b[i] {
1145 return false
1146 }
1147 }
1148 return true
1149}
1150
1151// equalBinds compares bind slices by value.
1152func equalBinds(a, b []AppBind) bool {
1153 if len(a) != len(b) {
1154 return false
1155 }
1156 for i := range a {
1157 if a[i] != b[i] {
1158 return false
1159 }
1160 }
1161 return true
1162}
1163
1164// equalDevices compares device slices as sets: reorder-only input is a
1165// no-op, while add/remove reshuffles the sorted comparison.
1166func equalDevices(a, b []string) bool {
1167 if len(a) != len(b) {
1168 return false
1169 }
1170 sortedA := slices.Sorted(slices.Values(a))
1171 sortedB := slices.Sorted(slices.Values(b))
1172 return slices.Equal(sortedA, sortedB)
1173}
1174
1175// appendDeviceChanges appends the service devices diff entry when the
1176// device sets differ. Split from diffService to keep its complexity
1177// within budget.
1178func appendDeviceChanges(changed []string, name string, desired, effective []string) []string {
1179 if !equalDevices(desired, effective) {
1180 changed = append(changed, "service/"+name+"/devices")
1181 }
1182 return changed
1183}