Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .dockerignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
.git
.github
bin
dist
tmp
.DS_Store
*.log
17 changes: 17 additions & 0 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -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
38 changes: 38 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
16 changes: 15 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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

# ===========================================================================
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
#
Expand Down
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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`.

Expand Down
19 changes: 19 additions & 0 deletions Vagrantfile
Original file line number Diff line number Diff line change
@@ -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
28 changes: 28 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
@@ -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"
24 changes: 24 additions & 0 deletions configs/router.docker.yaml
Original file line number Diff line number Diff line change
@@ -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
59 changes: 59 additions & 0 deletions docs/packaging.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
13 changes: 12 additions & 1 deletion internal/server/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 39 additions & 0 deletions internal/server/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
Loading
Loading