1// Package apptraffic projects active app definitions into traffic
2// snapshot entries consumed by the traffic manager.
3package apptraffic
4
5import (
6 "fmt"
7 "sort"
8 "strconv"
9 "strings"
10
11 "github.com/bnema/gordon/internal/domain"
12)
13
14// EntrypointPolicy is the installation policy inherited by generated
15// listeners: the canonical public bind, trusted CIDRs, raw-fallback
16// behavior, and transport limits. The code never reads installation
17// config; the caller supplies it.
18type EntrypointPolicy struct {
19 Name string
20 Address string
21 Protocol domain.EntryPointProtocol
22 TrustedCIDRs []string
23 RawFallback string
24 RawFallbackTrustedCIDRs []string
25 AllowPublicRawFallback bool
26}
27
28// listener projects the policy to the domain listener contract used for
29// exact publish compatibility checks.
30func (p EntrypointPolicy) listener() domain.EntryPointListener {
31 return domain.EntryPointListener{Address: p.Address, Protocol: p.Protocol}
32}
33
34// RouteEntry is one projected traffic entry.
35type RouteEntry struct {
36 // Kind is http, tcp, or udp.
37 Kind string
38 // RouterName is the deterministic generated router name.
39 RouterName string
40 // Entrypoint names the installation entrypoint (empty for http).
41 Entrypoint string
42 // Host is the canonical HTTP host (http only).
43 Host string
44 // BindIP and BindPort are the literal public bind (tcp/udp only).
45 BindIP string
46 BindPort int
47 // TLSMode is auto, always, or never (http only).
48 TLSMode string
49 // App and Service identify the owning workload.
50 App string
51 Service string
52 // Backend is the resolved loopback endpoint for the interface's
53 // container port. Zero when unbound (proxy fails closed).
54 Backend domain.AppBackend
55}
56
57// Project maps one app's active definitions to traffic entries.
58// Entrypoints supplies installation policy by name; unknown entrypoint references are validation errors.
59// Loopback endpoints resolve from each service's recorded backend binds;
60// services without a recorded bind for an interface port project with an
61// empty BackendHost (proxy fails closed).
62func Project(app string, active domain.AppActive, entrypoints map[string]EntrypointPolicy) ([]RouteEntry, error) {
63 if active.App != "" && active.App != app {
64 return nil, fmt.Errorf("%w: projection for %q got active state of %q", domain.ErrAppTrafficProjection, app, active.App)
65 }
66 var entries []RouteEntry
67 for name, svc := range active.Services {
68 svcEntries, err := projectService(app, name, svc, entrypoints)
69 if err != nil {
70 return nil, err
71 }
72 entries = append(entries, svcEntries...)
73 }
74 sort.Slice(entries, func(i, j int) bool {
75 if entries[i].RouterName != entries[j].RouterName {
76 return entries[i].RouterName < entries[j].RouterName
77 }
78 return entries[i].Backend.ContainerPort < entries[j].Backend.ContainerPort
79 })
80 return entries, nil
81}
82
83// projectService maps one effective service to entries. Endpoints
84// resolve from the recorded backend binds; missing binds stay empty
85// (proxy fails closed, never falls back to container IPs).
86func projectService(app, name string, eff domain.AppEffectiveService, entrypoints map[string]EntrypointPolicy) ([]RouteEntry, error) {
87 spec := eff.Spec
88 spec.Name = name
89 var entries []RouteEntry
90 for _, h := range spec.HTTP {
91 // Internal HTTP is reachable only from the app private network: it
92 // is never routed, so it must not project a traffic entry or a
93 // certificate host. Fail closed by skipping it entirely.
94 if !h.IsPublic() {
95 continue
96 }
97 entries = append(entries, RouteEntry{
98 Kind: "http",
99 RouterName: "app-" + app + "--" + spec.Name + "--http-" + sanitizeHost(h.Host),
100 Host: h.Host,
101 TLSMode: h.TLS,
102 App: app,
103 Service: spec.Name,
104 Backend: eff.BackendFor(h.Port),
105 })
106 }
107 for _, t := range spec.TCP {
108 policy, err := lookupEntrypoint(entrypoints, t.Entrypoint, app, spec.Name, "tcp")
109 if err != nil {
110 return nil, err
111 }
112 host, port, err := domain.ParsePublish(t.Publish)
113 if err != nil {
114 return nil, err
115 }
116 if err := domain.ValidatePublishForListener(t.Publish, domain.NetworkProtocolTCP, policy.listener(), t.Entrypoint); err != nil {
117 return nil, fmt.Errorf("%w: service %q tcp interface: %s", domain.ErrAppTrafficProjection, spec.Name, err)
118 }
119 entries = append(entries, RouteEntry{
120 Kind: "tcp",
121 RouterName: "app-" + app + "--" + spec.Name + "--tcp-" + itoa(t.Port),
122 Entrypoint: t.Entrypoint,
123 BindIP: host,
124 BindPort: port,
125 App: app,
126 Service: spec.Name,
127 Backend: eff.BackendFor(t.Port),
128 })
129 }
130 for _, u := range spec.UDP {
131 policy, err := lookupEntrypoint(entrypoints, u.Entrypoint, app, spec.Name, "udp")
132 if err != nil {
133 return nil, err
134 }
135 host, port, err := domain.ParsePublish(u.Publish)
136 if err != nil {
137 return nil, err
138 }
139 if err := domain.ValidatePublishForListener(u.Publish, domain.NetworkProtocolUDP, policy.listener(), u.Entrypoint); err != nil {
140 return nil, fmt.Errorf("%w: service %q udp interface: %s", domain.ErrAppTrafficProjection, spec.Name, err)
141 }
142 entries = append(entries, RouteEntry{
143 Kind: "udp",
144 RouterName: "app-" + app + "--" + spec.Name + "--udp-" + itoa(u.Port),
145 Entrypoint: u.Entrypoint,
146 BindIP: host,
147 BindPort: port,
148 App: app,
149 Service: spec.Name,
150 Backend: eff.BackendForProtocol(u.Port, domain.NetworkProtocolUDP),
151 })
152 }
153 return entries, nil
154}
155
156// lookupEntrypoint resolves a named installation entrypoint.
157func lookupEntrypoint(entrypoints map[string]EntrypointPolicy, name, app, service, kind string) (EntrypointPolicy, error) {
158 policy, ok := entrypoints[name]
159 if !ok {
160 return EntrypointPolicy{}, fmt.Errorf(
161 "%w: service %q %s references unknown entrypoint %q",
162 domain.ErrAppTrafficProjection, service, kind, name,
163 )
164 }
165 _ = app
166 return policy, nil
167}
168
169// sanitizeHost maps a hostname to a router-name fragment.
170func sanitizeHost(host string) string {
171 fragment := strings.ReplaceAll(host, ".", "-")
172 fragment = strings.ReplaceAll(fragment, ":", "-")
173 return strings.ReplaceAll(fragment, "/", "-")
174}
175
176// itoa formats a port number.
177func itoa(n int) string {
178 return strconv.Itoa(n)
179}