app_spec.go RawHistoryFolder

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	if !equalStringMaps(desired.Env, effective.Env) {
1017		diff.Changed = append(diff.Changed, "env")
1018	}
1019	diff.Changed = append(diff.Changed, diffNetworks(desired.Networks, effective.Networks)...)
1020	sort.Strings(diff.Added)
1021	sort.Strings(diff.Removed)
1022	sort.Strings(diff.Changed)
1023	return diff
1024}
1025
1026func diffNetworks(desired, effective []AppSharedNetwork) []string {
1027	desiredByName := make(map[string]AppSharedNetwork, len(desired))
1028	for _, network := range desired {
1029		desiredByName[network.Network] = network
1030	}
1031	effectiveByName := make(map[string]AppSharedNetwork, len(effective))
1032	for _, network := range effective {
1033		effectiveByName[network.Network] = network
1034	}
1035
1036	var changed []string
1037	for name, network := range desiredByName {
1038		previous, ok := effectiveByName[name]
1039		switch {
1040		case !ok:
1041			changed = append(changed, "network/"+name+"/added")
1042		case !equalStringSets(network.Services, previous.Services):
1043			changed = append(changed, "network/"+name+"/services")
1044		case !equalStringSets(network.Aliases, previous.Aliases):
1045			changed = append(changed, "network/"+name+"/aliases")
1046		}
1047	}
1048	for name := range effectiveByName {
1049		if _, ok := desiredByName[name]; !ok {
1050			changed = append(changed, "network/"+name+"/removed")
1051		}
1052	}
1053	return changed
1054}
1055
1056func equalStringSets(a, b []string) bool {
1057	if len(a) != len(b) {
1058		return false
1059	}
1060	counts := make(map[string]int, len(a))
1061	for _, value := range a {
1062		counts[value]++
1063	}
1064	for _, value := range b {
1065		counts[value]--
1066		if counts[value] < 0 {
1067			return false
1068		}
1069	}
1070	return true
1071}
1072
1073// SameAppService reports whether two service specs are equal under the
1074// same normalized comparison DiffAppSpec uses.
1075func SameAppService(a, b AppService) bool {
1076	return len(diffService(a.Name, a, b)) == 0
1077}
1078
1079// diffService compares two services field by field.
1080func diffService(name string, desired, effective AppService) []string {
1081	var changed []string
1082	if desired.Image != effective.Image {
1083		changed = append(changed, "service/"+name+"/image")
1084	}
1085	if strings.Join(desired.Command, "\x00") != strings.Join(effective.Command, "\x00") {
1086		changed = append(changed, "service/"+name+"/command")
1087	}
1088	if desired.StopGrace != effective.StopGrace {
1089		changed = append(changed, "service/"+name+"/stop_grace")
1090	}
1091	if desired.Readiness != effective.Readiness {
1092		changed = append(changed, "service/"+name+"/readiness")
1093	}
1094	if len(desired.HTTP) != len(effective.HTTP) || len(desired.TCP) != len(effective.TCP) ||
1095		len(desired.UDP) != len(effective.UDP) {
1096		changed = append(changed, "service/"+name+"/interfaces")
1097	} else if interfacesChanged(desired, effective) {
1098		changed = append(changed, "service/"+name+"/interfaces")
1099	}
1100	if !equalStringMaps(desired.Env, effective.Env) {
1101		changed = append(changed, "service/"+name+"/env")
1102	}
1103	changed = appendSecretChanges(changed, desired, effective)
1104	if !equalVolumes(desired.Volumes, effective.Volumes) {
1105		changed = append(changed, "service/"+name+"/volumes")
1106	}
1107	if !equalBinds(desired.Binds, effective.Binds) {
1108		changed = append(changed, "service/"+name+"/binds")
1109	}
1110	changed = appendDeviceChanges(changed, name, desired.Devices, effective.Devices)
1111	return appendOperationChanges(changed, name, desired, effective)
1112}
1113
1114// appendOperationChanges compares the operational (non-runtime) service
1115// settings: databases, backups, and telemetry.
1116func appendOperationChanges(changed []string, name string, desired, effective AppService) []string {
1117	if !reflect.DeepEqual(desired.Databases, effective.Databases) {
1118		changed = append(changed, "service/"+name+"/databases")
1119	}
1120	if !reflect.DeepEqual(desired.Backup, effective.Backup) {
1121		changed = append(changed, "service/"+name+"/backup")
1122	}
1123	if desired.LogExportDisabled != effective.LogExportDisabled {
1124		changed = append(changed, "service/"+name+"/telemetry")
1125	}
1126	return changed
1127}
1128
1129// appendSecretChanges appends sorted secret path changes when the secret
1130// names or their delivery modes differ.
1131func appendSecretChanges(changed []string, desired, effective AppService) []string {
1132	return append(changed, slices.Sorted(maps.Keys(secretPathChanges(desired, effective)))...)
1133}
1134
1135// secretPathChanges lists secret PATH changes (never values). A key whose
1136// delivery mode (env or file) changed counts as changed.
1137func secretPathChanges(desired, effective AppService) map[string]struct{} {
1138	paths := map[string]struct{}{}
1139	for envKey, secretName := range desired.Secrets {
1140		if effective.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) {
1141			paths["service/"+desired.Name+"/secret/"+secretName] = struct{}{}
1142		}
1143	}
1144	for envKey, secretName := range effective.Secrets {
1145		if desired.Secrets[envKey] != secretName || desired.SecretAsFile(envKey) != effective.SecretAsFile(envKey) {
1146			paths["service/"+effective.Name+"/secret/"+secretName] = struct{}{}
1147		}
1148	}
1149	return paths
1150}
1151
1152// interfacesChanged compares interface slices.
1153func interfacesChanged(desired, effective AppService) bool {
1154	for i := range desired.HTTP {
1155		if !httpInterfacesEqual(desired.HTTP[i], effective.HTTP[i]) {
1156			return true
1157		}
1158	}
1159	for i := range desired.TCP {
1160		if desired.TCP[i] != effective.TCP[i] {
1161			return true
1162		}
1163	}
1164	for i := range desired.UDP {
1165		if desired.UDP[i] != effective.UDP[i] {
1166			return true
1167		}
1168	}
1169	return false
1170}
1171
1172// httpInterfacesEqual compares two HTTP interfaces with visibility
1173// normalized. State written before the field existed reads as the zero
1174// value and must equal an explicit public, while a real public/internal
1175// flip is still a change.
1176func httpInterfacesEqual(a, b AppHTTPInterface) bool {
1177	if a.EffectiveVisibility() != b.EffectiveVisibility() {
1178		return false
1179	}
1180	a.Visibility, b.Visibility = "", ""
1181	return a == b
1182}
1183
1184// equalStringMaps compares string maps.
1185func equalStringMaps(a, b map[string]string) bool {
1186	if len(a) != len(b) {
1187		return false
1188	}
1189	for k, v := range a {
1190		if b[k] != v {
1191			return false
1192		}
1193	}
1194	return true
1195}
1196
1197// equalVolumes compares volume slices by value.
1198func equalVolumes(a, b []AppVolume) bool {
1199	if len(a) != len(b) {
1200		return false
1201	}
1202	for i := range a {
1203		if a[i] != b[i] {
1204			return false
1205		}
1206	}
1207	return true
1208}
1209
1210// equalBinds compares bind slices by value.
1211func equalBinds(a, b []AppBind) bool {
1212	if len(a) != len(b) {
1213		return false
1214	}
1215	for i := range a {
1216		if a[i] != b[i] {
1217			return false
1218		}
1219	}
1220	return true
1221}
1222
1223// equalDevices compares device slices as sets: reorder-only input is a
1224// no-op, while add/remove reshuffles the sorted comparison.
1225func equalDevices(a, b []string) bool {
1226	if len(a) != len(b) {
1227		return false
1228	}
1229	sortedA := slices.Sorted(slices.Values(a))
1230	sortedB := slices.Sorted(slices.Values(b))
1231	return slices.Equal(sortedA, sortedB)
1232}
1233
1234// appendDeviceChanges appends the service devices diff entry when the
1235// device sets differ. Split from diffService to keep its complexity
1236// within budget.
1237func appendDeviceChanges(changed []string, name string, desired, effective []string) []string {
1238	if !equalDevices(desired, effective) {
1239		changed = append(changed, "service/"+name+"/devices")
1240	}
1241	return changed
1242}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close