Skip to content

feat(k8s): expose UDP ports on cloud-compose via mixed-protocol LB#363

Open
smecsia wants to merge 5 commits into
mainfrom
feat/cloud-compose-udp-ports
Open

feat(k8s): expose UDP ports on cloud-compose via mixed-protocol LB#363
smecsia wants to merge 5 commits into
mainfrom
feat/cloud-compose-udp-ports

Conversation

@smecsia

@smecsia smecsia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor

Re-established on an SC-owned branch so CI runs with full secret access (the original fork PR #362 could not run secret-gated jobs). Original author preserved: @RuslanMikailov (Ruslan <ruslan.mikailov@fulldive.com>). Supersedes #362.

What

sc parses the compose file and generates its own k8s Deployment + Service via Pulumi; during that translation the port protocol was discarded. toRunPorts mapped every compose port to a bare int, so "7882:7882/udp" silently became a TCP port and UDP never reached the pod. This blocks a WebRTC media server (e.g. LiveKit SFU), where signaling is TCP (wss) but media is UDP.

Change

Thread the port protocol end-to-end:

  • CloudRunContainer.Ports is now []k8s.ContainerPort{Port, Protocol} instead of []int; toRunPorts preserves the compose protocol (udp"UDP"; tcp/unspecified → "").
  • New ports.go helpers set Protocol on ContainerPortArgs/ServicePortArgs, only for non-TCP protocols, so TCP-only specs stay byte-for-byte identical (TCP keeps http-<port> names; UDP gets udp-<port>).
  • autoTCPReadinessProbe no longer attaches a TCP/HTTP probe to a UDP-only port (resolves the old // TODO: support non-http ports).
  • serviceType is now expressible via cloudExtras and threaded CloudExtras → DeploymentConfig → kubeArgs, letting a service turn its own Service into a LoadBalancer (required for protocols Caddy cannot proxy).

A service exposing both TCP and UDP emits ONE mixed-protocol LoadBalancer Service (GA since k8s 1.26) rather than two Services, so a single external IP fronts the whole service. Announcing that IP in ICE candidates (LiveKit rtc.use_external_ip / node_ip) is the app's job, not sc's.

Backward compatible: ports with no protocol behave exactly as before; serviceType defaults to ClusterIP.

🤖 Generated with Claude Code

sc parses the compose file and generates its own k8s Deployment +
Service via Pulumi; during that translation the port protocol was
discarded. toRunPorts mapped every compose port to a bare int, so
"7882:7882/udp" silently became a TCP port and UDP never reached the
pod. This blocks deploying a WebRTC media server (e.g. LiveKit SFU),
where signaling is TCP (wss) but media is UDP.

Thread the port protocol end-to-end:

- CloudRunContainer.Ports is now []k8s.ContainerPort{Port, Protocol}
  instead of []int; toRunPorts preserves the compose protocol
  (udp -> "UDP"; tcp/unspecified -> "").
- New ports.go helpers set Protocol on ContainerPortArgs and
  ServicePortArgs, but only for non-TCP protocols, so TCP-only specs
  stay byte-for-byte identical (TCP keeps http-<port> names; UDP gets
  udp-<port>).
- autoTCPReadinessProbe no longer attaches a TCP/HTTP probe to a
  UDP-only port (resolves the old "// TODO: support non-http ports").
- serviceType is now expressible via cloudExtras and threaded
  CloudExtras -> DeploymentConfig -> kubeArgs, letting a service turn
  its own Service into a LoadBalancer (required for protocols Caddy
  cannot proxy).

A service exposing both TCP and UDP emits ONE mixed-protocol
LoadBalancer Service (GA since k8s 1.26, supported on recent GKE)
rather than splitting into two Services, so a single external IP
fronts the whole service. Announcing that IP in ICE candidates
(LiveKit rtc.use_external_ip / node_ip) is the app's job, not sc's.

Backward compatible: ports with no protocol behave exactly as before,
and serviceType defaults to ClusterIP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Ruslan <ruslan.mikailov@fulldive.com>
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Semgrep Scan Results

Repository: api | Commit: c6136e3

Check Status Details
⚠️ Semgrep Warning 1 warning(s), 5 total

Scanned at 2026-07-15 19:08 UTC

@smecsia smecsia added the ci-run label Jul 14, 2026
@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

Security Scan Results

Repository: api | Commit: c6136e3

Check Status Details
✅ Secret Scan Pass No secrets detected
⚠️ Dependencies (Trivy) High 2 high, 5 total
⚠️ Dependencies (Grype) High 2 high, 3 total
📦 SBOM Generated 523 components (CycloneDX)

Scanned at 2026-07-15 19:08 UTC

@github-actions

github-actions Bot commented Jul 14, 2026

Copy link
Copy Markdown

📊 Statement coverage

Measured on the documented included set (see docs/TESTING.md → Coverage scope). Observe-only — no regression gate is enforced yet.

Scope This PR main baseline Δ
Included set (Gold-tier denominator) 90.3% 90.3% +0.0 pp
Full set (whole repo, transparency) 28.1% 28.0% +0.1 pp

Baseline: main @ 60b1958

@smecsia

smecsia commented Jul 14, 2026

Copy link
Copy Markdown
Contributor Author

Heads-up for reviewers: the only red check here — govulncheck — is not caused by this change. It's GO-2026-5856 in the Go stdlib (crypto/tls, go1.26.4, fixed in go1.26.5), reachable through pre-existing paths (exec / mcp / ollama / openai / git / mongodb); none of the traces touch the UDP/ports code. It fails on every Go-touching PR until the toolchain is bumped.

Fix is in #364 (go.mod: go 1.26.4 → 1.26.5), where govulncheck now passes ✅. Once #364 merges, rebase this branch and the gate clears here too.

Everything else on this PR is green (build all platforms, tests, CodeQL, semgrep, secret scan, SBOM, coverage, DCO; commit verified).

smecsia added a commit that referenced this pull request Jul 14, 2026
## What
Bumps the `go` directive in `go.mod` from `1.26.4` to `1.26.5`.

## Why
`govulncheck` reads the stdlib version it scans from the `go` directive.
Go **1.26.4** is affected by **GO-2026-5856** (`crypto/tls`), fixed in
**go1.26.5**. The finding is *reachable* through several pre-existing
paths, so the reachability gate now fails on **every Go-touching PR**
until the toolchain is bumped:

```
Vulnerability #1: GO-2026-5856  (crypto/tls@go1.26.4 → fixed in go1.26.5)
  #1 pkg/util/exec.go … tls.Conn.Handshake
  #2 pkg/assistant/mcp/server.go … tls.Conn.HandshakeContext
  #3 pkg/assistant/llm/ollama.go … tls.Conn.Read
  #4 pkg/api/git/repo.go … tls.Conn.Write
  #5 pkg/clouds/pulumi/mongodb/drop_db.go … tls.Dial
  #6 pkg/assistant/llm/openai.go … tls.Dialer.DialContext
```

Bumping the directive clears the advisory fleet-wide (unblocks #363 and
any other open Go PR). `go1.26.5` is released; `GOTOOLCHAIN=auto`
already resolves the toolchain, and the other workflows read the version
from `go.mod` / auto-upgrade — so **no source change** is needed.

## Scope
One line in `go.mod`. No code changes.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Signed-off-by: Ilya Sadykov <smecsia@gmail.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
RuslanMikailov and others added 4 commits July 15, 2026 16:15
PR #362 threaded UDP protocol + a stack-level serviceType, but the Service was
still built solely from the ingress container's ports, and on GKE a mixed
TCP+UDP LoadBalancer failed to provision (LoadBalancerMixedProtocolNotSupported).
Both blocked self-hosting a LiveKit SFU alongside the HTTP backend in one stack.

- Service ports now include non-ingress run containers' ports (they share the
  pod's network namespace and the Service selects the pod), so a sidecar SFU's
  7880/7882 land on the same Service as backend:3000. Ingress ports win on a
  port-number clash; ports are deduped.
- When the emitted LoadBalancer Service mixes TCP and UDP, annotate it with
  cloud.google.com/l4-rbs: "enabled" so GKE uses the RBS/NEG-based NetLB, which
  supports mixed protocols (verified live: one external IP fronting
  7880/7881 TCP + 7882/UDP). No config passthrough exists for Service
  annotations, so this must be set by sc.

Tests: dedupePorts, hasMixedProtocols, and a non-ingress-UDP Service case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Ilya Sadykov <smecsia@gmail.com>
The Service applied args.ExternalTrafficPolicy (simple_container.go) and the
DeploymentArgs carried it, but nothing populated it for a client stack — the
field only existed on CaddyConfig, and CloudExtras had no way to set it. So
`cloudExtras.externalTrafficPolicy` was silently dropped and the Service stayed
Cluster. Thread it like serviceType: CloudExtras -> DeploymentConfig ->
kubeArgs -> Service.

Needed for self-hosted WebRTC media behind the LB: externalTrafficPolicy: Local
preserves the LB IP (and client source IP) on the return path, so clients accept
the SFU downlink. Cluster SNATs to a node IP, which WebRTC rejects (dtls timeout,
call drops ~2 min in).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: Ilya Sadykov <smecsia@gmail.com>
The preceding commit wired CloudExtras.ExternalTrafficPolicy through the
custom-stack path (kube_run.go) but the GKE Autopilot path
(gke_autopilot_stack.go) only forwarded ServiceType, so the Service's
externalTrafficPolicy was silently dropped to the default "Cluster" on
Autopilot. That SNATs to a node IP and breaks WebRTC return media — exactly
what "Local" fixes — so the setting must reach the Autopilot kubeArgs too.
Mirror the ServiceType line; no other behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Signed-off-by: Ilya Sadykov <smecsia@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants