v3.0.0 f19f453

app_spec.go RawHistoryFolder

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

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