diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..23b1d97 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.git +.github +bin +dist +tmp +.DS_Store +*.log diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml new file mode 100644 index 0000000..517c568 --- /dev/null +++ b/.github/workflows/docker.yml @@ -0,0 +1,17 @@ +# Docker workflow builds the image and runs the Compose smoke test against a +# mock OpenAI-compatible backend. +name: Docker + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - name: Smoke-test Compose stack + run: make docker-smoke diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a95ddc7 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,38 @@ +FROM golang:1.25-alpine AS build +WORKDIR /src + +COPY go.mod go.sum ./ +RUN go mod download + +COPY . . + +ARG VERSION=0.1.0-dev +ARG TARGETOS=linux +ARG TARGETARCH=amd64 + +RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \ + -trimpath \ + -ldflags "-s -w -X main.version=${VERSION}" \ + -o /out/devrail-router \ + ./cmd/devrail-router + +RUN CGO_ENABLED=0 GOOS="${TARGETOS}" GOARCH="${TARGETARCH}" go build \ + -trimpath \ + -o /out/mock-openai-backend \ + ./test/mock-openai-backend + +FROM gcr.io/distroless/static-debian12:nonroot + +LABEL org.opencontainers.image.title="DevRail Router" +LABEL org.opencontainers.image.description="Local-first LLM routing and control plane for private AI infrastructure" +LABEL org.opencontainers.image.source="https://github.com/devrail-dev/devrail-router" +LABEL org.opencontainers.image.licenses="MIT" + +COPY --from=build /out/devrail-router /usr/local/bin/devrail-router +COPY --from=build /out/mock-openai-backend /usr/local/bin/mock-openai-backend + +USER nonroot:nonroot +EXPOSE 8080 + +ENTRYPOINT ["/usr/local/bin/devrail-router"] +CMD ["serve", "-config", "/etc/devrail/router.yaml"] diff --git a/Makefile b/Makefile index 8175d62..b695646 100644 --- a/Makefile +++ b/Makefile @@ -51,7 +51,7 @@ HAS_RUST := $(filter rust,$(LANGUAGES)) # --------------------------------------------------------------------------- # .PHONY declarations # --------------------------------------------------------------------------- -.PHONY: help build clean lint format fix package package-smoke test security scan docs changelog check install-hooks init +.PHONY: help build clean docker-build docker-smoke lint format fix package package-smoke test security scan docs changelog check install-hooks init vagrant-smoke .PHONY: _lint _format _fix _test _security _scan _docs _changelog _check _check-config _init # =========================================================================== @@ -84,6 +84,12 @@ clean: ## Remove build and package outputs docs: ## Generate documentation $(DOCKER_RUN) make _docs +docker-build: ## Build the DevRail Router container image + docker build --build-arg VERSION="$(VERSION)" -t devrail-router:$(VERSION) . + +docker-smoke: ## Run Docker Compose smoke test against a mock OpenAI backend + bash test/smoke/docker-compose.sh + fix: ## Auto-fix formatting issues in-place $(DOCKER_RUN) make _fix @@ -160,6 +166,14 @@ security: ## Run language-specific security scanners test: ## Run all tests $(DOCKER_RUN) make _test +vagrant-smoke: ## Run optional Vagrant systemd installer smoke test + @if ! command -v vagrant >/dev/null 2>&1; then \ + echo "Error: vagrant is required for vagrant-smoke"; \ + exit 2; \ + fi + $(MAKE) package GOOS=linux GOARCH=amd64 + vagrant up --provision + # =========================================================================== # Internal targets (run inside container — do NOT invoke directly) # diff --git a/README.md b/README.md index 6aa3a63..5336ce3 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ This repository is in early foundation work. The current service supports: - YAML configuration - Linux tarball packaging - Linux/systemd install script and unit +- Docker image and Compose smoke testing with a mock OpenAI-compatible backend Routing policy, auth, telemetry, LM Studio lifecycle integration, and Omarchy integration are planned next. @@ -71,6 +72,16 @@ make package-smoke GOOS=linux GOARCH=amd64 The package is written to `dist/` and includes the binary, example config, systemd unit, and Linux installer. +Run the containerized smoke stack: + +```sh +make docker-smoke +``` + +The Compose stack starts DevRail Router plus a mock OpenAI-compatible backend +and verifies health, model listing, alias rewriting, backend auth injection, and +chat completion proxying. + ## Configuration See `configs/router.example.yaml`. @@ -106,6 +117,9 @@ Linux is the first-class target: See `docs/packaging.md` and `packaging/systemd/devrail-router.service`. +Docker is supported for proxy-only deployments and repeatable integration +testing. See `compose.yaml` and `configs/router.docker.yaml`. + Omarchy support is planned as a separate integration profile. See `integrations/omarchy/README.md`. diff --git a/Vagrantfile b/Vagrantfile new file mode 100644 index 0000000..0697e4c --- /dev/null +++ b/Vagrantfile @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +Vagrant.configure("2") do |config| + config.vm.box = ENV.fetch("DEVRAIL_VAGRANT_BOX", "bento/ubuntu-24.04") + config.vm.hostname = "devrail-router-smoke" + + config.vm.provider "virtualbox" do |vb| + vb.name = "devrail-router-smoke" + vb.cpus = 2 + vb.memory = 2048 + end + + config.vm.provider "libvirt" do |lv| + lv.cpus = 2 + lv.memory = 2048 + end + + config.vm.provision "shell", path: "test/smoke/vagrant-provision.sh" +end diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..0e281a7 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,28 @@ +services: + router: + build: + context: . + args: + VERSION: ${VERSION:-0.1.0-dev} + image: devrail-router:${VERSION:-dev} + command: ["serve", "-config", "/etc/devrail/router.yaml"] + environment: + MOCK_OPENAI_API_KEY: compose-secret + ports: + - "127.0.0.1:${DEVRAIL_ROUTER_HOST_PORT:-0}:8080" + volumes: + - ./configs/router.docker.yaml:/etc/devrail/router.yaml:ro + depends_on: + mock-openai: + condition: service_started + + mock-openai: + build: + context: . + args: + VERSION: ${VERSION:-0.1.0-dev} + image: devrail-router:${VERSION:-dev} + entrypoint: ["/usr/local/bin/mock-openai-backend"] + command: ["-address", "0.0.0.0:9000", "-expected-api-key", "compose-secret"] + expose: + - "9000" diff --git a/configs/router.docker.yaml b/configs/router.docker.yaml new file mode 100644 index 0000000..5702751 --- /dev/null +++ b/configs/router.docker.yaml @@ -0,0 +1,24 @@ +server: + address: 0.0.0.0:8080 + +models: + - id: local-coder + name: Local Coder + backend: mock-openai + target_model: qwen3-coder-30b-a3b-instruct + context_window: 65536 + max_output_tokens: 4096 + tool_calls: true + - id: local-coder-large + name: Local Coder Large + backend: mock-openai + target_model: qwen/qwen3.6-35b-a3b + context_window: 131072 + max_output_tokens: 4096 + tool_calls: true + +backends: + - id: mock-openai + type: openai-compatible + base_url: http://mock-openai:9000/v1 + api_key_env: MOCK_OPENAI_API_KEY diff --git a/docs/packaging.md b/docs/packaging.md index 7f8613c..9fae154 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -66,12 +66,71 @@ A container image is useful for proxy-only deployments and CI smoke tests. It is not the first-class LM Studio host install path because local desktop app and GPU integration are easier from a native Linux service. +Build the image: + +```sh +make docker-build +``` + +Run the Compose smoke stack: + +```sh +make docker-smoke +``` + +The smoke stack uses `configs/router.docker.yaml` and starts two services: + +- `router`: DevRail Router listening on an ephemeral `127.0.0.1` host port +- `mock-openai`: a tiny OpenAI-compatible backend used only for tests + +Set `DEVRAIL_ROUTER_HOST_PORT` to override the host port: + +```sh +DEVRAIL_ROUTER_HOST_PORT=18081 make docker-smoke +``` + +The smoke script verifies: + +- `/healthz` responds +- `/v1/models` exposes configured aliases +- `local-coder` is rewritten to the configured target model +- `local-coder-large` is rewritten separately +- backend auth is injected from `MOCK_OPENAI_API_KEY` +- chat completion requests are proxied end to end + +This is intentionally not a replacement for systemd acceptance testing. Docker +does not prove native installer behavior, service restart behavior, journald +logging, LM Studio desktop integration, `lms` discovery, or GPU/runtime behavior. + ## Omarchy Omarchy support should be an integration profile, not a fork of the core router. See `integrations/omarchy/README.md` for the expected plugin layout and safety constraints. +## Vagrant Acceptance Smoke + +Vagrant is optional and aimed at native Linux installer acceptance. It is useful +for the systemd/user/config paths that Docker does not model cleanly. + +```sh +make vagrant-smoke +``` + +The Vagrant smoke test builds a Linux AMD64 tarball, boots an Ubuntu VM, installs +DevRail Router through `packaging/linux/install.sh`, starts the systemd service, +checks `/healthz` and `/v1/models`, verifies reinstall preserves +`/etc/devrail/router.yaml`, and restarts the service. + +Use `DEVRAIL_VAGRANT_BOX` to try another Linux box: + +```sh +DEVRAIL_VAGRANT_BOX=bento/fedora-40 make vagrant-smoke +``` + +The Vagrant harness is not run in CI yet because it needs a local provider such +as VirtualBox, libvirt, or another Vagrant-compatible VM backend. + ## macOS ARM macOS support should arrive after the Linux service is stable: diff --git a/internal/server/server.go b/internal/server/server.go index 164b4c9..67b2653 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -111,7 +111,7 @@ func (s *Server) proxyOpenAI(w http.ResponseWriter, r *http.Request) { originalDirector := proxy.Director proxy.Director = func(req *http.Request) { originalDirector(req) - req.URL.Path = joinPath(target.Path, r.URL.Path) + req.URL.Path = joinOpenAIPath(target.Path, r.URL.Path) req.Host = target.Host setBackendAuth(req, backend) } @@ -180,6 +180,17 @@ func joinPath(basePath, requestPath string) string { return strings.TrimRight(basePath, "/") + "/" + strings.TrimLeft(requestPath, "/") } +func joinOpenAIPath(basePath, requestPath string) string { + basePath = strings.TrimRight(basePath, "/") + if basePath != "" && basePath != "/" { + if requestPath == basePath || strings.HasPrefix(requestPath, basePath+"/") { + return requestPath + } + } + + return joinPath(basePath, requestPath) +} + func writeJSON(w http.ResponseWriter, status int, value any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) diff --git a/internal/server/server_test.go b/internal/server/server_test.go index 11a4919..4b2380f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -58,6 +58,45 @@ func TestUnknownAliasReturnsBadRequest(t *testing.T) { } } +func TestJoinOpenAIPathAvoidsDuplicateVersionPrefix(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + basePath string + requestPath string + want string + }{ + { + name: "versioned base", + basePath: "/v1", + requestPath: "/v1/chat/completions", + want: "/v1/chat/completions", + }, + { + name: "root base", + basePath: "", + requestPath: "/v1/chat/completions", + want: "/v1/chat/completions", + }, + { + name: "nested base", + basePath: "/openai", + requestPath: "/v1/chat/completions", + want: "/openai/v1/chat/completions", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := joinOpenAIPath(tt.basePath, tt.requestPath); got != tt.want { + t.Fatalf("joinOpenAIPath(%q, %q) = %q, want %q", tt.basePath, tt.requestPath, got, tt.want) + } + }) + } +} + func testServer(t *testing.T) *Server { t.Helper() diff --git a/test/mock-openai-backend/main.go b/test/mock-openai-backend/main.go new file mode 100644 index 0000000..0eeecd2 --- /dev/null +++ b/test/mock-openai-backend/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "flag" + "fmt" + "log/slog" + "net/http" + "os" + "time" +) + +func main() { + address := flag.String("address", "127.0.0.1:9000", "listen address") + expectedAPIKey := flag.String("expected-api-key", "", "expected bearer token") + flag.Parse() + + mux := http.NewServeMux() + mux.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]string{"status": "ok"}) + }) + mux.HandleFunc("/v1/models", func(w http.ResponseWriter, r *http.Request) { + if !authorized(w, r, *expectedAPIKey) { + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "object": "list", + "data": []map[string]any{{ + "id": "mock-model", + "object": "model", + "owned_by": "mock-openai-backend", + }}, + }) + }) + mux.HandleFunc("/v1/chat/completions", func(w http.ResponseWriter, r *http.Request) { + if !authorized(w, r, *expectedAPIKey) { + return + } + var payload struct { + Model string `json:"model"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, fmt.Sprintf("decode request: %v", err), http.StatusBadRequest) + return + } + writeJSON(w, http.StatusOK, map[string]any{ + "id": "chatcmpl-mock", + "object": "chat.completion", + "created": time.Now().Unix(), + "model": payload.Model, + "choices": []map[string]any{{ + "index": 0, + "message": map[string]string{ + "role": "assistant", + "content": "ok from " + payload.Model, + }, + "finish_reason": "stop", + }}, + }) + }) + + slog.Info("starting mock OpenAI-compatible backend", "address", *address) + if err := http.ListenAndServe(*address, mux); err != nil { + slog.Error("mock backend stopped", "error", err) + os.Exit(1) + } +} + +func authorized(w http.ResponseWriter, r *http.Request, expectedAPIKey string) bool { + if expectedAPIKey == "" { + return true + } + if r.Header.Get("Authorization") == "Bearer "+expectedAPIKey { + return true + } + http.Error(w, "missing or invalid Authorization header", http.StatusUnauthorized) + return false +} + +func writeJSON(w http.ResponseWriter, status int, value any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + if err := json.NewEncoder(w).Encode(value); err != nil { + slog.Error("write response", "error", err) + } +} diff --git a/test/smoke/docker-compose.sh b/test/smoke/docker-compose.sh new file mode 100755 index 0000000..19f06f0 --- /dev/null +++ b/test/smoke/docker-compose.sh @@ -0,0 +1,81 @@ +#!/usr/bin/env bash +set -euo pipefail + +compose() { + if docker compose version >/dev/null 2>&1; then + docker compose "$@" + else + docker-compose "$@" + fi +} + +docker_helper_dir="" + +cleanup() { + compose down --remove-orphans --volumes >/dev/null 2>&1 || true + if [ -n "$docker_helper_dir" ]; then + rm -rf "$docker_helper_dir" + fi +} +trap cleanup EXIT + +if [ -f "${HOME}/.docker/config.json" ] && + grep -q '"credsStore"[[:space:]]*:[[:space:]]*"desktop"' "${HOME}/.docker/config.json" && + ! command -v docker-credential-desktop >/dev/null 2>&1; then + docker_helper_dir=$(mktemp -d) + { + printf '#!/usr/bin/env sh\n' + printf 'case "$1" in\n' + printf ' get) printf '"'"'{"Username":"","Secret":""}\\n'"'"' ;;\n' + printf ' list) printf '"'"'{}\\n'"'"' ;;\n' + printf ' store|erase) cat >/dev/null; exit 0 ;;\n' + printf ' *) exit 1 ;;\n' + printf 'esac\n' + } >"$docker_helper_dir/docker-credential-desktop" + chmod +x "$docker_helper_dir/docker-credential-desktop" + export PATH="$docker_helper_dir:$PATH" +fi + +compose up --build -d + +published_address=$(compose port router 8080) +published_port=${published_address##*:} +router_url="http://127.0.0.1:${published_port:?router port was not published}" + +for _ in $(seq 1 60); do + if curl -fsS "$router_url/healthz" >/dev/null; then + break + fi + sleep 1 +done + +curl -fsS "$router_url/healthz" | grep -q '"status":"ok"' +curl -fsS "$router_url/v1/models" | grep -q '"id":"local-coder"' + +response=$( + curl -fsS "$router_url/v1/chat/completions" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "local-coder", + "messages": [{"role": "user", "content": "Reply with ok."}], + "max_tokens": 16 + }' +) + +echo "$response" | grep -q '"model":"qwen3-coder-30b-a3b-instruct"' +echo "$response" | grep -q 'ok from qwen3-coder-30b-a3b-instruct' + +large_response=$( + curl -fsS "$router_url/v1/chat/completions" \ + -H 'Content-Type: application/json' \ + -d '{ + "model": "local-coder-large", + "messages": [{"role": "user", "content": "Reply with ok."}], + "max_tokens": 16 + }' +) + +echo "$large_response" | grep -q '"model":"qwen/qwen3.6-35b-a3b"' +echo "$large_response" | grep -q 'ok from qwen/qwen3.6-35b-a3b' + +echo "docker compose smoke passed" diff --git a/test/smoke/vagrant-provision.sh b/test/smoke/vagrant-provision.sh new file mode 100755 index 0000000..ee8c563 --- /dev/null +++ b/test/smoke/vagrant-provision.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd /vagrant + +package_path=$(find dist -maxdepth 1 -name 'devrail-router_*_linux_amd64.tar.gz' | sort | tail -n 1) +if [ -z "$package_path" ]; then + echo "missing Linux AMD64 package in dist/" >&2 + echo "run: make package GOOS=linux GOARCH=amd64" >&2 + exit 2 +fi + +apt-get update +DEBIAN_FRONTEND=noninteractive apt-get install -y ca-certificates curl tar + +work_dir=$(mktemp -d) +trap 'rm -rf "$work_dir"' EXIT + +tar -xzf "$package_path" -C "$work_dir" +package_dir=$(find "$work_dir" -maxdepth 1 -type d -name 'devrail-router_*_linux_amd64' | head -n 1) + +cd "$package_dir" +START_SERVICE=1 ./packaging/linux/install.sh + +systemctl is-enabled devrail-router.service +systemctl is-active devrail-router.service +curl -fsS http://127.0.0.1:8080/healthz | grep -q '"status":"ok"' +curl -fsS http://127.0.0.1:8080/v1/models | grep -q '"id":"local-coder"' + +printf '# preserved by vagrant smoke\n' >>/etc/devrail/router.yaml +./packaging/linux/install.sh +grep -q 'preserved by vagrant smoke' /etc/devrail/router.yaml + +systemctl restart devrail-router.service +systemctl is-active devrail-router.service +curl -fsS http://127.0.0.1:8080/healthz | grep -q '"status":"ok"' + +echo "vagrant smoke passed"