feat(traffic): expose traffic runtime status
- commit
- 39cada732d96494a81809bd2f5806cda6807bcdf
- author
- brice · · 2026-06-29 22:20 UTC
- parent
- 02269b2
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +864 -12
M docs/cli/index.md +5 -0
| @@ -42,6 +42,7 @@ Management commands run locally through in-process services by default. Add `--r | |||
| 42 | 42 | | `gordon routes` | Manage routes | [routes](./routes.md) | | |
| 43 | 43 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | |
| 44 | 44 | | `gordon status` | Show Gordon server status | [status](./status.md) | | |
| 45 | + | | `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) | | |
| 45 | 46 | | `gordon volumes` | Manage volumes | - | | |
| 46 | 47 | ||
| 47 | 48 | ## Client Commands | |
| @@ -93,6 +94,10 @@ gordon logs myapp.example.com -f # Follow container logs | |||
| 93 | 94 | # Check version | |
| 94 | 95 | gordon version | |
| 95 | 96 | ||
| 97 | + | # Traffic plane | |
| 98 | + | gordon traffic status --remote prod | |
| 99 | + | gordon traffic status --remote prod --json | |
| 100 | + | ||
| 96 | 101 | # Backups | |
| 97 | 102 | gordon backups list | |
| 98 | 103 | gordon backups run app.example.com | |
| @@ -42,6 +42,7 @@ Management commands run locally through in-process services by default. Add `--r | |||||
| 42 | | `gordon routes` | Manage routes | [routes](./routes.md) | | 42 | | `gordon routes` | Manage routes | [routes](./routes.md) | | ||
| 43 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | 43 | | `gordon secrets` | Manage secrets | [secrets](./secrets.md) | | ||
| 44 | | `gordon status` | Show Gordon server status | [status](./status.md) | | 44 | | `gordon status` | Show Gordon server status | [status](./status.md) | | ||
| 45 | + | | `gordon traffic` | Inspect traffic plane status | [traffic](./traffic.md) | | |||
| 45 | | `gordon volumes` | Manage volumes | - | | 46 | | `gordon volumes` | Manage volumes | - | | ||
| 46 | 47 | ||||
| 47 | ## Client Commands | 48 | ## Client Commands | ||
| @@ -93,6 +94,10 @@ gordon logs myapp.example.com -f # Follow container logs | |||||
| 93 | # Check version | 94 | # Check version | ||
| 94 | gordon version | 95 | gordon version | ||
| 95 | 96 | ||||
| 97 | + | # Traffic plane | |||
| 98 | + | gordon traffic status --remote prod | |||
| 99 | + | gordon traffic status --remote prod --json | |||
| 100 | + | ||||
| 96 | # Backups | 101 | # Backups | ||
| 97 | gordon backups list | 102 | gordon backups list | ||
| 98 | gordon backups run app.example.com | 103 | gordon backups run app.example.com | ||
A docs/cli/traffic.md +62 -0
| @@ -0,0 +1,62 @@ | |||
| 1 | + | # gordon traffic | |
| 2 | + | ||
| 3 | + | Inspect Gordon traffic plane status. | |
| 4 | + | ||
| 5 | + | ## Subcommands | |
| 6 | + | ||
| 7 | + | | Command | Description | | |
| 8 | + | |---------|-------------| | |
| 9 | + | | `gordon traffic status` | Show traffic entrypoints, counters, and reload status | | |
| 10 | + | ||
| 11 | + | ## Usage | |
| 12 | + | ||
| 13 | + | ```bash | |
| 14 | + | gordon traffic status --remote prod | |
| 15 | + | gordon traffic status --remote https://gordon.example.com --json | |
| 16 | + | ``` | |
| 17 | + | ||
| 18 | + | ## Flags | |
| 19 | + | ||
| 20 | + | | Flag | Description | | |
| 21 | + | |------|-------------| | |
| 22 | + | | `--json` | Output machine-readable JSON | | |
| 23 | + | | `--remote`, `-r` | Remote Gordon instance or saved remote name | | |
| 24 | + | ||
| 25 | + | ## JSON Output | |
| 26 | + | ||
| 27 | + | ```json | |
| 28 | + | { | |
| 29 | + | "last_reload_status": "ok", | |
| 30 | + | "entrypoints": [ | |
| 31 | + | { | |
| 32 | + | "name": "postgres", | |
| 33 | + | "address": "0.0.0.0:5432", | |
| 34 | + | "protocol": "tcp", | |
| 35 | + | "active": true, | |
| 36 | + | "active_tcp_connections": 1, | |
| 37 | + | "active_udp_sessions": 0, | |
| 38 | + | "total_accepted": 12, | |
| 39 | + | "total_refused": 0, | |
| 40 | + | "total_errors": 0, | |
| 41 | + | "bytes_in": 4096, | |
| 42 | + | "bytes_out": 8192 | |
| 43 | + | } | |
| 44 | + | ], | |
| 45 | + | "routers": [], | |
| 46 | + | "services": [], | |
| 47 | + | "counters": { | |
| 48 | + | "active_tcp_connections": 1, | |
| 49 | + | "active_udp_sessions": 0, | |
| 50 | + | "total_accepted": 12, | |
| 51 | + | "total_refused": 0, | |
| 52 | + | "total_errors": 0, | |
| 53 | + | "bytes_in": 4096, | |
| 54 | + | "bytes_out": 8192 | |
| 55 | + | } | |
| 56 | + | } | |
| 57 | + | ``` | |
| 58 | + | ||
| 59 | + | ## Related | |
| 60 | + | ||
| 61 | + | - [Traffic configuration](../config/traffic.md) | |
| 62 | + | - [Server status](./status.md) | |
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | # gordon traffic | |||
| 2 | + | ||||
| 3 | + | Inspect Gordon traffic plane status. | |||
| 4 | + | ||||
| 5 | + | ## Subcommands | |||
| 6 | + | ||||
| 7 | + | | Command | Description | | |||
| 8 | + | |---------|-------------| | |||
| 9 | + | | `gordon traffic status` | Show traffic entrypoints, counters, and reload status | | |||
| 10 | + | ||||
| 11 | + | ## Usage | |||
| 12 | + | ||||
| 13 | + | ```bash | |||
| 14 | + | gordon traffic status --remote prod | |||
| 15 | + | gordon traffic status --remote https://gordon.example.com --json | |||
| 16 | + | ``` | |||
| 17 | + | ||||
| 18 | + | ## Flags | |||
| 19 | + | ||||
| 20 | + | | Flag | Description | | |||
| 21 | + | |------|-------------| | |||
| 22 | + | | `--json` | Output machine-readable JSON | | |||
| 23 | + | | `--remote`, `-r` | Remote Gordon instance or saved remote name | | |||
| 24 | + | ||||
| 25 | + | ## JSON Output | |||
| 26 | + | ||||
| 27 | + | ```json | |||
| 28 | + | { | |||
| 29 | + | "last_reload_status": "ok", | |||
| 30 | + | "entrypoints": [ | |||
| 31 | + | { | |||
| 32 | + | "name": "postgres", | |||
| 33 | + | "address": "0.0.0.0:5432", | |||
| 34 | + | "protocol": "tcp", | |||
| 35 | + | "active": true, | |||
| 36 | + | "active_tcp_connections": 1, | |||
| 37 | + | "active_udp_sessions": 0, | |||
| 38 | + | "total_accepted": 12, | |||
| 39 | + | "total_refused": 0, | |||
| 40 | + | "total_errors": 0, | |||
| 41 | + | "bytes_in": 4096, | |||
| 42 | + | "bytes_out": 8192 | |||
| 43 | + | } | |||
| 44 | + | ], | |||
| 45 | + | "routers": [], | |||
| 46 | + | "services": [], | |||
| 47 | + | "counters": { | |||
| 48 | + | "active_tcp_connections": 1, | |||
| 49 | + | "active_udp_sessions": 0, | |||
| 50 | + | "total_accepted": 12, | |||
| 51 | + | "total_refused": 0, | |||
| 52 | + | "total_errors": 0, | |||
| 53 | + | "bytes_in": 4096, | |||
| 54 | + | "bytes_out": 8192 | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ``` | |||
| 58 | + | ||||
| 59 | + | ## Related | |||
| 60 | + | ||||
| 61 | + | - [Traffic configuration](../config/traffic.md) | |||
| 62 | + | - [Server status](./status.md) | |||
M docs/config/index.md +2 -0
| @@ -172,6 +172,7 @@ keep_last = 3 | |||
| 172 | 172 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | |
| 173 | 173 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | |
| 174 | 174 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | |
| 175 | + | | `[entrypoints]`, `[traffic]`, `[[network_services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) | | |
| 175 | 176 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | |
| 176 | 177 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | |
| 177 | 178 | | `[backups]` | Database backups | [Backups](./backups.md) | | |
| @@ -284,6 +285,7 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||
| 284 | 285 | - [Server Configuration](./server.md) | |
| 285 | 286 | - [Routes Configuration](./routes.md) | |
| 286 | 287 | - [External Routes](./external-routes.md) | |
| 288 | + | - [Traffic Plane](./traffic.md) | |
| 287 | 289 | - [Authentication](./auth.md) | |
| 288 | 290 | - [Telemetry](./telemetry.md) | |
| 289 | 291 | - [Backups](./backups.md) | |
| @@ -172,6 +172,7 @@ keep_last = 3 | |||||
| 172 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | 172 | | `[auto_route]` | Automatic route creation | [Auto Route](./auto-route.md) | | ||
| 173 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | 173 | | `[routes]` | Domain to image mapping | [Routes](./routes.md) | | ||
| 174 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | 174 | | `[external_routes]` | Non-containerized service proxying | [External Routes](./external-routes.md) | | ||
| 175 | + | | `[entrypoints]`, `[traffic]`, `[[network_services]]` | L4 and TLS passthrough traffic plane | [Traffic](./traffic.md) | | |||
| 175 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | 176 | | `[network_groups]` | Shared service networks | [Network Groups](./network-groups.md) | | ||
| 176 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | 177 | | `[attachments]` | Service dependencies | [Attachments](./attachments.md) | | ||
| 177 | | `[backups]` | Database backups | [Backups](./backups.md) | | 178 | | `[backups]` | Database backups | [Backups](./backups.md) | | ||
| @@ -284,6 +285,7 @@ Pattern: `GORDON_SECTION_KEY` (uppercase, underscores instead of dots) | |||||
| 284 | - [Server Configuration](./server.md) | 285 | - [Server Configuration](./server.md) | ||
| 285 | - [Routes Configuration](./routes.md) | 286 | - [Routes Configuration](./routes.md) | ||
| 286 | - [External Routes](./external-routes.md) | 287 | - [External Routes](./external-routes.md) | ||
| 288 | + | - [Traffic Plane](./traffic.md) | |||
| 287 | - [Authentication](./auth.md) | 289 | - [Authentication](./auth.md) | ||
| 288 | - [Telemetry](./telemetry.md) | 290 | - [Telemetry](./telemetry.md) | ||
| 289 | - [Backups](./backups.md) | 291 | - [Backups](./backups.md) | ||
A docs/config/traffic.md +101 -0
| @@ -0,0 +1,101 @@ | |||
| 1 | + | # Traffic Plane Configuration | |
| 2 | + | ||
| 3 | + | Gordon models network listeners as a traffic graph: entrypoints receive connections, routers select a service, and services resolve to backends. | |
| 4 | + | ||
| 5 | + | ## Entrypoints | |
| 6 | + | ||
| 7 | + | `server.port` creates the default HTTP entrypoint `web`. `server.tls_port` creates the default TLS multiplexer entrypoint `websecure` when non-zero. | |
| 8 | + | ||
| 9 | + | Custom entrypoints use the top-level `entrypoints` table: | |
| 10 | + | ||
| 11 | + | ```toml | |
| 12 | + | [entrypoints.postgres] | |
| 13 | + | address = "0.0.0.0:5432" | |
| 14 | + | protocol = "tcp" | |
| 15 | + | trusted_cidrs = ["100.64.0.0/10"] | |
| 16 | + | ||
| 17 | + | [entrypoints.game] | |
| 18 | + | address = "0.0.0.0:7777" | |
| 19 | + | protocol = "udp" | |
| 20 | + | ``` | |
| 21 | + | ||
| 22 | + | Protocols are `http`, `tls_mux`, `tcp`, and `udp`. | |
| 23 | + | ||
| 24 | + | ## Network Services | |
| 25 | + | ||
| 26 | + | Network services describe non-HTTP backends that L4 routers can target. | |
| 27 | + | ||
| 28 | + | ```toml | |
| 29 | + | [[network_services]] | |
| 30 | + | name = "postgres" | |
| 31 | + | ||
| 32 | + | [[network_services.ports]] | |
| 33 | + | name = "db" | |
| 34 | + | container = 5432 | |
| 35 | + | protocol = "tcp" | |
| 36 | + | ``` | |
| 37 | + | ||
| 38 | + | Service references use: | |
| 39 | + | ||
| 40 | + | - `route:<domain>` for configured HTTP routes | |
| 41 | + | - `external_route:<domain>` for configured external HTTP routes | |
| 42 | + | - `network_service:<service>:<port-name>` for TCP, UDP, and TLS passthrough | |
| 43 | + | ||
| 44 | + | `static:<name>` is reserved and currently unsupported. | |
| 45 | + | ||
| 46 | + | ## TCP Routers | |
| 47 | + | ||
| 48 | + | ```toml | |
| 49 | + | [traffic.tcp] | |
| 50 | + | dial_timeout = "10s" | |
| 51 | + | idle_timeout = "5m" | |
| 52 | + | drain_timeout = "30s" | |
| 53 | + | max_connections = 0 | |
| 54 | + | ||
| 55 | + | [[traffic.tcp.routers]] | |
| 56 | + | name = "postgres" | |
| 57 | + | entrypoint = "postgres" | |
| 58 | + | service = "network_service:postgres:db" | |
| 59 | + | ``` | |
| 60 | + | ||
| 61 | + | A plain TCP entrypoint supports one TCP router. | |
| 62 | + | ||
| 63 | + | ## UDP Routers | |
| 64 | + | ||
| 65 | + | ```toml | |
| 66 | + | [traffic.udp] | |
| 67 | + | idle_timeout = "30s" | |
| 68 | + | drain_timeout = "30s" | |
| 69 | + | max_sessions = 0 | |
| 70 | + | ||
| 71 | + | [[traffic.udp.routers]] | |
| 72 | + | name = "game" | |
| 73 | + | entrypoint = "game" | |
| 74 | + | service = "network_service:game:game" | |
| 75 | + | ``` | |
| 76 | + | ||
| 77 | + | UDP sessions are keyed by client address and expire after `idle_timeout`. | |
| 78 | + | ||
| 79 | + | ## TLS Passthrough | |
| 80 | + | ||
| 81 | + | TLS passthrough routers run on traffic-manager-owned `tls_mux` entrypoints and route by ClientHello SNI without terminating TLS. The default `websecure` entrypoint is reserved for Gordon's legacy HTTPS listener until full HTTP/HTTPS traffic-manager ownership is enabled; use a custom `tls_mux` entrypoint for passthrough. | |
| 82 | + | ||
| 83 | + | ```toml | |
| 84 | + | [entrypoints.rawtls] | |
| 85 | + | address = "0.0.0.0:9443" | |
| 86 | + | protocol = "tls_mux" | |
| 87 | + | ||
| 88 | + | [[traffic.tls.routers]] | |
| 89 | + | name = "raw-tls" | |
| 90 | + | entrypoint = "rawtls" | |
| 91 | + | sni = "raw.example.com" | |
| 92 | + | service = "network_service:raw:tls" | |
| 93 | + | ``` | |
| 94 | + | ||
| 95 | + | Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts are rejected at validation time. | |
| 96 | + | ||
| 97 | + | ## Related | |
| 98 | + | ||
| 99 | + | - [Server Settings](./server.md) | |
| 100 | + | - [Routes](./routes.md) | |
| 101 | + | - [CLI traffic status](../cli/traffic.md) | |
| @@ -0,0 +1,101 @@ | |||||
| 1 | + | # Traffic Plane Configuration | |||
| 2 | + | ||||
| 3 | + | Gordon models network listeners as a traffic graph: entrypoints receive connections, routers select a service, and services resolve to backends. | |||
| 4 | + | ||||
| 5 | + | ## Entrypoints | |||
| 6 | + | ||||
| 7 | + | `server.port` creates the default HTTP entrypoint `web`. `server.tls_port` creates the default TLS multiplexer entrypoint `websecure` when non-zero. | |||
| 8 | + | ||||
| 9 | + | Custom entrypoints use the top-level `entrypoints` table: | |||
| 10 | + | ||||
| 11 | + | ```toml | |||
| 12 | + | [entrypoints.postgres] | |||
| 13 | + | address = "0.0.0.0:5432" | |||
| 14 | + | protocol = "tcp" | |||
| 15 | + | trusted_cidrs = ["100.64.0.0/10"] | |||
| 16 | + | ||||
| 17 | + | [entrypoints.game] | |||
| 18 | + | address = "0.0.0.0:7777" | |||
| 19 | + | protocol = "udp" | |||
| 20 | + | ``` | |||
| 21 | + | ||||
| 22 | + | Protocols are `http`, `tls_mux`, `tcp`, and `udp`. | |||
| 23 | + | ||||
| 24 | + | ## Network Services | |||
| 25 | + | ||||
| 26 | + | Network services describe non-HTTP backends that L4 routers can target. | |||
| 27 | + | ||||
| 28 | + | ```toml | |||
| 29 | + | [[network_services]] | |||
| 30 | + | name = "postgres" | |||
| 31 | + | ||||
| 32 | + | [[network_services.ports]] | |||
| 33 | + | name = "db" | |||
| 34 | + | container = 5432 | |||
| 35 | + | protocol = "tcp" | |||
| 36 | + | ``` | |||
| 37 | + | ||||
| 38 | + | Service references use: | |||
| 39 | + | ||||
| 40 | + | - `route:<domain>` for configured HTTP routes | |||
| 41 | + | - `external_route:<domain>` for configured external HTTP routes | |||
| 42 | + | - `network_service:<service>:<port-name>` for TCP, UDP, and TLS passthrough | |||
| 43 | + | ||||
| 44 | + | `static:<name>` is reserved and currently unsupported. | |||
| 45 | + | ||||
| 46 | + | ## TCP Routers | |||
| 47 | + | ||||
| 48 | + | ```toml | |||
| 49 | + | [traffic.tcp] | |||
| 50 | + | dial_timeout = "10s" | |||
| 51 | + | idle_timeout = "5m" | |||
| 52 | + | drain_timeout = "30s" | |||
| 53 | + | max_connections = 0 | |||
| 54 | + | ||||
| 55 | + | [[traffic.tcp.routers]] | |||
| 56 | + | name = "postgres" | |||
| 57 | + | entrypoint = "postgres" | |||
| 58 | + | service = "network_service:postgres:db" | |||
| 59 | + | ``` | |||
| 60 | + | ||||
| 61 | + | A plain TCP entrypoint supports one TCP router. | |||
| 62 | + | ||||
| 63 | + | ## UDP Routers | |||
| 64 | + | ||||
| 65 | + | ```toml | |||
| 66 | + | [traffic.udp] | |||
| 67 | + | idle_timeout = "30s" | |||
| 68 | + | drain_timeout = "30s" | |||
| 69 | + | max_sessions = 0 | |||
| 70 | + | ||||
| 71 | + | [[traffic.udp.routers]] | |||
| 72 | + | name = "game" | |||
| 73 | + | entrypoint = "game" | |||
| 74 | + | service = "network_service:game:game" | |||
| 75 | + | ``` | |||
| 76 | + | ||||
| 77 | + | UDP sessions are keyed by client address and expire after `idle_timeout`. | |||
| 78 | + | ||||
| 79 | + | ## TLS Passthrough | |||
| 80 | + | ||||
| 81 | + | TLS passthrough routers run on traffic-manager-owned `tls_mux` entrypoints and route by ClientHello SNI without terminating TLS. The default `websecure` entrypoint is reserved for Gordon's legacy HTTPS listener until full HTTP/HTTPS traffic-manager ownership is enabled; use a custom `tls_mux` entrypoint for passthrough. | |||
| 82 | + | ||||
| 83 | + | ```toml | |||
| 84 | + | [entrypoints.rawtls] | |||
| 85 | + | address = "0.0.0.0:9443" | |||
| 86 | + | protocol = "tls_mux" | |||
| 87 | + | ||||
| 88 | + | [[traffic.tls.routers]] | |||
| 89 | + | name = "raw-tls" | |||
| 90 | + | entrypoint = "rawtls" | |||
| 91 | + | sni = "raw.example.com" | |||
| 92 | + | service = "network_service:raw:tls" | |||
| 93 | + | ``` | |||
| 94 | + | ||||
| 95 | + | Exact SNI matches win over wildcard matches. Ambiguous wildcard overlaps and HTTP-host/TLS-passthrough conflicts are rejected at validation time. | |||
| 96 | + | ||||
| 97 | + | ## Related | |||
| 98 | + | ||||
| 99 | + | - [Server Settings](./server.md) | |||
| 100 | + | - [Routes](./routes.md) | |||
| 101 | + | - [CLI traffic status](../cli/traffic.md) | |||
M docs/index.md +1 -0
| @@ -47,6 +47,7 @@ Gordon runs on your VPS and provides: | |||
| 47 | 47 | - [Configuration Overview](./config/index.md) - All configuration options | |
| 48 | 48 | - [Server Settings](./config/server.md) - Ports, domains, and runtime | |
| 49 | 49 | - [Routes](./config/routes.md) - Domain to container mapping | |
| 50 | + | - [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints | |
| 50 | 51 | - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows | |
| 51 | 52 | - [Secrets](./config/secrets.md) - Secure credential storage | |
| 52 | 53 | - [Network Isolation](./config/network-isolation.md) - Per-app network isolation | |
| @@ -47,6 +47,7 @@ Gordon runs on your VPS and provides: | |||||
| 47 | - [Configuration Overview](./config/index.md) - All configuration options | 47 | - [Configuration Overview](./config/index.md) - All configuration options | ||
| 48 | - [Server Settings](./config/server.md) - Ports, domains, and runtime | 48 | - [Server Settings](./config/server.md) - Ports, domains, and runtime | ||
| 49 | - [Routes](./config/routes.md) - Domain to container mapping | 49 | - [Routes](./config/routes.md) - Domain to container mapping | ||
| 50 | + | - [Traffic Plane](./config/traffic.md) - TCP, UDP, and TLS passthrough entrypoints | |||
| 50 | - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows | 51 | - [Authentication](./config/auth.md) - Registry auth plus remote CLI login/token workflows | ||
| 51 | - [Secrets](./config/secrets.md) - Secure credential storage | 52 | - [Secrets](./config/secrets.md) - Secure credential storage | ||
| 52 | - [Network Isolation](./config/network-isolation.md) - Per-app network isolation | 53 | - [Network Isolation](./config/network-isolation.md) - Per-app network isolation | ||
A internal/adapters/dto/traffic.go +148 -0
| @@ -0,0 +1,148 @@ | |||
| 1 | + | package dto | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/gordon/internal/domain" | |
| 4 | + | ||
| 5 | + | type TrafficStatusResponse struct { | |
| 6 | + | LastReloadStatus string `json:"last_reload_status"` | |
| 7 | + | LastReloadError string `json:"last_reload_error,omitempty"` | |
| 8 | + | EntryPoints []TrafficEntryPointStatus `json:"entrypoints"` | |
| 9 | + | Routers []TrafficRouterStatus `json:"routers"` | |
| 10 | + | Services []TrafficServiceStatus `json:"services"` | |
| 11 | + | Counters TrafficCounters `json:"counters"` | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | type TrafficEntryPointStatus struct { | |
| 15 | + | Name string `json:"name"` | |
| 16 | + | Address string `json:"address"` | |
| 17 | + | Protocol domain.EntryPointProtocol `json:"protocol"` | |
| 18 | + | Active bool `json:"active"` | |
| 19 | + | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |
| 20 | + | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |
| 21 | + | TotalAccepted int64 `json:"total_accepted"` | |
| 22 | + | TotalRefused int64 `json:"total_refused"` | |
| 23 | + | TotalErrors int64 `json:"total_errors"` | |
| 24 | + | BytesIn int64 `json:"bytes_in"` | |
| 25 | + | BytesOut int64 `json:"bytes_out"` | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | type TrafficRouterStatus struct { | |
| 29 | + | Name string `json:"name"` | |
| 30 | + | EntryPoint string `json:"entrypoint"` | |
| 31 | + | Protocol domain.RouterProtocol `json:"protocol"` | |
| 32 | + | Rule TrafficRule `json:"rule"` | |
| 33 | + | Service string `json:"service"` | |
| 34 | + | Active bool `json:"active"` | |
| 35 | + | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |
| 36 | + | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |
| 37 | + | TotalAccepted int64 `json:"total_accepted"` | |
| 38 | + | TotalRefused int64 `json:"total_refused"` | |
| 39 | + | TotalErrors int64 `json:"total_errors"` | |
| 40 | + | BytesIn int64 `json:"bytes_in"` | |
| 41 | + | BytesOut int64 `json:"bytes_out"` | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | type TrafficServiceStatus struct { | |
| 45 | + | Name string `json:"name"` | |
| 46 | + | Active bool `json:"active"` | |
| 47 | + | Backends []TrafficBackendStatus `json:"backends"` | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | type TrafficRule struct { | |
| 51 | + | Host string `json:"host,omitempty"` | |
| 52 | + | SNI string `json:"sni,omitempty"` | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | type TrafficBackendStatus struct { | |
| 56 | + | Name string `json:"name"` | |
| 57 | + | Host string `json:"host"` | |
| 58 | + | Port int `json:"port"` | |
| 59 | + | Protocol domain.NetworkProtocol `json:"protocol"` | |
| 60 | + | Active bool `json:"active"` | |
| 61 | + | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |
| 62 | + | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |
| 63 | + | TotalAccepted int64 `json:"total_accepted"` | |
| 64 | + | TotalRefused int64 `json:"total_refused"` | |
| 65 | + | TotalErrors int64 `json:"total_errors"` | |
| 66 | + | BytesIn int64 `json:"bytes_in"` | |
| 67 | + | BytesOut int64 `json:"bytes_out"` | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | type TrafficCounters struct { | |
| 71 | + | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |
| 72 | + | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |
| 73 | + | TotalAccepted int64 `json:"total_accepted"` | |
| 74 | + | TotalRefused int64 `json:"total_refused"` | |
| 75 | + | TotalErrors int64 `json:"total_errors"` | |
| 76 | + | BytesIn int64 `json:"bytes_in"` | |
| 77 | + | BytesOut int64 `json:"bytes_out"` | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func TrafficStatusFromDomain(status domain.TrafficStatus) TrafficStatusResponse { | |
| 81 | + | return TrafficStatusResponse{ | |
| 82 | + | LastReloadStatus: status.LastReloadStatus, | |
| 83 | + | LastReloadError: status.LastReloadError, | |
| 84 | + | EntryPoints: trafficEntryPointsFromDomain(status.EntryPoints), | |
| 85 | + | Routers: trafficRoutersFromDomain(status.Routers), | |
| 86 | + | Services: trafficServicesFromDomain(status.Services), | |
| 87 | + | Counters: trafficCountersFromDomain(status.Counters), | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | func trafficEntryPointsFromDomain(values []domain.EntryPointStatus) []TrafficEntryPointStatus { | |
| 92 | + | out := make([]TrafficEntryPointStatus, 0, len(values)) | |
| 93 | + | for _, value := range values { | |
| 94 | + | out = append(out, TrafficEntryPointStatus{ | |
| 95 | + | Name: value.Name, Address: value.Address, Protocol: value.Protocol, Active: value.Active, | |
| 96 | + | ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions, | |
| 97 | + | TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors, | |
| 98 | + | BytesIn: value.BytesIn, BytesOut: value.BytesOut, | |
| 99 | + | }) | |
| 100 | + | } | |
| 101 | + | return out | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | func trafficRoutersFromDomain(values []domain.TrafficRouterStatus) []TrafficRouterStatus { | |
| 105 | + | out := make([]TrafficRouterStatus, 0, len(values)) | |
| 106 | + | for _, value := range values { | |
| 107 | + | out = append(out, TrafficRouterStatus{ | |
| 108 | + | Name: value.Name, EntryPoint: value.EntryPoint, Protocol: value.Protocol, Rule: TrafficRule{Host: value.Rule.Host, SNI: value.Rule.SNI}, Service: value.Service, Active: value.Active, | |
| 109 | + | ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions, | |
| 110 | + | TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors, | |
| 111 | + | BytesIn: value.BytesIn, BytesOut: value.BytesOut, | |
| 112 | + | }) | |
| 113 | + | } | |
| 114 | + | return out | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func trafficServicesFromDomain(values []domain.TrafficServiceStatus) []TrafficServiceStatus { | |
| 118 | + | out := make([]TrafficServiceStatus, 0, len(values)) | |
| 119 | + | for _, value := range values { | |
| 120 | + | out = append(out, TrafficServiceStatus{Name: value.Name, Active: value.Active, Backends: trafficBackendsFromDomain(value.Backends)}) | |
| 121 | + | } | |
| 122 | + | return out | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | func trafficBackendsFromDomain(values []domain.TrafficBackendStatus) []TrafficBackendStatus { | |
| 126 | + | out := make([]TrafficBackendStatus, 0, len(values)) | |
| 127 | + | for _, value := range values { | |
| 128 | + | out = append(out, TrafficBackendStatus{ | |
| 129 | + | Name: value.Name, Host: value.Host, Port: value.Port, Protocol: value.Protocol, Active: value.Active, | |
| 130 | + | ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions, | |
| 131 | + | TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors, | |
| 132 | + | BytesIn: value.BytesIn, BytesOut: value.BytesOut, | |
| 133 | + | }) | |
| 134 | + | } | |
| 135 | + | return out | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func trafficCountersFromDomain(value domain.TrafficCounters) TrafficCounters { | |
| 139 | + | return TrafficCounters{ | |
| 140 | + | ActiveTCPConnections: value.ActiveTCPConnections, | |
| 141 | + | ActiveUDPSessions: value.ActiveUDPSessions, | |
| 142 | + | TotalAccepted: value.TotalAccepted, | |
| 143 | + | TotalRefused: value.TotalRefused, | |
| 144 | + | TotalErrors: value.TotalErrors, | |
| 145 | + | BytesIn: value.BytesIn, | |
| 146 | + | BytesOut: value.BytesOut, | |
| 147 | + | } | |
| 148 | + | } | |
| @@ -0,0 +1,148 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/gordon/internal/domain" | |||
| 4 | + | ||||
| 5 | + | type TrafficStatusResponse struct { | |||
| 6 | + | LastReloadStatus string `json:"last_reload_status"` | |||
| 7 | + | LastReloadError string `json:"last_reload_error,omitempty"` | |||
| 8 | + | EntryPoints []TrafficEntryPointStatus `json:"entrypoints"` | |||
| 9 | + | Routers []TrafficRouterStatus `json:"routers"` | |||
| 10 | + | Services []TrafficServiceStatus `json:"services"` | |||
| 11 | + | Counters TrafficCounters `json:"counters"` | |||
| 12 | + | } | |||
| 13 | + | ||||
| 14 | + | type TrafficEntryPointStatus struct { | |||
| 15 | + | Name string `json:"name"` | |||
| 16 | + | Address string `json:"address"` | |||
| 17 | + | Protocol domain.EntryPointProtocol `json:"protocol"` | |||
| 18 | + | Active bool `json:"active"` | |||
| 19 | + | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |||
| 20 | + | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |||
| 21 | + | TotalAccepted int64 `json:"total_accepted"` | |||
| 22 | + | TotalRefused int64 `json:"total_refused"` | |||
| 23 | + | TotalErrors int64 `json:"total_errors"` | |||
| 24 | + | BytesIn int64 `json:"bytes_in"` | |||
| 25 | + | BytesOut int64 `json:"bytes_out"` | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | type TrafficRouterStatus struct { | |||
| 29 | + | Name string `json:"name"` | |||
| 30 | + | EntryPoint string `json:"entrypoint"` | |||
| 31 | + | Protocol domain.RouterProtocol `json:"protocol"` | |||
| 32 | + | Rule TrafficRule `json:"rule"` | |||
| 33 | + | Service string `json:"service"` | |||
| 34 | + | Active bool `json:"active"` | |||
| 35 | + | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |||
| 36 | + | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |||
| 37 | + | TotalAccepted int64 `json:"total_accepted"` | |||
| 38 | + | TotalRefused int64 `json:"total_refused"` | |||
| 39 | + | TotalErrors int64 `json:"total_errors"` | |||
| 40 | + | BytesIn int64 `json:"bytes_in"` | |||
| 41 | + | BytesOut int64 `json:"bytes_out"` | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | type TrafficServiceStatus struct { | |||
| 45 | + | Name string `json:"name"` | |||
| 46 | + | Active bool `json:"active"` | |||
| 47 | + | Backends []TrafficBackendStatus `json:"backends"` | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | type TrafficRule struct { | |||
| 51 | + | Host string `json:"host,omitempty"` | |||
| 52 | + | SNI string `json:"sni,omitempty"` | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | type TrafficBackendStatus struct { | |||
| 56 | + | Name string `json:"name"` | |||
| 57 | + | Host string `json:"host"` | |||
| 58 | + | Port int `json:"port"` | |||
| 59 | + | Protocol domain.NetworkProtocol `json:"protocol"` | |||
| 60 | + | Active bool `json:"active"` | |||
| 61 | + | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |||
| 62 | + | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |||
| 63 | + | TotalAccepted int64 `json:"total_accepted"` | |||
| 64 | + | TotalRefused int64 `json:"total_refused"` | |||
| 65 | + | TotalErrors int64 `json:"total_errors"` | |||
| 66 | + | BytesIn int64 `json:"bytes_in"` | |||
| 67 | + | BytesOut int64 `json:"bytes_out"` | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | type TrafficCounters struct { | |||
| 71 | + | ActiveTCPConnections int64 `json:"active_tcp_connections"` | |||
| 72 | + | ActiveUDPSessions int64 `json:"active_udp_sessions"` | |||
| 73 | + | TotalAccepted int64 `json:"total_accepted"` | |||
| 74 | + | TotalRefused int64 `json:"total_refused"` | |||
| 75 | + | TotalErrors int64 `json:"total_errors"` | |||
| 76 | + | BytesIn int64 `json:"bytes_in"` | |||
| 77 | + | BytesOut int64 `json:"bytes_out"` | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func TrafficStatusFromDomain(status domain.TrafficStatus) TrafficStatusResponse { | |||
| 81 | + | return TrafficStatusResponse{ | |||
| 82 | + | LastReloadStatus: status.LastReloadStatus, | |||
| 83 | + | LastReloadError: status.LastReloadError, | |||
| 84 | + | EntryPoints: trafficEntryPointsFromDomain(status.EntryPoints), | |||
| 85 | + | Routers: trafficRoutersFromDomain(status.Routers), | |||
| 86 | + | Services: trafficServicesFromDomain(status.Services), | |||
| 87 | + | Counters: trafficCountersFromDomain(status.Counters), | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | func trafficEntryPointsFromDomain(values []domain.EntryPointStatus) []TrafficEntryPointStatus { | |||
| 92 | + | out := make([]TrafficEntryPointStatus, 0, len(values)) | |||
| 93 | + | for _, value := range values { | |||
| 94 | + | out = append(out, TrafficEntryPointStatus{ | |||
| 95 | + | Name: value.Name, Address: value.Address, Protocol: value.Protocol, Active: value.Active, | |||
| 96 | + | ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions, | |||
| 97 | + | TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors, | |||
| 98 | + | BytesIn: value.BytesIn, BytesOut: value.BytesOut, | |||
| 99 | + | }) | |||
| 100 | + | } | |||
| 101 | + | return out | |||
| 102 | + | } | |||
| 103 | + | ||||
| 104 | + | func trafficRoutersFromDomain(values []domain.TrafficRouterStatus) []TrafficRouterStatus { | |||
| 105 | + | out := make([]TrafficRouterStatus, 0, len(values)) | |||
| 106 | + | for _, value := range values { | |||
| 107 | + | out = append(out, TrafficRouterStatus{ | |||
| 108 | + | Name: value.Name, EntryPoint: value.EntryPoint, Protocol: value.Protocol, Rule: TrafficRule{Host: value.Rule.Host, SNI: value.Rule.SNI}, Service: value.Service, Active: value.Active, | |||
| 109 | + | ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions, | |||
| 110 | + | TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors, | |||
| 111 | + | BytesIn: value.BytesIn, BytesOut: value.BytesOut, | |||
| 112 | + | }) | |||
| 113 | + | } | |||
| 114 | + | return out | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func trafficServicesFromDomain(values []domain.TrafficServiceStatus) []TrafficServiceStatus { | |||
| 118 | + | out := make([]TrafficServiceStatus, 0, len(values)) | |||
| 119 | + | for _, value := range values { | |||
| 120 | + | out = append(out, TrafficServiceStatus{Name: value.Name, Active: value.Active, Backends: trafficBackendsFromDomain(value.Backends)}) | |||
| 121 | + | } | |||
| 122 | + | return out | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | func trafficBackendsFromDomain(values []domain.TrafficBackendStatus) []TrafficBackendStatus { | |||
| 126 | + | out := make([]TrafficBackendStatus, 0, len(values)) | |||
| 127 | + | for _, value := range values { | |||
| 128 | + | out = append(out, TrafficBackendStatus{ | |||
| 129 | + | Name: value.Name, Host: value.Host, Port: value.Port, Protocol: value.Protocol, Active: value.Active, | |||
| 130 | + | ActiveTCPConnections: value.ActiveTCPConnections, ActiveUDPSessions: value.ActiveUDPSessions, | |||
| 131 | + | TotalAccepted: value.TotalAccepted, TotalRefused: value.TotalRefused, TotalErrors: value.TotalErrors, | |||
| 132 | + | BytesIn: value.BytesIn, BytesOut: value.BytesOut, | |||
| 133 | + | }) | |||
| 134 | + | } | |||
| 135 | + | return out | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func trafficCountersFromDomain(value domain.TrafficCounters) TrafficCounters { | |||
| 139 | + | return TrafficCounters{ | |||
| 140 | + | ActiveTCPConnections: value.ActiveTCPConnections, | |||
| 141 | + | ActiveUDPSessions: value.ActiveUDPSessions, | |||
| 142 | + | TotalAccepted: value.TotalAccepted, | |||
| 143 | + | TotalRefused: value.TotalRefused, | |||
| 144 | + | TotalErrors: value.TotalErrors, | |||
| 145 | + | BytesIn: value.BytesIn, | |||
| 146 | + | BytesOut: value.BytesOut, | |||
| 147 | + | } | |||
| 148 | + | } | |||
M internal/adapters/in/cli/remote/client.go +21 -7
| @@ -701,6 +701,21 @@ func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, erro | |||
| 701 | 701 | return &status, nil | |
| 702 | 702 | } | |
| 703 | 703 | ||
| 704 | + | // GetTrafficStatus returns the traffic plane status. | |
| 705 | + | func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) { | |
| 706 | + | resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil) | |
| 707 | + | if err != nil { | |
| 708 | + | return nil, err | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | var status dto.TrafficStatusResponse | |
| 712 | + | if err := parseResponse(resp, &status); err != nil { | |
| 713 | + | return nil, err | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | return &status, nil | |
| 717 | + | } | |
| 718 | + | ||
| 704 | 719 | // GetStatus returns the Gordon server status. | |
| 705 | 720 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | |
| 706 | 721 | resp, err := c.request(ctx, http.MethodGet, "/status", nil) | |
| @@ -1351,19 +1366,18 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er | |||
| 1351 | 1366 | // Process complete SSE events | |
| 1352 | 1367 | for { | |
| 1353 | 1368 | data := lineBuffer.String() | |
| 1354 | - | idx := strings.Index(data, "\n\n") | |
| 1355 | - | if idx == -1 { | |
| 1369 | + | event, remaining, ok := strings.Cut(data, "\n\n") | |
| 1370 | + | if !ok { | |
| 1356 | 1371 | break | |
| 1357 | 1372 | } | |
| 1358 | 1373 | ||
| 1359 | - | event := data[:idx] | |
| 1360 | 1374 | lineBuffer.Reset() | |
| 1361 | - | lineBuffer.WriteString(data[idx+2:]) | |
| 1375 | + | lineBuffer.WriteString(remaining) | |
| 1362 | 1376 | ||
| 1363 | 1377 | // Parse SSE data lines | |
| 1364 | - | for _, line := range strings.Split(event, "\n") { | |
| 1365 | - | if strings.HasPrefix(line, "data: ") { | |
| 1366 | - | logLine := strings.TrimPrefix(line, "data: ") | |
| 1378 | + | for line := range strings.SplitSeq(event, "\n") { | |
| 1379 | + | logLine, ok := strings.CutPrefix(line, "data: ") | |
| 1380 | + | if ok { | |
| 1367 | 1381 | select { | |
| 1368 | 1382 | case ch <- logLine: | |
| 1369 | 1383 | case <-ctx.Done(): | |
| @@ -701,6 +701,21 @@ func (c *Client) GetTLSStatus(ctx context.Context) (*dto.TLSStatusResponse, erro | |||||
| 701 | return &status, nil | 701 | return &status, nil | ||
| 702 | } | 702 | } | ||
| 703 | 703 | ||||
| 704 | + | // GetTrafficStatus returns the traffic plane status. | |||
| 705 | + | func (c *Client) GetTrafficStatus(ctx context.Context) (*dto.TrafficStatusResponse, error) { | |||
| 706 | + | resp, err := c.request(ctx, http.MethodGet, "/traffic/status", nil) | |||
| 707 | + | if err != nil { | |||
| 708 | + | return nil, err | |||
| 709 | + | } | |||
| 710 | + | ||||
| 711 | + | var status dto.TrafficStatusResponse | |||
| 712 | + | if err := parseResponse(resp, &status); err != nil { | |||
| 713 | + | return nil, err | |||
| 714 | + | } | |||
| 715 | + | ||||
| 716 | + | return &status, nil | |||
| 717 | + | } | |||
| 718 | + | ||||
| 704 | // GetStatus returns the Gordon server status. | 719 | // GetStatus returns the Gordon server status. | ||
| 705 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | 720 | func (c *Client) GetStatus(ctx context.Context) (*Status, error) { | ||
| 706 | resp, err := c.request(ctx, http.MethodGet, "/status", nil) | 721 | resp, err := c.request(ctx, http.MethodGet, "/status", nil) | ||
| @@ -1351,19 +1366,18 @@ func (c *Client) streamLogs(ctx context.Context, path string) (<-chan string, er | |||||
| 1351 | // Process complete SSE events | 1366 | // Process complete SSE events | ||
| 1352 | for { | 1367 | for { | ||
| 1353 | data := lineBuffer.String() | 1368 | data := lineBuffer.String() | ||
| 1354 | - | idx := strings.Index(data, "\n\n") | 1369 | + | event, remaining, ok := strings.Cut(data, "\n\n") |
| 1355 | - | if idx == -1 { | 1370 | + | if !ok { |
| 1356 | break | 1371 | break | ||
| 1357 | } | 1372 | } | ||
| 1358 | 1373 | ||||
| 1359 | - | event := data[:idx] | |||
| 1360 | lineBuffer.Reset() | 1374 | lineBuffer.Reset() | ||
| 1361 | - | lineBuffer.WriteString(data[idx+2:]) | 1375 | + | lineBuffer.WriteString(remaining) |
| 1362 | 1376 | ||||
| 1363 | // Parse SSE data lines | 1377 | // Parse SSE data lines | ||
| 1364 | - | for _, line := range strings.Split(event, "\n") { | 1378 | + | for line := range strings.SplitSeq(event, "\n") { |
| 1365 | - | if strings.HasPrefix(line, "data: ") { | 1379 | + | logLine, ok := strings.CutPrefix(line, "data: ") |
| 1366 | - | logLine := strings.TrimPrefix(line, "data: ") | 1380 | + | if ok { |
| 1367 | select { | 1381 | select { | ||
| 1368 | case ch <- logLine: | 1382 | case ch <- logLine: | ||
| 1369 | case <-ctx.Done(): | 1383 | case <-ctx.Done(): | ||
M internal/adapters/in/cli/remote/client_test.go +17 -0
| @@ -117,6 +117,23 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) { | |||
| 117 | 117 | require.NoError(t, err) | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | + | func TestClientGetTrafficStatus(t *testing.T) { | |
| 121 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 122 | + | require.Equal(t, "/admin/traffic/status", r.URL.Path) | |
| 123 | + | require.Equal(t, http.MethodGet, r.Method) | |
| 124 | + | w.Header().Set("Content-Type", "application/json") | |
| 125 | + | _, _ = w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_tcp_connections":1}}`)) | |
| 126 | + | })) | |
| 127 | + | defer srv.Close() | |
| 128 | + | ||
| 129 | + | client := NewClient(srv.URL) | |
| 130 | + | status, err := client.GetTrafficStatus(context.Background()) | |
| 131 | + | require.NoError(t, err) | |
| 132 | + | require.NotNil(t, status) | |
| 133 | + | assert.Equal(t, "ok", status.LastReloadStatus) | |
| 134 | + | assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections) | |
| 135 | + | } | |
| 136 | + | ||
| 120 | 137 | func TestClientGetTLSStatus(t *testing.T) { | |
| 121 | 138 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |
| 122 | 139 | require.Equal(t, "/admin/tls/status", r.URL.Path) | |
| @@ -117,6 +117,23 @@ func TestClientWithInsecureTLS_AllowsSelfSignedCertificate(t *testing.T) { | |||||
| 117 | require.NoError(t, err) | 117 | require.NoError(t, err) | ||
| 118 | } | 118 | } | ||
| 119 | 119 | ||||
| 120 | + | func TestClientGetTrafficStatus(t *testing.T) { | |||
| 121 | + | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | |||
| 122 | + | require.Equal(t, "/admin/traffic/status", r.URL.Path) | |||
| 123 | + | require.Equal(t, http.MethodGet, r.Method) | |||
| 124 | + | w.Header().Set("Content-Type", "application/json") | |||
| 125 | + | _, _ = w.Write([]byte(`{"last_reload_status":"ok","counters":{"active_tcp_connections":1}}`)) | |||
| 126 | + | })) | |||
| 127 | + | defer srv.Close() | |||
| 128 | + | ||||
| 129 | + | client := NewClient(srv.URL) | |||
| 130 | + | status, err := client.GetTrafficStatus(context.Background()) | |||
| 131 | + | require.NoError(t, err) | |||
| 132 | + | require.NotNil(t, status) | |||
| 133 | + | assert.Equal(t, "ok", status.LastReloadStatus) | |||
| 134 | + | assert.Equal(t, int64(1), status.Counters.ActiveTCPConnections) | |||
| 135 | + | } | |||
| 136 | + | ||||
| 120 | func TestClientGetTLSStatus(t *testing.T) { | 137 | func TestClientGetTLSStatus(t *testing.T) { | ||
| 121 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 138 | srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | ||
| 122 | require.Equal(t, "/admin/tls/status", r.URL.Path) | 139 | require.Equal(t, "/admin/tls/status", r.URL.Path) | ||
M internal/adapters/in/cli/root.go +4 -0
| @@ -154,6 +154,10 @@ Commands are organized by where they run: | |||
| 154 | 154 | tlsCmd.GroupID = groupManage | |
| 155 | 155 | rootCmd.AddCommand(tlsCmd) | |
| 156 | 156 | ||
| 157 | + | trafficCmd := newTrafficCmd() | |
| 158 | + | trafficCmd.GroupID = groupManage | |
| 159 | + | rootCmd.AddCommand(trafficCmd) | |
| 160 | + | ||
| 157 | 161 | // Client-only commands (no server needed) | |
| 158 | 162 | remotesCmd := newRemotesCmd() | |
| 159 | 163 | remotesCmd.GroupID = groupClient | |
| @@ -154,6 +154,10 @@ Commands are organized by where they run: | |||||
| 154 | tlsCmd.GroupID = groupManage | 154 | tlsCmd.GroupID = groupManage | ||
| 155 | rootCmd.AddCommand(tlsCmd) | 155 | rootCmd.AddCommand(tlsCmd) | ||
| 156 | 156 | ||||
| 157 | + | trafficCmd := newTrafficCmd() | |||
| 158 | + | trafficCmd.GroupID = groupManage | |||
| 159 | + | rootCmd.AddCommand(trafficCmd) | |||
| 160 | + | ||||
| 157 | // Client-only commands (no server needed) | 161 | // Client-only commands (no server needed) | ||
| 158 | remotesCmd := newRemotesCmd() | 162 | remotesCmd := newRemotesCmd() | ||
| 159 | remotesCmd.GroupID = groupClient | 163 | remotesCmd.GroupID = groupClient | ||
A internal/adapters/in/cli/traffic.go +140 -0
| @@ -0,0 +1,140 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "io" | |
| 6 | + | ||
| 7 | + | "github.com/spf13/cobra" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func newTrafficCmd() *cobra.Command { | |
| 13 | + | cmd := &cobra.Command{ | |
| 14 | + | Use: "traffic", | |
| 15 | + | Short: "Inspect traffic plane status", | |
| 16 | + | } | |
| 17 | + | cmd.AddCommand(newTrafficStatusCmd()) | |
| 18 | + | return cmd | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func newTrafficStatusCmd() *cobra.Command { | |
| 22 | + | var jsonOut bool | |
| 23 | + | cmd := &cobra.Command{ | |
| 24 | + | Use: "status", | |
| 25 | + | Short: "Show traffic entrypoint, router, and counter status", | |
| 26 | + | RunE: func(cmd *cobra.Command, args []string) error { | |
| 27 | + | client, isRemote, err := GetRemoteClient() | |
| 28 | + | if err != nil { | |
| 29 | + | return err | |
| 30 | + | } | |
| 31 | + | if !isRemote { | |
| 32 | + | return fmt.Errorf("traffic status currently requires --remote") | |
| 33 | + | } | |
| 34 | + | status, err := client.GetTrafficStatus(cmd.Context()) | |
| 35 | + | if err != nil { | |
| 36 | + | return err | |
| 37 | + | } | |
| 38 | + | return renderTrafficStatus(cmd.OutOrStdout(), status, jsonOut) | |
| 39 | + | }, | |
| 40 | + | } | |
| 41 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON") | |
| 42 | + | return cmd | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func renderTrafficStatus(out io.Writer, status *dto.TrafficStatusResponse, jsonOut bool) error { | |
| 46 | + | if jsonOut { | |
| 47 | + | return writeJSON(out, status) | |
| 48 | + | } | |
| 49 | + | if err := cliWriteLine(out, cliRenderTitle("Traffic Status")); err != nil { | |
| 50 | + | return err | |
| 51 | + | } | |
| 52 | + | if err := cliWriteLine(out, cliRenderMeta("Reload", status.LastReloadStatus)); err != nil { | |
| 53 | + | return err | |
| 54 | + | } | |
| 55 | + | if status.LastReloadError != "" { | |
| 56 | + | if err := cliWriteLine(out, cliRenderWarning("Last reload error: "+status.LastReloadError)); err != nil { | |
| 57 | + | return err | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 61 | + | return err | |
| 62 | + | } | |
| 63 | + | if err := cliWriteLine(out, "Entrypoints:"); err != nil { | |
| 64 | + | return err | |
| 65 | + | } | |
| 66 | + | if len(status.EntryPoints) == 0 { | |
| 67 | + | if err := cliWriteLine(out, cliRenderMuted(" none")); err != nil { | |
| 68 | + | return err | |
| 69 | + | } | |
| 70 | + | } else { | |
| 71 | + | for _, entry := range status.EntryPoints { | |
| 72 | + | if err := cliWritef(out, " %s %s %s active=%t tcp=%d udp=%d accepted=%d refused=%d errors=%d\n", | |
| 73 | + | entry.Name, entry.Protocol, entry.Address, entry.Active, entry.ActiveTCPConnections, entry.ActiveUDPSessions, | |
| 74 | + | entry.TotalAccepted, entry.TotalRefused, entry.TotalErrors); err != nil { | |
| 75 | + | return err | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | } | |
| 79 | + | if err := renderTrafficRouters(out, status.Routers); err != nil { | |
| 80 | + | return err | |
| 81 | + | } | |
| 82 | + | if err := renderTrafficServices(out, status.Services); err != nil { | |
| 83 | + | return err | |
| 84 | + | } | |
| 85 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 86 | + | return err | |
| 87 | + | } | |
| 88 | + | return cliWritef(out, "Totals: tcp=%d udp=%d accepted=%d refused=%d errors=%d bytes_in=%d bytes_out=%d\n", | |
| 89 | + | status.Counters.ActiveTCPConnections, status.Counters.ActiveUDPSessions, status.Counters.TotalAccepted, | |
| 90 | + | status.Counters.TotalRefused, status.Counters.TotalErrors, status.Counters.BytesIn, status.Counters.BytesOut) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func renderTrafficRouters(out io.Writer, routers []dto.TrafficRouterStatus) error { | |
| 94 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 95 | + | return err | |
| 96 | + | } | |
| 97 | + | if err := cliWriteLine(out, "Routers:"); err != nil { | |
| 98 | + | return err | |
| 99 | + | } | |
| 100 | + | if len(routers) == 0 { | |
| 101 | + | return cliWriteLine(out, cliRenderMuted(" none")) | |
| 102 | + | } | |
| 103 | + | for _, router := range routers { | |
| 104 | + | rule := router.Rule.Host | |
| 105 | + | if rule == "" { | |
| 106 | + | rule = router.Rule.SNI | |
| 107 | + | } | |
| 108 | + | if err := cliWritef(out, " %s %s entrypoint=%s rule=%s service=%s active=%t accepted=%d refused=%d errors=%d\n", | |
| 109 | + | router.Name, router.Protocol, router.EntryPoint, rule, router.Service, router.Active, | |
| 110 | + | router.TotalAccepted, router.TotalRefused, router.TotalErrors); err != nil { | |
| 111 | + | return err | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | return nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func renderTrafficServices(out io.Writer, services []dto.TrafficServiceStatus) error { | |
| 118 | + | if err := cliWriteLine(out, ""); err != nil { | |
| 119 | + | return err | |
| 120 | + | } | |
| 121 | + | if err := cliWriteLine(out, "Services:"); err != nil { | |
| 122 | + | return err | |
| 123 | + | } | |
| 124 | + | if len(services) == 0 { | |
| 125 | + | return cliWriteLine(out, cliRenderMuted(" none")) | |
| 126 | + | } | |
| 127 | + | for _, service := range services { | |
| 128 | + | if err := cliWritef(out, " %s active=%t\n", service.Name, service.Active); err != nil { | |
| 129 | + | return err | |
| 130 | + | } | |
| 131 | + | for _, backend := range service.Backends { | |
| 132 | + | if err := cliWritef(out, " %s %s://%s:%d active=%t accepted=%d refused=%d errors=%d\n", | |
| 133 | + | backend.Name, backend.Protocol, backend.Host, backend.Port, backend.Active, | |
| 134 | + | backend.TotalAccepted, backend.TotalRefused, backend.TotalErrors); err != nil { | |
| 135 | + | return err | |
| 136 | + | } | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | return nil | |
| 140 | + | } | |
| @@ -0,0 +1,140 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "io" | |||
| 6 | + | ||||
| 7 | + | "github.com/spf13/cobra" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func newTrafficCmd() *cobra.Command { | |||
| 13 | + | cmd := &cobra.Command{ | |||
| 14 | + | Use: "traffic", | |||
| 15 | + | Short: "Inspect traffic plane status", | |||
| 16 | + | } | |||
| 17 | + | cmd.AddCommand(newTrafficStatusCmd()) | |||
| 18 | + | return cmd | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func newTrafficStatusCmd() *cobra.Command { | |||
| 22 | + | var jsonOut bool | |||
| 23 | + | cmd := &cobra.Command{ | |||
| 24 | + | Use: "status", | |||
| 25 | + | Short: "Show traffic entrypoint, router, and counter status", | |||
| 26 | + | RunE: func(cmd *cobra.Command, args []string) error { | |||
| 27 | + | client, isRemote, err := GetRemoteClient() | |||
| 28 | + | if err != nil { | |||
| 29 | + | return err | |||
| 30 | + | } | |||
| 31 | + | if !isRemote { | |||
| 32 | + | return fmt.Errorf("traffic status currently requires --remote") | |||
| 33 | + | } | |||
| 34 | + | status, err := client.GetTrafficStatus(cmd.Context()) | |||
| 35 | + | if err != nil { | |||
| 36 | + | return err | |||
| 37 | + | } | |||
| 38 | + | return renderTrafficStatus(cmd.OutOrStdout(), status, jsonOut) | |||
| 39 | + | }, | |||
| 40 | + | } | |||
| 41 | + | cmd.Flags().BoolVar(&jsonOut, "json", false, "Output JSON") | |||
| 42 | + | return cmd | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func renderTrafficStatus(out io.Writer, status *dto.TrafficStatusResponse, jsonOut bool) error { | |||
| 46 | + | if jsonOut { | |||
| 47 | + | return writeJSON(out, status) | |||
| 48 | + | } | |||
| 49 | + | if err := cliWriteLine(out, cliRenderTitle("Traffic Status")); err != nil { | |||
| 50 | + | return err | |||
| 51 | + | } | |||
| 52 | + | if err := cliWriteLine(out, cliRenderMeta("Reload", status.LastReloadStatus)); err != nil { | |||
| 53 | + | return err | |||
| 54 | + | } | |||
| 55 | + | if status.LastReloadError != "" { | |||
| 56 | + | if err := cliWriteLine(out, cliRenderWarning("Last reload error: "+status.LastReloadError)); err != nil { | |||
| 57 | + | return err | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 61 | + | return err | |||
| 62 | + | } | |||
| 63 | + | if err := cliWriteLine(out, "Entrypoints:"); err != nil { | |||
| 64 | + | return err | |||
| 65 | + | } | |||
| 66 | + | if len(status.EntryPoints) == 0 { | |||
| 67 | + | if err := cliWriteLine(out, cliRenderMuted(" none")); err != nil { | |||
| 68 | + | return err | |||
| 69 | + | } | |||
| 70 | + | } else { | |||
| 71 | + | for _, entry := range status.EntryPoints { | |||
| 72 | + | if err := cliWritef(out, " %s %s %s active=%t tcp=%d udp=%d accepted=%d refused=%d errors=%d\n", | |||
| 73 | + | entry.Name, entry.Protocol, entry.Address, entry.Active, entry.ActiveTCPConnections, entry.ActiveUDPSessions, | |||
| 74 | + | entry.TotalAccepted, entry.TotalRefused, entry.TotalErrors); err != nil { | |||
| 75 | + | return err | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | } | |||
| 79 | + | if err := renderTrafficRouters(out, status.Routers); err != nil { | |||
| 80 | + | return err | |||
| 81 | + | } | |||
| 82 | + | if err := renderTrafficServices(out, status.Services); err != nil { | |||
| 83 | + | return err | |||
| 84 | + | } | |||
| 85 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 86 | + | return err | |||
| 87 | + | } | |||
| 88 | + | return cliWritef(out, "Totals: tcp=%d udp=%d accepted=%d refused=%d errors=%d bytes_in=%d bytes_out=%d\n", | |||
| 89 | + | status.Counters.ActiveTCPConnections, status.Counters.ActiveUDPSessions, status.Counters.TotalAccepted, | |||
| 90 | + | status.Counters.TotalRefused, status.Counters.TotalErrors, status.Counters.BytesIn, status.Counters.BytesOut) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func renderTrafficRouters(out io.Writer, routers []dto.TrafficRouterStatus) error { | |||
| 94 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 95 | + | return err | |||
| 96 | + | } | |||
| 97 | + | if err := cliWriteLine(out, "Routers:"); err != nil { | |||
| 98 | + | return err | |||
| 99 | + | } | |||
| 100 | + | if len(routers) == 0 { | |||
| 101 | + | return cliWriteLine(out, cliRenderMuted(" none")) | |||
| 102 | + | } | |||
| 103 | + | for _, router := range routers { | |||
| 104 | + | rule := router.Rule.Host | |||
| 105 | + | if rule == "" { | |||
| 106 | + | rule = router.Rule.SNI | |||
| 107 | + | } | |||
| 108 | + | if err := cliWritef(out, " %s %s entrypoint=%s rule=%s service=%s active=%t accepted=%d refused=%d errors=%d\n", | |||
| 109 | + | router.Name, router.Protocol, router.EntryPoint, rule, router.Service, router.Active, | |||
| 110 | + | router.TotalAccepted, router.TotalRefused, router.TotalErrors); err != nil { | |||
| 111 | + | return err | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | return nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | func renderTrafficServices(out io.Writer, services []dto.TrafficServiceStatus) error { | |||
| 118 | + | if err := cliWriteLine(out, ""); err != nil { | |||
| 119 | + | return err | |||
| 120 | + | } | |||
| 121 | + | if err := cliWriteLine(out, "Services:"); err != nil { | |||
| 122 | + | return err | |||
| 123 | + | } | |||
| 124 | + | if len(services) == 0 { | |||
| 125 | + | return cliWriteLine(out, cliRenderMuted(" none")) | |||
| 126 | + | } | |||
| 127 | + | for _, service := range services { | |||
| 128 | + | if err := cliWritef(out, " %s active=%t\n", service.Name, service.Active); err != nil { | |||
| 129 | + | return err | |||
| 130 | + | } | |||
| 131 | + | for _, backend := range service.Backends { | |||
| 132 | + | if err := cliWritef(out, " %s %s://%s:%d active=%t accepted=%d refused=%d errors=%d\n", | |||
| 133 | + | backend.Name, backend.Protocol, backend.Host, backend.Port, backend.Active, | |||
| 134 | + | backend.TotalAccepted, backend.TotalRefused, backend.TotalErrors); err != nil { | |||
| 135 | + | return err | |||
| 136 | + | } | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | return nil | |||
| 140 | + | } | |||
A internal/adapters/in/cli/traffic_test.go +65 -0
| @@ -0,0 +1,65 @@ | |||
| 1 | + | package cli | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "encoding/json" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 12 | + | "github.com/bnema/gordon/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | func TestTrafficCommandExists(t *testing.T) { | |
| 16 | + | cmd := NewRootCmd() | |
| 17 | + | traffic, _, err := cmd.Find([]string{"traffic"}) | |
| 18 | + | require.NoError(t, err) | |
| 19 | + | require.NotNil(t, traffic) | |
| 20 | + | assert.Equal(t, "traffic", traffic.Name()) | |
| 21 | + | ||
| 22 | + | status, _, err := cmd.Find([]string{"traffic", "status"}) | |
| 23 | + | require.NoError(t, err) | |
| 24 | + | require.NotNil(t, status) | |
| 25 | + | assert.Equal(t, "status", status.Name()) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func TestTrafficStatusHumanOutput(t *testing.T) { | |
| 29 | + | status := &dto.TrafficStatusResponse{ | |
| 30 | + | LastReloadStatus: "ok", | |
| 31 | + | EntryPoints: []dto.TrafficEntryPointStatus{{ | |
| 32 | + | Name: "postgres", Address: "127.0.0.1:5432", Protocol: domain.EntryPointProtocolTCP, | |
| 33 | + | Active: true, ActiveTCPConnections: 1, TotalAccepted: 2, BytesIn: 10, BytesOut: 20, | |
| 34 | + | }}, | |
| 35 | + | Routers: []dto.TrafficRouterStatus{{ | |
| 36 | + | Name: "pg-router", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, | |
| 37 | + | Service: "network_service:postgres:db", Active: true, TotalAccepted: 2, | |
| 38 | + | }}, | |
| 39 | + | Services: []dto.TrafficServiceStatus{{ | |
| 40 | + | Name: "network_service:postgres:db", Active: true, | |
| 41 | + | Backends: []dto.TrafficBackendStatus{{Name: "postgres:db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP, Active: true}}, | |
| 42 | + | }}, | |
| 43 | + | Counters: dto.TrafficCounters{ActiveTCPConnections: 1, TotalAccepted: 2, BytesIn: 10, BytesOut: 20}, | |
| 44 | + | } | |
| 45 | + | var buf bytes.Buffer | |
| 46 | + | require.NoError(t, renderTrafficStatus(&buf, status, false)) | |
| 47 | + | output := buf.String() | |
| 48 | + | assert.Contains(t, output, "Traffic Status") | |
| 49 | + | assert.Contains(t, output, "postgres") | |
| 50 | + | assert.Contains(t, output, "127.0.0.1:5432") | |
| 51 | + | assert.Contains(t, output, "pg-router") | |
| 52 | + | assert.Contains(t, output, "network_service:postgres:db") | |
| 53 | + | assert.Contains(t, output, "tcp://127.0.0.1:5432") | |
| 54 | + | assert.Contains(t, output, "accepted=2") | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestTrafficStatusJSONOutput(t *testing.T) { | |
| 58 | + | status := &dto.TrafficStatusResponse{LastReloadStatus: "ok", Counters: dto.TrafficCounters{ActiveUDPSessions: 3}} | |
| 59 | + | var buf bytes.Buffer | |
| 60 | + | require.NoError(t, renderTrafficStatus(&buf, status, true)) | |
| 61 | + | var got dto.TrafficStatusResponse | |
| 62 | + | require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) | |
| 63 | + | assert.Equal(t, "ok", got.LastReloadStatus) | |
| 64 | + | assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions) | |
| 65 | + | } | |
| @@ -0,0 +1,65 @@ | |||||
| 1 | + | package cli | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestTrafficCommandExists(t *testing.T) { | |||
| 16 | + | cmd := NewRootCmd() | |||
| 17 | + | traffic, _, err := cmd.Find([]string{"traffic"}) | |||
| 18 | + | require.NoError(t, err) | |||
| 19 | + | require.NotNil(t, traffic) | |||
| 20 | + | assert.Equal(t, "traffic", traffic.Name()) | |||
| 21 | + | ||||
| 22 | + | status, _, err := cmd.Find([]string{"traffic", "status"}) | |||
| 23 | + | require.NoError(t, err) | |||
| 24 | + | require.NotNil(t, status) | |||
| 25 | + | assert.Equal(t, "status", status.Name()) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestTrafficStatusHumanOutput(t *testing.T) { | |||
| 29 | + | status := &dto.TrafficStatusResponse{ | |||
| 30 | + | LastReloadStatus: "ok", | |||
| 31 | + | EntryPoints: []dto.TrafficEntryPointStatus{{ | |||
| 32 | + | Name: "postgres", Address: "127.0.0.1:5432", Protocol: domain.EntryPointProtocolTCP, | |||
| 33 | + | Active: true, ActiveTCPConnections: 1, TotalAccepted: 2, BytesIn: 10, BytesOut: 20, | |||
| 34 | + | }}, | |||
| 35 | + | Routers: []dto.TrafficRouterStatus{{ | |||
| 36 | + | Name: "pg-router", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, | |||
| 37 | + | Service: "network_service:postgres:db", Active: true, TotalAccepted: 2, | |||
| 38 | + | }}, | |||
| 39 | + | Services: []dto.TrafficServiceStatus{{ | |||
| 40 | + | Name: "network_service:postgres:db", Active: true, | |||
| 41 | + | Backends: []dto.TrafficBackendStatus{{Name: "postgres:db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP, Active: true}}, | |||
| 42 | + | }}, | |||
| 43 | + | Counters: dto.TrafficCounters{ActiveTCPConnections: 1, TotalAccepted: 2, BytesIn: 10, BytesOut: 20}, | |||
| 44 | + | } | |||
| 45 | + | var buf bytes.Buffer | |||
| 46 | + | require.NoError(t, renderTrafficStatus(&buf, status, false)) | |||
| 47 | + | output := buf.String() | |||
| 48 | + | assert.Contains(t, output, "Traffic Status") | |||
| 49 | + | assert.Contains(t, output, "postgres") | |||
| 50 | + | assert.Contains(t, output, "127.0.0.1:5432") | |||
| 51 | + | assert.Contains(t, output, "pg-router") | |||
| 52 | + | assert.Contains(t, output, "network_service:postgres:db") | |||
| 53 | + | assert.Contains(t, output, "tcp://127.0.0.1:5432") | |||
| 54 | + | assert.Contains(t, output, "accepted=2") | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestTrafficStatusJSONOutput(t *testing.T) { | |||
| 58 | + | status := &dto.TrafficStatusResponse{LastReloadStatus: "ok", Counters: dto.TrafficCounters{ActiveUDPSessions: 3}} | |||
| 59 | + | var buf bytes.Buffer | |||
| 60 | + | require.NoError(t, renderTrafficStatus(&buf, status, true)) | |||
| 61 | + | var got dto.TrafficStatusResponse | |||
| 62 | + | require.NoError(t, json.Unmarshal(buf.Bytes(), &got)) | |||
| 63 | + | assert.Equal(t, "ok", got.LastReloadStatus) | |||
| 64 | + | assert.Equal(t, int64(3), got.Counters.ActiveUDPSessions) | |||
| 65 | + | } | |||
M internal/adapters/in/http/admin/handler.go +8 -0
| @@ -38,6 +38,10 @@ type reloadTrigger interface { | |||
| 38 | 38 | Trigger(ctx context.Context) error | |
| 39 | 39 | } | |
| 40 | 40 | ||
| 41 | + | type trafficStatusService interface { | |
| 42 | + | Status() domain.TrafficStatus | |
| 43 | + | } | |
| 44 | + | ||
| 41 | 45 | // Handler implements the HTTP handler for the admin API. | |
| 42 | 46 | type Handler struct { | |
| 43 | 47 | configSvc in.ConfigService | |
| @@ -54,6 +58,7 @@ type Handler struct { | |||
| 54 | 58 | previewSvc previewService | |
| 55 | 59 | reloadTrigger reloadTrigger | |
| 56 | 60 | publicTLSSvc in.PublicTLSService | |
| 61 | + | trafficSvc trafficStatusService | |
| 57 | 62 | log zerowrap.Logger | |
| 58 | 63 | } | |
| 59 | 64 | ||
| @@ -202,6 +207,7 @@ type HandlerDeps struct { | |||
| 202 | 207 | VolumeSvc in.VolumeService | |
| 203 | 208 | ReloadTrigger reloadTrigger | |
| 204 | 209 | PublicTLSSvc in.PublicTLSService | |
| 210 | + | TrafficSvc trafficStatusService | |
| 205 | 211 | } | |
| 206 | 212 | ||
| 207 | 213 | // NewHandler creates a new admin HTTP handler. | |
| @@ -221,6 +227,7 @@ func NewHandler(deps HandlerDeps) *Handler { | |||
| 221 | 227 | previewSvc: deps.PreviewSvc, | |
| 222 | 228 | reloadTrigger: deps.ReloadTrigger, | |
| 223 | 229 | publicTLSSvc: deps.PublicTLSSvc, | |
| 230 | + | trafficSvc: deps.TrafficSvc, | |
| 224 | 231 | log: deps.Log, | |
| 225 | 232 | } | |
| 226 | 233 | } | |
| @@ -274,6 +281,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||
| 274 | 281 | "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) }, | |
| 275 | 282 | "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) }, | |
| 276 | 283 | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | |
| 284 | + | "/traffic/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTrafficStatus(w, r) }, | |
| 277 | 285 | } | |
| 278 | 286 | if handler, ok := exactRoutes[path]; ok { | |
| 279 | 287 | return handler, true | |
| @@ -38,6 +38,10 @@ type reloadTrigger interface { | |||||
| 38 | Trigger(ctx context.Context) error | 38 | Trigger(ctx context.Context) error | ||
| 39 | } | 39 | } | ||
| 40 | 40 | ||||
| 41 | + | type trafficStatusService interface { | |||
| 42 | + | Status() domain.TrafficStatus | |||
| 43 | + | } | |||
| 44 | + | ||||
| 41 | // Handler implements the HTTP handler for the admin API. | 45 | // Handler implements the HTTP handler for the admin API. | ||
| 42 | type Handler struct { | 46 | type Handler struct { | ||
| 43 | configSvc in.ConfigService | 47 | configSvc in.ConfigService | ||
| @@ -54,6 +58,7 @@ type Handler struct { | |||||
| 54 | previewSvc previewService | 58 | previewSvc previewService | ||
| 55 | reloadTrigger reloadTrigger | 59 | reloadTrigger reloadTrigger | ||
| 56 | publicTLSSvc in.PublicTLSService | 60 | publicTLSSvc in.PublicTLSService | ||
| 61 | + | trafficSvc trafficStatusService | |||
| 57 | log zerowrap.Logger | 62 | log zerowrap.Logger | ||
| 58 | } | 63 | } | ||
| 59 | 64 | ||||
| @@ -202,6 +207,7 @@ type HandlerDeps struct { | |||||
| 202 | VolumeSvc in.VolumeService | 207 | VolumeSvc in.VolumeService | ||
| 203 | ReloadTrigger reloadTrigger | 208 | ReloadTrigger reloadTrigger | ||
| 204 | PublicTLSSvc in.PublicTLSService | 209 | PublicTLSSvc in.PublicTLSService | ||
| 210 | + | TrafficSvc trafficStatusService | |||
| 205 | } | 211 | } | ||
| 206 | 212 | ||||
| 207 | // NewHandler creates a new admin HTTP handler. | 213 | // NewHandler creates a new admin HTTP handler. | ||
| @@ -221,6 +227,7 @@ func NewHandler(deps HandlerDeps) *Handler { | |||||
| 221 | previewSvc: deps.PreviewSvc, | 227 | previewSvc: deps.PreviewSvc, | ||
| 222 | reloadTrigger: deps.ReloadTrigger, | 228 | reloadTrigger: deps.ReloadTrigger, | ||
| 223 | publicTLSSvc: deps.PublicTLSSvc, | 229 | publicTLSSvc: deps.PublicTLSSvc, | ||
| 230 | + | trafficSvc: deps.TrafficSvc, | |||
| 224 | log: deps.Log, | 231 | log: deps.Log, | ||
| 225 | } | 232 | } | ||
| 226 | } | 233 | } | ||
| @@ -274,6 +281,7 @@ func (h *Handler) matchRoute(path string) (routeHandler, bool) { | |||||
| 274 | "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) }, | 281 | "/attachments/orphans": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentOrphans(w, r) }, | ||
| 275 | "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) }, | 282 | "/attachments/prune": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleAttachmentPrune(w, r) }, | ||
| 276 | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | 283 | "/tls/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTLSStatus(w, r) }, | ||
| 284 | + | "/traffic/status": func(w http.ResponseWriter, r *http.Request, _ string) { h.handleTrafficStatus(w, r) }, | |||
| 277 | } | 285 | } | ||
| 278 | if handler, ok := exactRoutes[path]; ok { | 286 | if handler, ok := exactRoutes[path]; ok { | ||
| 279 | return handler, true | 287 | return handler, true | ||
A internal/adapters/in/http/admin/handler_traffic.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package admin | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/http" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 7 | + | "github.com/bnema/gordon/internal/domain" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func (h *Handler) handleTrafficStatus(w http.ResponseWriter, r *http.Request) { | |
| 11 | + | ctx := r.Context() | |
| 12 | + | if r.Method != http.MethodGet { | |
| 13 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |
| 14 | + | return | |
| 15 | + | } | |
| 16 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |
| 17 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |
| 18 | + | return | |
| 19 | + | } | |
| 20 | + | if h.trafficSvc == nil { | |
| 21 | + | h.sendJSON(w, http.StatusOK, dto.TrafficStatusResponse{LastReloadStatus: "unavailable"}) | |
| 22 | + | return | |
| 23 | + | } | |
| 24 | + | h.sendJSON(w, http.StatusOK, dto.TrafficStatusFromDomain(h.trafficSvc.Status())) | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/http" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 7 | + | "github.com/bnema/gordon/internal/domain" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func (h *Handler) handleTrafficStatus(w http.ResponseWriter, r *http.Request) { | |||
| 11 | + | ctx := r.Context() | |||
| 12 | + | if r.Method != http.MethodGet { | |||
| 13 | + | h.sendError(w, http.StatusMethodNotAllowed, "method not allowed") | |||
| 14 | + | return | |||
| 15 | + | } | |||
| 16 | + | if !HasAccess(ctx, domain.AdminResourceStatus, domain.AdminActionRead) { | |||
| 17 | + | h.sendError(w, http.StatusForbidden, "insufficient permissions for status:read") | |||
| 18 | + | return | |||
| 19 | + | } | |||
| 20 | + | if h.trafficSvc == nil { | |||
| 21 | + | h.sendJSON(w, http.StatusOK, dto.TrafficStatusResponse{LastReloadStatus: "unavailable"}) | |||
| 22 | + | return | |||
| 23 | + | } | |||
| 24 | + | h.sendJSON(w, http.StatusOK, dto.TrafficStatusFromDomain(h.trafficSvc.Status())) | |||
| 25 | + | } | |||
A internal/adapters/in/http/admin/handler_traffic_test.go +54 -0
| @@ -0,0 +1,54 @@ | |||
| 1 | + | package admin | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "net/http" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/stretchr/testify/assert" | |
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/gordon/internal/adapters/dto" | |
| 12 | + | "github.com/bnema/gordon/internal/domain" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | type fakeTrafficStatusService struct{ status domain.TrafficStatus } | |
| 16 | + | ||
| 17 | + | func (f fakeTrafficStatusService) Status() domain.TrafficStatus { return f.status } | |
| 18 | + | ||
| 19 | + | func TestHandler_TrafficStatus(t *testing.T) { | |
| 20 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |
| 21 | + | d.TrafficSvc = fakeTrafficStatusService{status: domain.TrafficStatus{ | |
| 22 | + | LastReloadStatus: "ok", | |
| 23 | + | EntryPoints: []domain.EntryPointStatus{{ | |
| 24 | + | Name: "postgres", Address: "127.0.0.1:5432", Protocol: domain.EntryPointProtocolTCP, | |
| 25 | + | Active: true, ActiveTCPConnections: 2, TotalAccepted: 3, BytesIn: 11, BytesOut: 22, | |
| 26 | + | }}, | |
| 27 | + | Counters: domain.TrafficCounters{ActiveTCPConnections: 2, TotalAccepted: 3, BytesIn: 11, BytesOut: 22}, | |
| 28 | + | }} | |
| 29 | + | }) | |
| 30 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |
| 31 | + | ||
| 32 | + | resp, err := http.Get(server.URL + "/admin/traffic/status") | |
| 33 | + | require.NoError(t, err) | |
| 34 | + | defer resp.Body.Close() | |
| 35 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |
| 36 | + | ||
| 37 | + | var body dto.TrafficStatusResponse | |
| 38 | + | require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) | |
| 39 | + | assert.Equal(t, "ok", body.LastReloadStatus) | |
| 40 | + | require.Len(t, body.EntryPoints, 1) | |
| 41 | + | assert.Equal(t, "postgres", body.EntryPoints[0].Name) | |
| 42 | + | assert.Equal(t, int64(2), body.Counters.ActiveTCPConnections) | |
| 43 | + | assert.Equal(t, int64(22), body.Counters.BytesOut) | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func TestHandler_TrafficStatusRequiresStatusRead(t *testing.T) { | |
| 47 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.TrafficSvc = fakeTrafficStatusService{} }) | |
| 48 | + | server := newScopedTestServer(t, handler, "admin:config:read") | |
| 49 | + | ||
| 50 | + | resp, err := http.Get(server.URL + "/admin/traffic/status") | |
| 51 | + | require.NoError(t, err) | |
| 52 | + | defer resp.Body.Close() | |
| 53 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |
| 54 | + | } | |
| @@ -0,0 +1,54 @@ | |||||
| 1 | + | package admin | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "encoding/json" | |||
| 5 | + | "net/http" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/stretchr/testify/assert" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/gordon/internal/adapters/dto" | |||
| 12 | + | "github.com/bnema/gordon/internal/domain" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type fakeTrafficStatusService struct{ status domain.TrafficStatus } | |||
| 16 | + | ||||
| 17 | + | func (f fakeTrafficStatusService) Status() domain.TrafficStatus { return f.status } | |||
| 18 | + | ||||
| 19 | + | func TestHandler_TrafficStatus(t *testing.T) { | |||
| 20 | + | handler := newTestHandler(t, func(d *HandlerDeps) { | |||
| 21 | + | d.TrafficSvc = fakeTrafficStatusService{status: domain.TrafficStatus{ | |||
| 22 | + | LastReloadStatus: "ok", | |||
| 23 | + | EntryPoints: []domain.EntryPointStatus{{ | |||
| 24 | + | Name: "postgres", Address: "127.0.0.1:5432", Protocol: domain.EntryPointProtocolTCP, | |||
| 25 | + | Active: true, ActiveTCPConnections: 2, TotalAccepted: 3, BytesIn: 11, BytesOut: 22, | |||
| 26 | + | }}, | |||
| 27 | + | Counters: domain.TrafficCounters{ActiveTCPConnections: 2, TotalAccepted: 3, BytesIn: 11, BytesOut: 22}, | |||
| 28 | + | }} | |||
| 29 | + | }) | |||
| 30 | + | server := newScopedTestServer(t, handler, "admin:status:read") | |||
| 31 | + | ||||
| 32 | + | resp, err := http.Get(server.URL + "/admin/traffic/status") | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | defer resp.Body.Close() | |||
| 35 | + | require.Equal(t, http.StatusOK, resp.StatusCode) | |||
| 36 | + | ||||
| 37 | + | var body dto.TrafficStatusResponse | |||
| 38 | + | require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) | |||
| 39 | + | assert.Equal(t, "ok", body.LastReloadStatus) | |||
| 40 | + | require.Len(t, body.EntryPoints, 1) | |||
| 41 | + | assert.Equal(t, "postgres", body.EntryPoints[0].Name) | |||
| 42 | + | assert.Equal(t, int64(2), body.Counters.ActiveTCPConnections) | |||
| 43 | + | assert.Equal(t, int64(22), body.Counters.BytesOut) | |||
| 44 | + | } | |||
| 45 | + | ||||
| 46 | + | func TestHandler_TrafficStatusRequiresStatusRead(t *testing.T) { | |||
| 47 | + | handler := newTestHandler(t, func(d *HandlerDeps) { d.TrafficSvc = fakeTrafficStatusService{} }) | |||
| 48 | + | server := newScopedTestServer(t, handler, "admin:config:read") | |||
| 49 | + | ||||
| 50 | + | resp, err := http.Get(server.URL + "/admin/traffic/status") | |||
| 51 | + | require.NoError(t, err) | |||
| 52 | + | defer resp.Body.Close() | |||
| 53 | + | assert.Equal(t, http.StatusForbidden, resp.StatusCode) | |||
| 54 | + | } | |||
M internal/adapters/in/traffic/manager.go +2 -2
| @@ -285,7 +285,7 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry | |||
| 285 | 285 | func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus { | |
| 286 | 286 | statuses := make([]domain.TrafficRouterStatus, 0, len(routers)) | |
| 287 | 287 | for _, router := range routers { | |
| 288 | - | status := domain.TrafficRouterStatus{Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, Active: true} | |
| 288 | + | status := domain.TrafficRouterStatus{Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, Rule: router.Rule, Service: router.Service, Active: true} | |
| 289 | 289 | if runtime := listeners[router.EntryPoint]; runtime != nil { | |
| 290 | 290 | counters := runtime.counters.snapshot() | |
| 291 | 291 | status.ActiveTCPConnections = counters.ActiveTCPConnections | |
| @@ -315,7 +315,7 @@ func serviceStatuses(services []domain.TrafficService) []domain.TrafficServiceSt | |||
| 315 | 315 | for _, service := range services { | |
| 316 | 316 | status := domain.TrafficServiceStatus{Name: service.Name, Active: true, Backends: make([]domain.TrafficBackendStatus, 0, len(service.Backends))} | |
| 317 | 317 | for _, backend := range service.Backends { | |
| 318 | - | status.Backends = append(status.Backends, domain.TrafficBackendStatus{Name: backend.Name, Active: true}) | |
| 318 | + | status.Backends = append(status.Backends, domain.TrafficBackendStatus{Name: backend.Name, Host: backend.Host, Port: backend.Port, Protocol: backend.Protocol, Active: true}) | |
| 319 | 319 | } | |
| 320 | 320 | statuses = append(statuses, status) | |
| 321 | 321 | } | |
| @@ -285,7 +285,7 @@ func entryPointStatuses(entries []domain.EntryPoint, listeners map[string]*entry | |||||
| 285 | func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus { | 285 | func routerStatuses(routers []domain.TrafficRouter, listeners map[string]*entryPointRuntime, udpListeners map[string]*udpEntryPointRuntime) []domain.TrafficRouterStatus { | ||
| 286 | statuses := make([]domain.TrafficRouterStatus, 0, len(routers)) | 286 | statuses := make([]domain.TrafficRouterStatus, 0, len(routers)) | ||
| 287 | for _, router := range routers { | 287 | for _, router := range routers { | ||
| 288 | - | status := domain.TrafficRouterStatus{Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, Active: true} | 288 | + | status := domain.TrafficRouterStatus{Name: router.Name, EntryPoint: router.EntryPoint, Protocol: router.Protocol, Rule: router.Rule, Service: router.Service, Active: true} |
| 289 | if runtime := listeners[router.EntryPoint]; runtime != nil { | 289 | if runtime := listeners[router.EntryPoint]; runtime != nil { | ||
| 290 | counters := runtime.counters.snapshot() | 290 | counters := runtime.counters.snapshot() | ||
| 291 | status.ActiveTCPConnections = counters.ActiveTCPConnections | 291 | status.ActiveTCPConnections = counters.ActiveTCPConnections | ||
| @@ -315,7 +315,7 @@ func serviceStatuses(services []domain.TrafficService) []domain.TrafficServiceSt | |||||
| 315 | for _, service := range services { | 315 | for _, service := range services { | ||
| 316 | status := domain.TrafficServiceStatus{Name: service.Name, Active: true, Backends: make([]domain.TrafficBackendStatus, 0, len(service.Backends))} | 316 | status := domain.TrafficServiceStatus{Name: service.Name, Active: true, Backends: make([]domain.TrafficBackendStatus, 0, len(service.Backends))} | ||
| 317 | for _, backend := range service.Backends { | 317 | for _, backend := range service.Backends { | ||
| 318 | - | status.Backends = append(status.Backends, domain.TrafficBackendStatus{Name: backend.Name, Active: true}) | 318 | + | status.Backends = append(status.Backends, domain.TrafficBackendStatus{Name: backend.Name, Host: backend.Host, Port: backend.Port, Protocol: backend.Protocol, Active: true}) |
| 319 | } | 319 | } | ||
| 320 | statuses = append(statuses, status) | 320 | statuses = append(statuses, status) | ||
| 321 | } | 321 | } | ||
M internal/app/run.go +29 -3
| @@ -57,6 +57,7 @@ import ( | |||
| 57 | 57 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | |
| 58 | 58 | proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy" | |
| 59 | 59 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | |
| 60 | + | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |
| 60 | 61 | ||
| 61 | 62 | // Boundaries | |
| 62 | 63 | "github.com/bnema/gordon/internal/boundaries/in" | |
| @@ -296,6 +297,7 @@ type services struct { | |||
| 296 | 297 | reloadCoordinator *reloadCoordinator | |
| 297 | 298 | publicTLSSvc in.PublicTLSService | |
| 298 | 299 | publicTLSRuntime publicTLSRuntime | |
| 300 | + | trafficManager *trafficadapter.Manager | |
| 299 | 301 | registryHandler interface { | |
| 300 | 302 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | |
| 301 | 303 | } | |
| @@ -558,7 +560,7 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, | |||
| 558 | 560 | return nil, err | |
| 559 | 561 | } | |
| 560 | 562 | ||
| 561 | - | if err := si.initRuntimeAndProxy(); err != nil { | |
| 563 | + | if err := si.initRuntimeProxyAndTraffic(); err != nil { | |
| 562 | 564 | return nil, err | |
| 563 | 565 | } | |
| 564 | 566 | ||
| @@ -710,6 +712,17 @@ func (si *serviceInit) initSecrets() error { | |||
| 710 | 712 | } | |
| 711 | 713 | ||
| 712 | 714 | // initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services. | |
| 715 | + | func (si *serviceInit) initRuntimeProxyAndTraffic() error { | |
| 716 | + | if err := si.initRuntimeAndProxy(); err != nil { | |
| 717 | + | return err | |
| 718 | + | } | |
| 719 | + | si.svc.trafficManager = trafficadapter.NewManager(si.log) | |
| 720 | + | if err := applyTrafficRuntimeConfig(si.ctx, si.svc.trafficManager, si.cfg, si.svc.configSvc); err != nil { | |
| 721 | + | return si.log.WrapErr(err, "failed to apply traffic configuration") | |
| 722 | + | } | |
| 723 | + | return nil | |
| 724 | + | } | |
| 725 | + | ||
| 713 | 726 | func (si *serviceInit) initRuntimeAndProxy() error { | |
| 714 | 727 | var err error | |
| 715 | 728 | ||
| @@ -756,6 +769,9 @@ func (si *serviceInit) registerReloadCoordinatorHooks() { | |||
| 756 | 769 | if err != nil { | |
| 757 | 770 | return err | |
| 758 | 771 | } | |
| 772 | + | if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil { | |
| 773 | + | return err | |
| 774 | + | } | |
| 759 | 775 | si.svc.containerSvc.UpdateConfig(containerCfg) | |
| 760 | 776 | return nil | |
| 761 | 777 | }) | |
| @@ -776,6 +792,9 @@ func (si *serviceInit) initHandlers() { | |||
| 776 | 792 | si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log) | |
| 777 | 793 | ||
| 778 | 794 | initPreviewService(si.ctx, si.cfg, si.svc, si.log) | |
| 795 | + | if si.svc.trafficManager == nil { | |
| 796 | + | si.svc.trafficManager = trafficadapter.NewManager(si.log) | |
| 797 | + | } | |
| 779 | 798 | ||
| 780 | 799 | si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | |
| 781 | 800 | ConfigSvc: si.svc.configSvc, | |
| @@ -793,6 +812,7 @@ func (si *serviceInit) initHandlers() { | |||
| 793 | 812 | ImageSvc: si.svc.imageSvc, | |
| 794 | 813 | VolumeSvc: si.svc.volumeSvc, | |
| 795 | 814 | PublicTLSSvc: si.svc.publicTLSSvc, | |
| 815 | + | TrafficSvc: si.svc.trafficManager, | |
| 796 | 816 | }) | |
| 797 | 817 | } | |
| 798 | 818 | ||
| @@ -2849,7 +2869,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||
| 2849 | 2869 | ||
| 2850 | 2870 | waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log) | |
| 2851 | 2871 | cleanupHandlers() // Stop debounce timers before draining containers | |
| 2852 | - | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, log) | |
| 2872 | + | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log) | |
| 2853 | 2873 | return nil | |
| 2854 | 2874 | } | |
| 2855 | 2875 | ||
| @@ -3101,7 +3121,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl | |||
| 3101 | 3121 | ||
| 3102 | 3122 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | |
| 3103 | 3123 | // the container service and cleans up runtime files. | |
| 3104 | - | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) { | |
| 3124 | + | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, log zerowrap.Logger) { | |
| 3105 | 3125 | log.Info().Msg("shutting down Gordon...") | |
| 3106 | 3126 | ||
| 3107 | 3127 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | |
| @@ -3117,6 +3137,12 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||
| 3117 | 3137 | } | |
| 3118 | 3138 | } | |
| 3119 | 3139 | ||
| 3140 | + | if trafficManager != nil { | |
| 3141 | + | if err := trafficManager.Shutdown(shutdownCtx); err != nil { | |
| 3142 | + | log.Warn().Err(err).Msg("traffic manager shutdown error") | |
| 3143 | + | } | |
| 3144 | + | } | |
| 3145 | + | ||
| 3120 | 3146 | // Stop PKI maintenance goroutines | |
| 3121 | 3147 | if pkiSvc != nil { | |
| 3122 | 3148 | pkiSvc.Stop() | |
| @@ -57,6 +57,7 @@ import ( | |||||
| 57 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | 57 | "github.com/bnema/gordon/internal/adapters/in/http/onboarding" | ||
| 58 | proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy" | 58 | proxyadapter "github.com/bnema/gordon/internal/adapters/in/http/proxy" | ||
| 59 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | 59 | "github.com/bnema/gordon/internal/adapters/in/http/registry" | ||
| 60 | + | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |||
| 60 | 61 | ||||
| 61 | // Boundaries | 62 | // Boundaries | ||
| 62 | "github.com/bnema/gordon/internal/boundaries/in" | 63 | "github.com/bnema/gordon/internal/boundaries/in" | ||
| @@ -296,6 +297,7 @@ type services struct { | |||||
| 296 | reloadCoordinator *reloadCoordinator | 297 | reloadCoordinator *reloadCoordinator | ||
| 297 | publicTLSSvc in.PublicTLSService | 298 | publicTLSSvc in.PublicTLSService | ||
| 298 | publicTLSRuntime publicTLSRuntime | 299 | publicTLSRuntime publicTLSRuntime | ||
| 300 | + | trafficManager *trafficadapter.Manager | |||
| 299 | registryHandler interface { | 301 | registryHandler interface { | ||
| 300 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | 302 | UpdateBlobLimits(maxBlobChunkSize, maxBlobSize int64) | ||
| 301 | } | 303 | } | ||
| @@ -558,7 +560,7 @@ func createServicesWithOptions(ctx context.Context, v *viper.Viper, cfg Config, | |||||
| 558 | return nil, err | 560 | return nil, err | ||
| 559 | } | 561 | } | ||
| 560 | 562 | ||||
| 561 | - | if err := si.initRuntimeAndProxy(); err != nil { | 563 | + | if err := si.initRuntimeProxyAndTraffic(); err != nil { |
| 562 | return nil, err | 564 | return nil, err | ||
| 563 | } | 565 | } | ||
| 564 | 566 | ||||
| @@ -710,6 +712,17 @@ func (si *serviceInit) initSecrets() error { | |||||
| 710 | } | 712 | } | ||
| 711 | 713 | ||||
| 712 | // initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services. | 714 | // initRuntimeAndProxy creates container, backup, registry, image, volume, and proxy services. | ||
| 715 | + | func (si *serviceInit) initRuntimeProxyAndTraffic() error { | |||
| 716 | + | if err := si.initRuntimeAndProxy(); err != nil { | |||
| 717 | + | return err | |||
| 718 | + | } | |||
| 719 | + | si.svc.trafficManager = trafficadapter.NewManager(si.log) | |||
| 720 | + | if err := applyTrafficRuntimeConfig(si.ctx, si.svc.trafficManager, si.cfg, si.svc.configSvc); err != nil { | |||
| 721 | + | return si.log.WrapErr(err, "failed to apply traffic configuration") | |||
| 722 | + | } | |||
| 723 | + | return nil | |||
| 724 | + | } | |||
| 725 | + | ||||
| 713 | func (si *serviceInit) initRuntimeAndProxy() error { | 726 | func (si *serviceInit) initRuntimeAndProxy() error { | ||
| 714 | var err error | 727 | var err error | ||
| 715 | 728 | ||||
| @@ -756,6 +769,9 @@ func (si *serviceInit) registerReloadCoordinatorHooks() { | |||||
| 756 | if err != nil { | 769 | if err != nil { | ||
| 757 | return err | 770 | return err | ||
| 758 | } | 771 | } | ||
| 772 | + | if err := applyTrafficRuntimeConfig(reloadCtx, si.svc.trafficManager, reloadCfg, si.svc.configSvc); err != nil { | |||
| 773 | + | return err | |||
| 774 | + | } | |||
| 759 | si.svc.containerSvc.UpdateConfig(containerCfg) | 775 | si.svc.containerSvc.UpdateConfig(containerCfg) | ||
| 760 | return nil | 776 | return nil | ||
| 761 | }) | 777 | }) | ||
| @@ -776,6 +792,9 @@ func (si *serviceInit) initHandlers() { | |||||
| 776 | si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log) | 792 | si.svc.logSvc = logs.NewService(resolveLogFilePath(si.cfg), si.cfg.Logging.File.Enabled, si.svc.containerSvc, si.svc.runtime, si.log) | ||
| 777 | 793 | ||||
| 778 | initPreviewService(si.ctx, si.cfg, si.svc, si.log) | 794 | initPreviewService(si.ctx, si.cfg, si.svc, si.log) | ||
| 795 | + | if si.svc.trafficManager == nil { | |||
| 796 | + | si.svc.trafficManager = trafficadapter.NewManager(si.log) | |||
| 797 | + | } | |||
| 779 | 798 | ||||
| 780 | si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | 799 | si.svc.adminHandler = admin.NewHandler(admin.HandlerDeps{ | ||
| 781 | ConfigSvc: si.svc.configSvc, | 800 | ConfigSvc: si.svc.configSvc, | ||
| @@ -793,6 +812,7 @@ func (si *serviceInit) initHandlers() { | |||||
| 793 | ImageSvc: si.svc.imageSvc, | 812 | ImageSvc: si.svc.imageSvc, | ||
| 794 | VolumeSvc: si.svc.volumeSvc, | 813 | VolumeSvc: si.svc.volumeSvc, | ||
| 795 | PublicTLSSvc: si.svc.publicTLSSvc, | 814 | PublicTLSSvc: si.svc.publicTLSSvc, | ||
| 815 | + | TrafficSvc: si.svc.trafficManager, | |||
| 796 | }) | 816 | }) | ||
| 797 | } | 817 | } | ||
| 798 | 818 | ||||
| @@ -2849,7 +2869,7 @@ func runServers(ctx context.Context, v *viper.Viper, cfg Config, svc *services, | |||||
| 2849 | 2869 | ||||
| 2850 | waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log) | 2870 | waitForShutdown(ctx, errChan, reloadChan, deployChan, reload, svc.eventBus, log) | ||
| 2851 | cleanupHandlers() // Stop debounce timers before draining containers | 2871 | cleanupHandlers() // Stop debounce timers before draining containers | ||
| 2852 | - | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, log) | 2872 | + | gracefulShutdown(registrySrv, proxySrv, tlsSrv, svc.containerSvc, svc.proxySvc, svc.pkiSvc, svc.publicTLSSvc, svc.trafficManager, log) |
| 2853 | return nil | 2873 | return nil | ||
| 2854 | } | 2874 | } | ||
| 2855 | 2875 | ||||
| @@ -3101,7 +3121,7 @@ func waitForShutdown(ctx context.Context, errChan <-chan error, reloadChan, depl | |||||
| 3101 | 3121 | ||||
| 3102 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | 3122 | // gracefulShutdown stops HTTP servers with a 30s timeout, then shuts down | ||
| 3103 | // the container service and cleans up runtime files. | 3123 | // the container service and cleans up runtime files. | ||
| 3104 | - | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, log zerowrap.Logger) { | 3124 | + | func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc *container.Service, proxySvc *proxy.Service, pkiSvc *pkiusecase.Service, publicTLS in.PublicTLSService, trafficManager *trafficadapter.Manager, log zerowrap.Logger) { |
| 3105 | log.Info().Msg("shutting down Gordon...") | 3125 | log.Info().Msg("shutting down Gordon...") | ||
| 3106 | 3126 | ||||
| 3107 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | 3127 | shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 30*time.Second) | ||
| @@ -3117,6 +3137,12 @@ func gracefulShutdown(registrySrv, proxySrv, tlsSrv *http.Server, containerSvc * | |||||
| 3117 | } | 3137 | } | ||
| 3118 | } | 3138 | } | ||
| 3119 | 3139 | ||||
| 3140 | + | if trafficManager != nil { | |||
| 3141 | + | if err := trafficManager.Shutdown(shutdownCtx); err != nil { | |||
| 3142 | + | log.Warn().Err(err).Msg("traffic manager shutdown error") | |||
| 3143 | + | } | |||
| 3144 | + | } | |||
| 3145 | + | ||||
| 3120 | // Stop PKI maintenance goroutines | 3146 | // Stop PKI maintenance goroutines | ||
| 3121 | if pkiSvc != nil { | 3147 | if pkiSvc != nil { | ||
| 3122 | pkiSvc.Stop() | 3148 | pkiSvc.Stop() | ||
A internal/app/traffic.go +87 -0
| @@ -0,0 +1,87 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |
| 8 | + | "github.com/bnema/gordon/internal/domain" | |
| 9 | + | trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Manager, cfg Config, configSvc interface { | |
| 13 | + | GetRoutes(context.Context) []domain.Route | |
| 14 | + | GetExternalRoutes() map[string]string | |
| 15 | + | }) error { | |
| 16 | + | if manager == nil || configSvc == nil { | |
| 17 | + | return nil | |
| 18 | + | } | |
| 19 | + | graph, err := trafficbuilder.Build(trafficbuilder.Input{ | |
| 20 | + | Server: trafficbuilder.ServerConfig{ | |
| 21 | + | Port: cfg.Server.Port, | |
| 22 | + | TLSPort: cfg.Server.TLSPort, | |
| 23 | + | }, | |
| 24 | + | EntryPoints: cfg.EntryPoints, | |
| 25 | + | Traffic: cfg.Traffic, | |
| 26 | + | Routes: configSvc.GetRoutes(ctx), | |
| 27 | + | ExternalRoutes: configSvc.GetExternalRoutes(), | |
| 28 | + | NetworkServices: cfg.NetworkServices, | |
| 29 | + | }) | |
| 30 | + | if err != nil { | |
| 31 | + | return err | |
| 32 | + | } | |
| 33 | + | owned, err := trafficRuntimeGraph(graph) | |
| 34 | + | if err != nil { | |
| 35 | + | return err | |
| 36 | + | } | |
| 37 | + | return manager.Apply(ctx, &owned) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func trafficRuntimeGraph(graph domain.TrafficGraph) (domain.TrafficGraph, error) { | |
| 41 | + | ownedEntryPoints := map[string]struct{}{} | |
| 42 | + | filtered := domain.TrafficGraph{Options: graph.Options} | |
| 43 | + | for _, entryPoint := range graph.EntryPoints { | |
| 44 | + | if trafficManagerOwnsEntryPoint(entryPoint) { | |
| 45 | + | filtered.EntryPoints = append(filtered.EntryPoints, entryPoint) | |
| 46 | + | ownedEntryPoints[entryPoint.Name] = struct{}{} | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | usedServices := map[string]struct{}{} | |
| 51 | + | for _, router := range graph.Routers { | |
| 52 | + | if _, ok := ownedEntryPoints[router.EntryPoint]; !ok { | |
| 53 | + | if trafficManagerRouterProtocol(router.Protocol) { | |
| 54 | + | return domain.TrafficGraph{}, fmt.Errorf("traffic router %q targets entrypoint %q, which is not owned by the traffic manager", router.Name, router.EntryPoint) | |
| 55 | + | } | |
| 56 | + | continue | |
| 57 | + | } | |
| 58 | + | filtered.Routers = append(filtered.Routers, router) | |
| 59 | + | usedServices[router.Service] = struct{}{} | |
| 60 | + | } | |
| 61 | + | for _, service := range graph.Services { | |
| 62 | + | if _, ok := usedServices[service.Name]; ok { | |
| 63 | + | filtered.Services = append(filtered.Services, service) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | return filtered, nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | func trafficManagerRouterProtocol(protocol domain.RouterProtocol) bool { | |
| 70 | + | switch protocol { | |
| 71 | + | case domain.RouterProtocolTCP, domain.RouterProtocolUDP, domain.RouterProtocolTLSPassthrough: | |
| 72 | + | return true | |
| 73 | + | default: | |
| 74 | + | return false | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | func trafficManagerOwnsEntryPoint(entryPoint domain.EntryPoint) bool { | |
| 79 | + | switch entryPoint.Protocol { | |
| 80 | + | case domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP: | |
| 81 | + | return true | |
| 82 | + | case domain.EntryPointProtocolTLSMux: | |
| 83 | + | return entryPoint.Name != trafficbuilder.DefaultTLSEntryPointName | |
| 84 | + | default: | |
| 85 | + | return false | |
| 86 | + | } | |
| 87 | + | } | |
| @@ -0,0 +1,87 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |||
| 8 | + | "github.com/bnema/gordon/internal/domain" | |||
| 9 | + | trafficbuilder "github.com/bnema/gordon/internal/usecase/traffic" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func applyTrafficRuntimeConfig(ctx context.Context, manager *trafficadapter.Manager, cfg Config, configSvc interface { | |||
| 13 | + | GetRoutes(context.Context) []domain.Route | |||
| 14 | + | GetExternalRoutes() map[string]string | |||
| 15 | + | }) error { | |||
| 16 | + | if manager == nil || configSvc == nil { | |||
| 17 | + | return nil | |||
| 18 | + | } | |||
| 19 | + | graph, err := trafficbuilder.Build(trafficbuilder.Input{ | |||
| 20 | + | Server: trafficbuilder.ServerConfig{ | |||
| 21 | + | Port: cfg.Server.Port, | |||
| 22 | + | TLSPort: cfg.Server.TLSPort, | |||
| 23 | + | }, | |||
| 24 | + | EntryPoints: cfg.EntryPoints, | |||
| 25 | + | Traffic: cfg.Traffic, | |||
| 26 | + | Routes: configSvc.GetRoutes(ctx), | |||
| 27 | + | ExternalRoutes: configSvc.GetExternalRoutes(), | |||
| 28 | + | NetworkServices: cfg.NetworkServices, | |||
| 29 | + | }) | |||
| 30 | + | if err != nil { | |||
| 31 | + | return err | |||
| 32 | + | } | |||
| 33 | + | owned, err := trafficRuntimeGraph(graph) | |||
| 34 | + | if err != nil { | |||
| 35 | + | return err | |||
| 36 | + | } | |||
| 37 | + | return manager.Apply(ctx, &owned) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func trafficRuntimeGraph(graph domain.TrafficGraph) (domain.TrafficGraph, error) { | |||
| 41 | + | ownedEntryPoints := map[string]struct{}{} | |||
| 42 | + | filtered := domain.TrafficGraph{Options: graph.Options} | |||
| 43 | + | for _, entryPoint := range graph.EntryPoints { | |||
| 44 | + | if trafficManagerOwnsEntryPoint(entryPoint) { | |||
| 45 | + | filtered.EntryPoints = append(filtered.EntryPoints, entryPoint) | |||
| 46 | + | ownedEntryPoints[entryPoint.Name] = struct{}{} | |||
| 47 | + | } | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | usedServices := map[string]struct{}{} | |||
| 51 | + | for _, router := range graph.Routers { | |||
| 52 | + | if _, ok := ownedEntryPoints[router.EntryPoint]; !ok { | |||
| 53 | + | if trafficManagerRouterProtocol(router.Protocol) { | |||
| 54 | + | return domain.TrafficGraph{}, fmt.Errorf("traffic router %q targets entrypoint %q, which is not owned by the traffic manager", router.Name, router.EntryPoint) | |||
| 55 | + | } | |||
| 56 | + | continue | |||
| 57 | + | } | |||
| 58 | + | filtered.Routers = append(filtered.Routers, router) | |||
| 59 | + | usedServices[router.Service] = struct{}{} | |||
| 60 | + | } | |||
| 61 | + | for _, service := range graph.Services { | |||
| 62 | + | if _, ok := usedServices[service.Name]; ok { | |||
| 63 | + | filtered.Services = append(filtered.Services, service) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | return filtered, nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | func trafficManagerRouterProtocol(protocol domain.RouterProtocol) bool { | |||
| 70 | + | switch protocol { | |||
| 71 | + | case domain.RouterProtocolTCP, domain.RouterProtocolUDP, domain.RouterProtocolTLSPassthrough: | |||
| 72 | + | return true | |||
| 73 | + | default: | |||
| 74 | + | return false | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func trafficManagerOwnsEntryPoint(entryPoint domain.EntryPoint) bool { | |||
| 79 | + | switch entryPoint.Protocol { | |||
| 80 | + | case domain.EntryPointProtocolTCP, domain.EntryPointProtocolUDP: | |||
| 81 | + | return true | |||
| 82 | + | case domain.EntryPointProtocolTLSMux: | |||
| 83 | + | return entryPoint.Name != trafficbuilder.DefaultTLSEntryPointName | |||
| 84 | + | default: | |||
| 85 | + | return false | |||
| 86 | + | } | |||
| 87 | + | } | |||
A internal/app/traffic_test.go +88 -0
| @@ -0,0 +1,88 @@ | |||
| 1 | + | package app | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "net" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/zerowrap" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ||
| 12 | + | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |
| 13 | + | "github.com/bnema/gordon/internal/domain" | |
| 14 | + | "github.com/bnema/gordon/internal/usecase/traffic" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func TestTrafficRuntimeGraphFiltersLegacyHTTPEntrypoints(t *testing.T) { | |
| 18 | + | graph := domain.TrafficGraph{ | |
| 19 | + | EntryPoints: []domain.EntryPoint{ | |
| 20 | + | {Name: traffic.DefaultHTTPEntryPointName, Address: "127.0.0.1:8080", Protocol: domain.EntryPointProtocolHTTP}, | |
| 21 | + | {Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}, | |
| 22 | + | {Name: "postgres", Address: "127.0.0.1:15432", Protocol: domain.EntryPointProtocolTCP}, | |
| 23 | + | }, | |
| 24 | + | Routers: []domain.TrafficRouter{ | |
| 25 | + | {Name: "app", EntryPoint: traffic.DefaultHTTPEntryPointName, Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"}, | |
| 26 | + | {Name: "postgres", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:db"}, | |
| 27 | + | }, | |
| 28 | + | Services: []domain.TrafficService{ | |
| 29 | + | {Name: "route:app.example.com"}, | |
| 30 | + | {Name: "network_service:postgres:db", Backends: []domain.TrafficBackend{{Name: "db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP}}}, | |
| 31 | + | }, | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | filtered, err := trafficRuntimeGraph(graph) | |
| 35 | + | require.NoError(t, err) | |
| 36 | + | require.Len(t, filtered.EntryPoints, 1) | |
| 37 | + | assert.Equal(t, "postgres", filtered.EntryPoints[0].Name) | |
| 38 | + | require.Len(t, filtered.Routers, 1) | |
| 39 | + | assert.Equal(t, "postgres", filtered.Routers[0].Name) | |
| 40 | + | require.Len(t, filtered.Services, 1) | |
| 41 | + | assert.Equal(t, "network_service:postgres:db", filtered.Services[0].Name) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func TestTrafficRuntimeGraphRejectsL4RouterOnLegacyEntrypoint(t *testing.T) { | |
| 45 | + | graph := domain.TrafficGraph{ | |
| 46 | + | EntryPoints: []domain.EntryPoint{{Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}}, | |
| 47 | + | Routers: []domain.TrafficRouter{{Name: "raw", EntryPoint: traffic.DefaultTLSEntryPointName, Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"}}, | |
| 48 | + | Services: []domain.TrafficService{{Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}}}, | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | _, err := trafficRuntimeGraph(graph) | |
| 52 | + | require.ErrorContains(t, err, "not owned by the traffic manager") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) { | |
| 56 | + | manager := trafficadapter.NewManager(zerowrap.Default()) | |
| 57 | + | defer func() { require.NoError(t, manager.Shutdown(context.Background())) }() | |
| 58 | + | cfg := Config{ | |
| 59 | + | EntryPoints: map[string]traffic.EntryPointConfig{ | |
| 60 | + | "postgres": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}, | |
| 61 | + | }, | |
| 62 | + | NetworkServices: []traffic.NetworkServiceConfig{{ | |
| 63 | + | Name: "postgres", | |
| 64 | + | Ports: []traffic.PortConfig{{Name: "db", Container: 5432, Protocol: domain.NetworkProtocolTCP}}, | |
| 65 | + | }}, | |
| 66 | + | } | |
| 67 | + | cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "postgres", EntryPoint: "postgres", Service: "network_service:postgres:db"}} | |
| 68 | + | ||
| 69 | + | require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, fakeTrafficConfigService{})) | |
| 70 | + | status := manager.Status() | |
| 71 | + | require.Len(t, status.EntryPoints, 1) | |
| 72 | + | assert.Equal(t, "postgres", status.EntryPoints[0].Name) | |
| 73 | + | assert.True(t, status.EntryPoints[0].Active) | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | type fakeTrafficConfigService struct{} | |
| 77 | + | ||
| 78 | + | func (fakeTrafficConfigService) GetRoutes(context.Context) []domain.Route { return nil } | |
| 79 | + | func (fakeTrafficConfigService) GetExternalRoutes() map[string]string { return nil } | |
| 80 | + | ||
| 81 | + | func freeTCPAddress(t *testing.T) string { | |
| 82 | + | t.Helper() | |
| 83 | + | listener, err := net.Listen("tcp", "127.0.0.1:0") | |
| 84 | + | require.NoError(t, err) | |
| 85 | + | address := listener.Addr().String() | |
| 86 | + | require.NoError(t, listener.Close()) | |
| 87 | + | return address | |
| 88 | + | } | |
| @@ -0,0 +1,88 @@ | |||||
| 1 | + | package app | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "net" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/zerowrap" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ||||
| 12 | + | trafficadapter "github.com/bnema/gordon/internal/adapters/in/traffic" | |||
| 13 | + | "github.com/bnema/gordon/internal/domain" | |||
| 14 | + | "github.com/bnema/gordon/internal/usecase/traffic" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func TestTrafficRuntimeGraphFiltersLegacyHTTPEntrypoints(t *testing.T) { | |||
| 18 | + | graph := domain.TrafficGraph{ | |||
| 19 | + | EntryPoints: []domain.EntryPoint{ | |||
| 20 | + | {Name: traffic.DefaultHTTPEntryPointName, Address: "127.0.0.1:8080", Protocol: domain.EntryPointProtocolHTTP}, | |||
| 21 | + | {Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}, | |||
| 22 | + | {Name: "postgres", Address: "127.0.0.1:15432", Protocol: domain.EntryPointProtocolTCP}, | |||
| 23 | + | }, | |||
| 24 | + | Routers: []domain.TrafficRouter{ | |||
| 25 | + | {Name: "app", EntryPoint: traffic.DefaultHTTPEntryPointName, Protocol: domain.RouterProtocolHTTP, Service: "route:app.example.com"}, | |||
| 26 | + | {Name: "postgres", EntryPoint: "postgres", Protocol: domain.RouterProtocolTCP, Service: "network_service:postgres:db"}, | |||
| 27 | + | }, | |||
| 28 | + | Services: []domain.TrafficService{ | |||
| 29 | + | {Name: "route:app.example.com"}, | |||
| 30 | + | {Name: "network_service:postgres:db", Backends: []domain.TrafficBackend{{Name: "db", Host: "127.0.0.1", Port: 5432, Protocol: domain.NetworkProtocolTCP}}}, | |||
| 31 | + | }, | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | filtered, err := trafficRuntimeGraph(graph) | |||
| 35 | + | require.NoError(t, err) | |||
| 36 | + | require.Len(t, filtered.EntryPoints, 1) | |||
| 37 | + | assert.Equal(t, "postgres", filtered.EntryPoints[0].Name) | |||
| 38 | + | require.Len(t, filtered.Routers, 1) | |||
| 39 | + | assert.Equal(t, "postgres", filtered.Routers[0].Name) | |||
| 40 | + | require.Len(t, filtered.Services, 1) | |||
| 41 | + | assert.Equal(t, "network_service:postgres:db", filtered.Services[0].Name) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func TestTrafficRuntimeGraphRejectsL4RouterOnLegacyEntrypoint(t *testing.T) { | |||
| 45 | + | graph := domain.TrafficGraph{ | |||
| 46 | + | EntryPoints: []domain.EntryPoint{{Name: traffic.DefaultTLSEntryPointName, Address: "127.0.0.1:8443", Protocol: domain.EntryPointProtocolTLSMux}}, | |||
| 47 | + | Routers: []domain.TrafficRouter{{Name: "raw", EntryPoint: traffic.DefaultTLSEntryPointName, Protocol: domain.RouterProtocolTLSPassthrough, Rule: domain.TrafficRule{SNI: "raw.example.com"}, Service: "network_service:raw:tls"}}, | |||
| 48 | + | Services: []domain.TrafficService{{Name: "network_service:raw:tls", Backends: []domain.TrafficBackend{{Name: "raw:tls", Host: "127.0.0.1", Port: 443, Protocol: domain.NetworkProtocolTCP}}}}, | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | _, err := trafficRuntimeGraph(graph) | |||
| 52 | + | require.ErrorContains(t, err, "not owned by the traffic manager") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestApplyTrafficRuntimeConfigAppliesCustomL4Entrypoint(t *testing.T) { | |||
| 56 | + | manager := trafficadapter.NewManager(zerowrap.Default()) | |||
| 57 | + | defer func() { require.NoError(t, manager.Shutdown(context.Background())) }() | |||
| 58 | + | cfg := Config{ | |||
| 59 | + | EntryPoints: map[string]traffic.EntryPointConfig{ | |||
| 60 | + | "postgres": {Address: freeTCPAddress(t), Protocol: domain.EntryPointProtocolTCP}, | |||
| 61 | + | }, | |||
| 62 | + | NetworkServices: []traffic.NetworkServiceConfig{{ | |||
| 63 | + | Name: "postgres", | |||
| 64 | + | Ports: []traffic.PortConfig{{Name: "db", Container: 5432, Protocol: domain.NetworkProtocolTCP}}, | |||
| 65 | + | }}, | |||
| 66 | + | } | |||
| 67 | + | cfg.Traffic.TCP.Routers = []traffic.RouterConfig{{Name: "postgres", EntryPoint: "postgres", Service: "network_service:postgres:db"}} | |||
| 68 | + | ||||
| 69 | + | require.NoError(t, applyTrafficRuntimeConfig(context.Background(), manager, cfg, fakeTrafficConfigService{})) | |||
| 70 | + | status := manager.Status() | |||
| 71 | + | require.Len(t, status.EntryPoints, 1) | |||
| 72 | + | assert.Equal(t, "postgres", status.EntryPoints[0].Name) | |||
| 73 | + | assert.True(t, status.EntryPoints[0].Active) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | type fakeTrafficConfigService struct{} | |||
| 77 | + | ||||
| 78 | + | func (fakeTrafficConfigService) GetRoutes(context.Context) []domain.Route { return nil } | |||
| 79 | + | func (fakeTrafficConfigService) GetExternalRoutes() map[string]string { return nil } | |||
| 80 | + | ||||
| 81 | + | func freeTCPAddress(t *testing.T) string { | |||
| 82 | + | t.Helper() | |||
| 83 | + | listener, err := net.Listen("tcp", "127.0.0.1:0") | |||
| 84 | + | require.NoError(t, err) | |||
| 85 | + | address := listener.Addr().String() | |||
| 86 | + | require.NoError(t, listener.Close()) | |||
| 87 | + | return address | |||
| 88 | + | } | |||
M internal/domain/traffic.go +5 -0
| @@ -117,6 +117,8 @@ type TrafficRouterStatus struct { | |||
| 117 | 117 | Name string | |
| 118 | 118 | EntryPoint string | |
| 119 | 119 | Protocol RouterProtocol | |
| 120 | + | Rule TrafficRule | |
| 121 | + | Service string | |
| 120 | 122 | Active bool | |
| 121 | 123 | ActiveTCPConnections int64 | |
| 122 | 124 | ActiveUDPSessions int64 | |
| @@ -135,6 +137,9 @@ type TrafficServiceStatus struct { | |||
| 135 | 137 | ||
| 136 | 138 | type TrafficBackendStatus struct { | |
| 137 | 139 | Name string | |
| 140 | + | Host string | |
| 141 | + | Port int | |
| 142 | + | Protocol NetworkProtocol | |
| 138 | 143 | Active bool | |
| 139 | 144 | ActiveTCPConnections int64 | |
| 140 | 145 | ActiveUDPSessions int64 | |
| @@ -117,6 +117,8 @@ type TrafficRouterStatus struct { | |||||
| 117 | Name string | 117 | Name string | ||
| 118 | EntryPoint string | 118 | EntryPoint string | ||
| 119 | Protocol RouterProtocol | 119 | Protocol RouterProtocol | ||
| 120 | + | Rule TrafficRule | |||
| 121 | + | Service string | |||
| 120 | Active bool | 122 | Active bool | ||
| 121 | ActiveTCPConnections int64 | 123 | ActiveTCPConnections int64 | ||
| 122 | ActiveUDPSessions int64 | 124 | ActiveUDPSessions int64 | ||
| @@ -135,6 +137,9 @@ type TrafficServiceStatus struct { | |||||
| 135 | 137 | ||||
| 136 | type TrafficBackendStatus struct { | 138 | type TrafficBackendStatus struct { | ||
| 137 | Name string | 139 | Name string | ||
| 140 | + | Host string | |||
| 141 | + | Port int | |||
| 142 | + | Protocol NetworkProtocol | |||
| 138 | Active bool | 143 | Active bool | ||
| 139 | ActiveTCPConnections int64 | 144 | ActiveTCPConnections int64 | ||
| 140 | ActiveUDPSessions int64 | 145 | ActiveUDPSessions int64 | ||