From bfb75d1fc30d53761236b28d465e48abf1a23cb6 Mon Sep 17 00:00:00 2001 From: David Zhao Date: Sun, 28 Jun 2026 09:28:18 +0200 Subject: [PATCH] feat: mock API server for testing server SDKs (#4627) * feat: mock API server for testing server SDKs * fixed lint --- .github/workflows/test-server-docker.yaml | 53 +++++ cmd/test-server/Dockerfile | 40 ++++ cmd/test-server/README.md | 84 ++++++++ cmd/test-server/handlers.go | 228 ++++++++++++++++++++ cmd/test-server/main.go | 247 ++++++++++++++++++++++ magefile.go | 5 + 6 files changed, 657 insertions(+) create mode 100644 .github/workflows/test-server-docker.yaml create mode 100644 cmd/test-server/Dockerfile create mode 100644 cmd/test-server/README.md create mode 100644 cmd/test-server/handlers.go create mode 100644 cmd/test-server/main.go diff --git a/.github/workflows/test-server-docker.yaml b/.github/workflows/test-server-docker.yaml new file mode 100644 index 000000000..a0a854eaf --- /dev/null +++ b/.github/workflows/test-server-docker.yaml @@ -0,0 +1,53 @@ +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Builds and publishes the SDK conformance test server (cmd/test-server) to +# Docker Hub as livekit/test-server:latest on every push to master. The server +# SDK repos boot this image in their CI to run region-failover tests. +name: Release Test Server to Docker + +permissions: + contents: read + +on: + workflow_dispatch: + push: + branches: + - master + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4 + + - name: Login to DockerHub + uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7 + with: + context: . + file: cmd/test-server/Dockerfile + push: true + platforms: linux/amd64,linux/arm64 + # Each build overrides the latest tag so SDK CI always boots the + # current mock server. + tags: livekit/test-server:latest diff --git a/cmd/test-server/Dockerfile b/cmd/test-server/Dockerfile new file mode 100644 index 000000000..0182aadea --- /dev/null +++ b/cmd/test-server/Dockerfile @@ -0,0 +1,40 @@ +# Copyright 2026 LiveKit, Inc. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Mock LiveKit API server for SDK conformance testing. Build from the repo +# root: docker build -f cmd/test-server/Dockerfile -t livekit/test-server . +FROM golang:1.26-alpine AS builder + +ARG TARGETARCH + +WORKDIR /workspace + +COPY go.mod go.mod +COPY go.sum go.sum +RUN go mod download + +COPY cmd/ cmd/ +COPY pkg/ pkg/ +COPY version/ version/ + +RUN CGO_ENABLED=0 GOOS=linux GOARCH=$TARGETARCH GO111MODULE=on go build -a -o livekit-test-server ./cmd/test-server + +FROM alpine + +COPY --from=builder /workspace/livekit-test-server /livekit-test-server + +# region-0 (primary, 9999) + 3 fallback regions +EXPOSE 9999 10000 10001 10002 + +ENTRYPOINT ["/livekit-test-server"] diff --git a/cmd/test-server/README.md b/cmd/test-server/README.md new file mode 100644 index 000000000..08dd75122 --- /dev/null +++ b/cmd/test-server/README.md @@ -0,0 +1,84 @@ +# LiveKit SDK test server + +A stateless, per-request programmable mock of the LiveKit server HTTP API. It +exists so the server SDKs (Go, Rust, Python, Node, Kotlin, Ruby) can exercise +client-side behavior against one shared implementation, published as a Docker +image and booted by each SDK's CI. + +## Why it looks the way it does + +- **Stateless.** All behavior is selected by per-request `X-Lk-Mock-*` headers, + so the server holds no mutable state and tests run in parallel. +- **Multi-port = multi-region.** The process binds one listener per simulated + region (`--ports`). A port's position in the list is its **region index**; + index `0` is the primary the SDK is initially pointed at. `GET + /settings/regions` advertises all of them in order. +- **One header drives every attempt.** The SDK sends the same control header on + the initial request *and* every failover retry. Each listener decides what to + do from its **own** index, so a single `X-Lk-Mock-Fail-Regions: 0` makes the + primary fail while the first fallback succeeds — no coordination needed. +- **The whole API is mocked with populated responses.** Every RoomService, + Egress, Ingress, SIP, and Connector method returns a type-correct, populated + response: scalar fields that share a name with the request are echoed (e.g. + `name`, `metadata`, `identity`, timeouts), `id`/`sid` fields get placeholder + values, and list endpoints return one element. Both protobuf and JSON Twirp + clients are supported. A client can override the response entirely with the + `X-Lk-Mock-Response` header (see below). Unregistered/future methods fall back + to an empty (all-default) message, which still decodes cleanly. + +## Running + +```bash +go run ./cmd/test-server # primary :9999, regions :10000-10002 +go run ./cmd/test-server --ports 9999,10000 # primary + one fallback + +# Docker +docker build -f cmd/test-server/Dockerfile -t livekit/test-server . +docker run -p 9999-10002:9999-10002 livekit/test-server +``` + +| Flag | Env | Default | Meaning | +|---|---|---|---| +| `--ports` | `LK_TEST_SERVER_PORTS` | `9999,10000,10001,10002` | listener ports; index = position | +| `--advertise-host` | `LK_TEST_SERVER_ADVERTISE_HOST` | `http://127.0.0.1` | base URL used in `/settings/regions` | +| `--bind` | `LK_TEST_SERVER_BIND` | `0.0.0.0` | bind address | +| `--twirp-prefix` | `LK_TEST_SERVER_TWIRP_PREFIX` | `/twirp` | Twirp path prefix | + +## Control protocol + +Request headers (sent by the SDK on API calls; the SDK must forward +client-configured custom headers onto the `/settings/regions` fetch and every +failover retry): + +| Header | Default | Effect | +|---|---|---| +| `X-Lk-Mock-Fail-Regions` | — | comma list of region indices that fail this request, e.g. `0` or `0,1`. Each listener fails only if its own index is listed. | +| `X-Lk-Mock-Fail-Mode` | `status` | how a failing region fails: `status`, `drop` (close connection → transport error), `delay`. | +| `X-Lk-Mock-Fail-Status` | `503` | HTTP status when failing with `status`/`delay`. | +| `X-Lk-Mock-Fail-Twirp-Code` | derived from status | Twirp error code string in the failure body. | +| `X-Lk-Mock-Delay-Ms` | `30000` | delay before a `delay`-mode region responds (for timeout tests). | +| `X-Lk-Mock-Regions-Status` | `200` | override the status of `GET /settings/regions`. | +| `X-Lk-Mock-Response` | — | protojson of the response message for the called method; replaces the populated default, giving full control over the returned payload. | + +Response headers: + +| Header | Meaning | +|---|---| +| `X-Lk-Mock-Region` | index of the region that served the response (blank on a failed region). Assert on this to confirm which region a failover landed on. | + +## Common recipes + +| Goal | Headers | +|---|---| +| Happy path | _(none)_ → 200 from region `0` | +| Failover succeeds on region 1 | `X-Lk-Mock-Fail-Regions: 0` | +| Exhaust to region 2 | `X-Lk-Mock-Fail-Regions: 0,1` | +| All regions down | `X-Lk-Mock-Fail-Regions: 0,1,2,3` | +| 4xx, no retry | `X-Lk-Mock-Fail-Regions: 0` + `X-Lk-Mock-Fail-Status: 400` | +| Transport-error failover | `X-Lk-Mock-Fail-Regions: 0` + `X-Lk-Mock-Fail-Mode: drop` | +| Region discovery unreachable | `X-Lk-Mock-Regions-Status: 500` | +| Custom response payload | `X-Lk-Mock-Response: {"sid":"RM_x","name":"my-room"}` | + +Note: SDK region failover normally only engages for `*.livekit.cloud` hosts. +Since tests point at `127.0.0.1`, set the SDK's failover-enable option to its +forced-on value so failover engages against localhost. diff --git a/cmd/test-server/handlers.go b/cmd/test-server/handlers.go new file mode 100644 index 000000000..f1568db95 --- /dev/null +++ b/cmd/test-server/handlers.go @@ -0,0 +1,228 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package main + +import ( + "io" + "net/http" + "strconv" + "strings" + + "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/reflect/protoreflect" + "google.golang.org/protobuf/types/known/emptypb" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/utils/protojson" +) + +// apiSpec captures the request and response message types for one Twirp method, +// so the mock can decode the incoming request and build a typed response. +type apiSpec struct { + newReq func() proto.Message + newResp func() proto.Message +} + +// ptrMsg constrains a pointer type that is also a proto.Message, letting reg +// construct fresh request/response values generically. +type ptrMsg[T any] interface { + *T + proto.Message +} + +// apiHandlers maps "./" to its message types. It +// covers the full LiveKit API surface; see init below. +var apiHandlers = map[string]apiSpec{} + +func reg[ReqT, RespT any, Req ptrMsg[ReqT], Resp ptrMsg[RespT]](key string) { + apiHandlers[key] = apiSpec{ + newReq: func() proto.Message { return Req(new(ReqT)) }, + newResp: func() proto.Message { return Resp(new(RespT)) }, + } +} + +func init() { + // RoomService + reg[livekit.CreateRoomRequest, livekit.Room]("livekit.RoomService/CreateRoom") + reg[livekit.ListRoomsRequest, livekit.ListRoomsResponse]("livekit.RoomService/ListRooms") + reg[livekit.DeleteRoomRequest, livekit.DeleteRoomResponse]("livekit.RoomService/DeleteRoom") + reg[livekit.ListParticipantsRequest, livekit.ListParticipantsResponse]("livekit.RoomService/ListParticipants") + reg[livekit.RoomParticipantIdentity, livekit.ParticipantInfo]("livekit.RoomService/GetParticipant") + reg[livekit.RoomParticipantIdentity, livekit.RemoveParticipantResponse]("livekit.RoomService/RemoveParticipant") + reg[livekit.MuteRoomTrackRequest, livekit.MuteRoomTrackResponse]("livekit.RoomService/MutePublishedTrack") + reg[livekit.UpdateParticipantRequest, livekit.ParticipantInfo]("livekit.RoomService/UpdateParticipant") + reg[livekit.UpdateSubscriptionsRequest, livekit.UpdateSubscriptionsResponse]("livekit.RoomService/UpdateSubscriptions") + reg[livekit.SendDataRequest, livekit.SendDataResponse]("livekit.RoomService/SendData") + reg[livekit.UpdateRoomMetadataRequest, livekit.Room]("livekit.RoomService/UpdateRoomMetadata") + reg[livekit.ForwardParticipantRequest, livekit.ForwardParticipantResponse]("livekit.RoomService/ForwardParticipant") + reg[livekit.MoveParticipantRequest, livekit.MoveParticipantResponse]("livekit.RoomService/MoveParticipant") + reg[livekit.PerformRpcRequest, livekit.PerformRpcResponse]("livekit.RoomService/PerformRpc") + + // Egress + reg[livekit.StartEgressRequest, livekit.EgressInfo]("livekit.Egress/StartEgress") + reg[livekit.UpdateLayoutRequest, livekit.EgressInfo]("livekit.Egress/UpdateLayout") + reg[livekit.UpdateStreamRequest, livekit.EgressInfo]("livekit.Egress/UpdateStream") + reg[livekit.ListEgressRequest, livekit.ListEgressResponse]("livekit.Egress/ListEgress") + reg[livekit.StopEgressRequest, livekit.EgressInfo]("livekit.Egress/StopEgress") + reg[livekit.RoomCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartRoomCompositeEgress") + reg[livekit.WebEgressRequest, livekit.EgressInfo]("livekit.Egress/StartWebEgress") + reg[livekit.ParticipantEgressRequest, livekit.EgressInfo]("livekit.Egress/StartParticipantEgress") + reg[livekit.TrackCompositeEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackCompositeEgress") + reg[livekit.TrackEgressRequest, livekit.EgressInfo]("livekit.Egress/StartTrackEgress") + + // Ingress + reg[livekit.CreateIngressRequest, livekit.IngressInfo]("livekit.Ingress/CreateIngress") + reg[livekit.UpdateIngressRequest, livekit.IngressInfo]("livekit.Ingress/UpdateIngress") + reg[livekit.ListIngressRequest, livekit.ListIngressResponse]("livekit.Ingress/ListIngress") + reg[livekit.DeleteIngressRequest, livekit.IngressInfo]("livekit.Ingress/DeleteIngress") + + // SIP + reg[livekit.ListSIPTrunkRequest, livekit.ListSIPTrunkResponse]("livekit.SIP/ListSIPTrunk") + reg[livekit.CreateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/CreateSIPInboundTrunk") + reg[livekit.CreateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/CreateSIPOutboundTrunk") + reg[livekit.UpdateSIPInboundTrunkRequest, livekit.SIPInboundTrunkInfo]("livekit.SIP/UpdateSIPInboundTrunk") + reg[livekit.UpdateSIPOutboundTrunkRequest, livekit.SIPOutboundTrunkInfo]("livekit.SIP/UpdateSIPOutboundTrunk") + reg[livekit.GetSIPInboundTrunkRequest, livekit.GetSIPInboundTrunkResponse]("livekit.SIP/GetSIPInboundTrunk") + reg[livekit.GetSIPOutboundTrunkRequest, livekit.GetSIPOutboundTrunkResponse]("livekit.SIP/GetSIPOutboundTrunk") + reg[livekit.ListSIPInboundTrunkRequest, livekit.ListSIPInboundTrunkResponse]("livekit.SIP/ListSIPInboundTrunk") + reg[livekit.ListSIPOutboundTrunkRequest, livekit.ListSIPOutboundTrunkResponse]("livekit.SIP/ListSIPOutboundTrunk") + reg[livekit.DeleteSIPTrunkRequest, livekit.SIPTrunkInfo]("livekit.SIP/DeleteSIPTrunk") + reg[livekit.CreateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/CreateSIPDispatchRule") + reg[livekit.UpdateSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/UpdateSIPDispatchRule") + reg[livekit.ListSIPDispatchRuleRequest, livekit.ListSIPDispatchRuleResponse]("livekit.SIP/ListSIPDispatchRule") + reg[livekit.DeleteSIPDispatchRuleRequest, livekit.SIPDispatchRuleInfo]("livekit.SIP/DeleteSIPDispatchRule") + reg[livekit.CreateSIPParticipantRequest, livekit.SIPParticipantInfo]("livekit.SIP/CreateSIPParticipant") + reg[livekit.TransferSIPParticipantRequest, emptypb.Empty]("livekit.SIP/TransferSIPParticipant") + + // Connector + reg[livekit.DialWhatsAppCallRequest, livekit.DialWhatsAppCallResponse]("livekit.Connector/DialWhatsAppCall") + reg[livekit.DisconnectWhatsAppCallRequest, livekit.DisconnectWhatsAppCallResponse]("livekit.Connector/DisconnectWhatsAppCall") + reg[livekit.ConnectWhatsAppCallRequest, livekit.ConnectWhatsAppCallResponse]("livekit.Connector/ConnectWhatsAppCall") + reg[livekit.AcceptWhatsAppCallRequest, livekit.AcceptWhatsAppCallResponse]("livekit.Connector/AcceptWhatsAppCall") + reg[livekit.ConnectTwilioCallRequest, livekit.ConnectTwilioCallResponse]("livekit.Connector/ConnectTwilioCall") +} + +// writeAPIResponse serves a populated, type-correct response for a known API +// method. The response is the reflection-populated default unless the request +// carries an X-Lk-Mock-Response header (protojson), which overrides it +// entirely. Content type (protobuf vs JSON) mirrors the request. +func (h *mockHandler) writeAPIResponse(w http.ResponseWriter, r *http.Request) { + json := strings.Contains(r.Header.Get("Content-Type"), "json") + w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex)) + + key := strings.TrimPrefix(r.URL.Path, h.twirpPrefix+"/") + spec, ok := apiHandlers[key] + if !ok { + // Unknown/future method: an empty body still decodes to a valid default + // message in every Twirp client. + writeEmptySuccess(w, json) + return + } + + body, _ := io.ReadAll(r.Body) + req := spec.newReq() + if json { + _ = protojson.Unmarshal(body, req) + } else { + _ = proto.Unmarshal(body, req) + } + + resp := spec.newResp() + if override := r.Header.Get(headerResponse); override != "" { + if err := protojson.Unmarshal([]byte(override), resp); err != nil { + // Malformed override: fall back to the populated default. + resp = spec.newResp() + populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1) + } + } else { + populateMessage(resp.ProtoReflect(), req.ProtoReflect(), 1) + } + + if json { + out, _ := protojson.Marshal(resp) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(out) + } else { + out, _ := proto.Marshal(resp) + w.Header().Set("Content-Type", "application/protobuf") + w.WriteHeader(http.StatusOK) + _, _ = w.Write(out) + } +} + +func writeEmptySuccess(w http.ResponseWriter, json bool) { + if json { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("{}")) + } else { + w.Header().Set("Content-Type", "application/protobuf") + w.WriteHeader(http.StatusOK) + } +} + +// populateMessage fills a response message with plausible values: it echoes +// scalar fields that share a name with the request, assigns placeholder values +// to id/sid fields, and adds one element to repeated-message (list) fields so +// list endpoints return non-empty results. depth bounds list-element nesting. +func populateMessage(m protoreflect.Message, req protoreflect.Message, depth int) { + fields := m.Descriptor().Fields() + for i := 0; i < fields.Len(); i++ { + fd := fields.Get(i) + + // Echo a same-named scalar field from the request (e.g. name, metadata, + // identity, room, timeouts). + if req != nil && fd.Cardinality() != protoreflect.Repeated && isScalarKind(fd.Kind()) { + if rf := req.Descriptor().Fields().ByName(fd.Name()); rf != nil && + rf.Kind() == fd.Kind() && rf.Cardinality() != protoreflect.Repeated && req.Has(rf) { + m.Set(fd, req.Get(rf)) + continue + } + } + + // Give id/sid-like string fields a deterministic placeholder. + if fd.Kind() == protoreflect.StringKind && fd.Cardinality() != protoreflect.Repeated && !m.Has(fd) { + n := string(fd.Name()) + if n == "id" || n == "sid" || strings.HasSuffix(n, "_id") || strings.HasSuffix(n, "_sid") { + m.Set(fd, protoreflect.ValueOfString("MOCK_"+strings.ToUpper(n))) + continue + } + } + + // Populate list endpoints with a single element so clients see results. + if depth > 0 && fd.IsList() && fd.Kind() == protoreflect.MessageKind { + list := m.Mutable(fd).List() + elem := list.NewElement() + populateMessage(elem.Message(), nil, depth-1) + list.Append(elem) + } + } +} + +func isScalarKind(k protoreflect.Kind) bool { + switch k { + case protoreflect.BoolKind, + protoreflect.Int32Kind, protoreflect.Sint32Kind, protoreflect.Uint32Kind, + protoreflect.Int64Kind, protoreflect.Sint64Kind, protoreflect.Uint64Kind, + protoreflect.Sfixed32Kind, protoreflect.Fixed32Kind, + protoreflect.Sfixed64Kind, protoreflect.Fixed64Kind, + protoreflect.FloatKind, protoreflect.DoubleKind, + protoreflect.StringKind, protoreflect.BytesKind: + return true + default: + return false + } +} diff --git a/cmd/test-server/main.go b/cmd/test-server/main.go new file mode 100644 index 000000000..66b756d47 --- /dev/null +++ b/cmd/test-server/main.go @@ -0,0 +1,247 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// Command test-server is a programmable mock of the LiveKit server HTTP API, +// used by the server SDKs to test client behavior. See cmd/test-server/README.md. +package main + +import ( + "errors" + "fmt" + "net/http" + "os" + "os/signal" + "strconv" + "strings" + "syscall" + "time" + + "github.com/livekit/protocol/livekit" + "github.com/livekit/protocol/utils/protojson" +) + +// X-Lk-Mock-* request headers control the mock's behavior; see the README. +const ( + headerFailRegions = "X-Lk-Mock-Fail-Regions" + headerFailMode = "X-Lk-Mock-Fail-Mode" + headerFailStatus = "X-Lk-Mock-Fail-Status" + headerFailTwirpCode = "X-Lk-Mock-Fail-Twirp-Code" + headerDelayMs = "X-Lk-Mock-Delay-Ms" + headerRegionsStatus = "X-Lk-Mock-Regions-Status" + headerResponse = "X-Lk-Mock-Response" + // headerRegion is set on responses to the index of the region that served it. + headerRegion = "X-Lk-Mock-Region" +) + +const defaultDelayMs = 30_000 + +func main() { + portsFlag := flagValue("--ports", "LK_TEST_SERVER_PORTS", "9999,10000,10001,10002") + advertiseHost := flagValue("--advertise-host", "LK_TEST_SERVER_ADVERTISE_HOST", "http://127.0.0.1") + bindAddr := flagValue("--bind", "LK_TEST_SERVER_BIND", "0.0.0.0") + twirpPrefix := flagValue("--twirp-prefix", "LK_TEST_SERVER_TWIRP_PREFIX", "/twirp") + + ports, err := parsePorts(portsFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "invalid --ports: %v\n", err) + os.Exit(1) + } + advertiseHost = strings.TrimRight(advertiseHost, "/") + + regions := &livekit.RegionSettings{} + for i, p := range ports { + regions.Regions = append(regions.Regions, &livekit.RegionInfo{ + Region: fmt.Sprintf("region-%d", i), + Url: fmt.Sprintf("%s:%d", advertiseHost, p), + Distance: int64(i), + }) + } + + errCh := make(chan error, len(ports)) + for i, p := range ports { + srv := &http.Server{ + Addr: fmt.Sprintf("%s:%d", bindAddr, p), + Handler: &mockHandler{regionIndex: i, regions: regions, twirpPrefix: twirpPrefix}, + } + go func() { errCh <- srv.ListenAndServe() }() + fmt.Printf("test-server: region-%d listening on %s:%d (advertised as %s:%d)\n", i, bindAddr, p, advertiseHost, p) + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM) + select { + case err := <-errCh: + fmt.Fprintf(os.Stderr, "listener failed: %v\n", err) + os.Exit(1) + case <-sigCh: + fmt.Println("test-server: shutting down") + } +} + +type mockHandler struct { + regionIndex int + regions *livekit.RegionSettings + twirpPrefix string +} + +func (h *mockHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + switch { + case r.URL.Path == "/settings/regions": + h.handleRegions(w, r) + case strings.HasPrefix(r.URL.Path, h.twirpPrefix+"/"): + h.handleTwirp(w, r) + case r.URL.Path == "/" || r.URL.Path == "/_test/health": + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + default: + http.NotFound(w, r) + } +} + +func (h *mockHandler) handleRegions(w http.ResponseWriter, r *http.Request) { + if status := parseStatus(r.Header.Get(headerRegionsStatus), 0); status != 0 && status != http.StatusOK { + w.WriteHeader(status) + return + } + body, err := protojson.Marshal(h.regions) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "max-age=0") + w.Header().Set(headerRegion, strconv.Itoa(h.regionIndex)) + _, _ = w.Write(body) +} + +func (h *mockHandler) handleTwirp(w http.ResponseWriter, r *http.Request) { + if h.shouldFail(r) { + h.fail(w, r) + return + } + h.writeAPIResponse(w, r) +} + +func (h *mockHandler) shouldFail(r *http.Request) bool { + for _, part := range strings.Split(r.Header.Get(headerFailRegions), ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + if idx, err := strconv.Atoi(part); err == nil && idx == h.regionIndex { + return true + } + } + return false +} + +func (h *mockHandler) fail(w http.ResponseWriter, r *http.Request) { + switch strings.ToLower(r.Header.Get(headerFailMode)) { + case "drop": + if hj, ok := w.(http.Hijacker); ok { + if conn, _, err := hj.Hijack(); err == nil { + _ = conn.Close() + return + } + } + w.WriteHeader(http.StatusServiceUnavailable) + case "delay": + delay := defaultDelayMs + if ms, err := strconv.Atoi(r.Header.Get(headerDelayMs)); err == nil && ms >= 0 { + delay = ms + } + time.Sleep(time.Duration(delay) * time.Millisecond) + writeTwirpError(w, r, parseStatus(r.Header.Get(headerFailStatus), http.StatusServiceUnavailable)) + default: + writeTwirpError(w, r, parseStatus(r.Header.Get(headerFailStatus), http.StatusServiceUnavailable)) + } +} + +func writeTwirpError(w http.ResponseWriter, r *http.Request, status int) { + code := r.Header.Get(headerFailTwirpCode) + if code == "" { + code = twirpCodeForStatus(status) + } + w.Header().Set("Content-Type", "application/json") + w.Header().Set(headerRegion, "") + w.WriteHeader(status) + _, _ = fmt.Fprintf(w, `{"code":%q,"msg":%q}`, code, fmt.Sprintf("mock failure (status %d)", status)) +} + +func twirpCodeForStatus(status int) string { + switch { + case status == http.StatusBadRequest: + return "invalid_argument" + case status == http.StatusUnauthorized: + return "unauthenticated" + case status == http.StatusForbidden: + return "permission_denied" + case status == http.StatusNotFound: + return "not_found" + case status == http.StatusTooManyRequests: + return "resource_exhausted" + case status >= 500: + return "unavailable" + default: + return "internal" + } +} + +func parseStatus(s string, def int) int { + if s == "" { + return def + } + if v, err := strconv.Atoi(strings.TrimSpace(s)); err == nil && v >= 100 && v <= 599 { + return v + } + return def +} + +func parsePorts(s string) ([]int, error) { + var ports []int + for _, part := range strings.Split(s, ",") { + part = strings.TrimSpace(part) + if part == "" { + continue + } + v, err := strconv.Atoi(part) + if err != nil { + return nil, fmt.Errorf("%q is not a port number", part) + } + ports = append(ports, v) + } + if len(ports) == 0 { + return nil, errors.New("at least one port is required") + } + return ports, nil +} + +// flagValue resolves a setting from a --flag, then an environment variable, then a default. +func flagValue(flag, env, def string) string { + prefix := flag + "=" + for i, arg := range os.Args[1:] { + if arg == flag { + if i+2 <= len(os.Args[1:]) { + return os.Args[1:][i+1] + } + } + if strings.HasPrefix(arg, prefix) { + return strings.TrimPrefix(arg, prefix) + } + } + if v := os.Getenv(env); v != "" { + return v + } + return def +} diff --git a/magefile.go b/magefile.go index 4423b01c9..d4d42fa68 100644 --- a/magefile.go +++ b/magefile.go @@ -177,6 +177,11 @@ func TestAll() error { return mageutil.Run(context.Background(), "go test ./... -count=1 -timeout=4m -v") } +// runs the SDK test server (cmd/test-server) in the foreground +func TestServer() error { + return mageutil.Run(context.Background(), "go run ./cmd/test-server") +} + // runs golangci-lint func Lint() error { if _, err := exec.LookPath("golangci-lint"); err != nil {