1package domain
2
3import (
4 "fmt"
5 "sort"
6)
7
8// ReservationsFor computes the global listener claims for a normalized
9// spec. It is the single owner of reservation derivation; the apps and
10// deployment use cases delegate to it. Only effective-public HTTP
11// interfaces claim a host: internal interfaces are never published, so
12// they hold no global listener reservation.
13func ReservationsFor(spec AppSpec) []AppListenerReservation {
14 var reservations []AppListenerReservation
15 for _, svc := range spec.Services {
16 for _, h := range svc.HTTP {
17 if !h.IsPublic() {
18 continue
19 }
20 reservations = append(reservations, AppListenerReservation{
21 Proto: "http",
22 Host: h.Host,
23 Service: svc.Name,
24 App: spec.Name,
25 })
26 }
27 for _, t := range svc.TCP {
28 host, port, err := ParsePublish(t.Publish)
29 if err != nil {
30 continue
31 }
32 reservations = append(reservations, AppListenerReservation{
33 Proto: "tcp",
34 IP: reservationIP(host),
35 Port: port,
36 Service: svc.Name,
37 App: spec.Name,
38 })
39 }
40 for _, u := range svc.UDP {
41 host, port, err := ParsePublish(u.Publish)
42 if err != nil {
43 continue
44 }
45 reservations = append(reservations, AppListenerReservation{
46 Proto: "udp",
47 IP: reservationIP(host),
48 Port: port,
49 Service: svc.Name,
50 App: spec.Name,
51 })
52 }
53 }
54 sort.Slice(reservations, func(i, j int) bool {
55 if reservations[i].Proto != reservations[j].Proto {
56 return reservations[i].Proto < reservations[j].Proto
57 }
58 if reservations[i].Host != reservations[j].Host {
59 return reservations[i].Host < reservations[j].Host
60 }
61 if reservations[i].Port != reservations[j].Port {
62 return reservations[i].Port < reservations[j].Port
63 }
64 return reservations[i].IP < reservations[j].IP
65 })
66 return reservations
67}
68
69// reservationIP maps an empty publish host to the wildcard entry.
70func reservationIP(host string) string {
71 if host == "" {
72 return "dual"
73 }
74 return host
75}
76
77// CheckReservations validates a candidate reservation set against the
78// global checkpoint plus wildcard-aware overlay rules. Same-app entries
79// in existing are retained until withdrawal, so the candidate's own app
80// entries never self-conflict. Intra-candidate claims overlap like
81// cross-app claims: a wildcard L4 bind collides with a specific bind on
82// the same proto and port.
83func CheckReservations(existing []AppListenerReservation, candidate []AppListenerReservation, app string) error {
84 // Duplicate and overlapping claims within the candidate itself.
85 for i, res := range candidate {
86 for _, other := range candidate[:i] {
87 if ReservationsOverlap(res, other) {
88 return fmt.Errorf(
89 "%w: %s claims %s twice (service %s vs %s)",
90 ErrAppReservationConflict,
91 app, describeReservation(res), other.Service, res.Service,
92 )
93 }
94 }
95 }
96 for _, res := range candidate {
97 for _, other := range existing {
98 if other.App == app {
99 continue
100 }
101 if ReservationsOverlap(res, other) {
102 return fmt.Errorf(
103 "%w: %s/%s conflicts with %s/%s on %s",
104 ErrAppReservationConflict,
105 app, res.Service, other.App, other.Service, describeReservation(res),
106 )
107 }
108 }
109 }
110 return nil
111}
112
113// ReservationsOverlap reports wildcard/specific conflicts within one
114// proto namespace.
115func ReservationsOverlap(a, b AppListenerReservation) bool {
116 if a.Proto != b.Proto {
117 return false
118 }
119 if a.Proto == "http" {
120 return a.Host == b.Host
121 }
122 if a.Port != b.Port {
123 return false
124 }
125 if IsReservationWildcard(a.IP) || IsReservationWildcard(b.IP) {
126 return true
127 }
128 return a.IP == b.IP
129}
130
131// IsReservationWildcard matches wildcard and dual-family entries.
132func IsReservationWildcard(ip string) bool {
133 return ip == "" || ip == "0.0.0.0" || ip == "::" || ip == "dual"
134}
135
136// describeReservation renders a human-readable claim.
137func describeReservation(res AppListenerReservation) string {
138 if res.Proto == "http" {
139 return "http://" + res.Host
140 }
141 return fmt.Sprintf("%s://%s:%d", res.Proto, res.IP, res.Port)
142}