detached 8345e76

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

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