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