detached 2443a00

feat(traffic): build graph from config

commit
2443a00dcc29ce381898442dccab50e4382bf4b3
author
brice · · 2026-06-29 21:11 UTC
parent
7924808
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +492 -0
M internal/app/run.go +5 -0
@@ -84,6 +84,7 @@ import (
8484 "github.com/bnema/gordon/internal/usecase/publictls"
8585 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
8686 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
87+ "github.com/bnema/gordon/internal/usecase/traffic"
8788 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
8889
8990 // Pkg
@@ -174,6 +175,10 @@ type Config struct {
174175 } `mapstructure:"rate_limit"`
175176 } `mapstructure:"api"`
176177
178+ EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"`
179+ Traffic traffic.Config `mapstructure:"traffic"`
180+ NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"`
181+
177182 Backups struct {
178183 // Legacy database backup keys. Prefer backups.databases.* for new configs.
179184 Enabled bool `mapstructure:"enabled"`
@@ -84,6 +84,7 @@ import (
84 "github.com/bnema/gordon/internal/usecase/publictls"84 "github.com/bnema/gordon/internal/usecase/publictls"
85 registrySvc "github.com/bnema/gordon/internal/usecase/registry"85 registrySvc "github.com/bnema/gordon/internal/usecase/registry"
86 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"86 secretsSvc "github.com/bnema/gordon/internal/usecase/secrets"
87+ "github.com/bnema/gordon/internal/usecase/traffic"
87 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"88 volumesSvc "github.com/bnema/gordon/internal/usecase/volumes"
88 89
89 // Pkg90 // Pkg
@@ -174,6 +175,10 @@ type Config struct {
174 } `mapstructure:"rate_limit"`175 } `mapstructure:"rate_limit"`
175 } `mapstructure:"api"`176 } `mapstructure:"api"`
176 177
178+ EntryPoints map[string]traffic.EntryPointConfig `mapstructure:"entrypoints"`
179+ Traffic traffic.Config `mapstructure:"traffic"`
180+ NetworkServices []traffic.NetworkServiceConfig `mapstructure:"network_services"`
181+
177 Backups struct {182 Backups struct {
178 // Legacy database backup keys. Prefer backups.databases.* for new configs.183 // Legacy database backup keys. Prefer backups.databases.* for new configs.
179 Enabled bool `mapstructure:"enabled"`184 Enabled bool `mapstructure:"enabled"`
A internal/usecase/traffic/builder.go +343 -0
@@ -0,0 +1,343 @@
1+package traffic
2+
3+import (
4+ "fmt"
5+ "net"
6+ "sort"
7+ "strconv"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+const (
15+ DefaultHTTPEntryPointName = "web"
16+ DefaultTLSEntryPointName = "websecure"
17+)
18+
19+// Input is the complete, usecase-level input used to build a validated traffic graph.
20+type Input struct {
21+ Server ServerConfig
22+ EntryPoints map[string]EntryPointConfig
23+ Traffic Config
24+ Routes []domain.Route
25+ ExternalRoutes map[string]string
26+ NetworkServices []NetworkServiceConfig
27+}
28+
29+type ServerConfig struct {
30+ Port int `mapstructure:"port"`
31+ TLSPort int `mapstructure:"tls_port"`
32+}
33+
34+type EntryPointConfig struct {
35+ Address string `mapstructure:"address"`
36+ Protocol domain.EntryPointProtocol `mapstructure:"protocol"`
37+ TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
38+}
39+
40+// Config holds traffic router and option configuration.
41+type Config struct {
42+ TCP TCPConfig `mapstructure:"tcp"`
43+ UDP UDPConfig `mapstructure:"udp"`
44+ TLS TLSConfig `mapstructure:"tls"`
45+}
46+
47+type TCPConfig struct {
48+ Routers []RouterConfig `mapstructure:"routers"`
49+ DialTimeout string `mapstructure:"dial_timeout"`
50+ IdleTimeout string `mapstructure:"idle_timeout"`
51+ DrainTimeout string `mapstructure:"drain_timeout"`
52+ MaxConnections int `mapstructure:"max_connections"`
53+}
54+
55+type UDPConfig struct {
56+ Routers []RouterConfig `mapstructure:"routers"`
57+ IdleTimeout string `mapstructure:"idle_timeout"`
58+ DrainTimeout string `mapstructure:"drain_timeout"`
59+ MaxSessions int `mapstructure:"max_sessions"`
60+}
61+
62+type TLSConfig struct {
63+ Routers []RouterConfig `mapstructure:"routers"`
64+}
65+
66+type RouterConfig struct {
67+ Name string `mapstructure:"name"`
68+ EntryPoint string `mapstructure:"entrypoint"`
69+ Host string `mapstructure:"host"`
70+ SNI string `mapstructure:"sni"`
71+ Service string `mapstructure:"service"`
72+}
73+
74+type NetworkServiceConfig struct {
75+ Name string `mapstructure:"name"`
76+ Ports []PortConfig `mapstructure:"ports"`
77+}
78+
79+type PortConfig struct {
80+ Name string `mapstructure:"name"`
81+ Container int `mapstructure:"container"`
82+ Protocol domain.NetworkProtocol `mapstructure:"protocol"`
83+}
84+
85+// Build builds and validates a TrafficGraph snapshot. Managed route services are
86+// intentionally backend-less because their concrete container endpoints are resolved at runtime.
87+func Build(input Input) (domain.TrafficGraph, error) {
88+ b := builder{input: input, services: map[string]domain.TrafficService{}}
89+ graph := domain.TrafficGraph{}
90+
91+ options, err := buildOptions(input.Traffic)
92+ if err != nil {
93+ return domain.TrafficGraph{}, err
94+ }
95+ if err := validateNetworkServices(input.NetworkServices); err != nil {
96+ return domain.TrafficGraph{}, err
97+ }
98+ graph.Options = options
99+ graph.EntryPoints = b.buildEntryPoints()
100+
101+ if err := b.addHTTPRoutes(&graph); err != nil {
102+ return domain.TrafficGraph{}, err
103+ }
104+ if err := b.addExternalRoutes(&graph); err != nil {
105+ return domain.TrafficGraph{}, err
106+ }
107+ if err := b.addExplicitRouters(&graph); err != nil {
108+ return domain.TrafficGraph{}, err
109+ }
110+
111+ graph.Services = make([]domain.TrafficService, 0, len(b.services))
112+ for _, svc := range b.services {
113+ graph.Services = append(graph.Services, svc)
114+ }
115+ sort.Slice(graph.Services, func(i, j int) bool { return graph.Services[i].Name < graph.Services[j].Name })
116+ if err := graph.Validate(); err != nil {
117+ return domain.TrafficGraph{}, err
118+ }
119+ return graph, nil
120+}
121+
122+type builder struct {
123+ input Input
124+ services map[string]domain.TrafficService
125+}
126+
127+func (b *builder) buildEntryPoints() []domain.EntryPoint {
128+ entries := make([]domain.EntryPoint, 0, len(b.input.EntryPoints)+2)
129+ if b.input.Server.Port != 0 {
130+ entries = append(entries, domain.EntryPoint{Name: DefaultHTTPEntryPointName, Address: portAddress(b.input.Server.Port), Protocol: domain.EntryPointProtocolHTTP})
131+ }
132+ if b.input.Server.TLSPort != 0 {
133+ entries = append(entries, domain.EntryPoint{Name: DefaultTLSEntryPointName, Address: portAddress(b.input.Server.TLSPort), Protocol: domain.EntryPointProtocolTLSMux})
134+ }
135+ for _, name := range sortedKeys(b.input.EntryPoints) {
136+ cfg := b.input.EntryPoints[name]
137+ entries = append(entries, domain.EntryPoint{Name: name, Address: cfg.Address, Protocol: cfg.Protocol, TrustedCIDRs: append([]string{}, cfg.TrustedCIDRs...)})
138+ }
139+ return entries
140+}
141+
142+func (b *builder) addHTTPRoutes(graph *domain.TrafficGraph) error {
143+ routes := append([]domain.Route{}, b.input.Routes...)
144+ sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
145+ for _, route := range routes {
146+ entryPoint := DefaultHTTPEntryPointName
147+ if route.HTTPS {
148+ entryPoint = DefaultTLSEntryPointName
149+ }
150+ serviceName := string(domain.TrafficServiceRefRoute) + ":" + route.Domain
151+ b.addService(domain.TrafficService{Name: serviceName})
152+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "route:" + route.Domain, EntryPoint: entryPoint, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: route.Domain}, Service: serviceName})
153+ }
154+ return nil
155+}
156+
157+func (b *builder) addExternalRoutes(graph *domain.TrafficGraph) error {
158+ for _, hostName := range sortedKeys(b.input.ExternalRoutes) {
159+ target := b.input.ExternalRoutes[hostName]
160+ host, port, err := parseBackendAddress(target)
161+ if err != nil {
162+ return fmt.Errorf("external route %q target %q: %w", hostName, target, err)
163+ }
164+ serviceName := string(domain.TrafficServiceRefExternalRoute) + ":" + hostName
165+ b.addService(domain.TrafficService{Name: serviceName, Backends: []domain.TrafficBackend{{Name: hostName, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}}})
166+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "external_route:" + hostName, EntryPoint: DefaultHTTPEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: hostName}, Service: serviceName})
167+ }
168+ return nil
169+}
170+
171+func (b *builder) addExplicitRouters(graph *domain.TrafficGraph) error {
172+ if err := b.addL4Routers(graph, b.input.Traffic.TCP.Routers, domain.RouterProtocolTCP, domain.NetworkProtocolTCP); err != nil {
173+ return err
174+ }
175+ if err := b.addL4Routers(graph, b.input.Traffic.UDP.Routers, domain.RouterProtocolUDP, domain.NetworkProtocolUDP); err != nil {
176+ return err
177+ }
178+ return b.addL4Routers(graph, b.input.Traffic.TLS.Routers, domain.RouterProtocolTLSPassthrough, domain.NetworkProtocolTCP)
179+}
180+
181+func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfig, protocol domain.RouterProtocol, backendProtocol domain.NetworkProtocol) error {
182+ ordered := append([]RouterConfig{}, routers...)
183+ sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name })
184+ for _, cfg := range ordered {
185+ service, err := b.resolveService(cfg.Service, backendProtocol)
186+ if err != nil {
187+ return fmt.Errorf("router %q: %w", cfg.Name, err)
188+ }
189+ b.addService(service)
190+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: cfg.Name, EntryPoint: cfg.EntryPoint, Protocol: protocol, Rule: domain.TrafficRule{Host: cfg.Host, SNI: cfg.SNI}, Service: cfg.Service})
191+ }
192+ return nil
193+}
194+
195+func (b *builder) resolveService(refValue string, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
196+ ref, err := domain.ParseTrafficServiceRef(refValue)
197+ if err != nil {
198+ return domain.TrafficService{}, err
199+ }
200+ if ref.Kind == domain.TrafficServiceRefStatic {
201+ return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", refValue)
202+ }
203+ if ref.Kind != domain.TrafficServiceRefNetworkService {
204+ return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name>", refValue)
205+ }
206+ ns, ok := b.networkService(ref.Name)
207+ if !ok {
208+ return domain.TrafficService{}, fmt.Errorf("unknown network service %q", ref.Name)
209+ }
210+ port, ok := networkServicePort(ns, ref.PortName)
211+ if !ok {
212+ return domain.TrafficService{}, fmt.Errorf("unknown port %q on network service %q", ref.PortName, ref.Name)
213+ }
214+ if port.Container < 1 || port.Container > 65535 {
215+ return domain.TrafficService{}, fmt.Errorf("invalid port %d on network service %q port %q", port.Container, ref.Name, ref.PortName)
216+ }
217+ if port.Protocol != protocol {
218+ return domain.TrafficService{}, fmt.Errorf("network service %q port %q protocol %s does not match router protocol %s", ref.Name, ref.PortName, port.Protocol, protocol)
219+ }
220+ return domain.TrafficService{Name: refValue, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: ref.Name, Port: port.Container, Protocol: port.Protocol}}}, nil
221+}
222+
223+func validateNetworkServices(services []NetworkServiceConfig) error {
224+ seen := map[string]struct{}{}
225+ for _, service := range services {
226+ if service.Name == "" {
227+ return fmt.Errorf("network service name is required")
228+ }
229+ if _, exists := seen[service.Name]; exists {
230+ return fmt.Errorf("duplicate network service %q", service.Name)
231+ }
232+ seen[service.Name] = struct{}{}
233+ if err := validateNetworkServicePorts(service); err != nil {
234+ return err
235+ }
236+ }
237+ return nil
238+}
239+
240+func validateNetworkServicePorts(service NetworkServiceConfig) error {
241+ seen := map[string]struct{}{}
242+ for _, port := range service.Ports {
243+ if port.Name == "" {
244+ return fmt.Errorf("network service %q port name is required", service.Name)
245+ }
246+ if _, exists := seen[port.Name]; exists {
247+ return fmt.Errorf("duplicate port %q on network service %q", port.Name, service.Name)
248+ }
249+ seen[port.Name] = struct{}{}
250+ }
251+ return nil
252+}
253+
254+func (b *builder) networkService(name string) (NetworkServiceConfig, bool) {
255+ for _, service := range b.input.NetworkServices {
256+ if service.Name == name {
257+ return service, true
258+ }
259+ }
260+ return NetworkServiceConfig{}, false
261+}
262+
263+func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, bool) {
264+ for _, port := range service.Ports {
265+ if port.Name == name {
266+ return port, true
267+ }
268+ }
269+ return PortConfig{}, false
270+}
271+
272+func (b *builder) addService(service domain.TrafficService) {
273+ if _, exists := b.services[service.Name]; !exists {
274+ b.services[service.Name] = service
275+ }
276+}
277+
278+func buildOptions(cfg Config) (domain.TrafficOptions, error) {
279+ tcpDial, err := parseDurationDefault(cfg.TCP.DialTimeout, 10*time.Second, "traffic.tcp.dial_timeout")
280+ if err != nil {
281+ return domain.TrafficOptions{}, err
282+ }
283+ tcpIdle, err := parseDurationDefault(cfg.TCP.IdleTimeout, 5*time.Minute, "traffic.tcp.idle_timeout")
284+ if err != nil {
285+ return domain.TrafficOptions{}, err
286+ }
287+ tcpDrain, err := parseDurationDefault(cfg.TCP.DrainTimeout, 30*time.Second, "traffic.tcp.drain_timeout")
288+ if err != nil {
289+ return domain.TrafficOptions{}, err
290+ }
291+ udpIdle, err := parseDurationDefault(cfg.UDP.IdleTimeout, 30*time.Second, "traffic.udp.idle_timeout")
292+ if err != nil {
293+ return domain.TrafficOptions{}, err
294+ }
295+ udpDrain, err := parseDurationDefault(cfg.UDP.DrainTimeout, 30*time.Second, "traffic.udp.drain_timeout")
296+ if err != nil {
297+ return domain.TrafficOptions{}, err
298+ }
299+ return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}}, nil
300+}
301+
302+func parseDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) {
303+ if strings.TrimSpace(value) == "" {
304+ return fallback, nil
305+ }
306+ d, err := time.ParseDuration(value)
307+ if err != nil {
308+ return 0, fmt.Errorf("invalid %s: %w", field, err)
309+ }
310+ return d, nil
311+}
312+
313+func parseBackendAddress(value string) (string, int, error) {
314+ host, portText, err := net.SplitHostPort(value)
315+ if err != nil {
316+ lastColon := strings.LastIndex(value, ":")
317+ if lastColon <= 0 || lastColon == len(value)-1 || strings.Count(value, ":") > 1 {
318+ return "", 0, err
319+ }
320+ host, portText = value[:lastColon], value[lastColon+1:]
321+ }
322+ port, err := strconv.Atoi(portText)
323+ if err != nil {
324+ return "", 0, fmt.Errorf("invalid port: %w", err)
325+ }
326+ if host == "" || port < 1 || port > 65535 {
327+ return "", 0, fmt.Errorf("host and valid port are required")
328+ }
329+ return host, port, nil
330+}
331+
332+func portAddress(port int) string {
333+ return ":" + strconv.Itoa(port)
334+}
335+
336+func sortedKeys[V any](m map[string]V) []string {
337+ keys := make([]string, 0, len(m))
338+ for k := range m {
339+ keys = append(keys, k)
340+ }
341+ sort.Strings(keys)
342+ return keys
343+}
@@ -0,0 +1,343 @@
1+package traffic
2+
3+import (
4+ "fmt"
5+ "net"
6+ "sort"
7+ "strconv"
8+ "strings"
9+ "time"
10+
11+ "github.com/bnema/gordon/internal/domain"
12+)
13+
14+const (
15+ DefaultHTTPEntryPointName = "web"
16+ DefaultTLSEntryPointName = "websecure"
17+)
18+
19+// Input is the complete, usecase-level input used to build a validated traffic graph.
20+type Input struct {
21+ Server ServerConfig
22+ EntryPoints map[string]EntryPointConfig
23+ Traffic Config
24+ Routes []domain.Route
25+ ExternalRoutes map[string]string
26+ NetworkServices []NetworkServiceConfig
27+}
28+
29+type ServerConfig struct {
30+ Port int `mapstructure:"port"`
31+ TLSPort int `mapstructure:"tls_port"`
32+}
33+
34+type EntryPointConfig struct {
35+ Address string `mapstructure:"address"`
36+ Protocol domain.EntryPointProtocol `mapstructure:"protocol"`
37+ TrustedCIDRs []string `mapstructure:"trusted_cidrs"`
38+}
39+
40+// Config holds traffic router and option configuration.
41+type Config struct {
42+ TCP TCPConfig `mapstructure:"tcp"`
43+ UDP UDPConfig `mapstructure:"udp"`
44+ TLS TLSConfig `mapstructure:"tls"`
45+}
46+
47+type TCPConfig struct {
48+ Routers []RouterConfig `mapstructure:"routers"`
49+ DialTimeout string `mapstructure:"dial_timeout"`
50+ IdleTimeout string `mapstructure:"idle_timeout"`
51+ DrainTimeout string `mapstructure:"drain_timeout"`
52+ MaxConnections int `mapstructure:"max_connections"`
53+}
54+
55+type UDPConfig struct {
56+ Routers []RouterConfig `mapstructure:"routers"`
57+ IdleTimeout string `mapstructure:"idle_timeout"`
58+ DrainTimeout string `mapstructure:"drain_timeout"`
59+ MaxSessions int `mapstructure:"max_sessions"`
60+}
61+
62+type TLSConfig struct {
63+ Routers []RouterConfig `mapstructure:"routers"`
64+}
65+
66+type RouterConfig struct {
67+ Name string `mapstructure:"name"`
68+ EntryPoint string `mapstructure:"entrypoint"`
69+ Host string `mapstructure:"host"`
70+ SNI string `mapstructure:"sni"`
71+ Service string `mapstructure:"service"`
72+}
73+
74+type NetworkServiceConfig struct {
75+ Name string `mapstructure:"name"`
76+ Ports []PortConfig `mapstructure:"ports"`
77+}
78+
79+type PortConfig struct {
80+ Name string `mapstructure:"name"`
81+ Container int `mapstructure:"container"`
82+ Protocol domain.NetworkProtocol `mapstructure:"protocol"`
83+}
84+
85+// Build builds and validates a TrafficGraph snapshot. Managed route services are
86+// intentionally backend-less because their concrete container endpoints are resolved at runtime.
87+func Build(input Input) (domain.TrafficGraph, error) {
88+ b := builder{input: input, services: map[string]domain.TrafficService{}}
89+ graph := domain.TrafficGraph{}
90+
91+ options, err := buildOptions(input.Traffic)
92+ if err != nil {
93+ return domain.TrafficGraph{}, err
94+ }
95+ if err := validateNetworkServices(input.NetworkServices); err != nil {
96+ return domain.TrafficGraph{}, err
97+ }
98+ graph.Options = options
99+ graph.EntryPoints = b.buildEntryPoints()
100+
101+ if err := b.addHTTPRoutes(&graph); err != nil {
102+ return domain.TrafficGraph{}, err
103+ }
104+ if err := b.addExternalRoutes(&graph); err != nil {
105+ return domain.TrafficGraph{}, err
106+ }
107+ if err := b.addExplicitRouters(&graph); err != nil {
108+ return domain.TrafficGraph{}, err
109+ }
110+
111+ graph.Services = make([]domain.TrafficService, 0, len(b.services))
112+ for _, svc := range b.services {
113+ graph.Services = append(graph.Services, svc)
114+ }
115+ sort.Slice(graph.Services, func(i, j int) bool { return graph.Services[i].Name < graph.Services[j].Name })
116+ if err := graph.Validate(); err != nil {
117+ return domain.TrafficGraph{}, err
118+ }
119+ return graph, nil
120+}
121+
122+type builder struct {
123+ input Input
124+ services map[string]domain.TrafficService
125+}
126+
127+func (b *builder) buildEntryPoints() []domain.EntryPoint {
128+ entries := make([]domain.EntryPoint, 0, len(b.input.EntryPoints)+2)
129+ if b.input.Server.Port != 0 {
130+ entries = append(entries, domain.EntryPoint{Name: DefaultHTTPEntryPointName, Address: portAddress(b.input.Server.Port), Protocol: domain.EntryPointProtocolHTTP})
131+ }
132+ if b.input.Server.TLSPort != 0 {
133+ entries = append(entries, domain.EntryPoint{Name: DefaultTLSEntryPointName, Address: portAddress(b.input.Server.TLSPort), Protocol: domain.EntryPointProtocolTLSMux})
134+ }
135+ for _, name := range sortedKeys(b.input.EntryPoints) {
136+ cfg := b.input.EntryPoints[name]
137+ entries = append(entries, domain.EntryPoint{Name: name, Address: cfg.Address, Protocol: cfg.Protocol, TrustedCIDRs: append([]string{}, cfg.TrustedCIDRs...)})
138+ }
139+ return entries
140+}
141+
142+func (b *builder) addHTTPRoutes(graph *domain.TrafficGraph) error {
143+ routes := append([]domain.Route{}, b.input.Routes...)
144+ sort.Slice(routes, func(i, j int) bool { return routes[i].Domain < routes[j].Domain })
145+ for _, route := range routes {
146+ entryPoint := DefaultHTTPEntryPointName
147+ if route.HTTPS {
148+ entryPoint = DefaultTLSEntryPointName
149+ }
150+ serviceName := string(domain.TrafficServiceRefRoute) + ":" + route.Domain
151+ b.addService(domain.TrafficService{Name: serviceName})
152+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "route:" + route.Domain, EntryPoint: entryPoint, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: route.Domain}, Service: serviceName})
153+ }
154+ return nil
155+}
156+
157+func (b *builder) addExternalRoutes(graph *domain.TrafficGraph) error {
158+ for _, hostName := range sortedKeys(b.input.ExternalRoutes) {
159+ target := b.input.ExternalRoutes[hostName]
160+ host, port, err := parseBackendAddress(target)
161+ if err != nil {
162+ return fmt.Errorf("external route %q target %q: %w", hostName, target, err)
163+ }
164+ serviceName := string(domain.TrafficServiceRefExternalRoute) + ":" + hostName
165+ b.addService(domain.TrafficService{Name: serviceName, Backends: []domain.TrafficBackend{{Name: hostName, Host: host, Port: port, Protocol: domain.NetworkProtocolTCP}}})
166+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: "external_route:" + hostName, EntryPoint: DefaultHTTPEntryPointName, Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: hostName}, Service: serviceName})
167+ }
168+ return nil
169+}
170+
171+func (b *builder) addExplicitRouters(graph *domain.TrafficGraph) error {
172+ if err := b.addL4Routers(graph, b.input.Traffic.TCP.Routers, domain.RouterProtocolTCP, domain.NetworkProtocolTCP); err != nil {
173+ return err
174+ }
175+ if err := b.addL4Routers(graph, b.input.Traffic.UDP.Routers, domain.RouterProtocolUDP, domain.NetworkProtocolUDP); err != nil {
176+ return err
177+ }
178+ return b.addL4Routers(graph, b.input.Traffic.TLS.Routers, domain.RouterProtocolTLSPassthrough, domain.NetworkProtocolTCP)
179+}
180+
181+func (b *builder) addL4Routers(graph *domain.TrafficGraph, routers []RouterConfig, protocol domain.RouterProtocol, backendProtocol domain.NetworkProtocol) error {
182+ ordered := append([]RouterConfig{}, routers...)
183+ sort.Slice(ordered, func(i, j int) bool { return ordered[i].Name < ordered[j].Name })
184+ for _, cfg := range ordered {
185+ service, err := b.resolveService(cfg.Service, backendProtocol)
186+ if err != nil {
187+ return fmt.Errorf("router %q: %w", cfg.Name, err)
188+ }
189+ b.addService(service)
190+ graph.Routers = append(graph.Routers, domain.TrafficRouter{Name: cfg.Name, EntryPoint: cfg.EntryPoint, Protocol: protocol, Rule: domain.TrafficRule{Host: cfg.Host, SNI: cfg.SNI}, Service: cfg.Service})
191+ }
192+ return nil
193+}
194+
195+func (b *builder) resolveService(refValue string, protocol domain.NetworkProtocol) (domain.TrafficService, error) {
196+ ref, err := domain.ParseTrafficServiceRef(refValue)
197+ if err != nil {
198+ return domain.TrafficService{}, err
199+ }
200+ if ref.Kind == domain.TrafficServiceRefStatic {
201+ return domain.TrafficService{}, fmt.Errorf("static service ref %q is unsupported", refValue)
202+ }
203+ if ref.Kind != domain.TrafficServiceRefNetworkService {
204+ return domain.TrafficService{}, fmt.Errorf("service ref %q must be network_service:<name>:<port-name>", refValue)
205+ }
206+ ns, ok := b.networkService(ref.Name)
207+ if !ok {
208+ return domain.TrafficService{}, fmt.Errorf("unknown network service %q", ref.Name)
209+ }
210+ port, ok := networkServicePort(ns, ref.PortName)
211+ if !ok {
212+ return domain.TrafficService{}, fmt.Errorf("unknown port %q on network service %q", ref.PortName, ref.Name)
213+ }
214+ if port.Container < 1 || port.Container > 65535 {
215+ return domain.TrafficService{}, fmt.Errorf("invalid port %d on network service %q port %q", port.Container, ref.Name, ref.PortName)
216+ }
217+ if port.Protocol != protocol {
218+ return domain.TrafficService{}, fmt.Errorf("network service %q port %q protocol %s does not match router protocol %s", ref.Name, ref.PortName, port.Protocol, protocol)
219+ }
220+ return domain.TrafficService{Name: refValue, Backends: []domain.TrafficBackend{{Name: ref.Name + ":" + ref.PortName, Host: ref.Name, Port: port.Container, Protocol: port.Protocol}}}, nil
221+}
222+
223+func validateNetworkServices(services []NetworkServiceConfig) error {
224+ seen := map[string]struct{}{}
225+ for _, service := range services {
226+ if service.Name == "" {
227+ return fmt.Errorf("network service name is required")
228+ }
229+ if _, exists := seen[service.Name]; exists {
230+ return fmt.Errorf("duplicate network service %q", service.Name)
231+ }
232+ seen[service.Name] = struct{}{}
233+ if err := validateNetworkServicePorts(service); err != nil {
234+ return err
235+ }
236+ }
237+ return nil
238+}
239+
240+func validateNetworkServicePorts(service NetworkServiceConfig) error {
241+ seen := map[string]struct{}{}
242+ for _, port := range service.Ports {
243+ if port.Name == "" {
244+ return fmt.Errorf("network service %q port name is required", service.Name)
245+ }
246+ if _, exists := seen[port.Name]; exists {
247+ return fmt.Errorf("duplicate port %q on network service %q", port.Name, service.Name)
248+ }
249+ seen[port.Name] = struct{}{}
250+ }
251+ return nil
252+}
253+
254+func (b *builder) networkService(name string) (NetworkServiceConfig, bool) {
255+ for _, service := range b.input.NetworkServices {
256+ if service.Name == name {
257+ return service, true
258+ }
259+ }
260+ return NetworkServiceConfig{}, false
261+}
262+
263+func networkServicePort(service NetworkServiceConfig, name string) (PortConfig, bool) {
264+ for _, port := range service.Ports {
265+ if port.Name == name {
266+ return port, true
267+ }
268+ }
269+ return PortConfig{}, false
270+}
271+
272+func (b *builder) addService(service domain.TrafficService) {
273+ if _, exists := b.services[service.Name]; !exists {
274+ b.services[service.Name] = service
275+ }
276+}
277+
278+func buildOptions(cfg Config) (domain.TrafficOptions, error) {
279+ tcpDial, err := parseDurationDefault(cfg.TCP.DialTimeout, 10*time.Second, "traffic.tcp.dial_timeout")
280+ if err != nil {
281+ return domain.TrafficOptions{}, err
282+ }
283+ tcpIdle, err := parseDurationDefault(cfg.TCP.IdleTimeout, 5*time.Minute, "traffic.tcp.idle_timeout")
284+ if err != nil {
285+ return domain.TrafficOptions{}, err
286+ }
287+ tcpDrain, err := parseDurationDefault(cfg.TCP.DrainTimeout, 30*time.Second, "traffic.tcp.drain_timeout")
288+ if err != nil {
289+ return domain.TrafficOptions{}, err
290+ }
291+ udpIdle, err := parseDurationDefault(cfg.UDP.IdleTimeout, 30*time.Second, "traffic.udp.idle_timeout")
292+ if err != nil {
293+ return domain.TrafficOptions{}, err
294+ }
295+ udpDrain, err := parseDurationDefault(cfg.UDP.DrainTimeout, 30*time.Second, "traffic.udp.drain_timeout")
296+ if err != nil {
297+ return domain.TrafficOptions{}, err
298+ }
299+ return domain.TrafficOptions{TCP: domain.TCPOptions{DialTimeout: tcpDial, IdleTimeout: tcpIdle, DrainTimeout: tcpDrain, MaxConnections: cfg.TCP.MaxConnections}, UDP: domain.UDPOptions{IdleTimeout: udpIdle, DrainTimeout: udpDrain, MaxSessions: cfg.UDP.MaxSessions}}, nil
300+}
301+
302+func parseDurationDefault(value string, fallback time.Duration, field string) (time.Duration, error) {
303+ if strings.TrimSpace(value) == "" {
304+ return fallback, nil
305+ }
306+ d, err := time.ParseDuration(value)
307+ if err != nil {
308+ return 0, fmt.Errorf("invalid %s: %w", field, err)
309+ }
310+ return d, nil
311+}
312+
313+func parseBackendAddress(value string) (string, int, error) {
314+ host, portText, err := net.SplitHostPort(value)
315+ if err != nil {
316+ lastColon := strings.LastIndex(value, ":")
317+ if lastColon <= 0 || lastColon == len(value)-1 || strings.Count(value, ":") > 1 {
318+ return "", 0, err
319+ }
320+ host, portText = value[:lastColon], value[lastColon+1:]
321+ }
322+ port, err := strconv.Atoi(portText)
323+ if err != nil {
324+ return "", 0, fmt.Errorf("invalid port: %w", err)
325+ }
326+ if host == "" || port < 1 || port > 65535 {
327+ return "", 0, fmt.Errorf("host and valid port are required")
328+ }
329+ return host, port, nil
330+}
331+
332+func portAddress(port int) string {
333+ return ":" + strconv.Itoa(port)
334+}
335+
336+func sortedKeys[V any](m map[string]V) []string {
337+ keys := make([]string, 0, len(m))
338+ for k := range m {
339+ keys = append(keys, k)
340+ }
341+ sort.Strings(keys)
342+ return keys
343+}
A internal/usecase/traffic/builder_test.go +144 -0
@@ -0,0 +1,144 @@
1+package traffic
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestBuildDefaultsAndHTTPRoutes(t *testing.T) {
13+ graph, err := Build(Input{
14+ Server: ServerConfig{Port: 80, TLSPort: 443},
15+ Routes: []domain.Route{{Domain: "app.example.com"}, {Domain: "secure.example.com", HTTPS: true}},
16+ ExternalRoutes: map[string]string{"ext.example.com": "backend.local:8080"},
17+ })
18+ require.NoError(t, err)
19+
20+ require.Contains(t, graph.EntryPoints, domain.EntryPoint{Name: "web", Address: ":80", Protocol: domain.EntryPointProtocolHTTP})
21+ require.Contains(t, graph.EntryPoints, domain.EntryPoint{Name: "websecure", Address: ":443", Protocol: domain.EntryPointProtocolTLSMux})
22+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "web", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
23+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:secure.example.com", EntryPoint: "websecure", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "secure.example.com"}, Service: "route:secure.example.com"})
24+ require.Contains(t, graph.Services, domain.TrafficService{Name: "external_route:ext.example.com", Backends: []domain.TrafficBackend{{Name: "ext.example.com", Host: "backend.local", Port: 8080, Protocol: domain.NetworkProtocolTCP}}})
25+}
26+
27+func TestBuildCustomEntryPointTrustedCIDRs(t *testing.T) {
28+ graph, err := Build(Input{EntryPoints: map[string]EntryPointConfig{"tcp-public": {Address: ":5432", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}}})
29+ require.NoError(t, err)
30+ require.Equal(t, []domain.EntryPoint{{Name: "tcp-public", Address: ":5432", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}}, graph.EntryPoints)
31+}
32+
33+func TestBuildExplicitTCPUDPTLSRouterResolution(t *testing.T) {
34+ graph, err := Build(Input{
35+ EntryPoints: map[string]EntryPointConfig{
36+ "tcp-db": {Address: ":5432", Protocol: domain.EntryPointProtocolTCP},
37+ "udp-dns": {Address: ":5353", Protocol: domain.EntryPointProtocolUDP},
38+ "tls-db": {Address: ":15432", Protocol: domain.EntryPointProtocolTLSMux},
39+ },
40+ Traffic: Config{
41+ TCP: TCPConfig{Routers: []RouterConfig{{Name: "db", EntryPoint: "tcp-db", Service: "network_service:postgres:sql"}}},
42+ UDP: UDPConfig{Routers: []RouterConfig{{Name: "dns", EntryPoint: "udp-dns", Service: "network_service:coredns:dns"}}},
43+ TLS: TLSConfig{Routers: []RouterConfig{{Name: "tlsdb", EntryPoint: "tls-db", SNI: "db.example.com", Service: "network_service:postgres:sql"}}},
44+ },
45+ NetworkServices: []NetworkServiceConfig{
46+ {Name: "postgres", Ports: []PortConfig{{Name: "sql", Container: 5432, Protocol: domain.NetworkProtocolTCP}}},
47+ {Name: "coredns", Ports: []PortConfig{{Name: "dns", Container: 53, Protocol: domain.NetworkProtocolUDP}}},
48+ },
49+ })
50+ require.NoError(t, err)
51+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "db", EntryPoint: "tcp-db", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:sql"})
52+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "dns", EntryPoint: "udp-dns", Protocol: domain.RouterProtocolUDP, Service: "network_service:coredns:dns"})
53+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "tlsdb", EntryPoint: "tls-db", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "db.example.com"}, Service: "network_service:postgres:sql"})
54+ require.Contains(t, graph.Services, domain.TrafficService{Name: "network_service:postgres:sql", Backends: []domain.TrafficBackend{{Name: "postgres:sql", Host: "postgres", Port: 5432, Protocol: domain.NetworkProtocolTCP}}})
55+}
56+
57+func TestBuildRejectsStaticServiceRef(t *testing.T) {
58+ _, err := Build(Input{
59+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
60+ Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "static:legacy"}}}},
61+ })
62+ require.ErrorContains(t, err, "static")
63+}
64+
65+func TestBuildRejectsTLSHTTPRouteConflict(t *testing.T) {
66+ _, err := Build(Input{
67+ Server: ServerConfig{TLSPort: 443},
68+ Routes: []domain.Route{{Domain: "app.example.com", HTTPS: true}},
69+ Traffic: Config{TLS: TLSConfig{Routers: []RouterConfig{{Name: "passthrough", EntryPoint: "websecure", SNI: "app.example.com", Service: "network_service:app:https"}}}},
70+ NetworkServices: []NetworkServiceConfig{{Name: "app", Ports: []PortConfig{{Name: "https", Container: 443, Protocol: domain.NetworkProtocolTCP}}}},
71+ })
72+ require.ErrorContains(t, err, "http host conflicts")
73+}
74+
75+func TestBuildOptionsParsing(t *testing.T) {
76+ graph, err := Build(Input{Traffic: Config{TCP: TCPConfig{DialTimeout: "1s", IdleTimeout: "2m", DrainTimeout: "3s", MaxConnections: 12}, UDP: UDPConfig{IdleTimeout: "4s", DrainTimeout: "5s", MaxSessions: 34}}})
77+ require.NoError(t, err)
78+ require.Equal(t, time.Second, graph.Options.TCP.DialTimeout)
79+ require.Equal(t, 2*time.Minute, graph.Options.TCP.IdleTimeout)
80+ require.Equal(t, 3*time.Second, graph.Options.TCP.DrainTimeout)
81+ require.Equal(t, 12, graph.Options.TCP.MaxConnections)
82+ require.Equal(t, 4*time.Second, graph.Options.UDP.IdleTimeout)
83+ require.Equal(t, 5*time.Second, graph.Options.UDP.DrainTimeout)
84+ require.Equal(t, 34, graph.Options.UDP.MaxSessions)
85+
86+ defaults, err := Build(Input{})
87+ require.NoError(t, err)
88+ require.Equal(t, 10*time.Second, defaults.Options.TCP.DialTimeout)
89+ require.Equal(t, 5*time.Minute, defaults.Options.TCP.IdleTimeout)
90+ require.Equal(t, 30*time.Second, defaults.Options.TCP.DrainTimeout)
91+ require.Equal(t, 30*time.Second, defaults.Options.UDP.IdleTimeout)
92+ require.Equal(t, 30*time.Second, defaults.Options.UDP.DrainTimeout)
93+
94+ _, err = Build(Input{Traffic: Config{TCP: TCPConfig{DialTimeout: "bogus"}}})
95+ require.ErrorContains(t, err, "traffic.tcp.dial_timeout")
96+}
97+
98+func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) {
99+ t.Run("duplicate service name", func(t *testing.T) {
100+ _, err := Build(Input{NetworkServices: []NetworkServiceConfig{
101+ {Name: "db", Ports: []PortConfig{{Name: "sql", Container: 5432, Protocol: domain.NetworkProtocolTCP}}},
102+ {Name: "db", Ports: []PortConfig{{Name: "sql", Container: 5433, Protocol: domain.NetworkProtocolTCP}}},
103+ }})
104+ require.ErrorContains(t, err, "duplicate network service")
105+ })
106+
107+ t.Run("duplicate port name", func(t *testing.T) {
108+ _, err := Build(Input{NetworkServices: []NetworkServiceConfig{
109+ {Name: "db", Ports: []PortConfig{
110+ {Name: "sql", Container: 5432, Protocol: domain.NetworkProtocolTCP},
111+ {Name: "sql", Container: 5433, Protocol: domain.NetworkProtocolTCP},
112+ }},
113+ }})
114+ require.ErrorContains(t, err, "duplicate port")
115+ })
116+}
117+
118+func TestBuildRejectsInvalidServiceRefs(t *testing.T) {
119+ base := Input{
120+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
121+ NetworkServices: []NetworkServiceConfig{{Name: "svc", Ports: []PortConfig{{Name: "udp", Container: 53, Protocol: domain.NetworkProtocolUDP}}}},
122+ }
123+
124+ t.Run("unknown service", func(t *testing.T) {
125+ input := base
126+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "network_service:missing:tcp"}}}}
127+ _, err := Build(input)
128+ require.ErrorContains(t, err, "unknown network service")
129+ })
130+
131+ t.Run("unknown port", func(t *testing.T) {
132+ input := base
133+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "network_service:svc:missing"}}}}
134+ _, err := Build(input)
135+ require.ErrorContains(t, err, "unknown port")
136+ })
137+
138+ t.Run("protocol mismatch", func(t *testing.T) {
139+ input := base
140+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "network_service:svc:udp"}}}}
141+ _, err := Build(input)
142+ require.ErrorContains(t, err, "does not match")
143+ })
144+}
@@ -0,0 +1,144 @@
1+package traffic
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/gordon/internal/domain"
10+)
11+
12+func TestBuildDefaultsAndHTTPRoutes(t *testing.T) {
13+ graph, err := Build(Input{
14+ Server: ServerConfig{Port: 80, TLSPort: 443},
15+ Routes: []domain.Route{{Domain: "app.example.com"}, {Domain: "secure.example.com", HTTPS: true}},
16+ ExternalRoutes: map[string]string{"ext.example.com": "backend.local:8080"},
17+ })
18+ require.NoError(t, err)
19+
20+ require.Contains(t, graph.EntryPoints, domain.EntryPoint{Name: "web", Address: ":80", Protocol: domain.EntryPointProtocolHTTP})
21+ require.Contains(t, graph.EntryPoints, domain.EntryPoint{Name: "websecure", Address: ":443", Protocol: domain.EntryPointProtocolTLSMux})
22+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:app.example.com", EntryPoint: "web", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "app.example.com"}, Service: "route:app.example.com"})
23+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "route:secure.example.com", EntryPoint: "websecure", Protocol: domain.RouterProtocolHTTP, Rule: domain.TrafficRule{Host: "secure.example.com"}, Service: "route:secure.example.com"})
24+ require.Contains(t, graph.Services, domain.TrafficService{Name: "external_route:ext.example.com", Backends: []domain.TrafficBackend{{Name: "ext.example.com", Host: "backend.local", Port: 8080, Protocol: domain.NetworkProtocolTCP}}})
25+}
26+
27+func TestBuildCustomEntryPointTrustedCIDRs(t *testing.T) {
28+ graph, err := Build(Input{EntryPoints: map[string]EntryPointConfig{"tcp-public": {Address: ":5432", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}}})
29+ require.NoError(t, err)
30+ require.Equal(t, []domain.EntryPoint{{Name: "tcp-public", Address: ":5432", Protocol: domain.EntryPointProtocolTCP, TrustedCIDRs: []string{"10.0.0.0/8"}}}, graph.EntryPoints)
31+}
32+
33+func TestBuildExplicitTCPUDPTLSRouterResolution(t *testing.T) {
34+ graph, err := Build(Input{
35+ EntryPoints: map[string]EntryPointConfig{
36+ "tcp-db": {Address: ":5432", Protocol: domain.EntryPointProtocolTCP},
37+ "udp-dns": {Address: ":5353", Protocol: domain.EntryPointProtocolUDP},
38+ "tls-db": {Address: ":15432", Protocol: domain.EntryPointProtocolTLSMux},
39+ },
40+ Traffic: Config{
41+ TCP: TCPConfig{Routers: []RouterConfig{{Name: "db", EntryPoint: "tcp-db", Service: "network_service:postgres:sql"}}},
42+ UDP: UDPConfig{Routers: []RouterConfig{{Name: "dns", EntryPoint: "udp-dns", Service: "network_service:coredns:dns"}}},
43+ TLS: TLSConfig{Routers: []RouterConfig{{Name: "tlsdb", EntryPoint: "tls-db", SNI: "db.example.com", Service: "network_service:postgres:sql"}}},
44+ },
45+ NetworkServices: []NetworkServiceConfig{
46+ {Name: "postgres", Ports: []PortConfig{{Name: "sql", Container: 5432, Protocol: domain.NetworkProtocolTCP}}},
47+ {Name: "coredns", Ports: []PortConfig{{Name: "dns", Container: 53, Protocol: domain.NetworkProtocolUDP}}},
48+ },
49+ })
50+ require.NoError(t, err)
51+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "db", EntryPoint: "tcp-db", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:sql"})
52+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "dns", EntryPoint: "udp-dns", Protocol: domain.RouterProtocolUDP, Service: "network_service:coredns:dns"})
53+ require.Contains(t, graph.Routers, domain.TrafficRouter{Name: "tlsdb", EntryPoint: "tls-db", Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "db.example.com"}, Service: "network_service:postgres:sql"})
54+ require.Contains(t, graph.Services, domain.TrafficService{Name: "network_service:postgres:sql", Backends: []domain.TrafficBackend{{Name: "postgres:sql", Host: "postgres", Port: 5432, Protocol: domain.NetworkProtocolTCP}}})
55+}
56+
57+func TestBuildRejectsStaticServiceRef(t *testing.T) {
58+ _, err := Build(Input{
59+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
60+ Traffic: Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "static:legacy"}}}},
61+ })
62+ require.ErrorContains(t, err, "static")
63+}
64+
65+func TestBuildRejectsTLSHTTPRouteConflict(t *testing.T) {
66+ _, err := Build(Input{
67+ Server: ServerConfig{TLSPort: 443},
68+ Routes: []domain.Route{{Domain: "app.example.com", HTTPS: true}},
69+ Traffic: Config{TLS: TLSConfig{Routers: []RouterConfig{{Name: "passthrough", EntryPoint: "websecure", SNI: "app.example.com", Service: "network_service:app:https"}}}},
70+ NetworkServices: []NetworkServiceConfig{{Name: "app", Ports: []PortConfig{{Name: "https", Container: 443, Protocol: domain.NetworkProtocolTCP}}}},
71+ })
72+ require.ErrorContains(t, err, "http host conflicts")
73+}
74+
75+func TestBuildOptionsParsing(t *testing.T) {
76+ graph, err := Build(Input{Traffic: Config{TCP: TCPConfig{DialTimeout: "1s", IdleTimeout: "2m", DrainTimeout: "3s", MaxConnections: 12}, UDP: UDPConfig{IdleTimeout: "4s", DrainTimeout: "5s", MaxSessions: 34}}})
77+ require.NoError(t, err)
78+ require.Equal(t, time.Second, graph.Options.TCP.DialTimeout)
79+ require.Equal(t, 2*time.Minute, graph.Options.TCP.IdleTimeout)
80+ require.Equal(t, 3*time.Second, graph.Options.TCP.DrainTimeout)
81+ require.Equal(t, 12, graph.Options.TCP.MaxConnections)
82+ require.Equal(t, 4*time.Second, graph.Options.UDP.IdleTimeout)
83+ require.Equal(t, 5*time.Second, graph.Options.UDP.DrainTimeout)
84+ require.Equal(t, 34, graph.Options.UDP.MaxSessions)
85+
86+ defaults, err := Build(Input{})
87+ require.NoError(t, err)
88+ require.Equal(t, 10*time.Second, defaults.Options.TCP.DialTimeout)
89+ require.Equal(t, 5*time.Minute, defaults.Options.TCP.IdleTimeout)
90+ require.Equal(t, 30*time.Second, defaults.Options.TCP.DrainTimeout)
91+ require.Equal(t, 30*time.Second, defaults.Options.UDP.IdleTimeout)
92+ require.Equal(t, 30*time.Second, defaults.Options.UDP.DrainTimeout)
93+
94+ _, err = Build(Input{Traffic: Config{TCP: TCPConfig{DialTimeout: "bogus"}}})
95+ require.ErrorContains(t, err, "traffic.tcp.dial_timeout")
96+}
97+
98+func TestBuildRejectsAmbiguousNetworkServices(t *testing.T) {
99+ t.Run("duplicate service name", func(t *testing.T) {
100+ _, err := Build(Input{NetworkServices: []NetworkServiceConfig{
101+ {Name: "db", Ports: []PortConfig{{Name: "sql", Container: 5432, Protocol: domain.NetworkProtocolTCP}}},
102+ {Name: "db", Ports: []PortConfig{{Name: "sql", Container: 5433, Protocol: domain.NetworkProtocolTCP}}},
103+ }})
104+ require.ErrorContains(t, err, "duplicate network service")
105+ })
106+
107+ t.Run("duplicate port name", func(t *testing.T) {
108+ _, err := Build(Input{NetworkServices: []NetworkServiceConfig{
109+ {Name: "db", Ports: []PortConfig{
110+ {Name: "sql", Container: 5432, Protocol: domain.NetworkProtocolTCP},
111+ {Name: "sql", Container: 5433, Protocol: domain.NetworkProtocolTCP},
112+ }},
113+ }})
114+ require.ErrorContains(t, err, "duplicate port")
115+ })
116+}
117+
118+func TestBuildRejectsInvalidServiceRefs(t *testing.T) {
119+ base := Input{
120+ EntryPoints: map[string]EntryPointConfig{"tcp": {Address: ":1234", Protocol: domain.EntryPointProtocolTCP}},
121+ NetworkServices: []NetworkServiceConfig{{Name: "svc", Ports: []PortConfig{{Name: "udp", Container: 53, Protocol: domain.NetworkProtocolUDP}}}},
122+ }
123+
124+ t.Run("unknown service", func(t *testing.T) {
125+ input := base
126+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "network_service:missing:tcp"}}}}
127+ _, err := Build(input)
128+ require.ErrorContains(t, err, "unknown network service")
129+ })
130+
131+ t.Run("unknown port", func(t *testing.T) {
132+ input := base
133+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "network_service:svc:missing"}}}}
134+ _, err := Build(input)
135+ require.ErrorContains(t, err, "unknown port")
136+ })
137+
138+ t.Run("protocol mismatch", func(t *testing.T) {
139+ input := base
140+ input.Traffic = Config{TCP: TCPConfig{Routers: []RouterConfig{{Name: "bad", EntryPoint: "tcp", Service: "network_service:svc:udp"}}}}
141+ _, err := Build(input)
142+ require.ErrorContains(t, err, "does not match")
143+ })
144+}

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