1package deployment
2
3import (
4 "context"
5 "fmt"
6 "sort"
7
8 "github.com/bnema/gordon/internal/domain"
9)
10
11// backendPorts collects every interface container port for loopback
12// publication: public HTTP + TCP interfaces on tcp plus UDP interfaces on
13// udp, plus an explicit TCP readiness port. Internal HTTP ports are
14// excluded: they are reached only over the private network and must keep
15// no host binding. Each publish is on 127.0.0.1 ephemeral (never public).
16// Deduplicated by (protocol, container port).
17func backendPorts(spec domain.AppService) []domain.ContainerBackendPort {
18 seen := map[domain.ContainerBackendPort]struct{}{}
19 var ports []domain.ContainerBackendPort
20 add := func(port int, protocol domain.NetworkProtocol) {
21 if port <= 0 {
22 return
23 }
24 key := domain.ContainerBackendPort{ContainerPort: port, Protocol: protocol}
25 if _, ok := seen[key]; !ok {
26 seen[key] = struct{}{}
27 ports = append(ports, key)
28 }
29 }
30 for _, h := range spec.HTTP {
31 if !h.IsPublic() {
32 continue
33 }
34 add(h.Port, domain.NetworkProtocolTCP)
35 }
36 for _, t := range spec.TCP {
37 add(t.Port, domain.NetworkProtocolTCP)
38 }
39 for _, u := range spec.UDP {
40 add(u.Port, domain.NetworkProtocolUDP)
41 }
42 // Readiness probes are TCP-only (validation rejects UDP-only
43 // services with tcp/http readiness); the readiness port matches a
44 // declared TCP container port. An internal-only port is never
45 // published, so readiness metadata cannot create a host binding for
46 // it.
47 if spec.Readiness.Port > 0 && !spec.InternallyOnlyPort(spec.Readiness.Port) {
48 add(spec.Readiness.Port, domain.NetworkProtocolTCP)
49 }
50 sort.Slice(ports, func(i, j int) bool {
51 if ports[i].Protocol != ports[j].Protocol {
52 return ports[i].Protocol < ports[j].Protocol
53 }
54 return ports[i].ContainerPort < ports[j].ContainerPort
55 })
56 return ports
57}
58
59// backendPublishes maps backend ports to 127.0.0.1 ephemeral publishes.
60func backendPublishes(spec domain.AppService) []domain.ContainerPortPublish {
61 ports := backendPorts(spec)
62 publishes := make([]domain.ContainerPortPublish, 0, len(ports))
63 for _, port := range ports {
64 publishes = append(publishes, domain.ContainerPortPublish{
65 HostIP: "127.0.0.1",
66 HostPort: 0,
67 ContainerPort: port.ContainerPort,
68 Protocol: port.Protocol,
69 })
70 }
71 return publishes
72}
73
74// readBackendBinds resolves each published container port to its
75// 127.0.0.1 host port and registers the Gordon-generated claims
76// (owner gordon-backend) in the global checkpoint. Missing binds fail:
77// an unbound backend can serve neither readiness nor proxy traffic.
78// A registration conflict removes the CANDIDATE container and fails —
79// callers must only pass freshly created candidates here, never an
80// existing ACTIVE container (see inspectBackendBinds).
81func (s *Service) readBackendBinds(ctx context.Context, app, service, containerID string, ports []domain.ContainerBackendPort) (map[int]int, map[int]int, error) {
82 if len(ports) == 0 {
83 return map[int]int{}, map[int]int{}, nil
84 }
85 observed, err := s.deps.Runtime.GetContainerBackendBinds(ctx, containerID, ports)
86 if err != nil {
87 return nil, nil, fmt.Errorf("deployment: backend binds: %w", err)
88 }
89 binds, udpBinds := splitBackendBinds(observed)
90 claims := backendClaims(app, service, containerID, observed)
91 if len(claims) == 0 {
92 return binds, udpBinds, nil
93 }
94 if err := s.deps.State.RegisterBackendBinds(ctx, claims); err != nil {
95 s.retireCandidate(ctx, app, service, containerID)
96 return nil, nil, err
97 }
98 return binds, udpBinds, nil
99}
100
101// inspectBackendBinds re-inspects the loopback publishes of an EXISTING
102// container and registers the Gordon-generated claims. Unlike
103// readBackendBinds it never stops or removes the container: a
104// registration conflict fails without touching the ACTIVE workload.
105// Missing binds fail: an unbound backend must not stay routable.
106func (s *Service) inspectBackendBinds(ctx context.Context, app, service, containerID string, ports []domain.ContainerBackendPort) (map[int]int, map[int]int, error) {
107 if len(ports) == 0 {
108 return map[int]int{}, map[int]int{}, nil
109 }
110 observed, err := s.deps.Runtime.GetContainerBackendBinds(ctx, containerID, ports)
111 if err != nil {
112 return nil, nil, fmt.Errorf("deployment: backend binds: %w", err)
113 }
114 binds, udpBinds := splitBackendBinds(observed)
115 claims := backendClaims(app, service, containerID, observed)
116 if len(claims) == 0 {
117 return binds, udpBinds, nil
118 }
119 if err := s.deps.State.RegisterBackendBinds(ctx, claims); err != nil {
120 return nil, nil, err
121 }
122 return binds, udpBinds, nil
123}
124
125// splitBackendBinds partitions observed binds by protocol. TCP feeds
126// readiness and HTTP/TCP proxying; UDP feeds UDP relaying.
127func splitBackendBinds(observed []domain.ContainerBackendBind) (map[int]int, map[int]int) {
128 binds := map[int]int{}
129 udpBinds := map[int]int{}
130 for _, bind := range observed {
131 if bind.Protocol == domain.NetworkProtocolUDP {
132 udpBinds[bind.ContainerPort] = bind.HostPort
133 } else {
134 binds[bind.ContainerPort] = bind.HostPort
135 }
136 }
137 return binds, udpBinds
138}
139
140// backendClaims maps observed binds to Gordon-generated loopback claims
141// with the exact protocol (tcp/udp) for the global checkpoint.
142func backendClaims(app, service, containerID string, observed []domain.ContainerBackendBind) []domain.AppListenerReservation {
143 claims := make([]domain.AppListenerReservation, 0, len(observed))
144 for _, bind := range observed {
145 claims = append(claims, domain.AppListenerReservation{
146 Proto: string(bind.Protocol),
147 IP: "127.0.0.1",
148 Port: bind.HostPort,
149 Service: service,
150 App: app,
151 Owner: domain.OwnerGordonBackend,
152 ContainerID: containerID,
153 })
154 }
155 return claims
156}