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