v3.0.0-alpha.1 6fdddb8

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

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