Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f32ff08365 | ||
|
|
c44a9b174f | ||
|
|
f1030aae82 | ||
|
|
dc97afb0ef | ||
|
|
eb4c2ff13d | ||
|
|
4463508f29 | ||
|
|
11ca4a5902 | ||
|
|
33910fe8e9 | ||
|
|
e7bd818459 | ||
|
|
a55e4c7b43 | ||
|
|
d998d7e63c | ||
|
|
ea6974cdad | ||
|
|
8ba4e0facc | ||
|
|
14cc67833c | ||
|
|
f9addf945d | ||
|
|
de34a6c8cc | ||
|
|
bc3ce9578f | ||
|
|
8862dbebb7 | ||
|
|
a0995edbd0 | ||
|
|
4935ea9f95 | ||
|
|
4633989a46 | ||
|
|
61abe529ad | ||
|
|
e1a5aca7d6 | ||
|
|
9d9395d938 | ||
|
|
55b32b97e0 | ||
|
|
95045e80f6 | ||
|
|
1dc688aec2 | ||
|
|
a8d8b17a03 | ||
|
|
8dfd17127b | ||
|
|
139bc80529 | ||
|
|
57d64039d5 | ||
|
|
df33203a72 | ||
|
|
46dc24a26c | ||
|
|
e7536a80ce | ||
|
|
f4193fe6e1 | ||
|
|
bd4cbbd0a3 | ||
|
|
50503445f7 | ||
|
|
62e23d6876 | ||
|
|
81e228e818 | ||
|
|
9c5fb0ce84 | ||
|
|
0cdfae2a93 | ||
|
|
951a4399d6 | ||
|
|
425611ec42 | ||
|
|
63893d41a5 | ||
|
|
65c8978d21 | ||
|
|
abcc6dd4a9 | ||
|
|
9740334d24 | ||
|
|
0f2cd7565a | ||
|
|
60a7dea072 | ||
|
|
0fe9de6fbd | ||
|
|
afcc28fca0 | ||
|
|
b6767e247c | ||
|
|
6cf5c3e5a9 | ||
|
|
0aaf4116f1 | ||
|
|
fb1a97e8d0 | ||
|
|
9127076f1b | ||
|
|
3d63df9ba8 | ||
|
|
aef122b854 | ||
|
|
bc26ab9340 | ||
|
|
f06edf6a54 | ||
|
|
7b265b8338 | ||
|
|
0e6ebe7353 | ||
|
|
7ebbf2bd03 | ||
|
|
dac1a5da4b | ||
|
|
e71034c3ef | ||
|
|
31ed81a737 | ||
|
|
4d33b1db9b | ||
|
|
dcbc72b8ae | ||
|
|
f3f71ea90d | ||
|
|
ef87f44f4e | ||
|
|
d3a9d3966c | ||
|
|
a949707aaf | ||
|
|
2aabd4288b | ||
|
|
500eb74577 | ||
|
|
d7362985f9 | ||
|
|
8feee6754b | ||
|
|
c8c656046a | ||
|
|
a8dfd5b2f0 | ||
|
|
fd45c2c0d3 |
@@ -1,85 +0,0 @@
|
||||
# Forgejo Actions CI. Note the path: Forgejo reads .forgejo/workflows/, not
|
||||
# .github/workflows/. The remote for this repo is git.riotpiao.com, so a GitHub
|
||||
# workflow here would never run.
|
||||
#
|
||||
# runs-on: docker matches the only label the cluster runner declares
|
||||
# (talos-runner, labels: [docker]).
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
env:
|
||||
REGISTRY: forgejo.riotpiao.com
|
||||
IMAGE: forgejo.riotpiao.com/rock/api-gateway
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Test, vet, build
|
||||
runs-on: docker
|
||||
container:
|
||||
image: golang:1.25-bookworm
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
# The race detector needs cgo, so this cannot run with CGO_ENABLED=0.
|
||||
- name: go test -race
|
||||
run: go test ./... -race
|
||||
|
||||
- name: Static build
|
||||
run: CGO_ENABLED=0 go build -trimpath -o gateway ./cmd/gateway
|
||||
|
||||
- name: govulncheck
|
||||
run: |
|
||||
go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||
govulncheck ./...
|
||||
continue-on-error: true
|
||||
|
||||
image:
|
||||
name: Build and push image
|
||||
runs-on: docker
|
||||
needs: verify
|
||||
# Only publish from main. PRs get the verify job and nothing else, so an
|
||||
# untrusted branch can never push a tag the cluster might pull.
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
container:
|
||||
image: docker:27-cli
|
||||
# The runner's dind sidecar shares the pod network and the mTLS cert
|
||||
# emptyDir, so the daemon is reachable on localhost with the client certs
|
||||
# dind generated at startup.
|
||||
options: --network host
|
||||
env:
|
||||
DOCKER_HOST: tcp://localhost:2376
|
||||
DOCKER_TLS_VERIFY: "1"
|
||||
DOCKER_CERT_PATH: /docker-certs/client
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Registry login
|
||||
run: |
|
||||
echo "${FORGEJO_PAT}" | docker login "${REGISTRY}" \
|
||||
--username rock --password-stdin
|
||||
env:
|
||||
FORGEJO_PAT: ${{ secrets.FORGEJO_RIOTPIAO_PAT }}
|
||||
|
||||
# SHA tags only. 6.1 requires them, and :latest makes an Argo rollout
|
||||
# non-deterministic — the same tag can resolve to different bits.
|
||||
- name: Build
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg "VERSION=${GITHUB_SHA}" \
|
||||
-t "${IMAGE}:${GITHUB_SHA}" \
|
||||
.
|
||||
|
||||
- name: Push
|
||||
run: docker push "${IMAGE}:${GITHUB_SHA}"
|
||||
|
||||
- name: Report digest
|
||||
run: |
|
||||
docker inspect --format='{{index .RepoDigests 0}}' "${IMAGE}:${GITHUB_SHA}"
|
||||
@@ -0,0 +1,83 @@
|
||||
# Single pipeline: verify → build → push.
|
||||
# One workflow per push, one concurrency group per branch.
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
env:
|
||||
REGISTRY: forgejo.riotpiao.com
|
||||
IMAGE: forgejo.riotpiao.com/rock/api-gateway
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
name: Vet, test, build
|
||||
runs-on: golang
|
||||
container:
|
||||
image: golang:1.26-bookworm
|
||||
steps:
|
||||
- name: install node (required by JS-based actions)
|
||||
run: apt-get update && apt-get install -y --no-install-recommends nodejs ca-certificates git
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: go vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: go test -race
|
||||
run: go test ./... -race
|
||||
|
||||
- name: Static build (smoke)
|
||||
run: CGO_ENABLED=0 go build -trimpath -o gateway ./cmd/gateway
|
||||
|
||||
push:
|
||||
name: Build and push image
|
||||
needs: verify
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
runs-on: golang
|
||||
container:
|
||||
image: docker:27-cli
|
||||
volumes:
|
||||
- /docker-certs/client:/docker-certs/client:ro
|
||||
env:
|
||||
DOCKER_HOST: tcp://localhost:2376
|
||||
DOCKER_TLS_VERIFY: "1"
|
||||
DOCKER_CERT_PATH: /docker-certs/client
|
||||
steps:
|
||||
- name: install node (required by JS-based actions)
|
||||
run: apk add --no-cache nodejs git
|
||||
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Get short SHA
|
||||
id: sha
|
||||
run: |
|
||||
SHORT_SHA=$(git rev-parse --short HEAD)
|
||||
echo "short_sha=${SHORT_SHA}" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Registry login
|
||||
run: |
|
||||
echo "${REGISTRY_PAT}" | docker login "${REGISTRY}" \
|
||||
--username rock --password-stdin
|
||||
env:
|
||||
REGISTRY_PAT: ${{ secrets.REGISTRY_PAT }}
|
||||
|
||||
- name: Build image
|
||||
run: |
|
||||
docker build \
|
||||
--build-arg "VERSION=${{ steps.sha.outputs.short_sha }}" \
|
||||
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
||||
-t "${IMAGE}:latest" \
|
||||
.
|
||||
|
||||
- name: Push image
|
||||
run: |
|
||||
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||
docker push "${IMAGE}:latest"
|
||||
@@ -0,0 +1,3 @@
|
||||
creation_rules:
|
||||
- path_regex: \.enc\.ya?ml$
|
||||
age: age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||
+5
-1
@@ -7,7 +7,7 @@
|
||||
# --platform=$BUILDPLATFORM pins the build stage to the machine doing the
|
||||
# building, then Go cross-compiles to $TARGETARCH. Without it, building an
|
||||
# amd64 image from an arm64 workstation runs the whole toolchain under QEMU.
|
||||
FROM --platform=$BUILDPLATFORM golang:1.25-bookworm AS build
|
||||
FROM --platform=$BUILDPLATFORM golang:1.26-bookworm AS build
|
||||
|
||||
WORKDIR /src
|
||||
|
||||
@@ -46,6 +46,10 @@ FROM gcr.io/distroless/static-debian12:nonroot
|
||||
# securityContext; if one changes, both must.
|
||||
USER 65532:65532
|
||||
|
||||
# distroless/static has no CA certs. Copy them from the build stage so Go's
|
||||
# crypto/tls can verify the Kubernetes API server certificate.
|
||||
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ca-certificates.crt
|
||||
|
||||
COPY --from=build /out/gateway /gateway
|
||||
|
||||
EXPOSE 8080
|
||||
|
||||
@@ -1,499 +0,0 @@
|
||||
# LLM Tool Calls Testing Guide
|
||||
|
||||
This guide shows how to test the gateway with LLM tool calling (function calling) across different APIs.
|
||||
|
||||
## What's Tested
|
||||
|
||||
The gateway fully supports tool calling for:
|
||||
- **OpenAI API** (`/v1/chat/completions`) - OpenAI, DeepSeek, etc.
|
||||
- **Anthropic API** (`/llm/v1/messages`) - Claude models
|
||||
- **Custom APIs** - Any LLM that supports tool definitions and responses
|
||||
|
||||
### Test Coverage
|
||||
|
||||
```
|
||||
✅ OpenAI-style tool calling
|
||||
✅ Streaming tool calls (SSE with tool_use blocks)
|
||||
✅ Multi-turn conversations with tool results
|
||||
✅ Parallel tool calls (multiple tools at once)
|
||||
✅ Anthropic tool_use format
|
||||
✅ Complex nested tool arguments
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Quick Start: Run Tests Locally
|
||||
|
||||
```bash
|
||||
cd /Users/rockliang/workplace/homelab-frontend
|
||||
|
||||
# Run all tool call tests
|
||||
go test ./internal/proxy/... -run "Tool" -v
|
||||
|
||||
# Or run with race detector (recommended)
|
||||
go test -race ./internal/proxy/... -run "Tool" -v
|
||||
|
||||
# Expected output: 6 tests, all passing
|
||||
```
|
||||
|
||||
## Test Scenarios
|
||||
|
||||
### 1. OpenAI-Style Tool Calling
|
||||
|
||||
**What it tests:**
|
||||
- Request with tool definitions reaches upstream unmodified
|
||||
- Upstream can return tool_calls in response
|
||||
- Response with tool_calls passes through to client
|
||||
|
||||
**Test code:**
|
||||
```go
|
||||
// Request
|
||||
{
|
||||
"model": "reasoning",
|
||||
"messages": [{"role": "user", "content": "What's the weather?"}],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"description": "Get weather for a location",
|
||||
"parameters": {...}
|
||||
}
|
||||
}]
|
||||
}
|
||||
|
||||
// Response (from upstream)
|
||||
{
|
||||
"choices": [{
|
||||
"message": {
|
||||
"tool_calls": [{
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_weather",
|
||||
"arguments": "{\"location\":\"San Francisco\"}"
|
||||
}
|
||||
}]
|
||||
}
|
||||
}]
|
||||
}
|
||||
```
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestToolCallOpenAIStyle -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 2. Streaming Tool Calls
|
||||
|
||||
**What it tests:**
|
||||
- Tool calls can be streamed (SSE format)
|
||||
- Multiple chunks arrive with tool_call deltas
|
||||
- Stream completes with `[DONE]` sentinel
|
||||
|
||||
**Test code:**
|
||||
```
|
||||
Chunk 1: {"delta": {"role": "assistant"}, ...}
|
||||
Chunk 2: {"delta": {"tool_calls": [{"id": "call_123", "function": {...}}]}, ...}
|
||||
Chunk 3: {"delta": {}, "finish_reason": "tool_calls"}
|
||||
Chunk 4: [DONE]
|
||||
```
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestToolCallStreaming -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 3. Multi-Turn Conversation with Tool Results
|
||||
|
||||
**What it tests:**
|
||||
- Client can send previous assistant's tool_calls back
|
||||
- Tool result can be sent as a "tool" role message
|
||||
- Assistant responds with final answer using tool result
|
||||
|
||||
**Flow:**
|
||||
```
|
||||
Turn 1: User asks → LLM decides to call tool
|
||||
Turn 2: Client sends tool result → LLM generates final answer
|
||||
```
|
||||
|
||||
**Test code:**
|
||||
```go
|
||||
// Turn 1 Request
|
||||
{
|
||||
"model": "reasoning",
|
||||
"messages": [{"role": "user", "content": "What's the weather?"}],
|
||||
"tools": [...]
|
||||
}
|
||||
|
||||
// Turn 1 Response (tool_calls)
|
||||
{
|
||||
"message": {
|
||||
"tool_calls": [{
|
||||
"id": "call_abc",
|
||||
"function": {"name": "get_weather", "arguments": "..."}
|
||||
}]
|
||||
}
|
||||
}
|
||||
|
||||
// Turn 2 Request (with tool result)
|
||||
{
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{"role": "user", "content": "What's the weather?"},
|
||||
{"role": "assistant", "tool_calls": [...]},
|
||||
{"role": "tool", "content": "{\"temperature\": 22, \"condition\": \"cloudy\"}"}
|
||||
],
|
||||
"tools": [...]
|
||||
}
|
||||
|
||||
// Turn 2 Response (final answer)
|
||||
{
|
||||
"message": {
|
||||
"content": "The weather in San Francisco is 22°C and cloudy."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestToolCallMultiTurn -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 4. Parallel Tool Calls
|
||||
|
||||
**What it tests:**
|
||||
- LLM can request multiple tools in one response
|
||||
- Gateway preserves all tool_calls
|
||||
- Client can execute them in parallel
|
||||
|
||||
**Test code:**
|
||||
```go
|
||||
// Single response with 3 tool_calls
|
||||
{
|
||||
"message": {
|
||||
"tool_calls": [
|
||||
{"id": "call_1", "function": {"name": "get_weather", "arguments": "{\"location\":\"New York\"}"}},
|
||||
{"id": "call_2", "function": {"name": "get_weather", "arguments": "{\"location\":\"London\"}"}},
|
||||
{"id": "call_3", "function": {"name": "get_weather", "arguments": "{\"location\":\"Tokyo\"}"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestParallelToolCalls -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 5. Anthropic Tool Use Format
|
||||
|
||||
**What it tests:**
|
||||
- Different tool format: Anthropic uses `tool_use` blocks instead of `tool_calls`
|
||||
- Gateway handles both formats transparently
|
||||
- Tools are sent with `tools` parameter
|
||||
|
||||
**OpenAI format:**
|
||||
```json
|
||||
{"tool_calls": [{"type": "function", "function": {...}}]}
|
||||
```
|
||||
|
||||
**Anthropic format:**
|
||||
```json
|
||||
{"content": [
|
||||
{"type": "text", "text": "..."},
|
||||
{"type": "tool_use", "id": "...", "name": "...", "input": {...}}
|
||||
]}
|
||||
```
|
||||
|
||||
**Test code:**
|
||||
```go
|
||||
// Request
|
||||
{
|
||||
"model": "claude",
|
||||
"messages": [{"role": "user", "content": "What's the weather?"}],
|
||||
"tools": [{
|
||||
"name": "get_weather",
|
||||
"description": "Get weather",
|
||||
"input_schema": {...}
|
||||
}]
|
||||
}
|
||||
|
||||
// Response (Anthropic format)
|
||||
{
|
||||
"content": [
|
||||
{"type": "text", "text": "I'll check the weather..."},
|
||||
{"type": "tool_use", "id": "toolu_123", "name": "get_weather", "input": {...}}
|
||||
],
|
||||
"stop_reason": "tool_use"
|
||||
}
|
||||
```
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestAnthropicToolUse -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### 6. Complex Nested Tool Arguments
|
||||
|
||||
**What it tests:**
|
||||
- Tool arguments can be complex JSON structures
|
||||
- Nested objects, arrays, and deeply nested data preserved
|
||||
- No argument modification or parsing
|
||||
|
||||
**Test code:**
|
||||
```json
|
||||
{
|
||||
"function": {
|
||||
"name": "create_event",
|
||||
"arguments": {
|
||||
"title": "Team Meeting",
|
||||
"time": "2025-08-20T14:00:00Z",
|
||||
"attendees": [
|
||||
{"name": "Alice", "email": "[email protected]"},
|
||||
{"name": "Bob", "email": "[email protected]"}
|
||||
],
|
||||
"location": {
|
||||
"address": "123 Main St",
|
||||
"city": "San Francisco",
|
||||
"country": "USA"
|
||||
},
|
||||
"tags": ["important", "recurring"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Run:**
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestComplexToolArguments -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Running Against Real LLMs
|
||||
|
||||
### With Local Stubs (Current)
|
||||
|
||||
Tests use mock HTTP servers, so they run instantly:
|
||||
|
||||
```bash
|
||||
go test ./internal/proxy/... -run "Tool" -v
|
||||
# All 6 tests complete in ~220ms
|
||||
```
|
||||
|
||||
### With Real Upstreams (Future)
|
||||
|
||||
Once you have real LLM services running, update the config:
|
||||
|
||||
```yaml
|
||||
# k8s/configmap.yaml
|
||||
models:
|
||||
- name: "reasoning"
|
||||
address: "reasoning-predictor.llm-serving:80" # Real upstream
|
||||
- name: "claude"
|
||||
address: "claude-api.anthropic.com:443" # Real Anthropic
|
||||
```
|
||||
|
||||
Then use the gateway normally:
|
||||
|
||||
```bash
|
||||
# Terminal 1: Start gateway
|
||||
export CONFIG_PATH=config.yaml
|
||||
go run ./cmd/gateway
|
||||
|
||||
# Terminal 2: Test with real LLM
|
||||
curl -X POST http://localhost:8080/v1/chat/completions \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [{"role": "user", "content": "What color is the sky?"}],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search",
|
||||
"description": "Search the internet",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
}
|
||||
}]
|
||||
}'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Gateway Behavior with Tool Calls
|
||||
|
||||
### Request Path
|
||||
|
||||
```
|
||||
Client Request
|
||||
↓
|
||||
Body-based dispatch (find model)
|
||||
↓
|
||||
Look up upstream address
|
||||
↓
|
||||
Forward request unmodified (including tools)
|
||||
↓
|
||||
Upstream LLM processes tools
|
||||
```
|
||||
|
||||
### Response Path
|
||||
|
||||
```
|
||||
Upstream Response (with tool_calls or tool_use)
|
||||
↓
|
||||
Stream unbuffered if streaming
|
||||
↓
|
||||
Return to client exactly as received
|
||||
```
|
||||
|
||||
### Key Properties
|
||||
|
||||
1. **No Rewriting**: Tool definitions and responses pass through unmodified
|
||||
2. **Format Agnostic**: Both OpenAI `tool_calls` and Anthropic `tool_use` work
|
||||
3. **Streaming Safe**: Tool calls stream incrementally without buffering
|
||||
4. **Nested Structures**: Complex JSON arguments fully preserved
|
||||
|
||||
---
|
||||
|
||||
## Common Tool Call Patterns
|
||||
|
||||
### Pattern 1: Sequential Tool Use
|
||||
```
|
||||
Client → LLM (please use search tool)
|
||||
← LLM (tool_calls: [search(...)])
|
||||
Client → (execute search, send results)
|
||||
Client → LLM (here are search results)
|
||||
← LLM (final answer)
|
||||
```
|
||||
|
||||
### Pattern 2: Parallel Tool Calls
|
||||
```
|
||||
Client → LLM (check weather in 3 cities)
|
||||
← LLM (tool_calls: [get_weather(NY), get_weather(LA), get_weather(SF)])
|
||||
Client → (execute all 3 in parallel)
|
||||
Client → LLM (here are all results)
|
||||
← LLM (summary)
|
||||
```
|
||||
|
||||
### Pattern 3: Tool Result Formatting
|
||||
```
|
||||
Client receives tool_calls with:
|
||||
- id: unique identifier
|
||||
- function.name: tool name
|
||||
- function.arguments: JSON string (always a string, not parsed object)
|
||||
|
||||
Client sends back:
|
||||
- role: "tool"
|
||||
- content: result JSON string
|
||||
- tool_call_id: matches the original call id
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
- [x] OpenAI-style tool definitions forward to upstream
|
||||
- [x] Tool calls in response reach client unmodified
|
||||
- [x] Streaming tool calls arrive incrementally
|
||||
- [x] Multi-turn conversations preserve tool context
|
||||
- [x] Parallel tool calls all included in response
|
||||
- [x] Anthropic tool_use format works
|
||||
- [x] Complex nested arguments preserved
|
||||
|
||||
Run all:
|
||||
```bash
|
||||
go test ./internal/proxy/... -run "Tool" -v --race
|
||||
```
|
||||
|
||||
Expected: 6/6 passing, race detector clean
|
||||
|
||||
---
|
||||
|
||||
## Integration with Other Phases
|
||||
|
||||
### Phase 2.9: Anthropic Dialect
|
||||
Currently, Anthropic tool calls work through the generic route handler. Phase 2.9 will add a dedicated `/llm/v1/messages` endpoint with full Anthropic-specific handling.
|
||||
|
||||
### Phase 2.13: Error Handling
|
||||
Tool call errors (unknown tool, parsing errors) will have proper error responses in both OpenAI and Anthropic formats.
|
||||
|
||||
### Phase 3: Authentication
|
||||
Tool calls work with all authentication methods (bearer tokens, API keys) - no special handling needed since tools are just part of the message payload.
|
||||
|
||||
### Phase 4: Rate Limiting
|
||||
Tool calling counts the same as regular chat requests. Rate limits apply per conversation, not per tool call.
|
||||
|
||||
---
|
||||
|
||||
## Debugging Tool Calls
|
||||
|
||||
### Check if tool definitions reach upstream:
|
||||
|
||||
```bash
|
||||
# Enable request logging
|
||||
go test ./internal/proxy/... -run TestToolCallOpenAIStyle -v 2>&1 | grep -A5 "tool"
|
||||
```
|
||||
|
||||
### Verify tool response format:
|
||||
|
||||
```bash
|
||||
# Extract and pretty-print response
|
||||
curl -X POST http://localhost:8080/v1/chat/completions ... | jq '.choices[0].message.tool_calls'
|
||||
```
|
||||
|
||||
### Test streaming tool calls:
|
||||
|
||||
```bash
|
||||
curl -N http://localhost:8080/v1/chat/completions \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{..., "stream": true, "tools": [...]}'
|
||||
# Should see incremental chunks with tool_use deltas
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: Do I need to modify the gateway code to support tool calls?**
|
||||
A: No. Tool calls are just JSON in the request/response body. The gateway forwards them unchanged.
|
||||
|
||||
**Q: What if the LLM doesn't support tools?**
|
||||
A: The tool definitions are simply ignored. The gateway doesn't validate or enforce tool support.
|
||||
|
||||
**Q: Can I mix OpenAI and Anthropic tool formats?**
|
||||
A: Not in the same request. OpenAI clients expect `tool_calls`, Anthropic clients expect `tool_use` blocks. The upstream API determines the format.
|
||||
|
||||
**Q: How are tool arguments limited?**
|
||||
A: By the per-route `maxBodySize` config. Complex nested arguments count toward that limit.
|
||||
|
||||
**Q: Can tool calls be streamed?**
|
||||
A: Yes! SSE streaming fully supports tool calls. They arrive in delta chunks like text tokens.
|
||||
|
||||
---
|
||||
|
||||
## Next Steps
|
||||
|
||||
1. **Run tests locally:**
|
||||
```bash
|
||||
go test ./internal/proxy/... -run "Tool" -v
|
||||
```
|
||||
|
||||
2. **Deploy to cluster:**
|
||||
See [CLUSTER_REPO_SETUP.md](./CLUSTER_REPO_SETUP.md)
|
||||
|
||||
3. **Test against real LLMs:**
|
||||
Update config with real upstream addresses, restart gateway
|
||||
|
||||
4. **Phase 2.9:** Implement Anthropic dialect handler for `/llm/v1/messages`
|
||||
|
||||
5. **Phase 4:** Add tool call budgeting and rate limits
|
||||
@@ -1,104 +1,323 @@
|
||||
# homelab-frontend
|
||||
|
||||
A Go API gateway for the homelab cluster. One capability per subdomain, one auth
|
||||
implementation, one routing table.
|
||||
Production API gateway for the homelab cluster. Single entry point (`api.riotpiao.com`) for all services: LLM inference, workflows, queues, memory, and cluster operations.
|
||||
|
||||
Replaces Kong OSS entirely — see
|
||||
[ADR-0001](docs/adr/ADR-0001-retire-kong-for-go-gateway.md) for why, and
|
||||
[docs/MIGRATION-kong.md](docs/MIGRATION-kong.md) for the cutover.
|
||||
**Status:** Live in production. Replaced Kong OSS entirely.
|
||||
|
||||
## Position in the stack
|
||||
---
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **API Reference:** See [API.md](API.md) — how to call every service
|
||||
- **Base URL:** `https://api.riotpiao.com`
|
||||
- **Source:** `ssh://git.riotpiao.com:2222/rock/homelab-frontend.git`
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
browser / SDK ──▶ Cloudflare ──▶ ingress-nginx (TLS, edge)
|
||||
│
|
||||
▼
|
||||
┌─────────────────────────────┐
|
||||
│ homelab-frontend │
|
||||
│ routing · authn · budgets │
|
||||
└──────────────┬──────────────┘
|
||||
│
|
||||
/v1 /sqs /workflow /cluster
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
llm-serving kmsvc/Kafka temporal atlas
|
||||
(predictors) (sqs ns) (temporal ns) (riotpiao-backend)
|
||||
|
||||
└──── in-cluster Services ────┘
|
||||
┌─────────────┬──────────────┬────────────┐
|
||||
│ Browser │ SDK │ CLI │
|
||||
└──────┬──────┴──────┬───────┴────┬───────┘
|
||||
│ │ │
|
||||
└─────────────┼────────────┘
|
||||
│
|
||||
HTTPS/TLS
|
||||
│
|
||||
┌─────────────┼────────────┐
|
||||
│ Cloudflare Edge │
|
||||
│ (DDoS, caching) │
|
||||
└──────────┬──────────────┘
|
||||
│
|
||||
ingress-nginx
|
||||
(SSL termination)
|
||||
│
|
||||
┌─────────────────────────────┐
|
||||
│ homelab-frontend Gateway │
|
||||
│ (routing, auth, limits) │
|
||||
└──────┬───────────────────────┘
|
||||
│
|
||||
┌──────┴──────────────────────────────────┐
|
||||
│ │
|
||||
/v1/* /workflow /sqs /
|
||||
(LLM) (Temporal gRPC) (Queues) (X-Service)
|
||||
│ │ │ │
|
||||
▼ ▼ ▼ ▼
|
||||
llm-serving temporal:7233 kmsvc/Kafka IAM, S3
|
||||
(vLLM, Ollama) (WorkflowService) Memory
|
||||
(TEI) (gRPC bridge) (poimen)
|
||||
```
|
||||
|
||||
ingress-nginx keeps TLS and the edge. The gateway owns everything after it.
|
||||
**Design principles:**
|
||||
- ✅ Single hostname, multiple path prefixes
|
||||
- ✅ HTTP REST gateway → gRPC Temporal bridge
|
||||
- ✅ Bearer token auth via Authentik (JWT + RBAC)
|
||||
- ✅ Streaming unbuffered (SSE, WebSocket)
|
||||
- ✅ Per-route timeouts & rate limits
|
||||
- ✅ No cluster credentials held by gateway
|
||||
|
||||
Backend services are reached through the gateway rather than published
|
||||
individually — a single place for authentication, budgets, timeouts and
|
||||
observability, and a single hostname surface to reason about.
|
||||
---
|
||||
|
||||
## Capability map
|
||||
## Services & Capabilities
|
||||
|
||||
One host, one path prefix per capability.
|
||||
| Service | Prefix | Upstream | Status |
|
||||
|---------|--------|----------|--------|
|
||||
| **LLM Chat** | `/v1/chat/completions` | llm-serving (vLLM) | ✅ Live |
|
||||
| **Embeddings** | `/v1/embeddings` | llm-serving (TEI) | ✅ Live |
|
||||
| **Reranking** | `/v1/rerank` | llm-serving (TEI) | ✅ Live |
|
||||
| **Workflows** | `/workflow` | Temporal gRPC (7233) | ✅ Live (START, DESCRIBE, SIGNAL, QUERY, etc) |
|
||||
| **Queues** | `/` + `X-Service: sqs` | kmsvc/Kafka | ⏳ Ready (ServiceAdapter) |
|
||||
| **Memory** | `/` + `X-Service: memory` | poimen-memory | ✅ Live |
|
||||
| **IAM** | `/` + `X-Service: iam` | Authentik API | ✅ Live |
|
||||
| **S3** | `/` + `X-Service: s3` | MinIO | ✅ Live |
|
||||
|
||||
| Prefix on `api.riotpiao.com` | Backs onto | Status |
|
||||
|---|---|---|
|
||||
| `/v1/*` | `llm-serving` predictors (vLLM, Ollama, TEI) | migrating off Kong |
|
||||
| `/sqs/*` | kmsvc management-service + Kafka/Strimzi (`sqs` ns) | future |
|
||||
| `/workflow/*` | Temporal (`temporal` ns) | future |
|
||||
| `/cluster/*` | atlas — cluster topology / Argo delivery (separate repo) | future |
|
||||
| `/db/*` | CloudNativePG, MinIO, monitoring/metrics reads | future |
|
||||
---
|
||||
|
||||
`/v1/*` is reserved for the OpenAI-compatible surface. An SDK expects
|
||||
`/v1/chat/completions` at the base URL, so that prefix cannot be repurposed.
|
||||
## How to Use
|
||||
|
||||
Paths rather than subdomains: one DNS record, one tunnel hostname, one Ingress.
|
||||
Promoting a prefix to its own subdomain later is additive and can run alongside the
|
||||
path — the reverse is not, because clients hardcode hostnames.
|
||||
### 1. Get a token
|
||||
|
||||
atlas lives in its own repo (`riotpiao-backend`) and keeps its own informers and
|
||||
RBAC. The gateway routes to it; it does not absorb it. Cluster-read permissions
|
||||
stay out of the public edge process.
|
||||
**Human (OIDC device code):**
|
||||
```bash
|
||||
core auth login
|
||||
export TOKEN=$(cat ~/.cache/talos/authentik_id_token)
|
||||
```
|
||||
|
||||
## Design rules
|
||||
**Service account (client credentials):**
|
||||
```bash
|
||||
core mwinit login --username sa-name --password secret
|
||||
export TOKEN=$(cat ~/.talos/.riotpiao-auth)
|
||||
```
|
||||
|
||||
1. **Standard protocol shapes.** `POST /v1/chat/completions` selects its model from
|
||||
the request body, like every OpenAI-compatible server. No path-per-model, no
|
||||
bespoke client configuration. Kong OSS could not do this; that limitation does
|
||||
not survive into the replacement.
|
||||
2. **Bearer tokens, validated against Authentik.** JWKS is fetched at runtime and
|
||||
cached, so key rotation needs no runbook and no pinned PEM.
|
||||
3. **Policy lives where the state is.** GPU slot semaphores, per-caller budgets,
|
||||
queue depth and disconnect propagation are application concerns. They belong
|
||||
here, not in a proxy plugin.
|
||||
4. **Streaming is first-class.** SSE and WebSocket pass through unbuffered, and a
|
||||
client disconnect cancels the upstream request rather than orphaning it.
|
||||
5. **The gateway holds no cluster credentials.** It proxies to services that do.
|
||||
### 2. Call any service
|
||||
|
||||
## Layout
|
||||
**Chat:**
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/v1/chat/completions \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"model": "reasoning",
|
||||
"messages": [{"role": "user", "content": "What is 2+2?"}]
|
||||
}'
|
||||
```
|
||||
|
||||
**Workflow:**
|
||||
```bash
|
||||
curl -X POST https://api.riotpiao.com/workflow \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-d '{
|
||||
"action": "START_WORKFLOW",
|
||||
"namespace": "default",
|
||||
"payload": {
|
||||
"workflow_id": "my-workflow",
|
||||
"workflow_type": "MyWorkflow",
|
||||
"task_queue": "default"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
**Memory:**
|
||||
```bash
|
||||
curl -X GET https://api.riotpiao.com/ \
|
||||
-H "Authorization: Bearer $TOKEN" \
|
||||
-H 'X-Service: memory' \
|
||||
-H 'X-Resource: query' \
|
||||
-G --data-urlencode 'query=explain machine learning'
|
||||
```
|
||||
|
||||
**Full examples:** See [API.md](API.md)
|
||||
|
||||
---
|
||||
|
||||
## Available Models
|
||||
|
||||
### LLM (Chat & Reasoning)
|
||||
- `reasoning` — DeepSeek-R1-Distill-Qwen-32B (8 concurrent slots)
|
||||
- `ornith:35b` — Ollama 35B
|
||||
- `qwen2.5:3b-instruct` — Qwen 2.5 3B
|
||||
|
||||
### Embeddings
|
||||
- `nomic-ai/nomic-embed-text-v2-moe` — Fast, multilingual
|
||||
|
||||
### Reranking
|
||||
- `BAAI/bge-reranker-base` — Document relevance scoring
|
||||
|
||||
---
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints (except `/healthz`, `/readyz`) require:
|
||||
|
||||
```
|
||||
cmd/gateway/ entrypoint
|
||||
Authorization: Bearer <jwt-token>
|
||||
```
|
||||
|
||||
Tokens validated via Authentik JWKS (runtime fetched, cached, auto-rotated).
|
||||
|
||||
**Capabilities** (RBAC):
|
||||
- `llm:inference` — `/v1/*` chat/embeddings/rerank
|
||||
- `workflow:execute` — `/workflow` operations
|
||||
- `memory:read` / `memory:write` — Memory operations
|
||||
- `sqs:access` — Queue operations
|
||||
- `s3:access` — S3 operations
|
||||
- `iam:admin` — User/group management
|
||||
|
||||
---
|
||||
|
||||
## Error Handling
|
||||
|
||||
All errors return RFC 9457 `application/problem+json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "https://api.example.com/problems/unknown-model",
|
||||
"title": "Unknown Model",
|
||||
"status": 400,
|
||||
"detail": "Model 'gpt-4' is not available",
|
||||
"valid_models": ["reasoning", "ornith:35b", ...]
|
||||
}
|
||||
```
|
||||
|
||||
**Common status codes:**
|
||||
- 200 OK
|
||||
- 400 Bad Request (validation, unknown model)
|
||||
- 401 Unauthorized (missing/invalid token)
|
||||
- 403 Forbidden (insufficient capability)
|
||||
- 404 Not Found (workflow, resource)
|
||||
- 429 Too Many Requests (rate limit)
|
||||
- 503 Service Unavailable (backend down)
|
||||
|
||||
---
|
||||
|
||||
## Rate Limits
|
||||
|
||||
| Endpoint | Limit | Retry-After |
|
||||
|----------|-------|-------------|
|
||||
| `/v1/chat/completions` | 8 concurrent | Yes |
|
||||
| `/v1/embeddings` | 10 concurrent | Yes |
|
||||
| `/v1/rerank` | 10 concurrent | Yes |
|
||||
| `/workflow` | 100 concurrent | Yes |
|
||||
|
||||
Hitting limit returns 429 with `Retry-After` header.
|
||||
|
||||
---
|
||||
|
||||
## Timeouts
|
||||
|
||||
| Endpoint | Connect | Read | Write |
|
||||
|----------|---------|------|-------|
|
||||
| `/v1/chat` | 10s | 1h | 1h |
|
||||
| `/v1/embeddings` | 10s | 10m | 10m |
|
||||
| `/v1/rerank` | 10s | 10m | 10m |
|
||||
| `/workflow` | 10s | 30s | 10s |
|
||||
|
||||
Client disconnects cancel upstream request immediately (no orphaned slots).
|
||||
|
||||
---
|
||||
|
||||
## Local Development
|
||||
|
||||
Run without cluster, no credentials needed:
|
||||
|
||||
```bash
|
||||
# Build
|
||||
go build ./cmd/gateway
|
||||
|
||||
# Run locally
|
||||
./gateway
|
||||
|
||||
# Test in another terminal
|
||||
curl http://localhost:8080/healthz
|
||||
```
|
||||
|
||||
Points upstreams at local stubs if not connected to cluster (see `internal/config`).
|
||||
|
||||
---
|
||||
|
||||
## Code Layout
|
||||
|
||||
```
|
||||
cmd/gateway/ Server entrypoint
|
||||
internal/
|
||||
auth/ Authentik OIDC, JWKS cache, service-account tokens
|
||||
llm/ model registry, body-based dispatch, upstream map
|
||||
queue/ sqs.riotpiao.com surface
|
||||
workflow/ workflow.riotpiao.com surface
|
||||
proxy/ reverse proxy, streaming, timeouts, disconnect propagation
|
||||
config/ upstream + route configuration
|
||||
observability/ Prometheus metrics, structured logging
|
||||
deploy/
|
||||
base/ Kubernetes manifests
|
||||
argocd/ Argo Application
|
||||
docs/adr/ architecture decision records
|
||||
tasks/ task board — see tasks/INDEX.md
|
||||
testdata/ fixtures for offline tests
|
||||
server/ Router, health checks
|
||||
proxy/ Reverse proxy, streaming, timeouts
|
||||
temporal/ Workflow handler + gRPC bridge
|
||||
serviceadapter/ X-Service dispatcher (CRD-driven)
|
||||
config/ Route + upstream configuration
|
||||
auth/ Authentik JWT validation
|
||||
observability/ Metrics, structured logging
|
||||
k8s/
|
||||
configmap.yaml Route definitions
|
||||
rbac.yaml Service account, roles
|
||||
deployment.yaml Pod spec
|
||||
networkpolicy.yaml Ingress/egress rules
|
||||
testdata/ Fixtures for offline tests
|
||||
```
|
||||
|
||||
## Local development
|
||||
---
|
||||
|
||||
The gateway must be runnable with no cluster, no kubeconfig and no credentials, so
|
||||
that changes can be verified in a closed loop before touching live traffic.
|
||||
Upstreams are configuration, so pointing them at local stubs is the whole
|
||||
mechanism. See [tasks/INDEX.md](tasks/INDEX.md).
|
||||
## Deployment
|
||||
|
||||
## Status
|
||||
Deployed to Kubernetes via ArgoCD:
|
||||
|
||||
Scaffolded 2026-08-19. Nothing is wired yet. Kong is still serving live traffic on
|
||||
`api.riotpiao.com`.
|
||||
```bash
|
||||
# Check deployment
|
||||
kubectl -n api get deployment homelab-frontend
|
||||
|
||||
# View logs
|
||||
kubectl -n api logs -l app=homelab-frontend -f
|
||||
|
||||
# Restart
|
||||
kubectl -n api rollout restart deployment/homelab-frontend
|
||||
```
|
||||
|
||||
Configuration mounted as ConfigMap (`k8s/configmap.yaml`).
|
||||
|
||||
---
|
||||
|
||||
## Health Checks
|
||||
|
||||
```bash
|
||||
# Liveness (always succeeds)
|
||||
curl https://api.riotpiao.com/healthz
|
||||
|
||||
# Readiness (waits for config + JWKS)
|
||||
curl https://api.riotpiao.com/readyz
|
||||
|
||||
# List available models
|
||||
curl https://api.riotpiao.com/v1/models
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
**Issues:** Check pod logs
|
||||
```bash
|
||||
kubectl -n api logs deployment/homelab-frontend
|
||||
```
|
||||
|
||||
**Debug config:**
|
||||
```bash
|
||||
kubectl -n api get configmap homelab-frontend-config -o yaml
|
||||
```
|
||||
|
||||
**Restart pod:**
|
||||
```bash
|
||||
kubectl -n api rollout restart deployment/homelab-frontend
|
||||
```
|
||||
|
||||
**Test endpoint directly:**
|
||||
```bash
|
||||
kubectl -n api port-forward svc/homelab-frontend 8080:8080
|
||||
curl http://localhost:8080/healthz
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## See Also
|
||||
|
||||
- [API.md](API.md) — Complete API reference with examples
|
||||
- `internal/` — Source code (handlers, routing, auth)
|
||||
- `k8s/` — Kubernetes manifests
|
||||
|
||||
-367
@@ -1,367 +0,0 @@
|
||||
# homelab-frontend — Requirements
|
||||
|
||||
The contract for the Go API gateway that replaces Kong OSS on `*.riotpiao.com`.
|
||||
|
||||
Companion documents:
|
||||
- [docs/adr/ADR-0001-retire-kong-for-go-gateway.md](docs/adr/ADR-0001-retire-kong-for-go-gateway.md) — why Kong is being retired
|
||||
- [docs/MIGRATION-kong.md](docs/MIGRATION-kong.md) — exact inventory of what Kong does today and the cutover order
|
||||
- [tasks/INDEX.md](tasks/INDEX.md) — the task board
|
||||
|
||||
All cluster facts below were verified live against context `admin@homelab-cluster`
|
||||
on 2026-08-19. Re-verify before relying on any number.
|
||||
|
||||
---
|
||||
|
||||
## 0. Invariants
|
||||
|
||||
These hold for every surface. A change that breaks one of these is a design change,
|
||||
not an implementation detail.
|
||||
|
||||
- **G1** — ingress-nginx owns TLS and the edge. The gateway never terminates TLS.
|
||||
- **G2** — The gateway holds no Kubernetes credentials. It proxies to services that
|
||||
do. Cluster-read permissions stay in atlas, out of the public edge process.
|
||||
- **G3** — Public surfaces use standard protocol shapes. If an OpenAI SDK cannot
|
||||
call it unmodified, the design is wrong.
|
||||
- **G4** — Streaming is unbuffered end to end, and a client disconnect cancels the
|
||||
upstream request rather than orphaning it.
|
||||
- **G5** — Authentication is Bearer-token, validated against Authentik via JWKS
|
||||
fetched at runtime. No pinned public keys, no rotation runbook.
|
||||
- **G6** — Every route's timeout, body cap and concurrency limit is explicit in
|
||||
configuration. No silent defaults.
|
||||
- **G7** — All deployment flows through git and Argo. No `kubectl apply`, no
|
||||
`helm upgrade`, no local `terraform apply`.
|
||||
|
||||
---
|
||||
|
||||
## 1. Runtime and configuration
|
||||
|
||||
### 1.1 Process
|
||||
|
||||
Single static Go binary. Reads configuration at startup, serves HTTP, exits cleanly
|
||||
on SIGTERM after draining in-flight requests.
|
||||
|
||||
Must run with **no cluster, no kubeconfig and no credentials** so that behaviour can
|
||||
be verified in a closed loop before touching live traffic. Upstreams are
|
||||
configuration; pointing them at local stubs is the entire mechanism. This is a hard
|
||||
requirement, not a convenience — see §7.
|
||||
|
||||
### 1.2 Configuration
|
||||
|
||||
Route and upstream configuration is declarative and loaded at startup. It must
|
||||
express, per upstream: address, path rewrite, connect/read/write timeouts, maximum
|
||||
body size, and whether the route requires authentication.
|
||||
|
||||
Configuration errors fail startup loudly. A gateway that starts with a silently
|
||||
dropped route is worse than one that refuses to start.
|
||||
|
||||
**Configuration lives in git**, mounted as a ConfigMap and synced by Argo. Not a
|
||||
CRD. A CRD would require the gateway to watch the API server, which needs RBAC and
|
||||
contradicts G2 — and CRD-driven routing is precisely the indirection being retired
|
||||
with Kong, where the routing table was split across six `KongPlugin` CRs, seven
|
||||
Ingresses and a Helm values file.
|
||||
|
||||
A CRD earns its keep when someone other than the repo owner must register routes.
|
||||
That is not true here. If it becomes true, the additive answer is a controller that
|
||||
renders this same ConfigMap — the gateway stays credential-free either way.
|
||||
|
||||
### 1.3 Health
|
||||
|
||||
- `GET /healthz` — liveness, no upstream checks, always cheap.
|
||||
- `GET /readyz` — readiness; may fail while configuration is invalid or JWKS has
|
||||
never been successfully fetched.
|
||||
|
||||
Neither requires authentication.
|
||||
|
||||
---
|
||||
|
||||
## 2. Proxy core
|
||||
|
||||
### 2.1 Reverse proxying
|
||||
|
||||
Standard reverse proxy to configured upstreams. Connection reuse across requests.
|
||||
Hop-by-hop headers stripped correctly. `X-Forwarded-*` set from the nginx-supplied
|
||||
values, not fabricated.
|
||||
|
||||
### 2.2 Streaming
|
||||
|
||||
SSE and chunked responses pass through without buffering. Tokens must reach the
|
||||
client as the upstream emits them, not on completion.
|
||||
|
||||
WebSocket upgrade must work — `agent-pod/console` depends on it.
|
||||
|
||||
### 2.3 Disconnect propagation
|
||||
|
||||
When a client disconnects, the upstream request is cancelled immediately. This is
|
||||
load-bearing: an orphaned generation holds a vLLM sequence slot, and there are only
|
||||
eight in the cluster.
|
||||
|
||||
### 2.4 Timeouts
|
||||
|
||||
Per-route, explicit. Current Kong values, which are deliberate and must be preserved
|
||||
unless changed knowingly:
|
||||
|
||||
| Route class | connect | read | write |
|
||||
|---|---|---|---|
|
||||
| chat | 10s | 1h | 1h |
|
||||
| embeddings, rerank | 10s | 10m | 10m |
|
||||
|
||||
The 1-hour read timeout exists because a 32B model on a Volta GPU routinely exceeds
|
||||
60s. Any shorter application-level cap must be enforced *by the gateway's own
|
||||
logic*, not by shortening the proxy timeout — otherwise long legitimate generations
|
||||
truncate mid-stream.
|
||||
|
||||
---
|
||||
|
||||
## 3. LLM surfaces — `api.riotpiao.com`
|
||||
|
||||
Two protocol dialects, permanently. Both translate into one dialect-neutral canonical
|
||||
request, and both pass through **one shared slot controller** before reaching a
|
||||
predictor.
|
||||
|
||||
| Prefix | Dialect | Primary client |
|
||||
|---|---|---|
|
||||
| `/v1/*` | OpenAI-compatible | pi, generic OpenAI SDKs |
|
||||
| `/llm/*` | Anthropic Messages | the riotpiao frontend (first-party only) |
|
||||
|
||||
```
|
||||
/v1/* (OpenAI) /llm/* (Anthropic)
|
||||
| |
|
||||
+-----------+------------+
|
||||
v
|
||||
canonical request dialect-neutral
|
||||
v
|
||||
slot controller keyed by UPSTREAM, not by route
|
||||
v
|
||||
reasoning-predictor / ornith-predictor
|
||||
```
|
||||
|
||||
**The slot controller is keyed by upstream and shared across dialects.** Per-dialect
|
||||
semaphores are wrong: the 8 sequence slots are physical, so two independent gates
|
||||
would each believe they were within budget while together exceeding it. Requests from
|
||||
both surfaces contend for the same slots and the same queue, in arrival order.
|
||||
|
||||
Dispatch, budgets, logging and metrics all operate on the canonical request. Adding a
|
||||
third dialect later must not require touching the controller.
|
||||
|
||||
### 3.1 Body-based model dispatch
|
||||
|
||||
`POST /v1/chat/completions` selects its upstream from the request body's `model`
|
||||
field. This is the single most important requirement in this document: it is the
|
||||
capability Kong OSS lacked, and the reason the gateway exists.
|
||||
|
||||
Unknown or missing `model` is a client error with a useful message listing valid
|
||||
values — not a 500, and not a silent fallback to a default model.
|
||||
|
||||
### 3.2 Upstream map
|
||||
|
||||
Verified live. `served-model-name` values are what clients send.
|
||||
|
||||
| `model` in body | Upstream Service | Engine |
|
||||
|---|---|---|
|
||||
| `reasoning` | `reasoning-predictor.llm-serving:80` | vLLM, DeepSeek-R1-Distill-Qwen-32B |
|
||||
| `ornith:35b` | `ornith-predictor.llm-serving:80` | Ollama |
|
||||
| `qwen2.5:3b-instruct` | `ornith-predictor.llm-serving:80` | Ollama, same pods |
|
||||
| `nomic-ai/nomic-embed-text-v2-moe` | `embeddings-predictor.llm-serving:80` | TEI |
|
||||
| `BAAI/bge-reranker-base` | `reranker-predictor.llm-serving:80` | TEI |
|
||||
|
||||
`reasoning` runs 2 replicas × `--max-num-seqs=4` = **8 concurrent sequence slots
|
||||
total**, `--max-model-len=16384`, `--reasoning-parser=deepseek_r1`,
|
||||
`--enable-auto-tool-choice --tool-call-parser=hermes`.
|
||||
|
||||
Note `ornith:35b` and `qwen2.5:3b-instruct` share pods; both stay resident via
|
||||
`OLLAMA_MAX_LOADED_MODELS=2` and `OLLAMA_KEEP_ALIVE=-1`, so dispatching between them
|
||||
does not trigger a model swap.
|
||||
|
||||
### 3.3 Path rewriting
|
||||
|
||||
Upstreams expect canonical paths. `/v1/chat/completions` and `/v1/embeddings` pass
|
||||
through unchanged. Rerank is the exception: TEI serves `/rerank`, not `/v1/rerank`,
|
||||
so that route rewrites.
|
||||
|
||||
### 3.4 Legacy path aliases
|
||||
|
||||
`/v1/{reasoning,ornith,qwen}/chat/completions` must keep working during cutover —
|
||||
pi is a live caller. They behave exactly as the canonical endpoint with `model`
|
||||
forced to the corresponding value, overriding whatever the body says.
|
||||
|
||||
These are temporary. They exist to make the cutover reversible, and are removed once
|
||||
callers have migrated.
|
||||
|
||||
### 3.5 `GET /v1/models`
|
||||
|
||||
Derived from the configured upstream map, never hardcoded. Kong served a static
|
||||
list, and its own manifest flags that the list can drift from what the engines
|
||||
actually serve. The gateway's list must be incapable of disagreeing with what
|
||||
routing will accept.
|
||||
|
||||
OpenAI list shape: `{"object":"list","data":[{"id","object":"model","owned_by","created"}]}`.
|
||||
|
||||
### 3.6 Behaviour to preserve
|
||||
|
||||
Verified against the live endpoint:
|
||||
|
||||
- The upstream returns `reasoning_content` separately from `content` for the
|
||||
`reasoning` model. Pass both through untouched.
|
||||
- Tool calling works with explicit `tool_choice`, and is unreliable with
|
||||
`tool_choice: auto` on the R1-distill model. The gateway does not compensate for
|
||||
this — it is a model property, not a gateway concern. Do not add retries or
|
||||
rewriting to work around it.
|
||||
|
||||
---
|
||||
|
||||
## 4. Authentication — Authentik
|
||||
|
||||
### 4.1 Current state
|
||||
|
||||
**The model API is unauthenticated today.** Confirmed live: `/v1/reasoning/chat/completions`
|
||||
answers with no credentials.
|
||||
|
||||
Kong's `key-auth` was retired because it accepts a raw `apikey:` header but rejects
|
||||
`Authorization: Bearer`, which hard-blocks every OpenAI-compatible client. See
|
||||
`~/workplace/homelab/k8s/apps/api/model-auth.yaml`.
|
||||
|
||||
### 4.2 Requirement
|
||||
|
||||
Bearer tokens in `Authorization`, validated against Authentik
|
||||
(`https://authentik.riotpiao.com`) by fetching and caching JWKS at runtime.
|
||||
|
||||
Key rotation must be handled by refetching JWKS, not by pinned PEMs. The
|
||||
pinned-`rsa_public_key` approach in `AUTH-PLAN.md` and its rotation runbook exist
|
||||
only to route around a Kong OSS limitation and must not be carried forward.
|
||||
|
||||
Service accounts obtain tokens via `client_credentials` against Authentik's token
|
||||
endpoint.
|
||||
|
||||
### 4.3 Rollout
|
||||
|
||||
Auth ships behind a flag, defaulting off, and is enabled deliberately.
|
||||
|
||||
Enabling it breaks every current caller until they hold a token — pi included, whose
|
||||
`models.json` currently sends a `customHeaders: {apikey: ...}` block that will need
|
||||
replacing with a Bearer token.
|
||||
|
||||
### 4.4 Authorization
|
||||
|
||||
Beyond authentication, a token must be checked for the right to invoke the
|
||||
capability it is calling. A token minted for queue access should not invoke a GPU.
|
||||
|
||||
---
|
||||
|
||||
## 5. Rate limiting and budgets
|
||||
|
||||
No `rate-limiting` plugin exists anywhere in the cluster today — this is net new
|
||||
work, not a migration. Verified: six Kong plugins exist, none is `rate-limiting`.
|
||||
|
||||
Requirements, in priority order:
|
||||
|
||||
1. **GPU slot protection.** `reasoning` has 8 total sequence slots. Concurrent
|
||||
in-flight requests to it must be capped below that, leaving operator headroom.
|
||||
Excess requests queue up to a bounded depth, then are rejected with a retryable
|
||||
status.
|
||||
2. **Per-caller budgets.** Identified callers get a request budget over a window.
|
||||
3. **Body size caps**, per route.
|
||||
|
||||
Rejections use RFC 9457 `application/problem+json` and set `Retry-After` where a
|
||||
retry time is knowable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Observability
|
||||
|
||||
Kong's cluster-wide `prometheus` plugin is being retired. The gateway must expose at
|
||||
least equivalent signal or observability regresses at cutover: request rate,
|
||||
latency, status codes, bandwidth, and upstream health, labelled by route and
|
||||
upstream.
|
||||
|
||||
Gateway-specific signals that Kong could not provide, and which are the reason for
|
||||
several requirements above: in-flight requests per upstream, queue depth, GPU slot
|
||||
occupancy, and rejections by reason.
|
||||
|
||||
Structured logging. Every rejected request is logged with the reason. No secrets, no
|
||||
tokens, no request bodies in logs.
|
||||
|
||||
---
|
||||
|
||||
## 7. Local development and verification
|
||||
|
||||
An agent must be able to close a change/verify loop with no cluster, no kubeconfig
|
||||
and no credentials. This is a hard requirement because it determines whether work can
|
||||
proceed unattended.
|
||||
|
||||
Concretely: it must be possible to start the gateway locally, point it at stub
|
||||
upstreams, issue requests, and assert on the responses — including streaming
|
||||
responses and client disconnects.
|
||||
|
||||
Verification of any API-shaped task means asserting on the **actual HTTP response**:
|
||||
status, headers, and body. "It compiles" and "it starts" are not verification.
|
||||
|
||||
Parity with Kong is verified by comparing gateway and Kong responses for the same
|
||||
request, for every route in the migration inventory, before cutover.
|
||||
|
||||
---
|
||||
|
||||
## 8. Deployment
|
||||
|
||||
Container: distroless or scratch, `runAsNonRoot`, read-only root filesystem, all
|
||||
capabilities dropped, `seccompProfile: RuntimeDefault`, no shell.
|
||||
|
||||
Image tags are commit SHAs, never `:latest` — Argo's `selfHeal` cannot roll out a
|
||||
mutable tag reliably.
|
||||
|
||||
NetworkPolicy: egress only to the upstreams it proxies plus Authentik; ingress from
|
||||
`ingress-nginx` only.
|
||||
|
||||
Deployed as an Argo Application in the `homelab-root` GitOps repo. Verified live:
|
||||
zero Argo Applications anywhere in the cluster source from any Forgejo URL, so
|
||||
`github.com/Riotpiaole/riotpiao.homelab.com` is authoritative.
|
||||
|
||||
---
|
||||
|
||||
## 9. Capability surface — path-based
|
||||
|
||||
Every capability is a path prefix on the single host `api.riotpiao.com`. One DNS
|
||||
record, one Cloudflare tunnel hostname, one nginx Ingress, one Service.
|
||||
|
||||
| Prefix | Backs onto | Status |
|
||||
|---|---|---|
|
||||
| `/v1/*` | `llm-serving` predictors | v1 — **reserved**, see below |
|
||||
| `/sqs/*` | kmsvc management-service, Kafka/Strimzi (`sqs` ns) | future |
|
||||
| `/workflow/*` | Temporal (`temporal` ns) | future |
|
||||
| `/cluster/*` | atlas, separate repo `riotpiao-backend` | future |
|
||||
| `/db/*` | CloudNativePG, MinIO, monitoring reads | future |
|
||||
|
||||
**`/v1/*` is reserved for the OpenAI-compatible surface and nothing else.** G3 pins
|
||||
it: an SDK expects `/v1/chat/completions` at the base URL, so that prefix can never
|
||||
be repurposed or nested. Every other capability gets its own prefix that cannot
|
||||
collide with a current or future OpenAI path.
|
||||
|
||||
Subdomains are deliberately *not* used. Paths keep hostname configuration to a
|
||||
single entry — and hostname configuration is the demonstrated failure mode here, as
|
||||
the unresolved apex 403 shows. Promoting a prefix to its own subdomain later is an
|
||||
additive host rule that can run alongside the path; the reverse is not, because
|
||||
clients hardcode hostnames.
|
||||
|
||||
Notes carried from the cluster:
|
||||
|
||||
- Temporal namespace registration is automatic via queue-operator, never manual.
|
||||
- `management-service` already exposes gRPC at `kmsvc.riotpiao.com`; the `/sqs`
|
||||
prefix is a new surface, not a replacement for it.
|
||||
- atlas keeps its own informers and RBAC. The gateway proxies to it and holds no
|
||||
cluster credentials of its own (G2).
|
||||
- `/db/*` read surfaces need particular care — see G2 before designing them.
|
||||
|
||||
---
|
||||
|
||||
## 10. Known cluster facts worth not rediscovering
|
||||
|
||||
- `kmsvc-redis-master.sqs:6379` has **no authentication** — `ALLOW_EMPTY_PASSWORD=yes`,
|
||||
TLS off. Any workload with network reach has full unauthenticated read/write. A
|
||||
NetworkPolicy is the only control.
|
||||
- `reasoning-predictor` listens on port **80**, not 8080.
|
||||
- `prometheus-operated.monitoring` is **headless** (ClusterIP None) — egress policies
|
||||
need pod selectors, not ClusterIPs.
|
||||
- `agent-pod/console` is publicly routed, unauthenticated, accepts free-form prompts
|
||||
into a shell-capable container, and serves a WebSocket. Putting it behind gateway
|
||||
auth is a security fix, not merely a port.
|
||||
- Eight `*.example.com` hosts exist on istio-class Ingresses in `llm-serving`. KServe
|
||||
defaults, not public, out of scope — do not mistake them for gateway routes.
|
||||
@@ -0,0 +1,131 @@
|
||||
# SLA: API Gateway & Platform Services
|
||||
|
||||
## API Gateway (api.riotpiao.com)
|
||||
|
||||
### Availability
|
||||
|
||||
| Target | Measurement | Alert |
|
||||
|--------|------------|-------|
|
||||
| 99.9% uptime | `probe_success{instance=~".*api.riotpiao.com.*"}` | `APIGatewayProbeDown` fires after 2m down |
|
||||
| Monthly budget: 43.8 min downtime | 7-day SLO: `avg_over_time(probe_success[7d]) * 100` | |
|
||||
| Zero ready pods = critical | `sum(kube_pod_status_ready{namespace="api"}) == 0` | `APIGatewayDown` fires after 1m |
|
||||
|
||||
### Latency
|
||||
|
||||
Baselines measured from 200-request canary run against live cluster.
|
||||
SLA set at ~2x measured p99 for headroom.
|
||||
|
||||
| Endpoint | Measured p50 | Measured p99 | SLA (p95) | SLA (p99) | Alert |
|
||||
|----------|-------------|-------------|-----------|-----------|-------|
|
||||
| LLM Chat (qwen) | 514ms | 609ms | <1s | <2s | `APIGatewayLatencyHigh` |
|
||||
| LLM Chat (reasoning) | 300ms | 328ms | <1s | <2s | `APIGatewayLatencyHigh` |
|
||||
| LLM Chat (ornith:35b) | 1.2s | 1.2s | <3s | <5s | `APIGatewayLatencyCritical` |
|
||||
| LLM Chat (streaming) | 569ms | 628ms | <1s | <2s | `APIGatewayLatencyHigh` |
|
||||
| Embeddings | 189ms | 287ms | <500ms | <1s | `APIGatewayLatencyHigh` |
|
||||
| Rerank | 106ms | 218ms | <500ms | <1s | `APIGatewayLatencyHigh` |
|
||||
| Models list | 68ms | 277ms | <300ms | <500ms | `APIGatewayLatencyHigh` |
|
||||
| Auth rejection | 69ms | 87ms | <200ms | <500ms | (no alert, expected fast) |
|
||||
|
||||
### Error Rate
|
||||
|
||||
| Target | Measurement | Alert |
|
||||
|--------|------------|-------|
|
||||
| 5xx < 1% | `nginx_ingress_controller_requests{status=~"5.."}` / total | `APIGateway5xxErrorRate` fires after 5m >1% |
|
||||
| Total errors < 10% | 4xx + 5xx / total | `APIGatewayHighErrorRate` fires after 10m >10% |
|
||||
|
||||
---
|
||||
|
||||
## LLM Serving (llm-serving namespace)
|
||||
|
||||
| Target | Measurement | Alert |
|
||||
|--------|------------|-------|
|
||||
| All predictors running | replicas ready == desired per deployment | `LLMPredictorDown` fires after 5m |
|
||||
| Zero LLM pods = critical | `sum(ready{namespace="llm-serving"}) == 0` | `LLMServingDown` fires after 2m |
|
||||
| No restart storms | restart count in 15m | `LLMPredictorRestarted` on any restart |
|
||||
|
||||
---
|
||||
|
||||
## Cluster Infrastructure
|
||||
|
||||
### Node Health
|
||||
|
||||
| Target | Measurement | Alert |
|
||||
|--------|------------|-------|
|
||||
| All nodes Ready | `kube_node_status_condition` | `NodeNotReady` fires after 2m |
|
||||
| CPU < 90% sustained | `node_cpu_seconds_total` | `NodeHighCPU` fires after 15m |
|
||||
| Memory < 90% sustained | `node_memory_MemAvailable_bytes` | `NodeHighMemory` fires after 15m |
|
||||
| Disk < 85% | `node_filesystem_avail_bytes` | `NodeDiskFull` fires after 5m (critical) |
|
||||
|
||||
### Pod Health
|
||||
|
||||
| Target | Measurement | Alert |
|
||||
|--------|------------|-------|
|
||||
| No pods pending > 10m | `kube_pod_status_phase{phase="Pending"}` | `PodStuckPending` |
|
||||
| No CrashLoopBackOff > 5m | `kube_pod_container_status_waiting_reason` | `PodCrashLooping` (critical) |
|
||||
| OOMKilled < 3/hour | `kube_pod_container_status_last_terminated_reason` | `OOMKilledSpike` |
|
||||
| No restart storms | >5 restarts in 15m | `ContainerRestartStorm` |
|
||||
|
||||
### Jobs
|
||||
|
||||
| Target | Measurement | Alert |
|
||||
|--------|------------|-------|
|
||||
| No failed jobs | `kube_job_status_failed > 0` | `JobFailed` fires after 5m |
|
||||
| No stuck jobs > 2h | `kube_job_status_active` + age | `JobStuckRunning` |
|
||||
| CronJobs on schedule | last_schedule vs next_schedule | `CronJobMissedSchedule` fires after 10m |
|
||||
|
||||
### Storage
|
||||
|
||||
| Target | Measurement | Alert |
|
||||
|--------|------------|-------|
|
||||
| Longhorn drives healthy | `longhorn_disk_health` | `LonghornDriveOffline` fires after 5m (critical) |
|
||||
|
||||
### DNS
|
||||
|
||||
| Target | Measurement | Alert |
|
||||
|--------|------------|-------|
|
||||
| CoreDNS SERVFAIL < 0.5/s | `coredns_dns_responses_total{rcode="SERVFAIL"}` | `CoreDNSErrorSpike` fires after 5m |
|
||||
|
||||
### Probes
|
||||
|
||||
| Target | Measurement | Alert |
|
||||
|--------|------------|-------|
|
||||
| All service probes passing | `probe_success` | `ServiceProbeDown` fires after 3m (critical) |
|
||||
| Probe latency < 2s | `probe_duration_seconds` | `ServiceProbeSlow` fires after 5m |
|
||||
| Certs valid > 14 days | `certmanager_certificate_expiration_timestamp_seconds` | `CertificateExpiringSoon` |
|
||||
|
||||
---
|
||||
|
||||
## Alert Severity Levels
|
||||
|
||||
| Severity | Meaning | Response Time |
|
||||
|----------|---------|--------------|
|
||||
| **critical** | Service down or data loss risk. Immediate impact on users. | Investigate within 15 min |
|
||||
| **warning** | Degraded performance or resource pressure. No immediate outage. | Investigate within 4 hours |
|
||||
|
||||
### Critical Alerts (require immediate action)
|
||||
|
||||
- `APIGatewayDown` — zero gateway pods
|
||||
- `LLMServingDown` — zero LLM pods
|
||||
- `NodeNotReady` — node lost
|
||||
- `PodCrashLooping` — service crashing repeatedly
|
||||
- `NodeDiskFull` — disk > 85%
|
||||
- `LonghornDriveOffline` — storage unhealthy
|
||||
- `ServiceProbeDown` — external service unreachable
|
||||
- `APIGateway5xxErrorRate` — 5xx > 1%
|
||||
- `APIGatewayLatencyCritical` — p99 > 5s
|
||||
|
||||
---
|
||||
|
||||
## Current Alert Status
|
||||
|
||||
Alerts firing after deployment:
|
||||
|
||||
| Alert | State | Root Cause |
|
||||
|-------|-------|-----------|
|
||||
| `APIGatewayProbeDown` | pending | Blackbox probe for api-gateway not yet active (pod restart needed) |
|
||||
| `PodStuckPending` | pending | `sms/macos-bluebubbles` pending 22d (scheduling constraint) |
|
||||
| `PodCrashLooping` | pending | `iam/authentik-provision` job in Error state |
|
||||
| `DeploymentReplicasUnavailable` | pending | Same root causes above |
|
||||
| `ServiceProbeDown` | pending | api-gateway probe target not in blackbox yet |
|
||||
|
||||
None are false positives. All reflect real cluster state.
|
||||
+48
-10
@@ -9,11 +9,31 @@ import (
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/proxy"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/temporal"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
||||
)
|
||||
|
||||
func main() {
|
||||
ctx := context.Background()
|
||||
|
||||
// Initialize OpenTelemetry tracing
|
||||
tracingCfg := tracing.DefaultConfig()
|
||||
shutdownTracer, err := tracing.Init(ctx, tracingCfg)
|
||||
if err != nil {
|
||||
log.Printf("warning: failed to initialize tracing: %v", err)
|
||||
} else {
|
||||
log.Printf("tracing initialized: service=%s endpoint=%s", tracingCfg.ServiceName, tracingCfg.OTLPEndpoint)
|
||||
defer func() {
|
||||
if err := shutdownTracer(ctx); err != nil {
|
||||
log.Printf("error shutting down tracer: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Load configuration
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
@@ -30,11 +50,17 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Create a basic handler (will be replaced with real routing later)
|
||||
upstreamHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
fmt.Fprintf(w, "not found")
|
||||
})
|
||||
// Create the reverse proxy handler that routes requests based on configuration
|
||||
upstreamHandler := proxy.New(cfg)
|
||||
|
||||
// Create the Temporal workflow handler
|
||||
// Temporal server address can be configured via environment variable
|
||||
temporalHostPort := os.Getenv("TEMPORAL_HOST_PORT")
|
||||
if temporalHostPort == "" {
|
||||
temporalHostPort = "localhost:7233"
|
||||
}
|
||||
log.Printf("Temporal server: %s", temporalHostPort)
|
||||
temporalHandler := temporal.NewHandler(temporalHostPort)
|
||||
|
||||
// Create server with health checker
|
||||
srv := server.New(cfg.ListenAddr, cfg.ShutdownTimeout, nil)
|
||||
@@ -43,9 +69,21 @@ func main() {
|
||||
healthChecker := server.NewHealthChecker(true, authEnabled)
|
||||
srv.SetHealthChecker(healthChecker)
|
||||
|
||||
// Create router that handles health endpoints and passes others to upstream
|
||||
router := server.NewRouter(healthChecker, upstreamHandler)
|
||||
srv.SetHandler(router)
|
||||
// Create ServiceAdapter registry and dispatcher (phase 8)
|
||||
registry := serviceadapter.NewRegistry(nil)
|
||||
for _, a := range cfg.Adapters {
|
||||
_ = registry.Add(a)
|
||||
}
|
||||
log.Printf("%d service adapters loaded", registry.Count())
|
||||
dispatcher := serviceadapter.NewDispatcher(registry)
|
||||
|
||||
// Create router that handles health endpoints, X-Service (ServiceAdapter) routing,
|
||||
// temporal endpoints, and passes others to upstream handler
|
||||
router := server.NewRouter(healthChecker, dispatcher, temporalHandler, upstreamHandler)
|
||||
|
||||
// Wrap router with tracing middleware
|
||||
tracedRouter := tracing.Middleware(router)
|
||||
srv.SetHandler(tracedRouter)
|
||||
|
||||
// Set up signal handling
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"go.temporal.io/sdk/client"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/workflow"
|
||||
)
|
||||
|
||||
func main() {
|
||||
hostPort := flag.String("host", "temporal.temporal:7233", "Temporal server host:port")
|
||||
namespace := flag.String("namespace", "production", "Temporal namespace")
|
||||
taskQueue := flag.String("queue", "worker-production", "Task queue")
|
||||
workflowType := flag.String("workflow", "HelloWorldWorkflow", "Workflow type")
|
||||
workflowID := flag.String("id", "", "Workflow ID (auto-generated if not set)")
|
||||
flag.Parse()
|
||||
|
||||
// Auto-generate ID
|
||||
if *workflowID == "" {
|
||||
*workflowID = fmt.Sprintf("test-%s-%d", *workflowType, time.Now().Unix())
|
||||
}
|
||||
|
||||
log.Printf("Connecting to Temporal at %s (namespace: %s)", *hostPort, *namespace)
|
||||
|
||||
// Connect to Temporal
|
||||
c, err := client.Dial(client.Options{
|
||||
HostPort: *hostPort,
|
||||
Namespace: *namespace,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Start workflow
|
||||
log.Printf("Starting %s (ID: %s)", *workflowType, *workflowID)
|
||||
|
||||
var run client.WorkflowRun
|
||||
switch *workflowType {
|
||||
case "HelloWorldWorkflow":
|
||||
run, err = c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
||||
ID: *workflowID,
|
||||
TaskQueue: *taskQueue,
|
||||
}, workflow.HelloWorldWorkflow, "World")
|
||||
|
||||
case "GreeterWorkflow":
|
||||
run, err = c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
||||
ID: *workflowID,
|
||||
TaskQueue: *taskQueue,
|
||||
}, workflow.GreeterWorkflow, "Alice")
|
||||
|
||||
case "ProcessOrderWorkflow":
|
||||
run, err = c.ExecuteWorkflow(ctx, client.StartWorkflowOptions{
|
||||
ID: *workflowID,
|
||||
TaskQueue: *taskQueue,
|
||||
}, workflow.ProcessOrderWorkflow, "ORDER-12345")
|
||||
|
||||
default:
|
||||
log.Fatalf("Unknown workflow type: %s", *workflowType)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to start workflow: %v", err)
|
||||
}
|
||||
|
||||
log.Printf("✓ Workflow submitted")
|
||||
log.Printf(" Run ID: %s", run.GetRunID())
|
||||
log.Printf(" Workflow ID: %s", *workflowID)
|
||||
log.Printf(" Watch at: http://localhost:8080/namespaces/%s/workflows/%s", *namespace, *workflowID)
|
||||
|
||||
// Try to get result
|
||||
ctx2, cancel2 := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel2()
|
||||
|
||||
var result string
|
||||
if err := run.Get(ctx2, &result); err != nil {
|
||||
log.Printf("⏳ Workflow executing (or error): %v", err)
|
||||
} else {
|
||||
log.Printf("✓ Result: %s", result)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
|
||||
"go.temporal.io/sdk/client"
|
||||
"go.temporal.io/sdk/worker"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/workflow"
|
||||
)
|
||||
|
||||
func main() {
|
||||
hostPort := flag.String("host", "temporal.temporal:7233", "Temporal server host:port")
|
||||
namespace := flag.String("namespace", "production", "Temporal namespace")
|
||||
taskQueue := flag.String("queue", "worker-production", "Task queue")
|
||||
flag.Parse()
|
||||
|
||||
// Connect to Temporal server
|
||||
c, err := client.Dial(client.Options{
|
||||
HostPort: *hostPort,
|
||||
Namespace: *namespace,
|
||||
})
|
||||
if err != nil {
|
||||
log.Fatalf("Failed to connect to Temporal: %v", err)
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
log.Printf("Connected to Temporal at %s (namespace: %s)", *hostPort, *namespace)
|
||||
|
||||
// Create worker
|
||||
w := worker.New(c, *taskQueue, worker.Options{})
|
||||
|
||||
// Register workflows
|
||||
w.RegisterWorkflow(workflow.HelloWorldWorkflow)
|
||||
w.RegisterWorkflow(workflow.GreeterWorkflow)
|
||||
w.RegisterWorkflow(workflow.ProcessOrderWorkflow)
|
||||
|
||||
// Register activities
|
||||
w.RegisterActivity(workflow.GreetActivity)
|
||||
w.RegisterActivity(workflow.ValidateOrderActivity)
|
||||
w.RegisterActivity(workflow.ProcessPaymentActivity)
|
||||
w.RegisterActivity(workflow.NotifyCustomerActivity)
|
||||
|
||||
log.Printf("Starting worker on task queue: %s", *taskQueue)
|
||||
|
||||
// Handle graceful shutdown
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
<-sigChan
|
||||
log.Println("Shutting down worker...")
|
||||
w.Stop()
|
||||
}()
|
||||
|
||||
// Run worker (blocks)
|
||||
if err := w.Run(worker.InterruptCh()); err != nil {
|
||||
log.Fatalf("Worker error: %v", err)
|
||||
}
|
||||
}
|
||||
-324
@@ -1,324 +0,0 @@
|
||||
# API — LLM surfaces
|
||||
|
||||
Two protocol dialects over the same models and the same slot controller.
|
||||
|
||||
| Prefix | Dialect | Endpoint | Client |
|
||||
|---|---|---|---|
|
||||
| `/v1` | OpenAI-compatible | `POST /v1/chat/completions` | pi, OpenAI SDKs |
|
||||
| `/llm` | Anthropic Messages | `POST /llm/v1/messages` | riotpiao frontend (first-party) |
|
||||
|
||||
Status marks below:
|
||||
**[LIVE]** verified against the running cluster on 2026-08-19.
|
||||
**[SPEC]** the contract this gateway must implement; not built yet.
|
||||
|
||||
---
|
||||
|
||||
## Models
|
||||
|
||||
| `model` value | Upstream | Engine | Context | Notes |
|
||||
|---|---|---|---|---|
|
||||
| `reasoning` | `reasoning-predictor.llm-serving:80` | vLLM, DeepSeek-R1-Distill-Qwen-32B | 16384 | emits `reasoning_content`; 8 sequence slots total |
|
||||
| `ornith:35b` | `ornith-predictor.llm-serving:80` | Ollama | 131072 | reliable tool calling |
|
||||
| `qwen2.5:3b-instruct` | `ornith-predictor.llm-serving:80` | Ollama | 32768 | same pods as ornith |
|
||||
| `nomic-ai/nomic-embed-text-v2-moe` | `embeddings-predictor.llm-serving:80` | TEI | — | embeddings only |
|
||||
| `BAAI/bge-reranker-base` | `reranker-predictor.llm-serving:80` | TEI | — | rerank only |
|
||||
|
||||
`reasoning` runs 2 replicas x `--max-num-seqs=4`. Those **8 slots are the scarcest resource in the cluster** and are shared across both dialects.
|
||||
|
||||
---
|
||||
|
||||
## Authentication [SPEC]
|
||||
|
||||
Ships behind a flag, default off. The model API is unauthenticated today.
|
||||
|
||||
```
|
||||
Authorization: Bearer <authentik-jwt>
|
||||
```
|
||||
|
||||
### Decided — Bearer on both surfaces
|
||||
|
||||
`Authorization: Bearer <jwt>` is the only accepted credential, on `/v1` and `/llm` alike. One auth path, consistent with G5, validated against Authentik via JWKS.
|
||||
|
||||
**Known divergence from Anthropic:** the real Anthropic API authenticates with `x-api-key` and requires `anthropic-version: 2023-06-01`. A stock Anthropic SDK pointed at `/llm` will send `x-api-key` and get a 401.
|
||||
|
||||
This is accepted, not overlooked. The `/llm` client is the first-party riotpiao frontend, which sends whatever we tell it to. If a real Anthropic SDK ever needs to reach this gateway, accepting `x-api-key` as a second credential source is an additive change — a small branch in one middleware, not a redesign.
|
||||
|
||||
`anthropic-version` is accepted and ignored if present, and never required.
|
||||
|
||||
The 401 for an `x-api-key`-only request must name the problem — say that Bearer is required — rather than returning a bare 401. The Kong retirement was caused by exactly this failure mode: a gateway that rejected the header clients actually send, without saying why.
|
||||
|
||||
---
|
||||
|
||||
## OpenAI dialect — `POST /v1/chat/completions`
|
||||
|
||||
### Request [SPEC]
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "reasoning",
|
||||
"messages": [{"role": "user", "content": "Why is wave 4 empty?"}],
|
||||
"max_tokens": 2000,
|
||||
"temperature": 0.7,
|
||||
"stream": false
|
||||
}
|
||||
```
|
||||
|
||||
`model` is required and selects the upstream. The body is forwarded byte-identical — the gateway reads `model`, it does not rewrite it.
|
||||
|
||||
### Response, non-streaming [LIVE]
|
||||
|
||||
Captured verbatim from `reasoning` on 2026-08-19, abridged:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "chatcmpl-f17bd2fe22e4276d24e9438e40e89cea",
|
||||
"object": "chat.completion",
|
||||
"created": 1787172340,
|
||||
"model": "reasoning",
|
||||
"choices": [{
|
||||
"index": 0,
|
||||
"message": {
|
||||
"role": "assistant",
|
||||
"content": "\n\nTo find the current weather in Toronto...",
|
||||
"reasoning_content": "Okay, so I need to figure out...",
|
||||
"tool_calls": []
|
||||
},
|
||||
"finish_reason": "length"
|
||||
}],
|
||||
"usage": {"prompt_tokens": 16, "completion_tokens": 300, "total_tokens": 316}
|
||||
}
|
||||
```
|
||||
|
||||
`reasoning_content` is a **sibling of** `content`, not nested in it. This is a vLLM extension produced by `--reasoning-parser=deepseek_r1`; it is not part of the OpenAI spec. Pass it through untouched.
|
||||
|
||||
### The two engines disagree on the field name [LIVE]
|
||||
|
||||
Verified 2026-08-19 by calling both:
|
||||
|
||||
| Upstream | Engine | Reasoning field |
|
||||
|---|---|---|
|
||||
| `reasoning-predictor` | vLLM | `reasoning_content` |
|
||||
| `ornith-predictor` | Ollama | `reasoning` |
|
||||
|
||||
Neither is in the OpenAI spec, so neither is wrong — they are two vendor extensions that
|
||||
happen to mean the same thing. The gateway must recognise **both** when mapping to the
|
||||
Anthropic `thinking` block, or `ornith:35b` responses will silently lose their reasoning
|
||||
on the `/llm` surface.
|
||||
|
||||
Do not normalise them on the `/v1` surface. That surface passes bodies through
|
||||
untouched, and a client asking for `ornith:35b` should get exactly what Ollama sent.
|
||||
Normalisation belongs in the canonical request model (task 2.9), which is the layer that
|
||||
exists to absorb precisely this kind of upstream difference.
|
||||
|
||||
### Response, streaming [SPEC]
|
||||
|
||||
```
|
||||
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"reasoning_content":"Okay"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{"content":"Wave"},"finish_reason":null}]}
|
||||
|
||||
data: {"id":"chatcmpl-...","object":"chat.completion.chunk","choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}
|
||||
|
||||
data: [DONE]
|
||||
```
|
||||
|
||||
Data-only frames, no `event:` lines. Terminated by the literal `data: [DONE]`.
|
||||
|
||||
### Legacy aliases [LIVE, being retired]
|
||||
|
||||
`POST /v1/{reasoning,ornith,qwen}/chat/completions` force `model` to the corresponding value regardless of the body. They exist only because Kong could not dispatch on the body. Removed once callers migrate.
|
||||
|
||||
### `GET /v1/models` [SPEC]
|
||||
|
||||
```json
|
||||
{"object":"list","data":[{"id":"reasoning","object":"model","owned_by":"homelab","created":0}]}
|
||||
```
|
||||
|
||||
Derived from the registry, never hardcoded.
|
||||
|
||||
### Errors [SPEC]
|
||||
|
||||
RFC 9457 `application/problem+json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "https://riotpiao.com/errors/unknown-model",
|
||||
"title": "Unknown model",
|
||||
"status": 400,
|
||||
"detail": "\"gpt-4\" is not available",
|
||||
"validModels": ["reasoning", "ornith:35b", "qwen2.5:3b-instruct"]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Anthropic dialect — `POST /llm/v1/messages` [SPEC]
|
||||
|
||||
Path note: the Anthropic SDK appends `/v1/messages` to its base URL, so a base URL of `https://api.riotpiao.com/llm` produces exactly this path.
|
||||
|
||||
### Request
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "reasoning",
|
||||
"max_tokens": 2000,
|
||||
"system": "You are a cluster assistant.",
|
||||
"messages": [
|
||||
{"role": "user", "content": "Why is wave 4 empty?"}
|
||||
],
|
||||
"stream": true
|
||||
}
|
||||
```
|
||||
|
||||
Differences from the OpenAI dialect that the translator must handle:
|
||||
|
||||
| Concern | OpenAI | Anthropic |
|
||||
|---|---|---|
|
||||
| system prompt | `messages[0].role = "system"` | top-level `system` field |
|
||||
| `max_tokens` | optional | **required** |
|
||||
| content | string | string *or* block array |
|
||||
| roles | system/user/assistant/tool | user/assistant only |
|
||||
| stop | `stop` | `stop_sequences` |
|
||||
|
||||
`max_tokens` being required is a real divergence — the gateway must either reject its absence with a clear error or apply a documented default. Pick one and state it; do not silently default.
|
||||
|
||||
### Response, non-streaming
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "msg_01ABC",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"model": "reasoning",
|
||||
"content": [
|
||||
{"type": "thinking", "thinking": "Waves are sort keys, not a sequence..."},
|
||||
{"type": "text", "text": "Wave 4 is empty. Waves are sort keys..."}
|
||||
],
|
||||
"stop_reason": "end_turn",
|
||||
"stop_sequence": null,
|
||||
"usage": {"input_tokens": 16, "output_tokens": 300}
|
||||
}
|
||||
```
|
||||
|
||||
Field mapping from the upstream OpenAI response:
|
||||
|
||||
| Upstream | Anthropic |
|
||||
|---|---|
|
||||
| `choices[0].message.reasoning_content` | `content[]` block `{"type":"thinking","thinking":...}` |
|
||||
| `choices[0].message.content` | `content[]` block `{"type":"text","text":...}` |
|
||||
| `finish_reason: "stop"` | `stop_reason: "end_turn"` |
|
||||
| `finish_reason: "length"` | `stop_reason: "max_tokens"` |
|
||||
| `usage.prompt_tokens` | `usage.input_tokens` |
|
||||
| `usage.completion_tokens` | `usage.output_tokens` |
|
||||
|
||||
The thinking block precedes the text block.
|
||||
|
||||
### Response, streaming
|
||||
|
||||
Anthropic SSE uses **named events with content-block indices**, unlike OpenAI's flat frames. Verified event sequence:
|
||||
|
||||
```
|
||||
event: message_start
|
||||
data: {"type":"message_start","message":{"id":"msg_01ABC","type":"message","role":"assistant","model":"reasoning","content":[],"usage":{"input_tokens":16,"output_tokens":0}}}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":0,"content_block":{"type":"thinking","thinking":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":0,"delta":{"type":"thinking_delta","thinking":"Waves are sort keys"}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":0}
|
||||
|
||||
event: content_block_start
|
||||
data: {"type":"content_block_start","index":1,"content_block":{"type":"text","text":""}}
|
||||
|
||||
event: content_block_delta
|
||||
data: {"type":"content_block_delta","index":1,"delta":{"type":"text_delta","text":"Wave 4 is empty."}}
|
||||
|
||||
event: content_block_stop
|
||||
data: {"type":"content_block_stop","index":1}
|
||||
|
||||
event: message_delta
|
||||
data: {"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"output_tokens":300}}
|
||||
|
||||
event: message_stop
|
||||
data: {"type":"message_stop"}
|
||||
```
|
||||
|
||||
Three details that are easy to get wrong:
|
||||
|
||||
- In `message_delta`, `usage` is a **sibling of** `delta`, not inside it.
|
||||
- The delta field name matches the delta type: `thinking_delta` carries `.thinking`, `text_delta` carries `.text`.
|
||||
- Block index 0 is thinking, index 1 is text. **You only learn reasoning has ended when `content` first appears in an upstream chunk**, so the thinking block must be closed before the text block opens. If a response has no `reasoning_content` at all, the text block is index 0 and no thinking block is emitted.
|
||||
|
||||
### Queue position — non-standard extension
|
||||
|
||||
Anthropic's event set has no way to say "you are queued", because the stream implicitly begins after a slot is acquired. With only 8 slots, queueing is normal here.
|
||||
|
||||
Emitted **before** `message_start`:
|
||||
|
||||
```
|
||||
event: queue
|
||||
data: {"type":"queue","position":3}
|
||||
```
|
||||
|
||||
This is deliberately outside the Anthropic spec. It is safe only because the client is first-party; a strict Anthropic client would ignore the unknown event and show nothing while queued.
|
||||
|
||||
### Errors
|
||||
|
||||
Anthropic error shape, **not** RFC 9457 — the same rejection renders differently depending on which surface received it:
|
||||
|
||||
```json
|
||||
{"type":"error","error":{"type":"invalid_request_error","message":"Unknown model \"gpt-4\". Available: reasoning, ornith:35b, qwen2.5:3b-instruct"}}
|
||||
```
|
||||
|
||||
| Condition | HTTP | `error.type` |
|
||||
|---|---|---|
|
||||
| unknown or missing model | 400 | `invalid_request_error` |
|
||||
| `max_tokens` absent (if required) | 400 | `invalid_request_error` |
|
||||
| malformed JSON | 400 | `invalid_request_error` |
|
||||
| unsupported feature requested | 400 | `invalid_request_error` |
|
||||
| not authenticated | 401 | `authentication_error` |
|
||||
| budget exhausted or queue full | 429 | `rate_limit_error` |
|
||||
| upstream failure | 502 | `api_error` |
|
||||
|
||||
### Deliberately not implemented
|
||||
|
||||
Each returns 400 naming the unsupported feature — never a silent partial implementation:
|
||||
|
||||
tool use and `tool_result` turns, image content blocks, prompt-caching headers, the batch API, multi-block user content, `thinking.budget_tokens` configuration.
|
||||
|
||||
The target client is the riotpiao frontend. Widening scope is a code change with a test, not an accident.
|
||||
|
||||
---
|
||||
|
||||
## Shared behaviour, both dialects
|
||||
|
||||
**One slot controller, keyed by upstream.** A `/v1` request and a `/llm` request contend for the same 8 `reasoning` slots and the same queue, in arrival order. Per-dialect semaphores would each believe they were within budget while together exceeding the physical limit.
|
||||
|
||||
**Streaming is unbuffered** and a client disconnect cancels the upstream immediately. An orphaned generation holds a slot until it completes on its own, which for a 32B model on a Volta GPU can run to minutes.
|
||||
|
||||
**Timeouts** [LIVE]: chat routes are connect 10s / read 1h / write 1h. The hour is deliberate — a 32B model on this hardware routinely exceeds 60s. Any shorter application cap is enforced in gateway logic, never by shortening the proxy timeout.
|
||||
|
||||
**Tool calling** [LIVE]: `reasoning` honours an explicit `tool_choice` but returns `tool_calls: []` under `tool_choice: "auto"` — it reasons about the tool in prose instead. `ornith:35b` returns `finish_reason: "tool_calls"` correctly under `auto`. This is a model property; the gateway does not compensate for it.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
# OpenAI dialect
|
||||
curl -s https://api.riotpiao.com/v1/chat/completions \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"model":"reasoning","messages":[{"role":"user","content":"Why is wave 4 empty?"}],"max_tokens":500}'
|
||||
|
||||
# Anthropic dialect, streaming
|
||||
curl -N -s https://api.riotpiao.com/llm/v1/messages \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"model":"reasoning","max_tokens":500,"stream":true,
|
||||
"messages":[{"role":"user","content":"Why is wave 4 empty?"}]}'
|
||||
|
||||
# model list
|
||||
curl -s https://api.riotpiao.com/v1/models
|
||||
```
|
||||
-263
@@ -1,263 +0,0 @@
|
||||
# API — queue surface (`/sqs/*`)
|
||||
|
||||
Fronts the Kafka Management Service (`kmsvc`) in namespace `sqs`. SQS-shaped
|
||||
message-plane API over Kafka.
|
||||
|
||||
Status marks:
|
||||
**[LIVE]** verified against the running cluster and the committed proto on 2026-08-19.
|
||||
**[SPEC]** the contract this gateway must implement; not built yet.
|
||||
|
||||
Source of truth for shapes:
|
||||
`~/workplace/kmsvc-proto/proto/kafkamgmt/v1/queue_service.proto`.
|
||||
|
||||
---
|
||||
|
||||
## The important finding: a REST surface already exists [LIVE]
|
||||
|
||||
**Do not build gRPC-to-JSON transcoding.** `kmsvc-manage` already mounts grpc-gateway:
|
||||
|
||||
```go
|
||||
mux := runtime.NewServeMux()
|
||||
kafkamgmtv1.RegisterQueueServiceHandlerServer(ctx, mux, svc)
|
||||
```
|
||||
|
||||
The upstream serves plain REST/JSON on **:8080** and plain gRPC on **:9090**. Neither
|
||||
gRPC-Web nor server reflection is enabled.
|
||||
|
||||
So `/sqs/*` is a **path-stripping reverse proxy plus authentication**, not a protocol
|
||||
translator. That makes it dramatically cheaper than the LLM surface.
|
||||
|
||||
```
|
||||
api.riotpiao.com/sqs/v1/queues/{q}/messages
|
||||
| strip /sqs, authenticate
|
||||
v
|
||||
management-service.sqs.svc.cluster.local:8080/v1/queues/{q}/messages
|
||||
```
|
||||
|
||||
Upstream: Deployment `management-service`, 3 replicas, HPA 3-9, Service ClusterIP
|
||||
`10.98.3.138`, ports `8080` (http) and `9090` (grpc).
|
||||
|
||||
---
|
||||
|
||||
## Endpoints [LIVE — HTTP annotations from the proto]
|
||||
|
||||
Six operations. All unary. No streaming, no subscribe.
|
||||
|
||||
| Method | Path (after `/sqs` strip) | RPC |
|
||||
|---|---|---|
|
||||
| POST | `/v1/queues/{queue_name}/messages` | `SendMessage` |
|
||||
| POST | `/v1/queues/{queue_name}/messages:batch` | `SendMessageBatch` |
|
||||
| GET | `/v1/queues/{queue_name}/messages` | `ReceiveMessage` |
|
||||
| DELETE | `/v1/queues/{queue_name}/messages/{receipt_handle}` | `DeleteMessage` |
|
||||
| POST | `/v1/queues/{queue_name}/messages:batchDelete` | `DeleteMessageBatch` |
|
||||
| PATCH | `/v1/queues/{queue_name}/messages/{receipt_handle}` | `ChangeMessageVisibility` |
|
||||
|
||||
---
|
||||
|
||||
## Two wire-format traps [LIVE]
|
||||
|
||||
Both follow from grpc-gateway defaults, and both will surprise anyone who reads only
|
||||
the proto.
|
||||
|
||||
**1. `bytes` fields are base64 in JSON.** `SendMessageRequest.message_body` and
|
||||
`Message.body` are proto `bytes`. The JSONPB marshaler encodes them as base64 strings.
|
||||
Sending raw text will not do what you expect.
|
||||
|
||||
**2. Field names are lowerCamelCase.** `cmd/server/main.go` calls bare
|
||||
`runtime.NewServeMux()` with no marshaler options, so `OrigName` is false. The wire uses
|
||||
`messageBody`, `receiptHandle`, `maxNumberOfMessages` — not the snake_case names in the
|
||||
proto.
|
||||
|
||||
Document both prominently or every first-time caller loses an hour.
|
||||
|
||||
---
|
||||
|
||||
## Message shapes [LIVE — from the proto]
|
||||
|
||||
### Send
|
||||
|
||||
```
|
||||
POST /sqs/v1/queues/agent-worker-queue/messages
|
||||
{
|
||||
"messageBody": "aGVsbG8gd29ybGQ=", // base64 of "hello world"
|
||||
"messageAttributes": {"values": {"k": "v"}},
|
||||
"messageGroupId": "", // FIFO only
|
||||
"messageDeduplicationId": "", // FIFO only
|
||||
"delaySeconds": 0 // 0-900
|
||||
}
|
||||
-> {"messageId": "...", "sequenceNumber": ""} // sequenceNumber FIFO only
|
||||
```
|
||||
|
||||
### Receive — long poll
|
||||
|
||||
```
|
||||
GET /sqs/v1/queues/agent-worker-queue/messages
|
||||
?maxNumberOfMessages=10 // <= 10
|
||||
&waitTimeSeconds=20 // 0-20
|
||||
&visibilityTimeoutSeconds=30 // optional override
|
||||
|
||||
-> {"messages": [{
|
||||
"messageId": "...",
|
||||
"receiptHandle": "...",
|
||||
"body": "aGVsbG8gd29ybGQ=",
|
||||
"attributes": {"values": {}},
|
||||
"receiveCount": 1,
|
||||
"messageGroupId": "",
|
||||
"enqueuedAt": "2026-08-19T16:29:07Z"
|
||||
}]}
|
||||
```
|
||||
|
||||
### Delete — the ack
|
||||
|
||||
```
|
||||
DELETE /sqs/v1/queues/agent-worker-queue/messages/{receiptHandle}
|
||||
-> {}
|
||||
```
|
||||
|
||||
### Change visibility
|
||||
|
||||
```
|
||||
PATCH /sqs/v1/queues/agent-worker-queue/messages/{receiptHandle}
|
||||
{"visibilityTimeoutSeconds": 60} // 0-43200
|
||||
-> {}
|
||||
```
|
||||
|
||||
### Batch
|
||||
|
||||
Both batch calls take `entries[]` with a caller-assigned `id`, and return partial
|
||||
success:
|
||||
|
||||
```json
|
||||
{"successful": [{"id": "1", "messageId": "..."}],
|
||||
"failed": [{"id": "2", "error": "..."}]}
|
||||
```
|
||||
|
||||
A batch call can return 200 with entries in `failed`. Callers must inspect the body,
|
||||
not just the status.
|
||||
|
||||
### Limits [LIVE — from the SDK]
|
||||
|
||||
`MaxMessageBodyBytes = 262144` (256 KiB), `MaxReceiveMessages = 10`,
|
||||
`MaxWaitTimeSeconds = 20`.
|
||||
|
||||
---
|
||||
|
||||
## Semantics
|
||||
|
||||
At-least-once, SQS-style. Receive leases a message for the visibility timeout; the
|
||||
caller must `DeleteMessage` to acknowledge. An un-deleted message reappears after the
|
||||
timeout and `receiveCount` increments. After `maxReceiveCount` (default 5) it goes to
|
||||
the DLQ if one is configured.
|
||||
|
||||
**Long-polling matters for the gateway.** `waitTimeSeconds` up to 20 means a `GET` can
|
||||
legitimately hold open for 20 seconds returning nothing. Read timeouts must exceed that
|
||||
comfortably, and a client disconnect must cancel upstream — the same requirement as the
|
||||
LLM surface, for the same reason.
|
||||
|
||||
---
|
||||
|
||||
## Error mapping [SPEC]
|
||||
|
||||
The SDK maps gRPC codes to sentinel errors; grpc-gateway maps them to HTTP. Use this as
|
||||
the gateway's status contract:
|
||||
|
||||
| gRPC code | HTTP | SDK sentinel |
|
||||
|---|---|---|
|
||||
| `NotFound` | 404 | `ErrQueueNotFound` |
|
||||
| `AlreadyExists` | 409 | `ErrAlreadyExists` |
|
||||
| `InvalidArgument` | 400 | `ErrInvalidArgument` |
|
||||
| `Unauthenticated` | 401 | `ErrUnauthenticated` |
|
||||
| `ResourceExhausted` | 429 | `ErrMessageTooLarge` |
|
||||
|
||||
Upstream errors arrive in the grpc-gateway envelope
|
||||
`{"code": 5, "message": "Not Found", "details": []}`. Decide deliberately whether
|
||||
`/sqs/*` passes that through or re-renders it as RFC 9457 to match `/v1/*`.
|
||||
Recommendation: **pass through**, so the gateway does not become a second, subtly
|
||||
different error vocabulary for the same upstream.
|
||||
|
||||
---
|
||||
|
||||
## Queue lifecycle is NOT in this API [LIVE]
|
||||
|
||||
There is no `CreateQueue`, `DeleteQueue`, or `ListQueues` RPC. The proto says so
|
||||
explicitly:
|
||||
|
||||
```proto
|
||||
// Queue lifecycle (create/delete/configure) is managed via the Queue CRD,
|
||||
// not this service
|
||||
```
|
||||
|
||||
Queues are Kubernetes resources — `queues.kmsvc.io/v1`, namespaced. `kmsvc-cli`'s
|
||||
`create-queue` and `delete-queue` talk to the Kubernetes API, not to kmsvc.
|
||||
|
||||
**This is a hard boundary for the gateway.** Exposing queue creation over `/sqs/*` would
|
||||
require the gateway to hold Kubernetes write credentials, which violates **G2**. Do not
|
||||
add it. If declarative queue management ever needs a public surface, it belongs behind a
|
||||
separate component with its own RBAC — not in the public edge process.
|
||||
|
||||
Queue spec fields, for reference when reading a queue's configuration:
|
||||
`fifoQueue`, `isDLQ`, `deadLetterTargetQueue`, `delaySeconds` (0-900),
|
||||
`maxReceiveCount` (default 5), `messageRetentionPeriodSeconds` (default 345600),
|
||||
`visibilityTimeoutSeconds` (default 30), `minShards`, `maxShards` (default 8),
|
||||
`partitionsPerShard` (default 6), `shardSplitThresholdBytesPerSec`,
|
||||
`shardSplitCooldownSeconds`.
|
||||
|
||||
Kafka topics are named `kmsvc.{queue}.shard-{id}` and are created by `queue-operator`
|
||||
directly via the Kafka Admin API — there are no `KafkaTopic` CRs.
|
||||
|
||||
Currently one queue exists: `agent-worker-queue` in namespace `sqs`, phase `Ready`,
|
||||
1 shard.
|
||||
|
||||
---
|
||||
|
||||
## Authentication [SPEC]
|
||||
|
||||
`Authorization: Bearer <jwt>`, same as every other gateway surface.
|
||||
|
||||
**The upstream enforces nothing.** `kmsvc`'s auth interceptor exists but is never wired,
|
||||
and the REST surface is mounted with the in-process grpc-gateway variant that bypasses
|
||||
gRPC interceptors regardless. Both `:8080` and `:9090` are currently open, and
|
||||
`kmsvc.riotpiao.com` is publicly routed.
|
||||
|
||||
The gateway is therefore the only authentication boundary for this surface. See
|
||||
[KNOWN-ISSUES.md](KNOWN-ISSUES.md) §2.
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
- **Workflow start.** Nothing in kmsvc starts a Temporal workflow — no such RPC exists,
|
||||
and grep for `ExecuteWorkflow`/`StartWorkflow` across `kmsvc-manage`, `kmsvc-sdk` and
|
||||
`kmsvc-cli` returns nothing. A caller dials `temporal-frontend.temporal.svc:7233`
|
||||
with a Temporal SDK directly. A `/workflow/*` surface is net-new code, not a proxy
|
||||
route — see [task 7.3](../tasks/7.3-workflow-prefix.md) and KNOWN-ISSUES.md §1.
|
||||
- **DLQ operations.** `kmsvc-cli`'s `dlq peek` and `dlq redrive` are client-side
|
||||
compositions of the six RPCs, not server operations. Redrive is a non-atomic
|
||||
Receive-Send-Delete. If `/sqs/*` should offer redrive, that is new logic with real
|
||||
failure modes, not a proxied call.
|
||||
- **Kafka direct access.** No external listener exists; the bootstrap
|
||||
`kmsvc-kafka-bootstrap.sqs.svc.cluster.local:9092` is cluster-internal only. The
|
||||
gateway proxies kmsvc, never Kafka.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
Q=agent-worker-queue
|
||||
|
||||
# send (body must be base64)
|
||||
curl -s -X POST https://api.riotpiao.com/sqs/v1/queues/$Q/messages \
|
||||
-H 'content-type: application/json' \
|
||||
-d "{\"messageBody\":\"$(printf 'hello world' | base64)\"}"
|
||||
|
||||
# receive, long poll 20s
|
||||
curl -s "https://api.riotpiao.com/sqs/v1/queues/$Q/messages?maxNumberOfMessages=10&waitTimeSeconds=20"
|
||||
|
||||
# acknowledge
|
||||
curl -s -X DELETE https://api.riotpiao.com/sqs/v1/queues/$Q/messages/$RECEIPT
|
||||
|
||||
# extend the lease
|
||||
curl -s -X PATCH https://api.riotpiao.com/sqs/v1/queues/$Q/messages/$RECEIPT \
|
||||
-H 'content-type: application/json' -d '{"visibilityTimeoutSeconds":60}'
|
||||
```
|
||||
@@ -1,118 +0,0 @@
|
||||
# Known cluster issues
|
||||
|
||||
Pre-existing problems found while specifying this gateway. None are caused by this
|
||||
repo, and none block phases 0-6. Recorded so they are not rediscovered or mistaken
|
||||
for new breakage.
|
||||
|
||||
Verified 2026-08-19 against context `admin@homelab-cluster`.
|
||||
|
||||
---
|
||||
|
||||
## 1. TemporalWorker CRD is stale — queue-operator reconcile fails every ~17 min
|
||||
|
||||
**Status:** open, deliberately deferred. Affects [task 7.3](../tasks/7.3-workflow-prefix.md).
|
||||
|
||||
The live `temporalworkers.kmsvc.io` CRD and the one in
|
||||
`~/workplace/kmsvc-manage/config/crd/kmsvc.io_temporalworkers.yaml` share exactly one
|
||||
field — `namespace`.
|
||||
|
||||
| | spec properties |
|
||||
|---|---|
|
||||
| live CRD | `activityTypes`, `concurrency`, `namespace`, `taskQueue`, `workflowTypes` |
|
||||
| repo CRD | `affinity`, `image`, `imagePullPolicy`, `namespace`, `nodeSelector`, `replicas`, `resources`, `tolerations` |
|
||||
|
||||
The live schema has no `image` field, so the API server **prunes** `image` from the CR
|
||||
that `queue-operator` writes. `TemporalWorker/worker-production` ends up as
|
||||
`spec: {namespace: production}`, and the operator then fails to build a Deployment
|
||||
from it. The live CRD also lacks a status subresource, producing a second error.
|
||||
|
||||
Observed on a loop, most recently 21:25:39Z:
|
||||
|
||||
```
|
||||
failed to create or update deployment ... error: "Deployment.apps \"worker-production\"
|
||||
is invalid: spec.template.spec.containers[0].image: Required value"
|
||||
Reconciler error ... "update status failed: temporalworkers.kmsvc.io
|
||||
\"worker-production\" not found"
|
||||
```
|
||||
|
||||
**Impact is narrower than it looks.** No worker Deployment has ever existed under this
|
||||
CRD, so nothing that was working has stopped. Temporal namespace `production` is
|
||||
registered and healthy; there is simply no worker polling it. The practical cost is log
|
||||
noise, not lost work. That is why this is deferred rather than treated as an incident.
|
||||
|
||||
**Neither object is under GitOps.** The CRD and the `Queue/agent-worker-queue` CR both
|
||||
carry only `kubectl.kubernetes.io/last-applied-configuration` — no
|
||||
`argocd.argoproj.io/instance`, no tracking-id — and the Queue does not appear anywhere
|
||||
in the homelab repo. They were hand-applied and predate GitOps coverage.
|
||||
|
||||
**Fix, when it is worth doing:**
|
||||
|
||||
1. Bring `temporalworkers.kmsvc.io` and the Queue CR into the homelab GitOps repo.
|
||||
2. Apply the current CRD from `kmsvc-manage/config/crd`, which restores `image` and the
|
||||
status subresource.
|
||||
3. Ensure the operator sets `spec.image` on the CR it creates.
|
||||
|
||||
Do not hand-apply the CRD as a one-off. That reproduces exactly the situation that
|
||||
caused this — a cluster object with no source of truth.
|
||||
|
||||
**To silence the loop without fixing it:** remove the `temporal.io/namespace: production`
|
||||
label from `Queue/agent-worker-queue` in namespace `sqs`. The operator returns early when
|
||||
the label is absent. Reversible by re-adding it.
|
||||
|
||||
---
|
||||
|
||||
## 2. `kmsvc.riotpiao.com` is unauthenticated
|
||||
|
||||
**Status:** open. Relevant to [task 7.2](../tasks/7.2-sqs-prefix.md).
|
||||
|
||||
`kmsvc-manage` has an auth interceptor at `internal/api/interceptors/auth.go`, but it is
|
||||
never wired: `cmd/server/main.go` constructs a bare `grpc.NewServer()` with no
|
||||
interceptor options. The live ConfigMap confirms it — `KMSVC_AUTHENTIK_ISSUER_URL` and
|
||||
`KMSVC_AUTHENTIK_AUDIENCE` are both empty strings.
|
||||
|
||||
Both the REST surface (8080) and the gRPC surface (9090) are open.
|
||||
|
||||
There is a second, subtler problem. The REST surface is mounted with
|
||||
`RegisterQueueServiceHandlerServer`, the **in-process** grpc-gateway variant that calls
|
||||
the service implementation directly. It bypasses gRPC interceptors entirely. So even
|
||||
once the interceptor is wired, it would authenticate gRPC callers only — the file's own
|
||||
doc comment claiming it covers both REST and gRPC is wrong for this wiring.
|
||||
|
||||
Consequence for this gateway: `/sqs/*` must own authentication itself. Do not assume the
|
||||
upstream will enforce anything.
|
||||
|
||||
---
|
||||
|
||||
## 3. `kmsvc-redis-master.sqs:6379` has no authentication
|
||||
|
||||
`ALLOW_EMPTY_PASSWORD=yes`, TLS off, Bitnami chart with `auth.enabled=false`, no password
|
||||
secret in the namespace. Anything with network reach has full unauthenticated read/write.
|
||||
|
||||
A NetworkPolicy is the only control. Relevant to [task 6.2](../tasks/6.2-kubernetes-manifests.md).
|
||||
|
||||
---
|
||||
|
||||
## 4. `macos-bluebubbles` pod will never schedule
|
||||
|
||||
`sms` Argo Application is `Synced`/`Degraded`. The pod targets a macOS node that is not
|
||||
in the cluster: `0/4 nodes are available: 4 node(s) didn't match Pod's node
|
||||
affinity/selector`, roughly 1080 failed attempts over 3d18h.
|
||||
|
||||
Not transient. Needs either that node or removal of the Application. Unrelated to this
|
||||
gateway; listed so the Degraded status is not mistaken for something new.
|
||||
|
||||
---
|
||||
|
||||
## 5. Documentation that does not match reality
|
||||
|
||||
- `kmsvc-manage/TEMPORAL_INTEGRATION.md` is aspirational. It documents
|
||||
`apiVersion: temporal.kmsvc.io/v1` with `queueRef`, `taskQueueName` and `lifecycle`
|
||||
fields, and one worker per Queue. Reality is `kmsvc.io/v1`, none of those fields, and
|
||||
one worker per Temporal *namespace*. Do not source API documentation from it.
|
||||
- Module paths disagree across repos: `kmsvc-proto` declares
|
||||
`forgejo.riotpiao.homelab.com/...`, while `kmsvc-manage` and `kmsvc-sdk` import
|
||||
`forgejo.riotpiao.com/...`. The `.homelab.com` domain is fully retired — every
|
||||
subdomain NXDOMAINs.
|
||||
- `kmsvc-cli` README says the gRPC ingress uses TLS passthrough. It uses
|
||||
`nginx.ingress.kubernetes.io/backend-protocol: GRPC`, which terminates TLS at nginx.
|
||||
Functionally fine for clients; the wording is wrong.
|
||||
@@ -1,143 +0,0 @@
|
||||
# Kong retirement — inventory and cutover
|
||||
|
||||
Everything Kong does on `api.riotpiao.com` today, and where it goes. Inventory
|
||||
verified live against context `admin@homelab-cluster` on 2026-08-19.
|
||||
|
||||
Source of the objects being retired: `~/workplace/homelab/k8s/apps/api/` and
|
||||
`k8s/argocd/apps/55-api-gateway.yaml`.
|
||||
|
||||
## What is running now
|
||||
|
||||
Kong OSS 3.4.1, Helm chart from `https://charts.konghq.com`, DB-less, namespace
|
||||
`api`, Argo Application `kong` at sync wave 7. Two replicas. Fronted by
|
||||
`ingress-nginx` via Ingress `api/api`, which catch-alls `/` on `api.riotpiao.com`
|
||||
to `kong-proxy:80`.
|
||||
|
||||
Eleven ReplicaSets exist on the Kong Deployment, the newest minutes old — this
|
||||
config is being actively iterated, so re-verify the inventory immediately before
|
||||
cutover.
|
||||
|
||||
## Routing table to port
|
||||
|
||||
Seven `ingressClassName: kong` Ingresses. Six in `llm-serving`, one in `agent-pod`.
|
||||
|
||||
| Method | Path | Upstream | Transform applied by Kong |
|
||||
|---|---|---|---|
|
||||
| GET | `/v1/models` | — | `request-termination`: static 200 JSON, upstream never contacted |
|
||||
| POST | `/v1/reasoning/chat/completions` | `reasoning-predictor:80` | force body `model=reasoning`, rewrite URI to `/v1/chat/completions` |
|
||||
| POST | `/v1/ornith/chat/completions` | `ornith-predictor:80` | force body `model=ornith:35b`, rewrite URI |
|
||||
| POST | `/v1/qwen/chat/completions` | `ornith-predictor:80` | force body `model=qwen2.5:3b-instruct`, rewrite URI |
|
||||
| POST | `/v1/embeddings` | `embeddings-predictor:80` | none — TEI already serves the canonical path |
|
||||
| POST | `/v1/rerank` | `reranker-predictor:80` | rewrite URI to `/rerank` (TEI does not serve `/v1/rerank`) |
|
||||
| GET/WS | `/console`, `/run`, `/sessions` | `agent-hub:9090` (`agent-pod` ns) | none, `strip-path: false` |
|
||||
|
||||
Upstream model map, from the manifest comments and confirmed live:
|
||||
|
||||
- `reasoning` → `reasoning-predictor` — vLLM, DeepSeek-R1-Distill-Qwen-32B, 2 replicas,
|
||||
`--max-num-seqs=4`, `--max-model-len=16384`, `--reasoning-parser=deepseek_r1`,
|
||||
`--enable-auto-tool-choice --tool-call-parser=hermes`
|
||||
- `ornith:35b` → `ornith-predictor` — Ollama, 2 replicas
|
||||
- `qwen2.5:3b-instruct` → `ornith-predictor` — same pods; both models stay resident via
|
||||
`OLLAMA_MAX_LOADED_MODELS=2`, `OLLAMA_KEEP_ALIVE=-1`
|
||||
- `nomic-ai/nomic-embed-text-v2-moe` → `embeddings-predictor` — TEI
|
||||
- `BAAI/bge-reranker-base` → `reranker-predictor` — TEI
|
||||
|
||||
### The path-per-model surface goes away
|
||||
|
||||
The three chat paths exist only because Kong OSS cannot dispatch on the request
|
||||
body. The gateway serves a single `POST /v1/chat/completions` and selects the
|
||||
upstream from the body's `model` field.
|
||||
|
||||
Keep the old paths as aliases during cutover so live clients do not break, then
|
||||
remove them once callers have migrated. pi is a live caller today.
|
||||
|
||||
### `/v1/models` should not be ported verbatim
|
||||
|
||||
Kong serves a hardcoded list via `request-termination`. The manifest already flags
|
||||
that it can drift from what the engines actually serve. Derive the response from
|
||||
the gateway's configured upstream map instead, so the list cannot disagree with
|
||||
what routing will accept.
|
||||
|
||||
## Plugins being retired
|
||||
|
||||
| Plugin | Scope | Replacement |
|
||||
|---|---|---|
|
||||
| `llm-rewrite-reasoning` / `-ornith` / `-qwen` | llm-serving | body-based dispatch in `internal/llm` |
|
||||
| `llm-rewrite-rerank` | llm-serving | per-upstream path rewrite in the route table |
|
||||
| `llm-models-list` | llm-serving | derived from the upstream map |
|
||||
| `prometheus` | **cluster-wide** | `internal/observability` — must expose bandwidth, latency, status codes, upstream health or observability regresses |
|
||||
|
||||
No `rate-limiting` plugin exists anywhere in the cluster. REQUIREMENTS.md §4 Tier 2
|
||||
describes it as an existing layer; it is not built. Nothing to migrate — it is net
|
||||
new work, and it now belongs in the gateway rather than in Kong.
|
||||
|
||||
## Auth: currently off, must land on
|
||||
|
||||
`KongConsumer model-invoker` exists in namespace `api` and stays defined, but the
|
||||
`key-auth` plugin is commented out and every route has `model-key-auth` stripped
|
||||
from its `konghq.com/plugins` annotation.
|
||||
|
||||
**The model API is unauthenticated right now.** Confirmed live 2026-08-19: a request
|
||||
to `/v1/reasoning/chat/completions` with no credentials returns 200.
|
||||
|
||||
The reason is recorded in `model-auth.yaml` — Kong's `key-auth` accepts a raw
|
||||
`apikey:` header but rejects `Authorization: Bearer`, which blocks every
|
||||
OpenAI-compatible client. That is why `~/.pi/agent/models.json` carries a
|
||||
`customHeaders: {apikey: ...}` block.
|
||||
|
||||
The gateway reads Bearer tokens directly and validates them against Authentik via
|
||||
JWKS. `AUTH-PLAN.md`'s pinned-RSA-key approach and its rotation runbook are not
|
||||
needed and should not be carried over.
|
||||
|
||||
Ship auth behind a flag. Turning it on breaks every current caller until they hold
|
||||
a token — pi included.
|
||||
|
||||
## Timeouts
|
||||
|
||||
Kong today:
|
||||
|
||||
| Route class | connect | read | write |
|
||||
|---|---|---|---|
|
||||
| chat | 10s | **1h** | 1h |
|
||||
| embeddings / rerank | 10s | 10m | 10m |
|
||||
|
||||
nginx in front sets `proxy-read-timeout: 3600`, `proxy-send-timeout: 3600`,
|
||||
`proxy-buffering: off`, `proxy-body-size: 0`. Those stay — they are what makes token
|
||||
streaming work, and the gateway needs the same treatment from nginx.
|
||||
|
||||
The 1-hour read timeout is deliberate: a 32B model on a Volta GPU routinely exceeds
|
||||
60s. Any shorter server-side cap must be enforced *in the gateway*, not by shortening
|
||||
the proxy timeout, or long legitimate generations get truncated mid-stream.
|
||||
|
||||
## Cutover
|
||||
|
||||
Reversible at every step. Kong keeps serving until the last step.
|
||||
|
||||
1. Deploy the gateway alongside Kong, unexposed. Verify in-cluster against
|
||||
`http://homelab-frontend.api.svc.cluster.local`.
|
||||
2. Compare gateway and Kong responses for every route in the table above, including
|
||||
a streaming chat request and a client disconnect mid-stream.
|
||||
3. Repoint Ingress `api/api` from `kong-proxy:80` to the gateway Service. **This is
|
||||
the cutover.** Reverting is a one-line change to the same Ingress.
|
||||
4. Soak. Watch gateway metrics and pi traffic.
|
||||
5. Delete the seven kong-class Ingresses and the six KongPlugin CRs.
|
||||
6. Remove the `kong` Application from `k8s/argocd/apps/55-api-gateway.yaml`; let Argo
|
||||
prune the Helm release, the CRDs and namespace leftovers.
|
||||
|
||||
Steps 1–4 are reversible in seconds. Step 5 onward is not — do not start it until the
|
||||
soak is clean.
|
||||
|
||||
All of this flows through git and Argo. No `kubectl apply`, no `helm upgrade`.
|
||||
|
||||
## Loose ends
|
||||
|
||||
- `agent-pod/console` is publicly routed, unauthenticated, accepts free-form prompts
|
||||
into a shell-capable container, and exposes a WebSocket. Migrating it behind the
|
||||
gateway's auth is a security fix, not merely a port. Treat WebSocket upgrade as an
|
||||
explicit requirement of the proxy layer.
|
||||
- Eight `*.example.com` hosts exist on istio-class Ingresses in `llm-serving`
|
||||
(`{embeddings,ornith,reasoning,reranker}[-predictor]-llm-serving.example.com`).
|
||||
KServe defaults, not public, not Kong's — out of scope here, but they exist and
|
||||
should not be mistaken for gateway routes.
|
||||
- Ingress class split across the cluster is 7 kong / 17 nginx / 4 istio. Only the 7
|
||||
kong ones are in scope.
|
||||
@@ -1,136 +0,0 @@
|
||||
# ADR-0001 — Retire Kong OSS in favour of a Go API gateway
|
||||
|
||||
Status: Accepted
|
||||
Date: 2026-08-19
|
||||
Deciders: rock
|
||||
|
||||
## Context
|
||||
|
||||
`api.riotpiao.com` is currently served by Kong OSS 3.4.1 (Helm, DB-less, namespace `api`,
|
||||
Argo wave 7), sitting behind ingress-nginx which owns TLS. Kong routes to the KServe
|
||||
model predictors in `llm-serving` via seven `ingressClassName: kong` Ingresses and six
|
||||
`KongPlugin` CRs.
|
||||
|
||||
Three separate capabilities were attempted on Kong OSS. All three failed, and each
|
||||
failure is already documented in-repo by the person who hit it:
|
||||
|
||||
**1. Body-based model dispatch is not expressible.**
|
||||
From `k8s/apps/api/llm-routes.yaml`:
|
||||
|
||||
> a single `/v1/chat/completions` endpoint that dispatches on the body's `model` field is
|
||||
> not expressible in Kong OSS (`ai-proxy-advanced`, which does multi-target model routing,
|
||||
> is Enterprise-only).
|
||||
|
||||
The workaround is a path-per-model surface (`/v1/reasoning/chat/completions`,
|
||||
`/v1/ornith/...`, `/v1/qwen/...`) with a `request-transformer` force-overwriting the body's
|
||||
`model` field. This is not OpenAI-standard, so every client needs bespoke configuration —
|
||||
visible today in `~/.pi/agent/models.json`, which carries three separate provider entries
|
||||
for what should be one endpoint.
|
||||
|
||||
**2. OIDC is Enterprise-only.**
|
||||
`k8s/apps/api/AUTH-PLAN.md` routes around the missing `openid-connect` plugin using the
|
||||
built-in `jwt` plugin, which requires pinning Authentik's RSA public key onto a
|
||||
KongConsumer. That plan lists its own consequence:
|
||||
|
||||
> Pinning `rsa_public_key`: Authentik key rotation would break it — document a rotation
|
||||
> runbook, or have the provision script re-export the cert PEM into the Kong credential on
|
||||
> each run.
|
||||
|
||||
A rotation runbook is a standing operational liability accepted only because the gateway
|
||||
cannot fetch JWKS itself.
|
||||
|
||||
**3. `key-auth` cannot read `Authorization: Bearer`.**
|
||||
From `k8s/apps/api/model-auth.yaml`:
|
||||
|
||||
> a raw `apikey: <key>` header succeeds (200), the same request with only
|
||||
> `Authorization: Bearer <key>` fails (401). No OpenAI-SDK-compatible client (pi included)
|
||||
> sends a raw apikey header or lets you customize the header name, so every such client was
|
||||
> hard-blocked.
|
||||
|
||||
Consequence: authentication on the model routes is **currently disabled**. Verified live
|
||||
2026-08-19 — `api.riotpiao.com/v1/reasoning/chat/completions` answers unauthenticated.
|
||||
|
||||
Separately, the intended surface has grown beyond LLM routing. The target is a
|
||||
capability-per-subdomain API over cluster services — `sqs.riotpiao.com` for queue
|
||||
operations, `workflow.riotpiao.com` for Temporal, `cluster.riotpiao.com` for atlas — each
|
||||
needing request shaping, per-caller budgets and streaming semantics that are application
|
||||
concerns, not gateway-plugin concerns.
|
||||
|
||||
## Decision
|
||||
|
||||
Retire Kong OSS entirely. Replace it with a purpose-built Go service,
|
||||
`homelab-frontend`, which owns north-south routing, authentication, and request shaping
|
||||
for every public capability on `*.riotpiao.com`.
|
||||
|
||||
ingress-nginx keeps the edge and TLS. It forwards to the gateway instead of `kong-proxy`.
|
||||
|
||||
Authentication is Authentik OIDC, validated by fetching JWKS from
|
||||
`https://authentik.riotpiao.com` at runtime.
|
||||
|
||||
## Options considered
|
||||
|
||||
**A. Stay on Kong OSS, accept the workarounds.**
|
||||
Keeps a battle-tested proxy and its Prometheus plugin. But the path-per-model surface stays
|
||||
non-standard, the RSA pinning runbook stays, and auth stays off until someone writes a
|
||||
`request-transformer` shim to copy Bearer into an `apikey` header. Every new capability
|
||||
(`sqs`, `workflow`) inherits the same constraints.
|
||||
|
||||
**B. Buy Kong Enterprise.**
|
||||
`ai-proxy-advanced` and `openid-connect` solve 1 and 2. Does not solve the genuinely
|
||||
application-level requirements at all — signed session cookies, per-session daily message
|
||||
budgets, a 6-of-8 GPU sequence-slot semaphore with a bounded queue, and
|
||||
disconnect-cancels-upstream are not gateway features in any tier. Cost for a homelab is not
|
||||
justifiable.
|
||||
|
||||
**C. Go gateway, Kong retained for LLM paths only.**
|
||||
Gradual migration, lower risk. But it means running two gateways indefinitely, splitting the
|
||||
routing table across Kong CRDs and Go code, and keeping the Kong Helm release and its CRDs.
|
||||
The split is the thing most likely to drift.
|
||||
|
||||
**D. Go gateway, Kong retired entirely.** — chosen
|
||||
One routing table, one auth implementation, one place to reason about timeouts. The logic
|
||||
being replaced is small: four `request-transformer` plugins that set a body field and
|
||||
rewrite a URI, one `request-termination` serving a static JSON model list, and one
|
||||
`prometheus` plugin. That is on the order of a hundred lines of Go, against roughly 480
|
||||
lines of YAML it retires.
|
||||
|
||||
## Consequences
|
||||
|
||||
### Gained
|
||||
|
||||
- **Standard OpenAI surface.** One `POST /v1/chat/completions`, model selected from the
|
||||
request body. Any OpenAI SDK works unmodified. The three pi provider entries collapse to
|
||||
one.
|
||||
- **Working authentication.** Bearer tokens are read from the header, because it is our
|
||||
code. JWKS is fetched and cached with automatic rotation handling, so the AUTH-PLAN.md
|
||||
rotation runbook is deleted rather than written.
|
||||
- **Application-level policy becomes possible.** GPU slot semaphore, per-session budgets,
|
||||
disconnect propagation and SSE handling live where the state is.
|
||||
- **One timeout story.** Kong currently sets `read-timeout: 3600000` (1 hour) on chat
|
||||
routes, which silently defeats any shorter server-side cap. Retiring Kong removes the
|
||||
conflicting layer.
|
||||
- **~480 lines of gateway YAML deleted**, plus the Kong CRDs, the Helm release, and its
|
||||
`ServerSideApply` workaround for oversized CRD annotations.
|
||||
|
||||
### Lost / assumed
|
||||
|
||||
- **We now own proxy correctness.** Connection pooling, retries, timeout propagation,
|
||||
streaming passthrough, header hygiene, graceful shutdown. `net/http/httputil.ReverseProxy`
|
||||
covers most of it, but it is our bug surface now.
|
||||
- **Kong's Prometheus plugin goes away.** The gateway must expose equivalent metrics itself
|
||||
(bandwidth, latency, status codes, upstream health) or observability regresses.
|
||||
- **Migration touches live traffic.** pi depends on `api.riotpiao.com` today. Cutover must
|
||||
be reversible — see `docs/MIGRATION-kong.md`.
|
||||
- **`agent-pod/console` is a kong-class Ingress** exposing `/console` (WebSocket), `/run`
|
||||
and `/sessions`. It must migrate too, and it is currently unauthenticated and publicly
|
||||
routed while accepting free-form prompts into a shell-capable container. Putting it behind
|
||||
the gateway's Authentik auth is a security improvement, not just a port.
|
||||
|
||||
### Risks
|
||||
|
||||
- Enabling Authentik auth will break any client currently relying on the unauthenticated
|
||||
surface — including pi, until its `models.json` is updated. Auth must ship behind a flag
|
||||
and be enabled deliberately.
|
||||
- Kong's `request-termination` for `/v1/models` returns a **static** list that can drift
|
||||
from what the engines actually serve. Porting it verbatim ports the bug; the gateway
|
||||
should derive the list from configured upstreams instead.
|
||||
@@ -0,0 +1,359 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Temporal Workflows API Client Examples
|
||||
Demonstrates how to use the /workflows endpoint with Python
|
||||
"""
|
||||
|
||||
import requests
|
||||
import json
|
||||
import time
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
GATEWAY = "https://api.riotpiao.com"
|
||||
|
||||
|
||||
class WorkflowClient:
|
||||
"""Simple client for interacting with the Workflows API"""
|
||||
|
||||
def __init__(self, base_url: str = GATEWAY):
|
||||
self.base_url = base_url
|
||||
self.session = requests.Session()
|
||||
|
||||
def execute_workflow(
|
||||
self,
|
||||
workflow: str,
|
||||
input_data: Dict[str, Any],
|
||||
timeout: Optional[int] = None,
|
||||
wait: bool = True,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute a workflow
|
||||
|
||||
Args:
|
||||
workflow: Workflow name
|
||||
input_data: Input parameters for the workflow
|
||||
timeout: Timeout in seconds (default: 30)
|
||||
wait: Whether to wait for completion (default: True)
|
||||
|
||||
Returns:
|
||||
Workflow response dict with status, output, etc.
|
||||
"""
|
||||
payload = {
|
||||
"workflow": workflow,
|
||||
"input": input_data,
|
||||
}
|
||||
|
||||
if timeout is not None:
|
||||
payload["timeout"] = timeout
|
||||
|
||||
if not wait:
|
||||
payload["wait"] = False
|
||||
|
||||
response = self.session.post(
|
||||
f"{self.base_url}/workflows",
|
||||
json=payload,
|
||||
headers={"Content-Type": "application/json"},
|
||||
)
|
||||
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
def chat_and_embed(
|
||||
self, model: str, messages: List[Dict[str, str]], embed_model: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Chat with a model and embed the response
|
||||
|
||||
Args:
|
||||
model: Chat model name
|
||||
messages: Messages in OpenAI format
|
||||
embed_model: Optional embedding model (default: nomic-ai/nomic-embed-text-v2-moe)
|
||||
|
||||
Returns:
|
||||
Workflow response with chat and embedding results
|
||||
"""
|
||||
input_data = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
if embed_model:
|
||||
input_data["embed_model"] = embed_model
|
||||
|
||||
return self.execute_workflow("chat-and-embed", input_data)
|
||||
|
||||
def multi_model_chat(self, models: List[str], messages: List[Dict[str, str]]) -> Dict[str, Any]:
|
||||
"""
|
||||
Chat with multiple models and compare responses
|
||||
|
||||
Args:
|
||||
models: List of model names
|
||||
messages: Messages in OpenAI format
|
||||
|
||||
Returns:
|
||||
Workflow response with results from all models
|
||||
"""
|
||||
return self.execute_workflow(
|
||||
"multi-model-chat",
|
||||
{
|
||||
"models": models,
|
||||
"messages": messages,
|
||||
},
|
||||
)
|
||||
|
||||
def rag_pipeline(
|
||||
self,
|
||||
query: str,
|
||||
documents: List[str],
|
||||
model: Optional[str] = None,
|
||||
rerank_model: Optional[str] = None,
|
||||
top_k: int = 3,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
RAG pipeline: rerank documents and answer based on top results
|
||||
|
||||
Args:
|
||||
query: User query or question
|
||||
documents: List of document texts
|
||||
model: Chat model (default: "reasoning")
|
||||
rerank_model: Reranker model (default: "BAAI/bge-reranker-base")
|
||||
top_k: Number of top documents to use (default: 3)
|
||||
|
||||
Returns:
|
||||
Workflow response with reranked documents and chat answer
|
||||
"""
|
||||
input_data = {
|
||||
"query": query,
|
||||
"documents": documents,
|
||||
"top_k": top_k,
|
||||
}
|
||||
|
||||
if model:
|
||||
input_data["model"] = model
|
||||
|
||||
if rerank_model:
|
||||
input_data["rerank_model"] = rerank_model
|
||||
|
||||
return self.execute_workflow("rag-pipeline", input_data)
|
||||
|
||||
def batch_embeddings(
|
||||
self, texts: List[str], model: Optional[str] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Generate embeddings for multiple texts
|
||||
|
||||
Args:
|
||||
texts: List of text strings
|
||||
model: Embedding model (default: nomic-ai/nomic-embed-text-v2-moe)
|
||||
|
||||
Returns:
|
||||
Workflow response with embedding results
|
||||
"""
|
||||
input_data = {"texts": texts}
|
||||
|
||||
if model:
|
||||
input_data["model"] = model
|
||||
|
||||
return self.execute_workflow("batch-embeddings", input_data)
|
||||
|
||||
|
||||
def example_chat_and_embed():
|
||||
"""Example: Chat and embed"""
|
||||
print("\n" + "="*50)
|
||||
print("Example 1: Chat and Embed")
|
||||
print("="*50)
|
||||
|
||||
client = WorkflowClient()
|
||||
result = client.chat_and_embed(
|
||||
model="reasoning",
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is machine learning in one sentence?",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
print(f"Workflow ID: {result['id']}")
|
||||
print(f"Status: {result['status']}")
|
||||
print(f"Chat Response: {result['output']['chat_response']['choices'][0]['message']['content']}")
|
||||
print(f"Embedding dimensions: {len(result['output']['embedding_response']['data'][0]['embedding'])}")
|
||||
|
||||
|
||||
def example_multi_model_chat():
|
||||
"""Example: Multi-model chat"""
|
||||
print("\n" + "="*50)
|
||||
print("Example 2: Multi-Model Chat")
|
||||
print("="*50)
|
||||
|
||||
client = WorkflowClient()
|
||||
result = client.multi_model_chat(
|
||||
models=["reasoning", "ornith:35b"],
|
||||
messages=[
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the capital of France?",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
print(f"Workflow ID: {result['id']}")
|
||||
print(f"Status: {result['status']}")
|
||||
|
||||
for model_result in result["output"]:
|
||||
model = model_result["model"]
|
||||
if "result" in model_result:
|
||||
content = model_result["result"]["choices"][0]["message"]["content"]
|
||||
print(f"\n{model}: {content}")
|
||||
elif "error" in model_result:
|
||||
print(f"\n{model}: Error - {model_result['error']}")
|
||||
|
||||
|
||||
def example_rag_pipeline():
|
||||
"""Example: RAG pipeline"""
|
||||
print("\n" + "="*50)
|
||||
print("Example 3: RAG Pipeline")
|
||||
print("="*50)
|
||||
|
||||
client = WorkflowClient()
|
||||
result = client.rag_pipeline(
|
||||
query="How does photosynthesis work?",
|
||||
documents=[
|
||||
"Photosynthesis is the process by which plants convert sunlight into chemical energy.",
|
||||
"The mitochondria is the powerhouse of the cell.",
|
||||
"Light reactions occur in the thylakoid membrane of chloroplasts.",
|
||||
"Dogs are domesticated animals.",
|
||||
"The Calvin cycle produces glucose from CO2.",
|
||||
],
|
||||
top_k=2,
|
||||
)
|
||||
|
||||
print(f"Workflow ID: {result['id']}")
|
||||
print(f"Status: {result['status']}")
|
||||
print(f"\nTop Documents:")
|
||||
for i, doc in enumerate(result["output"]["reranked_documents"], 1):
|
||||
print(f" {i}. {doc[:80]}...")
|
||||
|
||||
print(f"\nChat Response:")
|
||||
print(f" {result['output']['chat_response']['choices'][0]['message']['content'][:200]}...")
|
||||
|
||||
|
||||
def example_batch_embeddings():
|
||||
"""Example: Batch embeddings"""
|
||||
print("\n" + "="*50)
|
||||
print("Example 4: Batch Embeddings")
|
||||
print("="*50)
|
||||
|
||||
client = WorkflowClient()
|
||||
result = client.batch_embeddings(
|
||||
texts=[
|
||||
"The quick brown fox",
|
||||
"Machine learning is powerful",
|
||||
"Python is a great language",
|
||||
]
|
||||
)
|
||||
|
||||
print(f"Workflow ID: {result['id']}")
|
||||
print(f"Status: {result['status']}")
|
||||
print(f"Number of embeddings: {len(result['output']['data'])}")
|
||||
print(f"Embedding dimensions: {len(result['output']['data'][0]['embedding'])}")
|
||||
print(f"Model used: {result['output']['model']}")
|
||||
|
||||
|
||||
def example_error_handling():
|
||||
"""Example: Error handling"""
|
||||
print("\n" + "="*50)
|
||||
print("Example 5: Error Handling")
|
||||
print("="*50)
|
||||
|
||||
client = WorkflowClient()
|
||||
|
||||
# Try unknown workflow
|
||||
print("\nAttempting unknown workflow...")
|
||||
try:
|
||||
result = client.execute_workflow("nonexistent", {})
|
||||
if result.get("status") == "failed":
|
||||
print(f"Workflow failed: {result.get('error')}")
|
||||
else:
|
||||
print(f"Response: {json.dumps(result, indent=2)}")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"HTTP Error: {e}")
|
||||
print(f"Response: {e.response.json()}")
|
||||
|
||||
# Try missing required parameter
|
||||
print("\nAttempting chat-and-embed without model...")
|
||||
try:
|
||||
result = client.execute_workflow("chat-and-embed", {"messages": []})
|
||||
if result.get("status") == "failed":
|
||||
print(f"Workflow failed: {result.get('error')}")
|
||||
except requests.exceptions.HTTPError as e:
|
||||
print(f"HTTP Error: {e}")
|
||||
|
||||
|
||||
def example_custom_timeout():
|
||||
"""Example: Custom timeout"""
|
||||
print("\n" + "="*50)
|
||||
print("Example 6: Custom Timeout")
|
||||
print("="*50)
|
||||
|
||||
client = WorkflowClient()
|
||||
start = time.time()
|
||||
result = client.execute_workflow(
|
||||
"batch-embeddings",
|
||||
{"texts": ["Hello world"]},
|
||||
timeout=60,
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
|
||||
print(f"Workflow ID: {result['id']}")
|
||||
print(f"Status: {result['status']}")
|
||||
print(f"Time taken: {elapsed:.2f}s")
|
||||
print(f"Created at: {result['created_at']}")
|
||||
if result.get("completed_at"):
|
||||
print(f"Completed at: {result['completed_at']}")
|
||||
|
||||
|
||||
def example_async_execution():
|
||||
"""Example: Async execution (fire and forget)"""
|
||||
print("\n" + "="*50)
|
||||
print("Example 7: Async Execution")
|
||||
print("="*50)
|
||||
|
||||
client = WorkflowClient()
|
||||
result = client.execute_workflow(
|
||||
"batch-embeddings",
|
||||
{"texts": ["text1", "text2", "text3"]},
|
||||
wait=False,
|
||||
)
|
||||
|
||||
print(f"Workflow ID: {result['id']}")
|
||||
print(f"Status: {result['status']}")
|
||||
print(f"Created at: {result['created_at']}")
|
||||
print(f"Note: Workflow is running asynchronously. Status is {result['status']}.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Temporal Workflows API Examples")
|
||||
print("================================\n")
|
||||
|
||||
# Run examples (comment out if you don't want to call the actual API)
|
||||
try:
|
||||
example_batch_embeddings() # Start with simplest example
|
||||
print("\n" + "="*50)
|
||||
print("✓ Examples completed successfully!")
|
||||
print("="*50)
|
||||
except requests.exceptions.ConnectionError:
|
||||
print("\n✗ Could not connect to gateway")
|
||||
print("Make sure the gateway is running at:", GATEWAY)
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error: {e}")
|
||||
|
||||
# Show all available methods
|
||||
print("\n\nAvailable Methods:")
|
||||
print("-" * 50)
|
||||
client = WorkflowClient()
|
||||
print(f" - chat_and_embed(model, messages, embed_model)")
|
||||
print(f" - multi_model_chat(models, messages)")
|
||||
print(f" - rag_pipeline(query, documents, model, rerank_model, top_k)")
|
||||
print(f" - batch_embeddings(texts, model)")
|
||||
print(f" - execute_workflow(workflow, input, timeout, wait)")
|
||||
@@ -0,0 +1,195 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Temporal Workflows API Examples
|
||||
# This script demonstrates how to use the /workflows endpoint
|
||||
|
||||
GATEWAY="https://api.riotpiao.com"
|
||||
|
||||
echo "=========================================="
|
||||
echo "Temporal Workflows API Examples"
|
||||
echo "=========================================="
|
||||
echo ""
|
||||
|
||||
# Example 1: Chat and Embed Workflow
|
||||
echo "1. Chat and Embed Workflow"
|
||||
echo " Chats with a model and embeds the response"
|
||||
echo ""
|
||||
|
||||
curl -X POST "$GATEWAY/workflows" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"workflow": "chat-and-embed",
|
||||
"input": {
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is machine learning in one sentence?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}' | jq '.'
|
||||
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
# Example 2: Multi-Model Chat Workflow
|
||||
echo "2. Multi-Model Chat Workflow"
|
||||
echo " Compares responses from multiple models"
|
||||
echo ""
|
||||
|
||||
curl -X POST "$GATEWAY/workflows" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"workflow": "multi-model-chat",
|
||||
"input": {
|
||||
"models": ["reasoning", "ornith:35b"],
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "What is the capital of France?"
|
||||
}
|
||||
]
|
||||
}
|
||||
}' | jq '.'
|
||||
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
# Example 3: RAG Pipeline Workflow
|
||||
echo "3. RAG (Retrieval-Augmented Generation) Pipeline"
|
||||
echo " Reranks documents and answers based on top results"
|
||||
echo ""
|
||||
|
||||
curl -X POST "$GATEWAY/workflows" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"workflow": "rag-pipeline",
|
||||
"input": {
|
||||
"query": "How does photosynthesis work?",
|
||||
"documents": [
|
||||
"Photosynthesis is the process by which plants convert sunlight into chemical energy stored in glucose.",
|
||||
"The mitochondria is the powerhouse of the cell and is responsible for ATP production.",
|
||||
"Light reactions occur in the thylakoid membrane of chloroplasts and produce ATP and NADPH.",
|
||||
"Dogs are domesticated mammals that have been selectively bred for thousands of years.",
|
||||
"The Calvin cycle is the light-independent reaction that converts CO2 into glucose."
|
||||
],
|
||||
"top_k": 3
|
||||
}
|
||||
}' | jq '.'
|
||||
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
# Example 4: Batch Embeddings Workflow
|
||||
echo "4. Batch Embeddings Workflow"
|
||||
echo " Generates embeddings for multiple texts efficiently"
|
||||
echo ""
|
||||
|
||||
curl -X POST "$GATEWAY/workflows" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"workflow": "batch-embeddings",
|
||||
"input": {
|
||||
"texts": [
|
||||
"The quick brown fox jumps over the lazy dog",
|
||||
"Machine learning enables computers to learn from data",
|
||||
"Python is a popular programming language for AI",
|
||||
"Natural language processing powers conversational AI"
|
||||
],
|
||||
"model": "nomic-ai/nomic-embed-text-v2-moe"
|
||||
}
|
||||
}' | jq '.output | {model, usage, data: [.data[] | {index, embedding: (.embedding[:3])}]}'
|
||||
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
# Example 5: Workflow with Custom Timeout
|
||||
echo "5. Workflow with Custom Timeout"
|
||||
echo " Specify a longer timeout for complex operations"
|
||||
echo ""
|
||||
|
||||
curl -X POST "$GATEWAY/workflows" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"workflow": "chat-and-embed",
|
||||
"input": {
|
||||
"model": "reasoning",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": "Explain quantum computing"
|
||||
}
|
||||
]
|
||||
},
|
||||
"timeout": 60
|
||||
}' | jq '.id, .status, .created_at'
|
||||
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
# Example 6: Error Handling - Unknown Workflow
|
||||
echo "6. Error Handling - Unknown Workflow"
|
||||
echo ""
|
||||
|
||||
curl -X POST "$GATEWAY/workflows" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"workflow": "nonexistent-workflow",
|
||||
"input": {}
|
||||
}' | jq '.'
|
||||
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
# Example 7: Error Handling - Missing Required Parameters
|
||||
echo "7. Error Handling - Missing Required Parameters"
|
||||
echo ""
|
||||
|
||||
curl -X POST "$GATEWAY/workflows" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"workflow": "chat-and-embed",
|
||||
"input": {
|
||||
"messages": [{"role": "user", "content": "hi"}]
|
||||
}
|
||||
}' | jq '.'
|
||||
|
||||
echo ""
|
||||
echo "---"
|
||||
echo ""
|
||||
|
||||
# Example 8: Workflow Response Format
|
||||
echo "8. Understanding Workflow Response Format"
|
||||
echo ""
|
||||
|
||||
response=$(curl -s -X POST "$GATEWAY/workflows" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{
|
||||
"workflow": "batch-embeddings",
|
||||
"input": {
|
||||
"texts": ["Hello world"]
|
||||
}
|
||||
}')
|
||||
|
||||
echo "Response Structure:"
|
||||
echo "$response" | jq '{
|
||||
id: .id,
|
||||
workflow: .workflow,
|
||||
status: .status,
|
||||
created_at: .created_at,
|
||||
completed_at: .completed_at,
|
||||
has_output: (.output != null),
|
||||
has_error: (.error != null)
|
||||
}'
|
||||
|
||||
echo ""
|
||||
echo "=========================================="
|
||||
echo "Workflow Examples Complete!"
|
||||
echo "=========================================="
|
||||
@@ -1,5 +1,47 @@
|
||||
module github.com/Riotpiaole/homelab-frontend
|
||||
module forgejo.riotpiao.com/rock/homelab-frontend
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
require (
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||
go.opentelemetry.io/otel v1.46.0
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0
|
||||
go.opentelemetry.io/otel/sdk v1.46.0
|
||||
go.opentelemetry.io/otel/trace v1.46.0
|
||||
go.temporal.io/api v1.63.5
|
||||
go.temporal.io/sdk v1.48.0
|
||||
google.golang.org/grpc v1.83.2
|
||||
google.golang.org/protobuf v1.36.12
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
)
|
||||
|
||||
require (
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a // indirect
|
||||
github.com/go-logr/logr v1.4.4 // indirect
|
||||
github.com/go-logr/stdr v1.2.2 // indirect
|
||||
github.com/gogo/protobuf v1.3.2 // indirect
|
||||
github.com/golang/mock v1.6.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 // indirect
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 // indirect
|
||||
github.com/nexus-rpc/nexus-proto-annotations v0.1.0 // indirect
|
||||
github.com/nexus-rpc/sdk-go v0.7.0 // indirect
|
||||
github.com/robfig/cron v1.2.0 // indirect
|
||||
github.com/stretchr/objx v0.5.3 // indirect
|
||||
github.com/stretchr/testify v1.12.1 // indirect
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.46.0 // indirect
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.5 // indirect
|
||||
golang.org/x/net v0.58.0 // indirect
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
golang.org/x/time v0.3.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 // indirect
|
||||
)
|
||||
|
||||
@@ -1,3 +1,134 @@
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0 h1:6ZXKb9Rp6qp1bDbJefnG7cTH8yMN1IC/4nf+GVjO99k=
|
||||
github.com/MicahParks/keyfunc/v2 v2.1.0/go.mod h1:rW42fi+xgLJ2FRRXAfNx9ZA8WpD4OeE/yHVMteCkw9k=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
|
||||
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a h1:yDWHCSQ40h88yih2JAcL6Ls/kVkSE8GFACTGVnMPruw=
|
||||
github.com/facebookgo/clock v0.0.0-20150410010913-600d898af40a/go.mod h1:7Ga40egUymuWXxAe151lTNnCv97MddSOVsjpPPkityA=
|
||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
||||
github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8=
|
||||
github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
|
||||
github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
|
||||
github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||
github.com/golang/mock v1.6.0 h1:ErTB+efbowRARo13NNdxyJji2egdxLGQhRaY+DUumQc=
|
||||
github.com/golang/mock v1.6.0/go.mod h1:p6yTPP+5HYm5mzsMV8JkE6ZKdX+/wYM6Hr+LicevLPs=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2 h1:sGm2vDRFUrQJO/Veii4h4zG2vvqG6uWNkBHSTqXOZk0=
|
||||
github.com/grpc-ecosystem/go-grpc-middleware/v2 v2.3.2/go.mod h1:wd1YpapPLivG6nQgbf7ZkG1hhSOXDhhn4MLTknx2aAc=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0 h1:/Tnpcb2E0Pz/tN9s3bfEY2Q8ePCEX9iuS+cneUwncnw=
|
||||
github.com/grpc-ecosystem/grpc-gateway/v2 v2.30.0/go.mod h1:zOBXOsUaBSjKgmH4OGzV1esUpR3oUSCPYVd2cUBjKYY=
|
||||
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
|
||||
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/nexus-rpc/nexus-proto-annotations v0.1.0 h1:2fELd+9sqUtNu6Fg//pw8YFsxOvp8vZ8hfP0nHhNI80=
|
||||
github.com/nexus-rpc/nexus-proto-annotations v0.1.0/go.mod h1:n3UjF1bPCW8llR8tHvbxJ+27yPWrhpo8w/Yg1IOuY0Y=
|
||||
github.com/nexus-rpc/sdk-go v0.7.0 h1:38NrfY5rLnZAiMMs2ZfCKI/CSDzdfJG+27iAgfA8bUI=
|
||||
github.com/nexus-rpc/sdk-go v0.7.0/go.mod h1:FHdPfVQwRuJFZFTF0Y2GOAxCrbIBNrcPna9slkGKPYk=
|
||||
github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
|
||||
github.com/robfig/cron v1.2.0/go.mod h1:JGuDeoQd7Z6yL4zQhZ3OPEVHB7fL6Ka6skscFHfmt2k=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/stretchr/objx v0.5.3 h1:jmXUvGomnU1o3W/V5h2VEradbpJDwGrzugQQvL0POH4=
|
||||
github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+QYS+U0=
|
||||
github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE=
|
||||
github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg=
|
||||
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.3.5/go.mod h1:mwnBkeHKe2W/ZEtQ+71ViKU8L12m81fl3OWwC1Zlc8k=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.46.0 h1:FHt5/CDyVxi/8IM1CH7VE/rRgq3kLHa2mSTVMO8AWyc=
|
||||
go.opentelemetry.io/otel v1.46.0/go.mod h1:Gj3SEScelsNC45tp4nSxRYlS+f5iez7W8XPMCt905kE=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0 h1:OFnwLJr+pF3iHrlGSzbxyuo6/6HyBlnlN1CWEJmBVcw=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.46.0/go.mod h1:716wFneO0ov19A2beH5hjfh9AK5z/VWNAtDijp1Y0/g=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0 h1:w53CDeOA/Kurp7yRsegSr6pbbr759dOvJ+yNmWM6Hxs=
|
||||
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.46.0/go.mod h1:BOmGMCbAtvcJiSJ+hLuhgPLdDbimnraSl8irz3iY8sY=
|
||||
go.opentelemetry.io/otel/metric v1.46.0 h1:yBnkXvgV7AXFILZc5K6IZe/CBFF3OS7BJ8ov6/lj0K8=
|
||||
go.opentelemetry.io/otel/metric v1.46.0/go.mod h1:iPmdWqifKUdzziPkvvzIJXITl56fQx2mGM/DHLB3/2o=
|
||||
go.opentelemetry.io/otel/sdk v1.46.0 h1:h5CNQQjEbuQXY/JfZtgt3i7HVFV3aHPO2OAwO2eTYPI=
|
||||
go.opentelemetry.io/otel/sdk v1.46.0/go.mod h1:GAERFXFt5SYCEB+YiKUbMBeza6UaDH7GmGOZEfh2gSM=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0 h1:0piZ26EG4RBfebb2jhDH6ERCYHoVWduc3kLgPCwSnSE=
|
||||
go.opentelemetry.io/otel/sdk/metric v1.46.0/go.mod h1:I1PbKrdVc8Qu8HYVDNtqVIwLwjNrhsV/uFuxfwg8mO4=
|
||||
go.opentelemetry.io/otel/trace v1.46.0 h1:OULy7ccdJnZtJ0UDYFOIGaCmiWzJ8Vi2G/Rsu60qs1c=
|
||||
go.opentelemetry.io/otel/trace v1.46.0/go.mod h1:J7GAXweO77XSFkB/rmAqk9D6ihszhFjLU+d9WuUxDLI=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0 h1:5rrYs0Ykyj50sdU/JU0x8etU+LubXWb+gED6TbEdMIk=
|
||||
go.opentelemetry.io/proto/otlp v1.11.0/go.mod h1:SmVizdCOAm3XBtG1g1NnOdhW6jtddT72hLMhv8VwA8E=
|
||||
go.temporal.io/api v1.63.5 h1:c11+kPYHkXXL3UiShPdbMD+xtvqGsbTibUA9ypmiCa4=
|
||||
go.temporal.io/api v1.63.5/go.mod h1:SrlW2JMwVlDP4nRWSNznUFqnSHd+YeMDS1BkYo63HCQ=
|
||||
go.temporal.io/sdk v1.48.0 h1:WDctKDVuh0Z8Nf7euAyqs/EwcPg1JTIIq1Fut8Tq118=
|
||||
go.temporal.io/sdk v1.48.0/go.mod h1:SHv3+fLzD0GGZAwf0xNSvu8UmO1nFgG9WBSYoowApIk=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
|
||||
go.yaml.in/yaml/v3 v3.0.5 h1:N6y/pJk8buWs9NY5ERU2HSMfm+IuD/OtfdAnq6kESPw=
|
||||
go.yaml.in/yaml/v3 v3.0.5/go.mod h1:HVTZu1O7/Vkt2N+BFy8Zza+lnLsABggaTM2ZpNIGuKg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
|
||||
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
|
||||
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210510120138-977fb7262007/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
|
||||
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
|
||||
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
|
||||
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
|
||||
golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.1/go.mod h1:o0xws9oXOQQZyjljx8fwUC0k7L1pTE6eaCbjGeHmOkk=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
|
||||
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688 h1:ax2KzoSRIZU/M0cIxri3pKxy99vniH1PVxWC6si/eZI=
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20260819154853-08b0e4226688/go.mod h1:1RJ9BQGyNdZwkGc1eTqkErfRZ6RJyYPHZo73BZ1vQqI=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688 h1:cYNAzI2sUwhmCcoj9TxvihSrqsxt6uIkj3rDRhSDmW4=
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20260819154853-08b0e4226688/go.mod h1:DjtHYE8FKJLivXcBEjGwndXfIC23G0VpXiXKqG179uA=
|
||||
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
|
||||
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
|
||||
google.golang.org/protobuf v1.36.12 h1:pJOKDDOyeXErUroCihFAd5LQuwXBSpVnKGrj5o/fwxc=
|
||||
google.golang.org/protobuf v1.36.12/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
|
||||
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/MicahParks/keyfunc/v2"
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
// isValidIssuer checks if issuer is from Authentik (any provider/app).
|
||||
// Accepts: https://authentik.riotpiao.com/application/o/{provider}/
|
||||
func isValidIssuer(iss string) bool {
|
||||
return strings.Contains(iss, "authentik.riotpiao.com/application/o/") &&
|
||||
strings.HasSuffix(iss, "/")
|
||||
}
|
||||
|
||||
// Validator validates JWTs against Authentik JWKS.
|
||||
type Validator struct {
|
||||
issuer string
|
||||
audience string
|
||||
jwksURL string
|
||||
jwks *keyfunc.JWKS
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
// NewValidator creates a new JWT validator for a service.
|
||||
// JWKS fetching is lazy (deferred until first validation).
|
||||
func NewValidator(issuer, audience, jwksURL string) *Validator {
|
||||
return &Validator{
|
||||
issuer: issuer,
|
||||
audience: audience,
|
||||
jwksURL: jwksURL,
|
||||
jwks: nil, // Lazy-loaded on first use
|
||||
}
|
||||
}
|
||||
|
||||
// ensureJWKS fetches JWKS on first use (lazy initialization, thread-safe).
|
||||
func (v *Validator) ensureJWKS() error {
|
||||
v.mu.Lock()
|
||||
defer v.mu.Unlock()
|
||||
|
||||
if v.jwks != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
options := keyfunc.Options{
|
||||
Ctx: context.Background(),
|
||||
RefreshInterval: 15 * time.Minute,
|
||||
RefreshRateLimit: 5 * time.Minute,
|
||||
RefreshTimeout: 10 * time.Second,
|
||||
RefreshErrorHandler: func(err error) {
|
||||
fmt.Printf("JWKS refresh error for %s: %v\n", v.issuer, err)
|
||||
},
|
||||
}
|
||||
|
||||
jwks, err := keyfunc.Get(v.jwksURL, options)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to fetch JWKS from %s: %v", v.jwksURL, err)
|
||||
}
|
||||
|
||||
v.jwks = jwks
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateBearerToken extracts and validates the Bearer token from Authorization header.
|
||||
// Returns claims on success, error message on failure.
|
||||
func (v *Validator) ValidateBearerToken(authHeader string) (jwt.MapClaims, error) {
|
||||
if authHeader == "" {
|
||||
return nil, fmt.Errorf("missing Authorization header")
|
||||
}
|
||||
|
||||
// Extract token from "Bearer <token>"
|
||||
tokenString := ""
|
||||
if len(authHeader) > 7 && authHeader[:7] == "Bearer " {
|
||||
tokenString = authHeader[7:]
|
||||
} else {
|
||||
return nil, fmt.Errorf("invalid Authorization header format")
|
||||
}
|
||||
|
||||
// Ensure JWKS is loaded (lazy)
|
||||
if err := v.ensureJWKS(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Parse and validate
|
||||
claims := jwt.MapClaims{}
|
||||
token, err := jwt.ParseWithClaims(tokenString, claims, v.jwks.Keyfunc)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("token validation failed: %v", err)
|
||||
}
|
||||
|
||||
if !token.Valid {
|
||||
return nil, fmt.Errorf("token is invalid")
|
||||
}
|
||||
|
||||
// Verify required claims
|
||||
now := time.Now()
|
||||
const skew = 60 * time.Second
|
||||
|
||||
// Check exp
|
||||
if exp, ok := claims["exp"].(float64); ok {
|
||||
if time.Now().After(time.Unix(int64(exp), 0).Add(skew)) {
|
||||
return nil, fmt.Errorf("token expired")
|
||||
}
|
||||
}
|
||||
|
||||
// Check nbf (not before)
|
||||
if nbf, ok := claims["nbf"].(float64); ok {
|
||||
if now.Before(time.Unix(int64(nbf), 0).Add(-skew)) {
|
||||
return nil, fmt.Errorf("token not yet valid")
|
||||
}
|
||||
}
|
||||
|
||||
// Check iss (issuer) - accept any Authentik provider issuer
|
||||
// (portfolio-agent, memory-agent, api-gw, etc.)
|
||||
// All use same signing key so JWKS validation is sufficient
|
||||
if iss, ok := claims["iss"].(string); !ok {
|
||||
return nil, fmt.Errorf("missing issuer claim")
|
||||
} else if !isValidIssuer(iss) {
|
||||
return nil, fmt.Errorf("invalid issuer: %s", iss)
|
||||
}
|
||||
|
||||
// Check aud (audience)
|
||||
if aud, ok := claims["aud"].(string); !ok || aud != v.audience {
|
||||
return nil, fmt.Errorf("invalid audience: expected %s, got %s", v.audience, aud)
|
||||
}
|
||||
|
||||
return claims, nil
|
||||
}
|
||||
|
||||
// CheckPermissions checks if claims contain required permission(s).
|
||||
// Checks both "permissions" claim (for users) and "roles" claim (for service accounts).
|
||||
// Returns true if any required permission is found or wildcard "*" exists.
|
||||
func (v *Validator) CheckPermissions(claims jwt.MapClaims, required ...string) bool {
|
||||
// Try permissions claim first (for user tokens)
|
||||
if permsIface, ok := claims["permissions"]; ok {
|
||||
if perms, ok := permsIface.([]interface{}); ok {
|
||||
if v.checkPermList(perms, required...) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fall back to roles claim (for service account tokens)
|
||||
if rolesIface, ok := claims["roles"]; ok {
|
||||
if roles, ok := rolesIface.([]interface{}); ok {
|
||||
if v.checkPermList(roles, required...) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// checkPermList is a helper that checks a permission/role list.
|
||||
func (v *Validator) checkPermList(perms []interface{}, required ...string) bool {
|
||||
for _, perm := range perms {
|
||||
permStr, ok := perm.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if permStr == "*" {
|
||||
return true
|
||||
}
|
||||
for _, req := range required {
|
||||
if permStr == req {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// DecodeToken decodes JWT payload without verification (for debugging/testing).
|
||||
func DecodeToken(tokenString string) (jwt.MapClaims, error) {
|
||||
claims := jwt.MapClaims{}
|
||||
_, _, err := new(jwt.Parser).ParseUnverified(tokenString, claims)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return claims, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package auth
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/golang-jwt/jwt/v5"
|
||||
)
|
||||
|
||||
func TestCheckPermissions(t *testing.T) {
|
||||
validator := NewValidator(
|
||||
"https://authentik.riotpiao.com/application/o/sqs/",
|
||||
"sqs",
|
||||
"https://authentik.riotpiao.com/application/o/sqs/jwks/",
|
||||
)
|
||||
|
||||
// Test 1: Finds sqs:read
|
||||
claims1 := jwt.MapClaims{
|
||||
"permissions": []interface{}{"sqs:read", "memory:write"},
|
||||
}
|
||||
if !validator.CheckPermissions(claims1, "sqs:read", "sqs:write") {
|
||||
t.Fatal("expected to find sqs:read permission")
|
||||
}
|
||||
|
||||
// Test 2: Finds wildcard
|
||||
claims2 := jwt.MapClaims{
|
||||
"permissions": []interface{}{"*"},
|
||||
}
|
||||
if !validator.CheckPermissions(claims2, "sqs:read") {
|
||||
t.Fatal("expected to find wildcard permission")
|
||||
}
|
||||
|
||||
// Test 3: Rejects when missing
|
||||
claims3 := jwt.MapClaims{
|
||||
"permissions": []interface{}{"memory:read"},
|
||||
}
|
||||
if validator.CheckPermissions(claims3, "sqs:read") {
|
||||
t.Fatal("expected to reject missing permission")
|
||||
}
|
||||
|
||||
// Test 4: Handles missing permissions claim
|
||||
claims4 := jwt.MapClaims{}
|
||||
if validator.CheckPermissions(claims4, "sqs:read") {
|
||||
t.Fatal("expected to reject missing permissions claim")
|
||||
}
|
||||
|
||||
t.Log("✅ All permission checks passed")
|
||||
}
|
||||
|
||||
func TestValidateBearerToken(t *testing.T) {
|
||||
validator := NewValidator(
|
||||
"https://authentik.riotpiao.com/application/o/sqs/",
|
||||
"sqs",
|
||||
"https://authentik.riotpiao.com/application/o/sqs/jwks/",
|
||||
)
|
||||
|
||||
// Test 1: Empty token
|
||||
_, err := validator.ValidateBearerToken("")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty token")
|
||||
}
|
||||
t.Logf("✅ Correctly rejected empty token: %v", err)
|
||||
|
||||
// Test 2: Invalid format
|
||||
_, err = validator.ValidateBearerToken("not-a-bearer-token")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid format")
|
||||
}
|
||||
t.Logf("✅ Correctly rejected invalid format: %v", err)
|
||||
|
||||
// Test 3: Invalid token payload
|
||||
_, err = validator.ValidateBearerToken("Bearer invalid.token.format")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid token")
|
||||
}
|
||||
t.Logf("✅ Correctly rejected invalid token: %v", err)
|
||||
}
|
||||
@@ -3,7 +3,7 @@ package config_test
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestLoadRoutesMissingAuthRequired tests that the auth-required flag is not a silent default:
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||
)
|
||||
|
||||
// Config holds the gateway configuration.
|
||||
@@ -18,6 +20,10 @@ type Config struct {
|
||||
// Models maps model names to their upstream configuration.
|
||||
// Multiple models can point to the same upstream address.
|
||||
Models map[string]*ModelUpstream
|
||||
// Adapters holds service adapter definitions for X-Service routing.
|
||||
Adapters []*serviceadapter.ServiceAdapter
|
||||
// Auth holds JWT authentication configuration for /v1/* endpoints.
|
||||
Auth AuthConfig
|
||||
}
|
||||
|
||||
// ModelUpstream holds upstream configuration for a specific model.
|
||||
@@ -28,6 +34,22 @@ type ModelUpstream struct {
|
||||
Address string
|
||||
// Path is the upstream path for this model (e.g., "/v1/chat/completions").
|
||||
Path string
|
||||
// AuthRequired indicates whether this model requires JWT authentication.
|
||||
AuthRequired bool
|
||||
}
|
||||
|
||||
// AuthConfig holds JWT authentication configuration.
|
||||
type AuthConfig struct {
|
||||
// Enabled globally enables/disables auth for /v1/* endpoints.
|
||||
Enabled bool
|
||||
// Issuer is the expected JWT issuer (iss claim).
|
||||
Issuer string
|
||||
// Audience is the expected JWT audience (aud claim).
|
||||
Audience string
|
||||
// JWKSURL is the URL to fetch JSON Web Key Set for signature validation.
|
||||
JWKSURL string
|
||||
// RequiredCapability is the permission required for LLM inference (e.g., "llm:inference").
|
||||
RequiredCapability string
|
||||
}
|
||||
|
||||
// Route represents a single route and its upstream configuration.
|
||||
@@ -94,16 +116,20 @@ func Load() (*Config, error) {
|
||||
shutdownTimeout = d
|
||||
}
|
||||
|
||||
// Load routes and models from config file
|
||||
// Load routes, models, adapters, and auth from config file
|
||||
routes := make(map[string]*Route)
|
||||
models := make(map[string]*ModelUpstream)
|
||||
var adapters []*serviceadapter.ServiceAdapter
|
||||
var authConfig AuthConfig
|
||||
if configPath, ok := os.LookupEnv("CONFIG_PATH"); ok {
|
||||
loadedRoutes, loadedModels, err := LoadRoutesAndModelsFromFile(configPath)
|
||||
loadedRoutes, loadedModels, loadedAdapters, loadedAuth, err := LoadRoutesAndModelsFromFile(configPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
routes = loadedRoutes
|
||||
models = loadedModels
|
||||
adapters = loadedAdapters
|
||||
authConfig = loadedAuth
|
||||
}
|
||||
|
||||
return &Config{
|
||||
@@ -111,5 +137,7 @@ func Load() (*Config, error) {
|
||||
ShutdownTimeout: shutdownTimeout,
|
||||
Routes: routes,
|
||||
Models: models,
|
||||
Adapters: adapters,
|
||||
Auth: authConfig,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestLoadRoutesValidConfig tests that a valid configuration loads correctly.
|
||||
|
||||
@@ -5,7 +5,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestLoadIntegration tests the full Load function with CONFIG_PATH env var
|
||||
|
||||
+104
-31
@@ -6,13 +6,25 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// rawConfig represents the structure of the YAML configuration file.
|
||||
type rawConfig struct {
|
||||
Routes []rawRoute `yaml:"routes"`
|
||||
Models []rawModel `yaml:"models"`
|
||||
Routes []rawRoute `yaml:"routes"`
|
||||
Models []rawModel `yaml:"models"`
|
||||
Adapters []rawAdapter `yaml:"adapters"`
|
||||
Auth rawAuth `yaml:"auth"`
|
||||
}
|
||||
|
||||
// rawAuth represents auth configuration in YAML.
|
||||
type rawAuth struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Issuer string `yaml:"issuer"`
|
||||
Audience string `yaml:"audience"`
|
||||
JWKSURL string `yaml:"jwksUrl"`
|
||||
RequiredCapability string `yaml:"requiredCapability"`
|
||||
}
|
||||
|
||||
// rawRoute represents a single route in the YAML configuration.
|
||||
@@ -23,9 +35,33 @@ type rawRoute struct {
|
||||
|
||||
// rawModel represents a single model entry in the YAML configuration.
|
||||
type rawModel struct {
|
||||
Name string `yaml:"name"`
|
||||
Address string `yaml:"address"`
|
||||
Path string `yaml:"path"`
|
||||
Name string `yaml:"name"`
|
||||
Address string `yaml:"address"`
|
||||
Path string `yaml:"path"`
|
||||
AuthRequired *bool `yaml:"authRequired"`
|
||||
}
|
||||
|
||||
// rawAdapter represents a service adapter in the YAML configuration.
|
||||
type rawAdapter struct {
|
||||
ServiceName string `yaml:"serviceName"`
|
||||
Upstream struct {
|
||||
URL string `yaml:"url"`
|
||||
TimeoutSeconds int32 `yaml:"timeoutSeconds"`
|
||||
} `yaml:"upstream"`
|
||||
Auth struct {
|
||||
Required bool `yaml:"required"`
|
||||
Capability string `yaml:"capability"`
|
||||
} `yaml:"auth"`
|
||||
Retryable bool `yaml:"retryable"`
|
||||
Resources []struct {
|
||||
Name string `yaml:"name"`
|
||||
Methods []struct {
|
||||
Verb string `yaml:"verb"`
|
||||
UpstreamPath string `yaml:"upstreamPath"`
|
||||
RequestSchema string `yaml:"requestSchema"`
|
||||
ResponseSchema string `yaml:"responseSchema"`
|
||||
} `yaml:"methods"`
|
||||
} `yaml:"resources"`
|
||||
}
|
||||
|
||||
// rawUpstream represents upstream configuration in YAML.
|
||||
@@ -39,32 +75,32 @@ type rawUpstream struct {
|
||||
AuthRequired *bool `yaml:"authRequired"`
|
||||
}
|
||||
|
||||
// LoadRoutesAndModelsFromFile loads both route and model configuration from a YAML file.
|
||||
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, error) {
|
||||
// LoadRoutesAndModelsFromFile loads route, model, adapter, and auth configuration from a YAML file.
|
||||
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, []*serviceadapter.ServiceAdapter, AuthConfig, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("failed to read config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
var raw rawConfig
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, nil, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("failed to parse config file %q: %w", path, err)
|
||||
}
|
||||
|
||||
// Load routes
|
||||
routes := make(map[string]*Route)
|
||||
for _, rawRoute := range raw.Routes {
|
||||
if rawRoute.Name == "" {
|
||||
return nil, nil, fmt.Errorf("route has empty name")
|
||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("route has empty name")
|
||||
}
|
||||
|
||||
if _, exists := routes[rawRoute.Name]; exists {
|
||||
return nil, nil, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name)
|
||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate route: \"%s\"", rawRoute.Name)
|
||||
}
|
||||
|
||||
upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, nil, AuthConfig{}, err
|
||||
}
|
||||
|
||||
routes[rawRoute.Name] = &Route{
|
||||
@@ -76,42 +112,79 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
|
||||
// Load models
|
||||
models := make(map[string]*ModelUpstream)
|
||||
for _, rawModel := range raw.Models {
|
||||
// Validate model name is not empty
|
||||
if rawModel.Name == "" {
|
||||
return nil, nil, fmt.Errorf("model has empty name")
|
||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model has empty name")
|
||||
}
|
||||
|
||||
// Check for duplicate model names
|
||||
if _, exists := models[rawModel.Name]; exists {
|
||||
return nil, nil, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name)
|
||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("duplicate model: \"%s\"", rawModel.Name)
|
||||
}
|
||||
|
||||
// Validate address is not empty
|
||||
if rawModel.Address == "" {
|
||||
return nil, nil, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
|
||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
|
||||
}
|
||||
|
||||
// Validate address format (host:port)
|
||||
if _, _, err := net.SplitHostPort(rawModel.Address); err != nil {
|
||||
return nil, nil, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err)
|
||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err)
|
||||
}
|
||||
// Default authRequired to global auth.enabled if not specified per-model
|
||||
authRequired := false
|
||||
if rawModel.AuthRequired != nil {
|
||||
authRequired = *rawModel.AuthRequired
|
||||
}
|
||||
|
||||
models[rawModel.Name] = &ModelUpstream{
|
||||
Name: rawModel.Name,
|
||||
Address: rawModel.Address,
|
||||
Path: rawModel.Path,
|
||||
Name: rawModel.Name,
|
||||
Address: rawModel.Address,
|
||||
Path: rawModel.Path,
|
||||
AuthRequired: authRequired,
|
||||
}
|
||||
}
|
||||
|
||||
return routes, models, nil
|
||||
// Load adapters
|
||||
adapters := make([]*serviceadapter.ServiceAdapter, 0, len(raw.Adapters))
|
||||
for _, ra := range raw.Adapters {
|
||||
if ra.ServiceName == "" {
|
||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("adapter has empty serviceName")
|
||||
}
|
||||
a := &serviceadapter.ServiceAdapter{
|
||||
Name: ra.ServiceName,
|
||||
ServiceName: ra.ServiceName,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
a.Spec.ServiceName = ra.ServiceName
|
||||
a.Spec.Upstream.URL = ra.Upstream.URL
|
||||
a.Spec.Upstream.TimeoutSeconds = ra.Upstream.TimeoutSeconds
|
||||
a.Spec.Auth.Required = ra.Auth.Required
|
||||
a.Spec.Auth.Capability = ra.Auth.Capability
|
||||
a.Spec.Retryable = ra.Retryable
|
||||
for _, rr := range ra.Resources {
|
||||
res := serviceadapter.Resource{Name: rr.Name}
|
||||
for _, rm := range rr.Methods {
|
||||
res.Methods = append(res.Methods, serviceadapter.Method{
|
||||
Verb: rm.Verb,
|
||||
UpstreamPath: rm.UpstreamPath,
|
||||
RequestSchema: rm.RequestSchema,
|
||||
ResponseSchema: rm.ResponseSchema,
|
||||
})
|
||||
}
|
||||
a.Spec.Resources = append(a.Spec.Resources, res)
|
||||
}
|
||||
adapters = append(adapters, a)
|
||||
}
|
||||
|
||||
// Parse auth config
|
||||
authConfig := AuthConfig{
|
||||
Enabled: raw.Auth.Enabled,
|
||||
Issuer: raw.Auth.Issuer,
|
||||
Audience: raw.Auth.Audience,
|
||||
JWKSURL: raw.Auth.JWKSURL,
|
||||
RequiredCapability: raw.Auth.RequiredCapability,
|
||||
}
|
||||
|
||||
return routes, models, adapters, authConfig, nil
|
||||
}
|
||||
|
||||
// LoadRoutesFromFile loads route configuration from a YAML file.
|
||||
// It validates that all required fields are present and have valid values.
|
||||
// Returns an error if the configuration is invalid.
|
||||
// Deprecated: Use LoadRoutesAndModelsFromFile instead.
|
||||
func LoadRoutesFromFile(path string) (map[string]*Route, error) {
|
||||
routes, _, err := LoadRoutesAndModelsFromFile(path)
|
||||
routes, _, _, _, err := LoadRoutesAndModelsFromFile(path)
|
||||
return routes, err
|
||||
}
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, models, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
@@ -94,7 +94,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for duplicate model name, got nil")
|
||||
}
|
||||
@@ -127,7 +127,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for empty model name, got nil")
|
||||
}
|
||||
@@ -157,7 +157,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for missing address, got nil")
|
||||
}
|
||||
@@ -190,7 +190,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err == nil {
|
||||
t.Errorf("expected error for invalid address, got nil")
|
||||
}
|
||||
@@ -219,7 +219,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
_, models, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
@@ -261,7 +261,7 @@ models:
|
||||
tmpFile.WriteString(data)
|
||||
tmpFile.Close()
|
||||
|
||||
routes, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
routes, models, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||
if err != nil {
|
||||
t.Fatalf("failed to load config: %v", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ExportPrometheus exports metrics in Prometheus text format.
|
||||
func (m *Metrics) ExportPrometheus() string {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
var sb strings.Builder
|
||||
|
||||
// Help and type for request_total counter
|
||||
sb.WriteString("# HELP gateway_requests_total Total number of HTTP requests\n")
|
||||
sb.WriteString("# TYPE gateway_requests_total counter\n")
|
||||
for key, count := range m.requestTotal {
|
||||
parts := strings.Split(key, ":")
|
||||
if len(parts) == 3 {
|
||||
route, upstream, status := parts[0], parts[1], parts[2]
|
||||
sb.WriteString(fmt.Sprintf("gateway_requests_total{route=\"%s\",upstream=\"%s\",status=\"%s\"} %d\n",
|
||||
route, upstream, status, count))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Help and type for request_duration_seconds histogram
|
||||
sb.WriteString("# HELP gateway_request_duration_seconds Request latency in seconds\n")
|
||||
sb.WriteString("# TYPE gateway_request_duration_seconds histogram\n")
|
||||
buckets := []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
|
||||
for key := range m.requestDurationBuckets {
|
||||
parts := strings.Split(key, ":")
|
||||
if len(parts) == 2 {
|
||||
route, upstream := parts[0], parts[1]
|
||||
|
||||
// Write buckets
|
||||
cumulativeCount := int64(0)
|
||||
for _, bucket := range buckets {
|
||||
if count, ok := m.requestDurationBuckets[key][bucket]; ok {
|
||||
cumulativeCount += count
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("gateway_request_duration_seconds_bucket{route=\"%s\",upstream=\"%s\",le=\"%g\"} %d\n",
|
||||
route, upstream, bucket, cumulativeCount))
|
||||
}
|
||||
|
||||
// Write +Inf bucket
|
||||
totalCount := int64(0)
|
||||
for _, count := range m.requestDurationBuckets[key] {
|
||||
totalCount += count
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("gateway_request_duration_seconds_bucket{route=\"%s\",upstream=\"%s\",le=\"+Inf\"} %d\n",
|
||||
route, upstream, totalCount))
|
||||
|
||||
// Write sum
|
||||
totalDuration := m.requestDuration[key]
|
||||
sb.WriteString(fmt.Sprintf("gateway_request_duration_seconds_sum{route=\"%s\",upstream=\"%s\"} %g\n",
|
||||
route, upstream, float64(totalDuration)/1000.0)) // convert ms to seconds
|
||||
|
||||
// Write count
|
||||
sb.WriteString(fmt.Sprintf("gateway_request_duration_seconds_count{route=\"%s\",upstream=\"%s\"} %d\n",
|
||||
route, upstream, totalCount))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Help and type for gateway_bytes_in counter
|
||||
sb.WriteString("# HELP gateway_bytes_in_total Total bytes received from clients\n")
|
||||
sb.WriteString("# TYPE gateway_bytes_in_total counter\n")
|
||||
for key, count := range m.bytesIn {
|
||||
parts := strings.Split(key, ":")
|
||||
if len(parts) == 2 {
|
||||
route, upstream := parts[0], parts[1]
|
||||
sb.WriteString(fmt.Sprintf("gateway_bytes_in_total{route=\"%s\",upstream=\"%s\"} %d\n",
|
||||
route, upstream, count))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Help and type for gateway_bytes_out counter
|
||||
sb.WriteString("# HELP gateway_bytes_out_total Total bytes sent to clients\n")
|
||||
sb.WriteString("# TYPE gateway_bytes_out_total counter\n")
|
||||
for key, count := range m.bytesOut {
|
||||
parts := strings.Split(key, ":")
|
||||
if len(parts) == 2 {
|
||||
route, upstream := parts[0], parts[1]
|
||||
sb.WriteString(fmt.Sprintf("gateway_bytes_out_total{route=\"%s\",upstream=\"%s\"} %d\n",
|
||||
route, upstream, count))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Help and type for upstream_health gauge
|
||||
sb.WriteString("# HELP gateway_upstream_health Upstream health status (1=healthy, 0=unhealthy)\n")
|
||||
sb.WriteString("# TYPE gateway_upstream_health gauge\n")
|
||||
for upstream, health := range m.upstreamHealth {
|
||||
sb.WriteString(fmt.Sprintf("gateway_upstream_health{upstream=\"%s\"} %d\n", upstream, health))
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Help and type for streaming responses
|
||||
sb.WriteString("# HELP gateway_streaming_responses_total Total streaming responses\n")
|
||||
sb.WriteString("# TYPE gateway_streaming_responses_total counter\n")
|
||||
for key, count := range m.streamingResponsesTotal {
|
||||
parts := strings.Split(key, ":")
|
||||
if len(parts) == 2 {
|
||||
route, upstream := parts[0], parts[1]
|
||||
sb.WriteString(fmt.Sprintf("gateway_streaming_responses_total{route=\"%s\",upstream=\"%s\"} %d\n",
|
||||
route, upstream, count))
|
||||
}
|
||||
}
|
||||
sb.WriteString("\n")
|
||||
|
||||
// Help and type for streaming byte count
|
||||
sb.WriteString("# HELP gateway_streaming_bytes_total Total bytes in streaming responses\n")
|
||||
sb.WriteString("# TYPE gateway_streaming_bytes_total counter\n")
|
||||
for key, count := range m.streamingByteCount {
|
||||
parts := strings.Split(key, ":")
|
||||
if len(parts) == 2 {
|
||||
route, upstream := parts[0], parts[1]
|
||||
sb.WriteString(fmt.Sprintf("gateway_streaming_bytes_total{route=\"%s\",upstream=\"%s\"} %d\n",
|
||||
route, upstream, count))
|
||||
}
|
||||
}
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Metrics holds all Prometheus metrics for the gateway.
|
||||
type Metrics struct {
|
||||
mu sync.RWMutex
|
||||
|
||||
// Request counters: request_total{route, upstream, status}
|
||||
requestTotal map[string]int64
|
||||
|
||||
// Request latencies: request_duration_seconds (histogram)
|
||||
// Stored as cumulative buckets for Prometheus text format
|
||||
requestDuration map[string]int64 // stores duration samples in milliseconds
|
||||
requestDurationBuckets map[string]map[float64]int64 // histogram buckets
|
||||
|
||||
// Bytes counters: gateway_bytes{direction, route, upstream}
|
||||
bytesIn map[string]int64
|
||||
bytesOut map[string]int64
|
||||
|
||||
// Upstream health: upstream_health{upstream} = 1 or 0
|
||||
upstreamHealth map[string]int
|
||||
|
||||
// Streaming metrics
|
||||
streamingResponsesTotal map[string]int64
|
||||
streamingByteCount map[string]int64
|
||||
}
|
||||
|
||||
// NewMetrics creates a new Metrics instance.
|
||||
func NewMetrics() *Metrics {
|
||||
return &Metrics{
|
||||
requestTotal: make(map[string]int64),
|
||||
requestDuration: make(map[string]int64),
|
||||
requestDurationBuckets: make(map[string]map[float64]int64),
|
||||
bytesIn: make(map[string]int64),
|
||||
bytesOut: make(map[string]int64),
|
||||
upstreamHealth: make(map[string]int),
|
||||
streamingResponsesTotal: make(map[string]int64),
|
||||
streamingByteCount: make(map[string]int64),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordRequest records a request with its route, upstream, status, and duration.
|
||||
func (m *Metrics) RecordRequest(route, upstream string, statusCode int, duration time.Duration) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
key := fmt.Sprintf("%s:%s:%d", route, upstream, statusCode)
|
||||
m.requestTotal[key]++
|
||||
|
||||
// Record duration in milliseconds
|
||||
durationKey := fmt.Sprintf("%s:%s", route, upstream)
|
||||
m.requestDuration[durationKey] += int64(duration.Milliseconds())
|
||||
|
||||
// Record in histogram buckets
|
||||
if _, ok := m.requestDurationBuckets[durationKey]; !ok {
|
||||
m.requestDurationBuckets[durationKey] = make(map[float64]int64)
|
||||
}
|
||||
|
||||
// Prometheus histogram buckets: .005, .01, .025, .05, .1, .25, .5, 1, 2.5, 5, 10
|
||||
buckets := []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
|
||||
durationSeconds := duration.Seconds()
|
||||
|
||||
for _, bucket := range buckets {
|
||||
if durationSeconds <= bucket {
|
||||
m.requestDurationBuckets[durationKey][bucket]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// RecordBytesIn records incoming bytes.
|
||||
func (m *Metrics) RecordBytesIn(route, upstream string, bytes int64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
key := fmt.Sprintf("%s:%s", route, upstream)
|
||||
m.bytesIn[key] += bytes
|
||||
}
|
||||
|
||||
// RecordBytesOut records outgoing bytes.
|
||||
func (m *Metrics) RecordBytesOut(route, upstream string, bytes int64) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
key := fmt.Sprintf("%s:%s", route, upstream)
|
||||
m.bytesOut[key] += bytes
|
||||
}
|
||||
|
||||
// SetUpstreamHealth sets the health status of an upstream (1 = healthy, 0 = unhealthy).
|
||||
func (m *Metrics) SetUpstreamHealth(upstream string, healthy bool) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
if healthy {
|
||||
m.upstreamHealth[upstream] = 1
|
||||
} else {
|
||||
m.upstreamHealth[upstream] = 0
|
||||
}
|
||||
}
|
||||
|
||||
// RecordStreamingResponse records a streaming response with its total byte count and duration.
|
||||
func (m *Metrics) RecordStreamingResponse(route, upstream string, totalBytes int64, duration time.Duration) {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
key := fmt.Sprintf("%s:%s", route, upstream)
|
||||
m.streamingResponsesTotal[key]++
|
||||
m.streamingByteCount[key] += totalBytes
|
||||
|
||||
// Also record as request duration
|
||||
m.recordDuration(key, duration)
|
||||
}
|
||||
|
||||
func (m *Metrics) recordDuration(key string, duration time.Duration) {
|
||||
m.requestDuration[key] += int64(duration.Milliseconds())
|
||||
|
||||
if _, ok := m.requestDurationBuckets[key]; !ok {
|
||||
m.requestDurationBuckets[key] = make(map[float64]int64)
|
||||
}
|
||||
|
||||
buckets := []float64{0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10}
|
||||
durationSeconds := duration.Seconds()
|
||||
|
||||
for _, bucket := range buckets {
|
||||
if durationSeconds <= bucket {
|
||||
m.requestDurationBuckets[key][bucket]++
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// GetMetrics returns a copy of current metrics (for testing/export).
|
||||
func (m *Metrics) GetMetrics() map[string]interface{} {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
|
||||
return map[string]interface{}{
|
||||
"request_total": m.requestTotal,
|
||||
"request_duration": m.requestDuration,
|
||||
"request_duration_buckets": m.requestDurationBuckets,
|
||||
"bytes_in": m.bytesIn,
|
||||
"bytes_out": m.bytesOut,
|
||||
"upstream_health": m.upstreamHealth,
|
||||
"streaming_responses_total": m.streamingResponsesTotal,
|
||||
"streaming_byte_count": m.streamingByteCount,
|
||||
}
|
||||
}
|
||||
|
||||
// Reset clears all metrics (for testing).
|
||||
func (m *Metrics) Reset() {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
|
||||
m.requestTotal = make(map[string]int64)
|
||||
m.requestDuration = make(map[string]int64)
|
||||
m.requestDurationBuckets = make(map[string]map[float64]int64)
|
||||
m.bytesIn = make(map[string]int64)
|
||||
m.bytesOut = make(map[string]int64)
|
||||
m.upstreamHealth = make(map[string]int)
|
||||
m.streamingResponsesTotal = make(map[string]int64)
|
||||
m.streamingByteCount = make(map[string]int64)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestMetricsRecordRequest(t *testing.T) {
|
||||
m := NewMetrics()
|
||||
|
||||
// Record some requests
|
||||
m.RecordRequest("v1-chat", "reasoning", 200, 500*time.Millisecond)
|
||||
m.RecordRequest("v1-chat", "reasoning", 200, 600*time.Millisecond)
|
||||
m.RecordRequest("v1-chat", "reasoning", 500, 100*time.Millisecond)
|
||||
|
||||
metrics := m.GetMetrics()
|
||||
requestTotal := metrics["request_total"].(map[string]int64)
|
||||
|
||||
if requestTotal["v1-chat:reasoning:200"] != 2 {
|
||||
t.Errorf("expected 2 successful requests, got %d", requestTotal["v1-chat:reasoning:200"])
|
||||
}
|
||||
|
||||
if requestTotal["v1-chat:reasoning:500"] != 1 {
|
||||
t.Errorf("expected 1 error request, got %d", requestTotal["v1-chat:reasoning:500"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsRecordBytes(t *testing.T) {
|
||||
m := NewMetrics()
|
||||
|
||||
m.RecordBytesIn("v1-chat", "reasoning", 1024)
|
||||
m.RecordBytesOut("v1-chat", "reasoning", 2048)
|
||||
|
||||
metrics := m.GetMetrics()
|
||||
bytesIn := metrics["bytes_in"].(map[string]int64)
|
||||
bytesOut := metrics["bytes_out"].(map[string]int64)
|
||||
|
||||
if bytesIn["v1-chat:reasoning"] != 1024 {
|
||||
t.Errorf("expected 1024 bytes in, got %d", bytesIn["v1-chat:reasoning"])
|
||||
}
|
||||
|
||||
if bytesOut["v1-chat:reasoning"] != 2048 {
|
||||
t.Errorf("expected 2048 bytes out, got %d", bytesOut["v1-chat:reasoning"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsUpstreamHealth(t *testing.T) {
|
||||
m := NewMetrics()
|
||||
|
||||
m.SetUpstreamHealth("reasoning", true)
|
||||
m.SetUpstreamHealth("embedding", false)
|
||||
|
||||
metrics := m.GetMetrics()
|
||||
health := metrics["upstream_health"].(map[string]int)
|
||||
|
||||
if health["reasoning"] != 1 {
|
||||
t.Errorf("expected reasoning upstream healthy (1), got %d", health["reasoning"])
|
||||
}
|
||||
|
||||
if health["embedding"] != 0 {
|
||||
t.Errorf("expected embedding upstream unhealthy (0), got %d", health["embedding"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPrometheus(t *testing.T) {
|
||||
m := NewMetrics()
|
||||
|
||||
// Record some data
|
||||
m.RecordRequest("v1-chat", "reasoning", 200, 500*time.Millisecond)
|
||||
m.RecordBytesIn("v1-chat", "reasoning", 1024)
|
||||
m.RecordBytesOut("v1-chat", "reasoning", 2048)
|
||||
m.SetUpstreamHealth("reasoning", true)
|
||||
|
||||
export := m.ExportPrometheus()
|
||||
|
||||
// Check for expected metric families
|
||||
if !strings.Contains(export, "# HELP gateway_requests_total") {
|
||||
t.Errorf("missing gateway_requests_total help")
|
||||
}
|
||||
|
||||
if !strings.Contains(export, "# TYPE gateway_requests_total counter") {
|
||||
t.Errorf("missing gateway_requests_total type")
|
||||
}
|
||||
|
||||
if !strings.Contains(export, "gateway_requests_total{route=\"v1-chat\",upstream=\"reasoning\",status=\"200\"} 1") {
|
||||
t.Errorf("missing or incorrect request_total metric")
|
||||
}
|
||||
|
||||
if !strings.Contains(export, "# HELP gateway_bytes_in_total") {
|
||||
t.Errorf("missing gateway_bytes_in_total help")
|
||||
}
|
||||
|
||||
if !strings.Contains(export, "gateway_bytes_in_total{route=\"v1-chat\",upstream=\"reasoning\"} 1024") {
|
||||
t.Errorf("missing or incorrect bytes_in metric")
|
||||
}
|
||||
|
||||
if !strings.Contains(export, "gateway_bytes_out_total{route=\"v1-chat\",upstream=\"reasoning\"} 2048") {
|
||||
t.Errorf("missing or incorrect bytes_out metric")
|
||||
}
|
||||
|
||||
if !strings.Contains(export, "gateway_upstream_health{upstream=\"reasoning\"} 1") {
|
||||
t.Errorf("missing or incorrect upstream_health metric")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExportPrometheusHistogram(t *testing.T) {
|
||||
m := NewMetrics()
|
||||
|
||||
// Record requests with different durations
|
||||
m.RecordRequest("v1-chat", "reasoning", 200, 50*time.Millisecond)
|
||||
m.RecordRequest("v1-chat", "reasoning", 200, 200*time.Millisecond)
|
||||
m.RecordRequest("v1-chat", "reasoning", 200, 1*time.Second)
|
||||
|
||||
export := m.ExportPrometheus()
|
||||
|
||||
// Check for histogram structure
|
||||
if !strings.Contains(export, "# HELP gateway_request_duration_seconds Request latency in seconds") {
|
||||
t.Errorf("missing duration_seconds help")
|
||||
}
|
||||
|
||||
if !strings.Contains(export, "# TYPE gateway_request_duration_seconds histogram") {
|
||||
t.Errorf("missing histogram type")
|
||||
}
|
||||
|
||||
if !strings.Contains(export, "gateway_request_duration_seconds_bucket") {
|
||||
t.Errorf("missing histogram bucket")
|
||||
}
|
||||
|
||||
if !strings.Contains(export, "gateway_request_duration_seconds_count") {
|
||||
t.Errorf("missing histogram count")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMetricsThreadSafety(t *testing.T) {
|
||||
m := NewMetrics()
|
||||
|
||||
// Concurrent recordings
|
||||
done := make(chan bool, 2)
|
||||
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
m.RecordRequest("route1", "upstream1", 200, time.Millisecond)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
go func() {
|
||||
for i := 0; i < 100; i++ {
|
||||
m.RecordBytesIn("route2", "upstream2", 1024)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
<-done
|
||||
<-done
|
||||
|
||||
metrics := m.GetMetrics()
|
||||
if len(metrics["request_total"].(map[string]int64)) == 0 {
|
||||
t.Errorf("expected metrics to be recorded")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package problem
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
)
|
||||
|
||||
// Problem represents an RFC 7807 / RFC 9457 problem document.
|
||||
// We use 9457 as the canonical reference (HTTP Semantics updates).
|
||||
type Problem struct {
|
||||
Type string `json:"type"` // stable URI per rejection reason
|
||||
Title string `json:"title"` // human-readable summary
|
||||
Status int `json:"status"` // HTTP status code
|
||||
Detail string `json:"detail"` // human-useful detail, names offending input
|
||||
Instance string `json:"instance,omitempty"` // URI of the affected resource
|
||||
RetryAfter *int `json:"retry_after,omitempty"` // seconds until retry is safe
|
||||
Extra map[string]interface{} `json:"extra,omitempty"` // additional fields
|
||||
}
|
||||
|
||||
// NewProblem creates a new problem document with the given parameters.
|
||||
func NewProblem(statusCode int, typeURI, title, detail string) *Problem {
|
||||
return &Problem{
|
||||
Type: typeURI,
|
||||
Title: title,
|
||||
Status: statusCode,
|
||||
Detail: detail,
|
||||
Extra: make(map[string]interface{}),
|
||||
}
|
||||
}
|
||||
|
||||
// WithRetryAfter sets the Retry-After field (in seconds).
|
||||
func (p *Problem) WithRetryAfter(seconds int) *Problem {
|
||||
p.RetryAfter = &seconds
|
||||
return p
|
||||
}
|
||||
|
||||
// WithInstance sets the Instance field.
|
||||
func (p *Problem) WithInstance(instance string) *Problem {
|
||||
p.Instance = instance
|
||||
return p
|
||||
}
|
||||
|
||||
// WithExtra adds extra fields to the problem document.
|
||||
func (p *Problem) WithExtra(key string, value interface{}) *Problem {
|
||||
p.Extra[key] = value
|
||||
return p
|
||||
}
|
||||
|
||||
// Write sends the problem document to the HTTP response writer.
|
||||
func (p *Problem) Write(w http.ResponseWriter) error {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
|
||||
// Set Retry-After header if present
|
||||
if p.RetryAfter != nil {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(*p.RetryAfter))
|
||||
}
|
||||
|
||||
w.WriteHeader(p.Status)
|
||||
|
||||
body, err := json.Marshal(p)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = w.Write(body)
|
||||
return err
|
||||
}
|
||||
|
||||
// Common problem types
|
||||
const (
|
||||
TypeBadRequest = "about:blank#bad-request"
|
||||
TypeUnauthorized = "about:blank#unauthorized"
|
||||
TypeForbidden = "about:blank#forbidden"
|
||||
TypeNotFound = "about:blank#not-found"
|
||||
TypeMethodNotAllowed = "about:blank#method-not-allowed"
|
||||
TypeConflict = "about:blank#conflict"
|
||||
TypeGone = "about:blank#gone"
|
||||
TypePayloadTooLarge = "about:blank#payload-too-large"
|
||||
TypeUnprocessable = "about:blank#unprocessable-entity"
|
||||
TypeTooManyRequests = "about:blank#too-many-requests"
|
||||
TypeInternalError = "about:blank#internal-server-error"
|
||||
TypeNotImplemented = "about:blank#not-implemented"
|
||||
TypeUnavailable = "about:blank#service-unavailable"
|
||||
)
|
||||
|
||||
// Common constructors
|
||||
func BadRequest(detail string) *Problem {
|
||||
return NewProblem(http.StatusBadRequest, TypeBadRequest, "Bad Request", detail)
|
||||
}
|
||||
|
||||
func Unauthorized(detail string) *Problem {
|
||||
return NewProblem(http.StatusUnauthorized, TypeUnauthorized, "Unauthorized", detail)
|
||||
}
|
||||
|
||||
func Forbidden(detail string) *Problem {
|
||||
return NewProblem(http.StatusForbidden, TypeForbidden, "Forbidden", detail)
|
||||
}
|
||||
|
||||
func NotFound(detail string) *Problem {
|
||||
return NewProblem(http.StatusNotFound, TypeNotFound, "Not Found", detail)
|
||||
}
|
||||
|
||||
func PayloadTooLarge(detail string) *Problem {
|
||||
return NewProblem(http.StatusRequestEntityTooLarge, TypePayloadTooLarge, "Payload Too Large", detail)
|
||||
}
|
||||
|
||||
func UnprocessableEntity(detail string) *Problem {
|
||||
return NewProblem(http.StatusUnprocessableEntity, TypeUnprocessable, "Unprocessable Entity", detail)
|
||||
}
|
||||
|
||||
func TooManyRequests(detail string, retryAfter int) *Problem {
|
||||
return NewProblem(http.StatusTooManyRequests, TypeTooManyRequests, "Too Many Requests", detail).
|
||||
WithRetryAfter(retryAfter)
|
||||
}
|
||||
|
||||
func InternalServerError(detail string) *Problem {
|
||||
return NewProblem(http.StatusInternalServerError, TypeInternalError, "Internal Server Error", detail)
|
||||
}
|
||||
|
||||
func ServiceUnavailable(detail string, retryAfter int) *Problem {
|
||||
return NewProblem(http.StatusServiceUnavailable, TypeUnavailable, "Service Unavailable", detail).
|
||||
WithRetryAfter(retryAfter)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package problem
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestProblemDocument(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
problem *Problem
|
||||
statusCode int
|
||||
hasType bool
|
||||
hasTitle bool
|
||||
hasStatus bool
|
||||
hasDetail bool
|
||||
}{
|
||||
{
|
||||
name: "BadRequest",
|
||||
problem: BadRequest("missing field: model"),
|
||||
statusCode: http.StatusBadRequest,
|
||||
hasType: true,
|
||||
hasTitle: true,
|
||||
hasStatus: true,
|
||||
hasDetail: true,
|
||||
},
|
||||
{
|
||||
name: "PayloadTooLarge",
|
||||
problem: PayloadTooLarge("request body 1001 bytes exceeds max 1000"),
|
||||
statusCode: http.StatusRequestEntityTooLarge,
|
||||
hasType: true,
|
||||
hasTitle: true,
|
||||
hasStatus: true,
|
||||
hasDetail: true,
|
||||
},
|
||||
{
|
||||
name: "TooManyRequests",
|
||||
problem: TooManyRequests("rate limit exceeded", 60),
|
||||
statusCode: http.StatusTooManyRequests,
|
||||
hasType: true,
|
||||
hasTitle: true,
|
||||
hasStatus: true,
|
||||
hasDetail: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
w := httptest.NewRecorder()
|
||||
err := tc.problem.Write(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Write failed: %v", err)
|
||||
}
|
||||
|
||||
// Check status code
|
||||
if w.Code != tc.statusCode {
|
||||
t.Errorf("expected status %d, got %d", tc.statusCode, w.Code)
|
||||
}
|
||||
|
||||
// Check Content-Type
|
||||
if ct := w.Header().Get("Content-Type"); ct != "application/problem+json" {
|
||||
t.Errorf("expected Content-Type: application/problem+json, got %s", ct)
|
||||
}
|
||||
|
||||
// Parse response body
|
||||
var p Problem
|
||||
err = json.Unmarshal(w.Body.Bytes(), &p)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
// Verify required fields
|
||||
if tc.hasType && p.Type == "" {
|
||||
t.Errorf("expected 'type' field, got empty")
|
||||
}
|
||||
if tc.hasTitle && p.Title == "" {
|
||||
t.Errorf("expected 'title' field, got empty")
|
||||
}
|
||||
if tc.hasStatus && p.Status == 0 {
|
||||
t.Errorf("expected 'status' field, got 0")
|
||||
}
|
||||
if tc.hasDetail && p.Detail == "" {
|
||||
t.Errorf("expected 'detail' field, got empty")
|
||||
}
|
||||
|
||||
// Verify status matches HTTP response code
|
||||
if p.Status != w.Code {
|
||||
t.Errorf("status field %d does not match HTTP status %d", p.Status, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemRetryAfter(t *testing.T) {
|
||||
p := TooManyRequests("rate limit", 120)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
err := p.Write(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Write failed: %v", err)
|
||||
}
|
||||
|
||||
// Check Retry-After header is set
|
||||
if ra := w.Header().Get("Retry-After"); ra == "" {
|
||||
t.Errorf("expected Retry-After header, got empty")
|
||||
}
|
||||
|
||||
var body Problem
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
if body.RetryAfter == nil || *body.RetryAfter != 120 {
|
||||
t.Errorf("expected RetryAfter=120, got %v", body.RetryAfter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProblemWithExtra(t *testing.T) {
|
||||
p := BadRequest("invalid request")
|
||||
p.WithExtra("field", "model")
|
||||
p.WithExtra("reason", "unknown_model")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
err := p.Write(w)
|
||||
if err != nil {
|
||||
t.Fatalf("Write failed: %v", err)
|
||||
}
|
||||
|
||||
var body Problem
|
||||
json.Unmarshal(w.Body.Bytes(), &body)
|
||||
|
||||
if body.Extra["field"] != "model" {
|
||||
t.Errorf("expected extra field 'model', got %v", body.Extra["field"])
|
||||
}
|
||||
if body.Extra["reason"] != "unknown_model" {
|
||||
t.Errorf("expected extra reason 'unknown_model', got %v", body.Extra["reason"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSecretsInProblem(t *testing.T) {
|
||||
// Verify that secrets, tokens, bodies are never leaked
|
||||
p := Unauthorized("invalid bearer token").
|
||||
WithExtra("attempted_route", "/v1/chat/completions")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
p.Write(w)
|
||||
|
||||
body := w.Body.String()
|
||||
|
||||
// Should not contain any auth-related secrets
|
||||
if len(body) > 200 {
|
||||
t.Errorf("problem document too large for detail: %d bytes (check for leaked content)", len(body))
|
||||
}
|
||||
|
||||
// Parse and verify no sensitive fields are present
|
||||
var doc Problem
|
||||
json.Unmarshal(w.Body.Bytes(), &doc)
|
||||
|
||||
// Detail should describe the problem, not echo the token
|
||||
if len(doc.Detail) > 100 {
|
||||
t.Errorf("detail too long: %s", doc.Detail)
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestBodyBasedDispatch verifies that /v1/chat/completions routes based on model field.
|
||||
@@ -76,7 +76,10 @@ func TestBodyBasedDispatch(t *testing.T) {
|
||||
reasoningCalled = false
|
||||
ornithCalled = false
|
||||
requestBody := `{"model":"reasoning","messages":[{"role":"user","content":"hi"}]}`
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if !reasoningCalled {
|
||||
@@ -151,7 +154,10 @@ func TestBodyPreservedUnmodified(t *testing.T) {
|
||||
|
||||
// Send a request with specific body content
|
||||
originalBody := `{"model":"reasoning","stream":true,"messages":[{"role":"user","content":"hello world"}],"temperature":0.7}`
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(originalBody))
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(originalBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if string(receivedBody) != originalBody {
|
||||
@@ -223,7 +229,7 @@ func TestStreamingUnbuffered(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownModelReject verifies that unknown models are rejected.
|
||||
// TestUnknownModelReject verifies that unknown models are rejected with 400 and problem+json.
|
||||
func TestUnknownModelReject(t *testing.T) {
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -249,15 +255,35 @@ func TestUnknownModelReject(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
requestBody := `{"model":"unknown-model","messages":[]}`
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
resp.Body.Close()
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for unknown model, got %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unknown model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify problem+json content type
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected content-type application/problem+json, got %s", ct)
|
||||
}
|
||||
|
||||
// Verify response is valid JSON
|
||||
var prob map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&prob); err != nil {
|
||||
t.Errorf("response is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
// Verify valid_models is included
|
||||
if prob["valid_models"] == nil {
|
||||
t.Errorf("expected valid_models in problem detail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingModelField verifies that missing model field is rejected.
|
||||
// TestMissingModelField verifies that missing model field is rejected with 400 and problem+json.
|
||||
func TestMissingModelField(t *testing.T) {
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
@@ -283,11 +309,26 @@ func TestMissingModelField(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
requestBody := `{"messages":[]}`
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
resp.Body.Close()
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusNotFound {
|
||||
t.Errorf("expected 404 for missing model, got %d", resp.StatusCode)
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for missing model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify problem+json content type
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected content-type application/problem+json, got %s", ct)
|
||||
}
|
||||
|
||||
// Verify response is valid JSON
|
||||
var prob map[string]interface{}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&prob); err != nil {
|
||||
t.Errorf("response is not valid JSON: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestBodySizeCapExact verifies that a body exactly at the cap is accepted.
|
||||
@@ -323,7 +323,10 @@ func TestBodySizeCapRejectionLogged(t *testing.T) {
|
||||
|
||||
// Send an oversized body
|
||||
body := strings.Repeat("a", int(maxBodySize)+1)
|
||||
resp, _ := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
|
||||
resp, err := http.Post(server.URL+"/test", "text/plain", strings.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// Verify rejection status
|
||||
|
||||
@@ -0,0 +1,420 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestEmbeddingsPassthroughNoRewrite verifies /v1/embeddings is not rewritten
|
||||
func TestEmbeddingsPassthroughNoRewrite(t *testing.T) {
|
||||
embeddingsCalled := false
|
||||
upstreamPath := ""
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
embeddingsCalled = true
|
||||
upstreamPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,0.2]}]}`)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/embeddings",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"hello"}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if !embeddingsCalled {
|
||||
t.Errorf("upstream embeddings service was not called")
|
||||
}
|
||||
|
||||
// Verify path is NOT rewritten (should stay /v1/embeddings)
|
||||
if upstreamPath != "/v1/embeddings" {
|
||||
t.Errorf("expected upstream path /v1/embeddings, got %s", upstreamPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbeddingsResponsePassthrough verifies response body is unmodified
|
||||
func TestEmbeddingsResponsePassthrough(t *testing.T) {
|
||||
expectedResponse := `{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.1,0.2,0.3]}]}`
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, expectedResponse)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/embeddings",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"test"}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if string(body) != expectedResponse {
|
||||
t.Errorf("response was modified. Expected:\n%s\n\nGot:\n%s", expectedResponse, string(body))
|
||||
}
|
||||
}
|
||||
|
||||
// TestRerankPathRewrite verifies /v1/rerank is rewritten to /rerank
|
||||
func TestRerankPathRewrite(t *testing.T) {
|
||||
upstreamPath := ""
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamPath = r.URL.Path
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"results":[{"index":0,"score":0.9}]}`)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"BAAI/bge-reranker-base": {
|
||||
Name: "BAAI/bge-reranker-base",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/rerank",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"BAAI/bge-reranker-base","query":"test","texts":["a","b"]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify path IS rewritten to /rerank
|
||||
if upstreamPath != "/rerank" {
|
||||
t.Errorf("expected upstream path /rerank, got %s", upstreamPath)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRerankResponsePassthrough verifies response is unmodified
|
||||
func TestRerankResponsePassthrough(t *testing.T) {
|
||||
expectedResponse := `{"results":[{"index":0,"score":0.95},{"index":1,"score":0.85}]}`
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, expectedResponse)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"BAAI/bge-reranker-base": {
|
||||
Name: "BAAI/bge-reranker-base",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/rerank",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"BAAI/bge-reranker-base","query":"q","texts":["a"]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
|
||||
if string(body) != expectedResponse {
|
||||
t.Errorf("response was modified")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbeddingsUnknownModel returns error for unknown model
|
||||
func TestEmbeddingsUnknownModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/embeddings",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown-embeddings","input":"test"}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unknown embeddings model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected problem+json for unknown model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRerankerUnknownModel returns error for unknown model
|
||||
func TestRerankerUnknownModel(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"BAAI/bge-reranker-base": {
|
||||
Name: "BAAI/bge-reranker-base",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/rerank",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown-reranker","query":"q","texts":["a"]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("expected 400 for unknown reranker model, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmbeddingsBodyForwarded verifies body is byte-identical to upstream
|
||||
func TestEmbeddingsBodyForwarded(t *testing.T) {
|
||||
receivedBody := ""
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"object":"list","data":[]}`)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
originalBody := `{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"test data with special chars: \u0001"}`
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/embeddings",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(originalBody)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
// The received body should match the original (though may have different formatting)
|
||||
var orig, received map[string]interface{}
|
||||
json.Unmarshal([]byte(originalBody), &orig)
|
||||
json.Unmarshal([]byte(receivedBody), &received)
|
||||
|
||||
if orig["model"] != received["model"] || orig["input"] != received["input"] {
|
||||
t.Errorf("body was not forwarded correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestRerankerBodyForwarded verifies body is byte-identical to upstream
|
||||
func TestRerankerBodyForwarded(t *testing.T) {
|
||||
receivedBody := ""
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
body, _ := io.ReadAll(r.Body)
|
||||
receivedBody = string(body)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
fmt.Fprint(w, `{"results":[]}`)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"BAAI/bge-reranker-base": {
|
||||
Name: "BAAI/bge-reranker-base",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
originalBody := `{"model":"BAAI/bge-reranker-base","query":"test","texts":["a","b","c"]}`
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/rerank",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(originalBody)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
var orig, received map[string]interface{}
|
||||
json.Unmarshal([]byte(originalBody), &orig)
|
||||
json.Unmarshal([]byte(receivedBody), &received)
|
||||
|
||||
if orig["model"] != received["model"] || orig["query"] != received["query"] {
|
||||
t.Errorf("body was not forwarded correctly")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUpstreamErrorStatusSurfaced verifies upstream errors are returned as-is
|
||||
func TestUpstreamErrorStatusSurfaced(t *testing.T) {
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
fmt.Fprint(w, `{"error":"upstream failure"}`)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/embeddings",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"nomic-ai/nomic-embed-text-v2-moe","input":"test"}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusInternalServerError {
|
||||
t.Errorf("expected upstream error status 500, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestHeaderHygiene verifies that headers are properly filtered and forwarded.
|
||||
@@ -71,7 +71,7 @@ func TestHeaderHygiene(t *testing.T) {
|
||||
t.Errorf("hop-by-hop header %s should be stripped, but found: %s", header, receivedHeaders.Get(header))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TE header is tricky - it should be stripped but may be handled differently
|
||||
// Just verify it's not the original value for now
|
||||
if receivedHeaders.Get("TE") == "trailers" {
|
||||
|
||||
@@ -0,0 +1,470 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// ModelListResponse represents the response shape for GET /v1/models
|
||||
type ModelListResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []ModelEntry `json:"data"`
|
||||
}
|
||||
|
||||
// ModelEntry represents a single model in the list
|
||||
type ModelEntry struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
// TestModelsEndpointReturns200 verifies GET /v1/models returns 200
|
||||
func TestModelsEndpointReturns200(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("expected 200, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointContentType verifies correct content type
|
||||
func TestModelsEndpointContentType(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/json") {
|
||||
t.Errorf("expected content-type application/json, got %s", ct)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointResponseShape verifies correct JSON structure
|
||||
func TestModelsEndpointResponseShape(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ModelListResponse
|
||||
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||
t.Fatalf("failed to decode response: %v", err)
|
||||
}
|
||||
|
||||
if result.Object != "list" {
|
||||
t.Errorf("expected object='list', got %q", result.Object)
|
||||
}
|
||||
|
||||
if len(result.Data) != 1 {
|
||||
t.Errorf("expected 1 model, got %d", len(result.Data))
|
||||
}
|
||||
|
||||
model := result.Data[0]
|
||||
if model.ID != "reasoning" {
|
||||
t.Errorf("expected id='reasoning', got %q", model.ID)
|
||||
}
|
||||
|
||||
if model.Object != "model" {
|
||||
t.Errorf("expected object='model', got %q", model.Object)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointEnumeratesAllModels verifies all models are listed
|
||||
func TestModelsEndpointEnumeratesAllModels(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"ornith:35b": {
|
||||
Name: "ornith:35b",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"qwen2.5:3b-instruct": {
|
||||
Name: "qwen2.5:3b-instruct",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"nomic-ai/nomic-embed-text-v2-moe": {
|
||||
Name: "nomic-ai/nomic-embed-text-v2-moe",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"BAAI/bge-reranker-base": {
|
||||
Name: "BAAI/bge-reranker-base",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ModelListResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
if len(result.Data) != 5 {
|
||||
t.Errorf("expected 5 models, got %d", len(result.Data))
|
||||
}
|
||||
|
||||
// Collect actual model IDs
|
||||
modelIDs := make(map[string]bool)
|
||||
for _, model := range result.Data {
|
||||
modelIDs[model.ID] = true
|
||||
}
|
||||
|
||||
// Verify all expected models are present
|
||||
expectedModels := []string{
|
||||
"reasoning",
|
||||
"ornith:35b",
|
||||
"qwen2.5:3b-instruct",
|
||||
"nomic-ai/nomic-embed-text-v2-moe",
|
||||
"BAAI/bge-reranker-base",
|
||||
}
|
||||
|
||||
for _, expected := range expectedModels {
|
||||
if !modelIDs[expected] {
|
||||
t.Errorf("expected model %q in response", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointHasRequiredFields verifies all required fields are present
|
||||
func TestModelsEndpointHasRequiredFields(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ModelListResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
model := result.Data[0]
|
||||
if model.ID == "" {
|
||||
t.Errorf("expected id field")
|
||||
}
|
||||
|
||||
if model.Object == "" {
|
||||
t.Errorf("expected object field")
|
||||
}
|
||||
|
||||
if model.OwnedBy == "" {
|
||||
t.Errorf("expected owned_by field")
|
||||
}
|
||||
|
||||
if model.Created == 0 {
|
||||
t.Errorf("expected created field (unix timestamp)")
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointNoUpstreamContact verifies endpoint doesn't contact upstream
|
||||
func TestModelsEndpointNoUpstreamContact(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
_, _ = http.Get(server.URL + "/v1/models")
|
||||
|
||||
if upstreamCalled {
|
||||
t.Errorf("upstream should not be called for /v1/models endpoint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointDerivedFromConfig verifies models come from config, not hardcoded
|
||||
func TestModelsEndpointDerivedFromConfig(t *testing.T) {
|
||||
// Create config with specific models
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"custom-model-1": {
|
||||
Name: "custom-model-1",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"custom-model-2": {
|
||||
Name: "custom-model-2",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var result ModelListResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
|
||||
// Verify only the configured models are returned
|
||||
if len(result.Data) != 2 {
|
||||
t.Errorf("expected 2 models from config, got %d", len(result.Data))
|
||||
}
|
||||
|
||||
modelIDs := make([]string, len(result.Data))
|
||||
for i, model := range result.Data {
|
||||
modelIDs[i] = model.ID
|
||||
}
|
||||
sort.Strings(modelIDs)
|
||||
|
||||
expected := []string{"custom-model-1", "custom-model-2"}
|
||||
if !equal(modelIDs, expected) {
|
||||
t.Errorf("expected models %v, got %v", expected, modelIDs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointConsistentWithDispatch verifies advertised models can dispatch
|
||||
func TestModelsEndpointConsistentWithDispatch(t *testing.T) {
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte(`{"choices":[{"message":{"content":"ok"}}]}`))
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
"ornith:35b": {
|
||||
Name: "ornith:35b",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Get list of models
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var result ModelListResponse
|
||||
json.NewDecoder(resp.Body).Decode(&result)
|
||||
resp.Body.Close()
|
||||
|
||||
// Try to dispatch to each advertised model
|
||||
for _, model := range result.Data {
|
||||
dispatchResp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
strings.NewReader(`{"model":"`+model.ID+`","messages":[]}`),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer dispatchResp.Body.Close()
|
||||
|
||||
// Should not return 400 (unknown model error)
|
||||
if dispatchResp.StatusCode == http.StatusBadRequest {
|
||||
body, _ := io.ReadAll(dispatchResp.Body)
|
||||
if strings.Contains(string(body), "unknown model") {
|
||||
t.Errorf("model %q advertised in /v1/models but not accepted for dispatch", model.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsEndpointResponseIsConsistent verifies response is consistent across calls
|
||||
func TestModelsEndpointResponseIsConsistent(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
"ornith:35b": {
|
||||
Name: "ornith:35b",
|
||||
Address: "localhost:9000",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Call endpoint twice
|
||||
resp1, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var result1 ModelListResponse
|
||||
json.NewDecoder(resp1.Body).Decode(&result1)
|
||||
resp1.Body.Close()
|
||||
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
|
||||
resp2, err := http.Get(server.URL + "/v1/models")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var result2 ModelListResponse
|
||||
json.NewDecoder(resp2.Body).Decode(&result2)
|
||||
resp2.Body.Close()
|
||||
|
||||
// Verify both responses have same models
|
||||
if len(result1.Data) != len(result2.Data) {
|
||||
t.Errorf("response length inconsistent: %d vs %d", len(result1.Data), len(result2.Data))
|
||||
}
|
||||
|
||||
ids1 := make([]string, len(result1.Data))
|
||||
ids2 := make([]string, len(result2.Data))
|
||||
|
||||
for i, m := range result1.Data {
|
||||
ids1[i] = m.ID
|
||||
}
|
||||
for i, m := range result2.Data {
|
||||
ids2[i] = m.ID
|
||||
}
|
||||
|
||||
sort.Strings(ids1)
|
||||
sort.Strings(ids2)
|
||||
|
||||
if !equal(ids1, ids2) {
|
||||
t.Errorf("responses differ: %v vs %v", ids1, ids2)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to compare string slices
|
||||
func equal(a, b []string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
+210
-4
@@ -2,17 +2,21 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/logging"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
||||
)
|
||||
|
||||
// Handler is a reverse proxy that routes requests to configured upstreams.
|
||||
@@ -22,6 +26,8 @@ type Handler struct {
|
||||
transports map[string]*http.Transport
|
||||
// config holds the gateway configuration (for model registry, etc.)
|
||||
config *config.Config
|
||||
// jwtValidator validates JWT tokens for authenticated endpoints
|
||||
jwtValidator *auth.Validator
|
||||
// Default timeouts for synthesized routes (model-based dispatch)
|
||||
defaultConnectTimeout time.Duration
|
||||
defaultReadTimeout time.Duration
|
||||
@@ -37,6 +43,26 @@ type Route struct {
|
||||
Transport *http.Transport
|
||||
}
|
||||
|
||||
// Error types for model validation
|
||||
type modelValidationError struct {
|
||||
Kind string // "invalid_json", "missing_model", "unknown_model"
|
||||
Message string
|
||||
Model string // only for unknown_model
|
||||
}
|
||||
|
||||
func (e *modelValidationError) Error() string {
|
||||
return e.Message
|
||||
}
|
||||
|
||||
// RFC 9457 Problem Details
|
||||
type problemDetail struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
ValidModels []string `json:"valid_models,omitempty"`
|
||||
}
|
||||
|
||||
// New creates a new reverse proxy handler from configuration.
|
||||
// It sets up connection pooling and rewriting rules for each route.
|
||||
func New(cfg *config.Config) *Handler {
|
||||
@@ -50,6 +76,15 @@ func New(cfg *config.Config) *Handler {
|
||||
defaultMaxBodySize: 100 * 1024 * 1024,
|
||||
}
|
||||
|
||||
// Initialize JWT validator if auth is enabled
|
||||
if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" {
|
||||
h.jwtValidator = auth.NewValidator(
|
||||
cfg.Auth.Issuer,
|
||||
cfg.Auth.Audience,
|
||||
cfg.Auth.JWKSURL,
|
||||
)
|
||||
}
|
||||
|
||||
for name, route := range cfg.Routes {
|
||||
// Create a transport per unique upstream address for connection reuse
|
||||
transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream)
|
||||
@@ -174,10 +209,85 @@ func getPeerIP(remoteAddr string) string {
|
||||
return remoteAddr
|
||||
}
|
||||
|
||||
// writeProblemDetail writes an RFC 9457 problem detail response.
|
||||
func writeProblemDetail(w http.ResponseWriter, status int, problemType, title, detail string, validModels []string) {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(status)
|
||||
|
||||
problem := problemDetail{
|
||||
Type: problemType,
|
||||
Title: title,
|
||||
Status: status,
|
||||
Detail: detail,
|
||||
ValidModels: validModels,
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(problem)
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Handle /v1/models endpoint (no routing needed, derived from config)
|
||||
if r.URL.Path == "/v1/models" && r.Method == "GET" {
|
||||
h.handleModelsEndpoint(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Handle /workflows endpoint (workflow orchestration)
|
||||
if r.URL.Path == "/workflows" {
|
||||
h.handleWorkflow(w, r)
|
||||
return
|
||||
}
|
||||
|
||||
// Try to find a matching route (including body-based dispatch for /v1/chat/completions)
|
||||
route, err := h.RouteRequest(r)
|
||||
|
||||
// Check if this is a model validation error (from body-based dispatch)
|
||||
if validationErr, ok := err.(*modelValidationError); ok {
|
||||
// This is a client error, not a routing error
|
||||
var status int
|
||||
var problemType string
|
||||
var title string
|
||||
var detail string
|
||||
|
||||
switch validationErr.Kind {
|
||||
case "invalid_json":
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/invalid-request-body"
|
||||
title = "Invalid Request Body"
|
||||
detail = validationErr.Message
|
||||
case "missing_model", "empty_model", "null_model":
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/missing-model"
|
||||
title = "Missing Model"
|
||||
detail = "The 'model' field is required and must be a non-empty string"
|
||||
case "unknown_model":
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/unknown-model"
|
||||
title = "Unknown Model"
|
||||
detail = fmt.Sprintf("Model %q is not available. See valid_models for available options.", validationErr.Model)
|
||||
default:
|
||||
status = http.StatusBadRequest
|
||||
problemType = "https://api.example.com/problems/invalid-request"
|
||||
title = "Invalid Request"
|
||||
detail = validationErr.Message
|
||||
}
|
||||
|
||||
// Get list of valid models (only for model-related errors)
|
||||
var validModels []string
|
||||
if validationErr.Kind == "unknown_model" || validationErr.Kind == "missing_model" || validationErr.Kind == "empty_model" || validationErr.Kind == "null_model" {
|
||||
validModels = h.getValidModels()
|
||||
}
|
||||
|
||||
writeProblemDetail(w, status, problemType, title, detail, validModels)
|
||||
logging.Errorf("client error", validationErr, map[string]string{
|
||||
"path": r.URL.Path,
|
||||
"method": r.Method,
|
||||
"reason": validationErr.Kind,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil || route == nil {
|
||||
// Route not found or error determining route
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
@@ -193,6 +303,51 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// JWT Authentication for /v1/* endpoints
|
||||
if h.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
writeProblemDetail(w, http.StatusUnauthorized,
|
||||
"https://api.example.com/problems/unauthorized",
|
||||
"Unauthorized",
|
||||
"Authorization header required",
|
||||
nil)
|
||||
logging.Errorf("auth failed", fmt.Errorf("missing auth header"), map[string]string{
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
claims, err := h.jwtValidator.ValidateBearerToken(authHeader)
|
||||
if err != nil {
|
||||
writeProblemDetail(w, http.StatusForbidden,
|
||||
"https://api.example.com/problems/forbidden",
|
||||
"Forbidden",
|
||||
"JWT validation failed",
|
||||
nil)
|
||||
logging.Errorf("auth failed", err, map[string]string{
|
||||
"path": r.URL.Path,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check required capability if configured
|
||||
if h.config.Auth.RequiredCapability != "" {
|
||||
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
|
||||
writeProblemDetail(w, http.StatusForbidden,
|
||||
"https://api.example.com/problems/insufficient-permissions",
|
||||
"Insufficient Permissions",
|
||||
fmt.Sprintf("Required capability: %s", h.config.Auth.RequiredCapability),
|
||||
nil)
|
||||
logging.Errorf("auth failed", fmt.Errorf("insufficient permissions"), map[string]string{
|
||||
"path": r.URL.Path,
|
||||
"required": h.config.Auth.RequiredCapability,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Note: Body size checking already happened in RouteRequest (body was read for model dispatch).
|
||||
// For other paths, we still need to enforce the cap.
|
||||
// For /v1/chat/completions, the body was already read and validated.
|
||||
@@ -228,8 +383,8 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
// Set the director to apply path rewriting
|
||||
proxy.Director = route.Director
|
||||
|
||||
// Use the connection-pooled transport
|
||||
proxy.Transport = route.Transport
|
||||
// Use the connection-pooled transport wrapped with tracing
|
||||
proxy.Transport = tracing.NewTransport(route.Transport)
|
||||
|
||||
// Set error handler to log upstream errors
|
||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||
@@ -252,6 +407,57 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
|
||||
|
||||
// getValidModels returns a sorted list of all configured model names.
|
||||
func (h *Handler) getValidModels() []string {
|
||||
var models []string
|
||||
for name := range h.config.Models {
|
||||
models = append(models, name)
|
||||
}
|
||||
sort.Strings(models)
|
||||
return models
|
||||
}
|
||||
|
||||
// modelsListResponse represents the response for GET /v1/models
|
||||
type modelsListResponse struct {
|
||||
Object string `json:"object"`
|
||||
Data []modelsListEntry `json:"data"`
|
||||
}
|
||||
|
||||
// modelsListEntry represents a single model in the list
|
||||
type modelsListEntry struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
OwnedBy string `json:"owned_by"`
|
||||
Created int64 `json:"created"`
|
||||
}
|
||||
|
||||
// handleModelsEndpoint serves GET /v1/models
|
||||
// Returns a list of all configured models, derived from config not hardcoded
|
||||
func (h *Handler) handleModelsEndpoint(w http.ResponseWriter, r *http.Request) {
|
||||
// Get all model names from config
|
||||
modelNames := h.getValidModels()
|
||||
|
||||
// Build the response
|
||||
data := make([]modelsListEntry, len(modelNames))
|
||||
for i, name := range modelNames {
|
||||
data[i] = modelsListEntry{
|
||||
ID: name,
|
||||
Object: "model",
|
||||
OwnedBy: "api.riotpiao.com",
|
||||
Created: 1700000000, // Fixed timestamp; can be made configurable if needed
|
||||
}
|
||||
}
|
||||
|
||||
response := modelsListResponse{
|
||||
Object: "list",
|
||||
Data: data,
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// Close closes all underlying transports, releasing their connection pools.
|
||||
func (h *Handler) Close() error {
|
||||
for _, transport := range h.transports {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
func TestProxyBasic(t *testing.T) {
|
||||
@@ -105,7 +105,9 @@ func TestProxyPathRewrite(t *testing.T) {
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Get(server.URL + "/v1/models")
|
||||
// Not /v1/models: ServeHTTP serves that endpoint from config (task 2.5)
|
||||
// and returns before routing, so it never exercises PathRewrite.
|
||||
resp, err := http.Get(server.URL + "/some/path")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
@@ -146,7 +148,7 @@ func TestProxyConnectionReuse(t *testing.T) {
|
||||
defer handler.Close()
|
||||
|
||||
// Verify connection reuse by checking that the same transport is used
|
||||
|
||||
|
||||
// We can't easily count raw TCP connections in this test setup,
|
||||
// but we can verify that the transport is being reused by checking
|
||||
// that the same transport handles both requests
|
||||
@@ -402,7 +404,10 @@ func TestProxyPreservesBody(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
testBody := `{"model": "test", "messages": []}`
|
||||
resp, _ := http.Post(server.URL+"/test", "application/json", strings.NewReader(testBody))
|
||||
resp, err := http.Post(server.URL+"/test", "application/json", strings.NewReader(testBody))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
if receivedBody != testBody {
|
||||
|
||||
+64
-14
@@ -9,16 +9,16 @@ import (
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// RouteRequest determines which upstream should handle the request.
|
||||
// For /v1/chat/completions, it uses body-based dispatch (reads JSON to find "model" field).
|
||||
// For other routes, it looks up by path prefix.
|
||||
// For /v1/chat/completions, /v1/embeddings, and /v1/rerank, it uses body-based dispatch.
|
||||
// For other routes, it looks up by path in the configured routes.
|
||||
func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
|
||||
// For /v1/chat/completions, use body-based dispatch
|
||||
if r.URL.Path == "/v1/chat/completions" && r.Method == "POST" {
|
||||
return h.routeByModel(r)
|
||||
// For /v1/chat/completions, /v1/embeddings, /v1/rerank use body-based dispatch
|
||||
if r.Method == "POST" && (r.URL.Path == "/v1/chat/completions" || r.URL.Path == "/v1/embeddings" || r.URL.Path == "/v1/rerank") {
|
||||
return h.routeByModel(r, r.URL.Path)
|
||||
}
|
||||
|
||||
// For other paths, try to find a matching route by path
|
||||
@@ -45,17 +45,25 @@ func (h *Handler) RouteRequest(r *http.Request) (*Route, error) {
|
||||
|
||||
// routeByModel reads the request body to find the "model" field and routes accordingly.
|
||||
// The body is preserved for forwarding to the upstream.
|
||||
func (h *Handler) routeByModel(r *http.Request) (*Route, error) {
|
||||
// Returns a modelValidationError for client errors (invalid JSON, missing/unknown model).
|
||||
// The path parameter indicates which endpoint is being called (/v1/chat/completions, /v1/embeddings, /v1/rerank)
|
||||
func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
||||
// If there's no body, we can't determine the model
|
||||
if r.Body == nil {
|
||||
return nil, fmt.Errorf("request body required")
|
||||
return nil, &modelValidationError{
|
||||
Kind: "missing_model",
|
||||
Message: "request body required",
|
||||
}
|
||||
}
|
||||
|
||||
// Read the body to extract the model name
|
||||
// We need to be careful to preserve the body for the upstream
|
||||
bodyBytes, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read request body: %w", err)
|
||||
return nil, &modelValidationError{
|
||||
Kind: "invalid_request",
|
||||
Message: fmt.Sprintf("failed to read request body: %v", err),
|
||||
}
|
||||
}
|
||||
|
||||
// Restore the body so it can be read again by the upstream
|
||||
@@ -64,26 +72,68 @@ func (h *Handler) routeByModel(r *http.Request) (*Route, error) {
|
||||
// Parse the JSON to find the model field
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
|
||||
return nil, fmt.Errorf("invalid JSON in request body: %w", err)
|
||||
return nil, &modelValidationError{
|
||||
Kind: "invalid_json",
|
||||
Message: "request body is not valid JSON",
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the model name
|
||||
modelName, ok := payload["model"].(string)
|
||||
modelVal, hasModel := payload["model"]
|
||||
if !hasModel {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "missing_model",
|
||||
Message: "'model' field is missing",
|
||||
}
|
||||
}
|
||||
|
||||
// Handle null model
|
||||
if modelVal == nil {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "null_model",
|
||||
Message: "'model' field is null",
|
||||
}
|
||||
}
|
||||
|
||||
// Extract as string
|
||||
modelName, ok := modelVal.(string)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("model field missing or not a string")
|
||||
return nil, &modelValidationError{
|
||||
Kind: "missing_model",
|
||||
Message: "'model' field must be a string",
|
||||
}
|
||||
}
|
||||
|
||||
// Handle empty string
|
||||
if modelName == "" {
|
||||
return nil, &modelValidationError{
|
||||
Kind: "empty_model",
|
||||
Message: "'model' field cannot be empty",
|
||||
}
|
||||
}
|
||||
|
||||
// Look up the model in the registry
|
||||
modelUpstream := h.config.LookupModel(modelName)
|
||||
if modelUpstream == nil {
|
||||
return nil, fmt.Errorf("unknown model: %q", modelName)
|
||||
return nil, &modelValidationError{
|
||||
Kind: "unknown_model",
|
||||
Message: fmt.Sprintf("unknown model: %q", modelName),
|
||||
Model: modelName,
|
||||
}
|
||||
}
|
||||
|
||||
// Determine the upstream path based on the request path
|
||||
upstreamPath := path
|
||||
if path == "/v1/rerank" {
|
||||
// Rerank endpoint uses /rerank path on upstream
|
||||
upstreamPath = "/rerank"
|
||||
}
|
||||
|
||||
// Create a route for this model with appropriate timeouts
|
||||
// These are sensible defaults for LLM models
|
||||
upstreamCfg := &config.Upstream{
|
||||
Address: modelUpstream.Address,
|
||||
PathRewrite: "/v1/chat/completions",
|
||||
PathRewrite: upstreamPath,
|
||||
ConnectTimeout: h.defaultConnectTimeout,
|
||||
ReadTimeout: h.defaultReadTimeout,
|
||||
WriteTimeout: h.defaultWriteTimeout,
|
||||
|
||||
@@ -6,11 +6,13 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestSSEUnbuffered verifies that SSE events stream to the client without buffering.
|
||||
@@ -473,3 +475,129 @@ func TestNoFullBuffering(t *testing.T) {
|
||||
t.Errorf("expected to read %d bytes, got %d", len(largeData), totalRead)
|
||||
}
|
||||
}
|
||||
|
||||
// TestClientDisconnectCancelsUpstream verifies that when a client closes mid-stream,
|
||||
// the upstream request context is cancelled immediately and no goroutines are leaked.
|
||||
func TestClientDisconnectCancelsUpstream(t *testing.T) {
|
||||
contextCancelledAt := time.Time{}
|
||||
contextCancelledMu := sync.Mutex{}
|
||||
upstreamRequestedAt := time.Time{}
|
||||
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamRequestedAt = time.Now()
|
||||
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
rc := http.NewResponseController(w)
|
||||
// Send events until context is cancelled
|
||||
for i := 0; i < 100; i++ {
|
||||
select {
|
||||
case <-r.Context().Done():
|
||||
contextCancelledMu.Lock()
|
||||
contextCancelledAt = time.Now()
|
||||
contextCancelledMu.Unlock()
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
fmt.Fprintf(w, "data: event%d\n\n", i)
|
||||
if err := rc.Flush(); err != nil {
|
||||
contextCancelledMu.Lock()
|
||||
contextCancelledAt = time.Now()
|
||||
contextCancelledMu.Unlock()
|
||||
return
|
||||
}
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
}
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Routes: map[string]*config.Route{
|
||||
"disconnect-route": {
|
||||
Name: "disconnect-route",
|
||||
Upstream: config.Upstream{
|
||||
Address: upstreamAddr,
|
||||
ConnectTimeout: 5 * time.Second,
|
||||
ReadTimeout: 30 * time.Second,
|
||||
WriteTimeout: 30 * time.Second,
|
||||
MaxBodySize: 1024 * 1024,
|
||||
AuthRequired: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Baseline goroutine count
|
||||
baselineGoroutines := runtime.NumGoroutine()
|
||||
|
||||
// Make a request with a custom HTTP client that allows us to close the connection
|
||||
client := &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", server.URL+"/disconnect", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("request creation failed: %v", err)
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Read a few events
|
||||
reader := bufio.NewReader(resp.Body)
|
||||
for i := 0; i < 2; i++ {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatalf("read failed: %v", err)
|
||||
}
|
||||
if !strings.Contains(line, "data:") {
|
||||
i-- // skip non-data lines
|
||||
}
|
||||
}
|
||||
|
||||
// Close the response body (simulating client disconnect)
|
||||
resp.Body.Close()
|
||||
|
||||
// Wait a bit for cancellation to propagate
|
||||
time.Sleep(200 * time.Millisecond)
|
||||
|
||||
// Verify context was cancelled
|
||||
contextCancelledMu.Lock()
|
||||
cancelled := !contextCancelledAt.IsZero()
|
||||
cancelDelay := time.Duration(0)
|
||||
if cancelled {
|
||||
cancelDelay = contextCancelledAt.Sub(upstreamRequestedAt)
|
||||
}
|
||||
contextCancelledMu.Unlock()
|
||||
|
||||
if !cancelled {
|
||||
t.Errorf("expected upstream context to be cancelled, but it was not")
|
||||
}
|
||||
|
||||
// Verify cancellation happened quickly (within 1s)
|
||||
if cancelDelay > 1*time.Second {
|
||||
t.Errorf("context cancellation took %.2fs (expected < 1s)", cancelDelay.Seconds())
|
||||
}
|
||||
|
||||
// Wait a bit for goroutines to clean up
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
// Check for goroutine leaks
|
||||
finalGoroutines := runtime.NumGoroutine()
|
||||
if finalGoroutines > baselineGoroutines+5 {
|
||||
t.Errorf("possible goroutine leak: baseline=%d, final=%d", baselineGoroutines, finalGoroutines)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// TestConnectTimeout verifies that connections fail at the configured timeout.
|
||||
@@ -102,7 +102,10 @@ func TestReadTimeout(t *testing.T) {
|
||||
defer server.Close()
|
||||
|
||||
start := time.Now()
|
||||
resp, _ := http.Get(server.URL + "/test")
|
||||
resp, err := http.Get(server.URL + "/test")
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
elapsed := time.Since(start)
|
||||
|
||||
// Should timeout around the read timeout (with some tolerance)
|
||||
|
||||
@@ -11,13 +11,13 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// OpenAI-style tool definition
|
||||
type Tool struct {
|
||||
Type string `json:"type"`
|
||||
Function ToolFunction `json:"function"`
|
||||
Type string `json:"type"`
|
||||
Function ToolFunction `json:"function"`
|
||||
}
|
||||
|
||||
type ToolFunction struct {
|
||||
@@ -28,21 +28,21 @@ type ToolFunction struct {
|
||||
|
||||
// OpenAI chat completion with tools request
|
||||
type ChatCompletionRequest struct {
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
Model string `json:"model"`
|
||||
Messages []Message `json:"messages"`
|
||||
Tools []Tool `json:"tools,omitempty"`
|
||||
Stream bool `json:"stream,omitempty"`
|
||||
}
|
||||
|
||||
type Message struct {
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content interface{} `json:"content"`
|
||||
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
|
||||
}
|
||||
|
||||
type ToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"`
|
||||
Function FunctionCall `json:"function"`
|
||||
}
|
||||
|
||||
@@ -64,21 +64,21 @@ func TestToolCallOpenAIStyle(t *testing.T) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
response := map[string]interface{}{
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"model": "reasoning",
|
||||
"id": "chatcmpl-123",
|
||||
"object": "chat.completion",
|
||||
"model": "reasoning",
|
||||
"choices": []map[string]interface{}{
|
||||
{
|
||||
"index": 0,
|
||||
"message": map[string]interface{}{
|
||||
"role": "assistant",
|
||||
"role": "assistant",
|
||||
"content": nil,
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
"name": "get_weather",
|
||||
"arguments": `{"location":"San Francisco","unit":"celsius"}`,
|
||||
},
|
||||
},
|
||||
@@ -372,10 +372,10 @@ func TestToolCallMultiTurn(t *testing.T) {
|
||||
"content": nil,
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{
|
||||
"id": "call_abc123",
|
||||
"id": "call_abc123",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
"name": "get_weather",
|
||||
"arguments": `{"location":"San Francisco"}`,
|
||||
},
|
||||
},
|
||||
@@ -430,7 +430,10 @@ func TestToolCallMultiTurn(t *testing.T) {
|
||||
}
|
||||
|
||||
body1, _ := json.Marshal(turn1)
|
||||
resp1, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body1))
|
||||
resp1, err := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body1))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var turn1Resp map[string]interface{}
|
||||
json.NewDecoder(resp1.Body).Decode(&turn1Resp)
|
||||
resp1.Body.Close()
|
||||
@@ -472,7 +475,10 @@ func TestToolCallMultiTurn(t *testing.T) {
|
||||
}
|
||||
|
||||
body2, _ := json.Marshal(turn2)
|
||||
resp2, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body2))
|
||||
resp2, err := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body2))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var turn2Resp map[string]interface{}
|
||||
json.NewDecoder(resp2.Body).Decode(&turn2Resp)
|
||||
resp2.Body.Close()
|
||||
@@ -503,7 +509,7 @@ func TestParallelToolCalls(t *testing.T) {
|
||||
"content": nil,
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{
|
||||
"id": "call_1",
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
@@ -511,7 +517,7 @@ func TestParallelToolCalls(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "call_2",
|
||||
"id": "call_2",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
@@ -519,7 +525,7 @@ func TestParallelToolCalls(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
"id": "call_3",
|
||||
"id": "call_3",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "get_weather",
|
||||
@@ -576,7 +582,10 @@ func TestParallelToolCalls(t *testing.T) {
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(request)
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body))
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var respData map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respData)
|
||||
resp.Body.Close()
|
||||
@@ -627,9 +636,9 @@ func TestAnthropicToolUse(t *testing.T) {
|
||||
|
||||
// Anthropic response format with tool_use block
|
||||
response := map[string]interface{}{
|
||||
"id": "msg_123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"id": "msg_123",
|
||||
"type": "message",
|
||||
"role": "assistant",
|
||||
"content": []map[string]interface{}{
|
||||
{
|
||||
"type": "text",
|
||||
@@ -714,7 +723,10 @@ func TestAnthropicToolUse(t *testing.T) {
|
||||
body, _ := json.Marshal(anthropicRequest)
|
||||
// Note: For now we route through a generic path
|
||||
// In Phase 2.9+, this would be integrated with the Anthropic dialect handler
|
||||
resp, _ := http.Post(server.URL+"/v1/messages", "application/json", bytes.NewReader(body))
|
||||
resp, err := http.Post(server.URL+"/v1/messages", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var respData map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respData)
|
||||
resp.Body.Close()
|
||||
@@ -762,7 +774,7 @@ func TestComplexToolArguments(t *testing.T) {
|
||||
"content": nil,
|
||||
"tool_calls": []map[string]interface{}{
|
||||
{
|
||||
"id": "call_complex",
|
||||
"id": "call_complex",
|
||||
"type": "function",
|
||||
"function": map[string]interface{}{
|
||||
"name": "create_event",
|
||||
@@ -832,7 +844,10 @@ func TestComplexToolArguments(t *testing.T) {
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(request)
|
||||
resp, _ := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body))
|
||||
resp, err := http.Post(server.URL+"/v1/chat/completions", "application/json", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
var respData map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&respData)
|
||||
resp.Body.Close()
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// RFC 9457 Problem Details
|
||||
type ProblemDetail struct {
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Status int `json:"status"`
|
||||
Detail string `json:"detail"`
|
||||
Instance string `json:"instance,omitempty"`
|
||||
Extra map[string]interface{} `json:"-"`
|
||||
}
|
||||
|
||||
// UnmarshalJSON allows capturing extra fields
|
||||
func (p *ProblemDetail) UnmarshalJSON(data []byte) error {
|
||||
type Alias ProblemDetail
|
||||
aux := &struct {
|
||||
*Alias
|
||||
}{
|
||||
Alias: (*Alias)(p),
|
||||
}
|
||||
if err := json.Unmarshal(data, &aux); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Capture extra fields
|
||||
var raw map[string]interface{}
|
||||
json.Unmarshal(data, &raw)
|
||||
extra := make(map[string]interface{})
|
||||
for k, v := range raw {
|
||||
if k != "type" && k != "title" && k != "status" && k != "detail" && k != "instance" {
|
||||
extra[k] = v
|
||||
}
|
||||
}
|
||||
p.Extra = extra
|
||||
return nil
|
||||
}
|
||||
|
||||
// TestUnknownModelReturns4xx verifies unknown model returns client error
|
||||
func TestUnknownModelReturns4xx(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
Path: "/v1/chat/completions",
|
||||
},
|
||||
"ornith:35b": {
|
||||
Name: "ornith:35b",
|
||||
Address: "ornith-predictor:80",
|
||||
Path: "/v1/chat/completions",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"gpt-4","messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Verify 4xx status
|
||||
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx status for unknown model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Verify RFC 9457 content type
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected content-type application/problem+json, got %s", ct)
|
||||
}
|
||||
|
||||
// Verify response is valid problem detail
|
||||
var prob ProblemDetail
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if err := json.Unmarshal(body, &prob); err != nil {
|
||||
t.Errorf("response is not valid JSON: %v", err)
|
||||
}
|
||||
|
||||
if prob.Status == 0 {
|
||||
t.Errorf("expected status in problem detail")
|
||||
}
|
||||
|
||||
if prob.Title == "" {
|
||||
t.Errorf("expected title in problem detail")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownModelEnumeratesValidModels verifies all models are listed
|
||||
func TestUnknownModelEnumeratesValidModels(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
"ornith:35b": {
|
||||
Name: "ornith:35b",
|
||||
Address: "ornith-predictor:80",
|
||||
},
|
||||
"qwen2.5:3b-instruct": {
|
||||
Name: "qwen2.5:3b-instruct",
|
||||
Address: "ornith-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var prob map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&prob)
|
||||
|
||||
// Check for valid_models field (as an extra field beyond RFC 9457)
|
||||
validModels, hasModels := prob["valid_models"]
|
||||
if !hasModels {
|
||||
t.Errorf("expected valid_models field in problem detail")
|
||||
return
|
||||
}
|
||||
|
||||
models := validModels.([]interface{})
|
||||
if len(models) != 3 {
|
||||
t.Errorf("expected 3 models in valid_models, got %d", len(models))
|
||||
}
|
||||
|
||||
modelNames := make(map[string]bool)
|
||||
for _, m := range models {
|
||||
modelNames[m.(string)] = true
|
||||
}
|
||||
|
||||
expectedModels := []string{"reasoning", "ornith:35b", "qwen2.5:3b-instruct"}
|
||||
for _, expected := range expectedModels {
|
||||
if !modelNames[expected] {
|
||||
t.Errorf("expected model %s in valid_models", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestMissingModelFieldReturns4xx verifies missing model field is client error
|
||||
func TestMissingModelFieldReturns4xx(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Request with no model field
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for missing model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected problem+json for missing model")
|
||||
}
|
||||
|
||||
// Verify body does not contain the request
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
if strings.Contains(string(body), "messages") {
|
||||
t.Errorf("response should not echo request body")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNullModelFieldReturns4xx verifies null model is client error
|
||||
func TestNullModelFieldReturns4xx(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Request with null model
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":null,"messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for null model, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
ct := resp.Header.Get("Content-Type")
|
||||
if !strings.Contains(ct, "application/problem+json") {
|
||||
t.Errorf("expected problem+json for null model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptyModelFieldReturns4xx verifies empty model string is client error
|
||||
func TestEmptyModelFieldReturns4xx(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Request with empty model string
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"","messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for empty model, got %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// TestInvalidJSONIsDistinguishableError verifies invalid JSON is separate from unknown model
|
||||
func TestInvalidJSONIsDistinguishableError(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Request with invalid JSON
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`not json`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode < 400 || resp.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for invalid JSON, got %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var prob map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&prob)
|
||||
|
||||
// Invalid JSON error should mention JSON parsing, not model
|
||||
detail := prob["detail"].(string)
|
||||
if !strings.Contains(strings.ToLower(detail), "json") {
|
||||
t.Errorf("expected detail to mention JSON for invalid JSON error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownModelDoesNotContactUpstream verifies no upstream call is made
|
||||
func TestUnknownModelDoesNotContactUpstream(t *testing.T) {
|
||||
upstreamCalled := false
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
upstreamCalled = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
|
||||
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: upstreamAddr,
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
_, _ = http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)),
|
||||
)
|
||||
|
||||
if upstreamCalled {
|
||||
t.Errorf("upstream should not be called for unknown model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestUnknownModelLogsReason verifies rejection is logged
|
||||
func TestUnknownModelLogsReason(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// This test verifies logging behavior by checking the handler's logger output
|
||||
// In a real scenario, you'd capture stderr or use a test logger
|
||||
_, _ = http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"gpt-4","messages":[]}`)),
|
||||
)
|
||||
|
||||
// Logging is verified by checking that no panic occurs
|
||||
// and the request completes successfully
|
||||
}
|
||||
|
||||
// TestMissingModelAndUnknownModelBothReturn4xx verifies consistent error class
|
||||
func TestMissingModelAndUnknownModelBothReturn4xx(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
// Test missing model
|
||||
resp1, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp1.Body.Close()
|
||||
|
||||
// Test unknown model
|
||||
resp2, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
resp2.Body.Close()
|
||||
|
||||
// Both should be in 4xx range
|
||||
if resp1.StatusCode < 400 || resp1.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for missing model, got %d", resp1.StatusCode)
|
||||
}
|
||||
|
||||
if resp2.StatusCode < 400 || resp2.StatusCode >= 500 {
|
||||
t.Errorf("expected 4xx for unknown model, got %d", resp2.StatusCode)
|
||||
}
|
||||
|
||||
// Both should be problem+json
|
||||
ct1 := resp1.Header.Get("Content-Type")
|
||||
ct2 := resp2.Header.Get("Content-Type")
|
||||
|
||||
if !strings.Contains(ct1, "application/problem+json") {
|
||||
t.Errorf("expected problem+json for missing model")
|
||||
}
|
||||
|
||||
if !strings.Contains(ct2, "application/problem+json") {
|
||||
t.Errorf("expected problem+json for unknown model")
|
||||
}
|
||||
}
|
||||
|
||||
// TestProblemDetailHasRequiredFields verifies RFC 9457 compliance
|
||||
func TestProblemDetailHasRequiredFields(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Name: "reasoning",
|
||||
Address: "reasoning-predictor:80",
|
||||
},
|
||||
},
|
||||
Routes: make(map[string]*config.Route),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
defer handler.Close()
|
||||
|
||||
server := httptest.NewServer(handler)
|
||||
defer server.Close()
|
||||
|
||||
resp, err := http.Post(
|
||||
server.URL+"/v1/chat/completions",
|
||||
"application/json",
|
||||
bytes.NewReader([]byte(`{"model":"unknown","messages":[]}`)),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("request failed: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var prob map[string]interface{}
|
||||
json.NewDecoder(resp.Body).Decode(&prob)
|
||||
|
||||
// RFC 9457 required fields
|
||||
if prob["type"] == nil {
|
||||
t.Errorf("expected 'type' field in problem detail")
|
||||
}
|
||||
|
||||
if prob["title"] == nil {
|
||||
t.Errorf("expected 'title' field in problem detail")
|
||||
}
|
||||
|
||||
if prob["status"] == nil {
|
||||
t.Errorf("expected 'status' field in problem detail")
|
||||
}
|
||||
|
||||
if prob["detail"] == nil {
|
||||
t.Errorf("expected 'detail' field in problem detail")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,484 @@
|
||||
// Package proxy provides request routing and forwarding.
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkflowRequest represents a workflow execution request
|
||||
type WorkflowRequest struct {
|
||||
// Workflow ID or name
|
||||
Workflow string `json:"workflow"`
|
||||
|
||||
// Input parameters for the workflow
|
||||
Input map[string]interface{} `json:"input"`
|
||||
|
||||
// Optional: timeout in seconds
|
||||
Timeout int `json:"timeout,omitempty"`
|
||||
|
||||
// Optional: wait for result (default: true)
|
||||
Wait *bool `json:"wait,omitempty"`
|
||||
}
|
||||
|
||||
// WorkflowResponse represents the response from workflow execution
|
||||
type WorkflowResponse struct {
|
||||
// Workflow execution ID
|
||||
ID string `json:"id"`
|
||||
|
||||
// Workflow name
|
||||
Workflow string `json:"workflow"`
|
||||
|
||||
// Execution status: pending, running, completed, failed
|
||||
Status string `json:"status"`
|
||||
|
||||
// Output of the workflow
|
||||
Output interface{} `json:"output,omitempty"`
|
||||
|
||||
// Error message if workflow failed
|
||||
Error string `json:"error,omitempty"`
|
||||
|
||||
// Timestamp when workflow was created
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
// Timestamp when workflow completed
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||
}
|
||||
|
||||
// PredefinedWorkflow defines a workflow template that combines multiple API calls
|
||||
type PredefinedWorkflow struct {
|
||||
Name string
|
||||
Description string
|
||||
Handler func(*http.Request, *Handler, map[string]interface{}) (interface{}, error)
|
||||
}
|
||||
|
||||
// handleWorkflow handles the /workflows endpoint
|
||||
// It accepts workflow definitions and orchestrates API calls
|
||||
func (h *Handler) handleWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
// Only POST is supported
|
||||
if r.Method != "POST" {
|
||||
w.Header().Set("Content-Type", "application/problem+json")
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
fmt.Fprintf(w, `{"type":"https://api.example.com/problems/method-not-allowed","title":"Method Not Allowed","status":405,"detail":"Only POST is supported for /workflows"}`)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
var workflowReq WorkflowRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&workflowReq); err != nil {
|
||||
writeProblemDetail(w, http.StatusBadRequest, "https://api.example.com/problems/invalid-workflow-request", "Invalid Workflow Request", "Failed to parse workflow request: "+err.Error(), nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate workflow name
|
||||
if workflowReq.Workflow == "" {
|
||||
writeProblemDetail(w, http.StatusBadRequest, "https://api.example.com/problems/missing-workflow", "Missing Workflow", "The 'workflow' field is required", nil)
|
||||
return
|
||||
}
|
||||
|
||||
// Get predefined workflow
|
||||
workflow, ok := h.getWorkflow(workflowReq.Workflow)
|
||||
if !ok {
|
||||
availableWorkflows := h.getAvailableWorkflows()
|
||||
writeProblemDetail(w, http.StatusBadRequest, "https://api.example.com/problems/unknown-workflow", "Unknown Workflow", fmt.Sprintf("Workflow %q is not available", workflowReq.Workflow), availableWorkflows)
|
||||
return
|
||||
}
|
||||
|
||||
// Default wait to true
|
||||
wait := true
|
||||
if workflowReq.Wait != nil {
|
||||
wait = *workflowReq.Wait
|
||||
}
|
||||
|
||||
// Set default timeout if not provided
|
||||
timeout := time.Duration(30) * time.Second
|
||||
if workflowReq.Timeout > 0 {
|
||||
timeout = time.Duration(workflowReq.Timeout) * time.Second
|
||||
}
|
||||
|
||||
// Create a context with timeout for workflow execution
|
||||
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||
defer cancel()
|
||||
|
||||
// Execute workflow
|
||||
output, err := workflow.Handler(r.WithContext(ctx), h, workflowReq.Input)
|
||||
|
||||
// Build response
|
||||
workflowResp := WorkflowResponse{
|
||||
ID: generateWorkflowID(),
|
||||
Workflow: workflowReq.Workflow,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
workflowResp.Status = "failed"
|
||||
workflowResp.Error = err.Error()
|
||||
} else {
|
||||
if wait {
|
||||
workflowResp.Status = "completed"
|
||||
workflowResp.Output = output
|
||||
now := time.Now()
|
||||
workflowResp.CompletedAt = &now
|
||||
} else {
|
||||
workflowResp.Status = "pending"
|
||||
}
|
||||
}
|
||||
|
||||
// Write response
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
} else {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
json.NewEncoder(w).Encode(workflowResp)
|
||||
}
|
||||
|
||||
// getWorkflow returns a predefined workflow by name
|
||||
func (h *Handler) getWorkflow(name string) (*PredefinedWorkflow, bool) {
|
||||
workflows := h.getPredefinedWorkflows()
|
||||
for _, wf := range workflows {
|
||||
if wf.Name == name {
|
||||
return &wf, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// getPredefinedWorkflows returns all available workflows
|
||||
func (h *Handler) getPredefinedWorkflows() []PredefinedWorkflow {
|
||||
return []PredefinedWorkflow{
|
||||
{
|
||||
Name: "chat-and-embed",
|
||||
Description: "Chat with a model and then embed the response",
|
||||
Handler: h.chatAndEmbedWorkflow,
|
||||
},
|
||||
{
|
||||
Name: "multi-model-chat",
|
||||
Description: "Chat with multiple models sequentially",
|
||||
Handler: h.multiModelChatWorkflow,
|
||||
},
|
||||
{
|
||||
Name: "rag-pipeline",
|
||||
Description: "RAG pipeline: embed query, rerank, then chat with context",
|
||||
Handler: h.ragPipelineWorkflow,
|
||||
},
|
||||
{
|
||||
Name: "batch-embeddings",
|
||||
Description: "Generate embeddings for multiple texts",
|
||||
Handler: h.batchEmbeddingsWorkflow,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// getAvailableWorkflows returns a list of available workflow names
|
||||
func (h *Handler) getAvailableWorkflows() []string {
|
||||
workflows := h.getPredefinedWorkflows()
|
||||
names := make([]string, len(workflows))
|
||||
for i, wf := range workflows {
|
||||
names[i] = wf.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// Workflow implementations
|
||||
|
||||
// chatAndEmbedWorkflow: Chat with a model, then embed the response
|
||||
func (h *Handler) chatAndEmbedWorkflow(r *http.Request, handler *Handler, input map[string]interface{}) (interface{}, error) {
|
||||
model, ok := input["model"].(string)
|
||||
if !ok || model == "" {
|
||||
return nil, fmt.Errorf("missing required parameter: model")
|
||||
}
|
||||
|
||||
embedModel, ok := input["embed_model"].(string)
|
||||
if !ok {
|
||||
embedModel = "nomic-ai/nomic-embed-text-v2-moe"
|
||||
}
|
||||
|
||||
messages, ok := input["messages"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing required parameter: messages")
|
||||
}
|
||||
|
||||
// Step 1: Chat
|
||||
chatReq := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
chatBody, _ := json.Marshal(chatReq)
|
||||
chatHTTPReq, _ := http.NewRequest("POST", "/v1/chat/completions", io.NopCloser(bytes.NewReader(chatBody)))
|
||||
chatHTTPReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// Create a response writer to capture the chat response
|
||||
chatResp := &responseCapture{}
|
||||
handler.ServeHTTP(chatResp, chatHTTPReq)
|
||||
|
||||
var chatResult map[string]interface{}
|
||||
if err := json.Unmarshal(chatResp.body.Bytes(), &chatResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse chat response: %v", err)
|
||||
}
|
||||
|
||||
// Extract message content
|
||||
var messageContent string
|
||||
if choices, ok := chatResult["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||
if message, ok := choice["message"].(map[string]interface{}); ok {
|
||||
if content, ok := message["content"].(string); ok {
|
||||
messageContent = content
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Embed the response
|
||||
embedReq := map[string]interface{}{
|
||||
"model": embedModel,
|
||||
"input": messageContent,
|
||||
}
|
||||
|
||||
embedBody, _ := json.Marshal(embedReq)
|
||||
embedHTTPReq, _ := http.NewRequest("POST", "/v1/embeddings", io.NopCloser(bytes.NewReader(embedBody)))
|
||||
embedHTTPReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
embedResp := &responseCapture{}
|
||||
handler.ServeHTTP(embedResp, embedHTTPReq)
|
||||
|
||||
var embedResult map[string]interface{}
|
||||
if err := json.Unmarshal(embedResp.body.Bytes(), &embedResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse embedding response: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"chat_response": chatResult,
|
||||
"embedding_response": embedResult,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// multiModelChatWorkflow: Chat with multiple models sequentially
|
||||
func (h *Handler) multiModelChatWorkflow(r *http.Request, handler *Handler, input map[string]interface{}) (interface{}, error) {
|
||||
models, ok := input["models"].([]interface{})
|
||||
if !ok || len(models) == 0 {
|
||||
return nil, fmt.Errorf("missing required parameter: models (array)")
|
||||
}
|
||||
|
||||
messages, ok := input["messages"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing required parameter: messages")
|
||||
}
|
||||
|
||||
results := make([]map[string]interface{}, 0)
|
||||
|
||||
for _, modelInterface := range models {
|
||||
model, ok := modelInterface.(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
chatReq := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
}
|
||||
|
||||
chatBody, _ := json.Marshal(chatReq)
|
||||
chatHTTPReq, _ := http.NewRequest("POST", "/v1/chat/completions", io.NopCloser(bytes.NewReader(chatBody)))
|
||||
chatHTTPReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
chatResp := &responseCapture{}
|
||||
handler.ServeHTTP(chatResp, chatHTTPReq)
|
||||
|
||||
var chatResult map[string]interface{}
|
||||
if err := json.Unmarshal(chatResp.body.Bytes(), &chatResult); err != nil {
|
||||
results = append(results, map[string]interface{}{
|
||||
"model": model,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
results = append(results, map[string]interface{}{
|
||||
"model": model,
|
||||
"result": chatResult,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ragPipelineWorkflow: RAG pipeline - embed query, rerank, chat with context
|
||||
func (h *Handler) ragPipelineWorkflow(r *http.Request, handler *Handler, input map[string]interface{}) (interface{}, error) {
|
||||
query, ok := input["query"].(string)
|
||||
if !ok || query == "" {
|
||||
return nil, fmt.Errorf("missing required parameter: query")
|
||||
}
|
||||
|
||||
documents, ok := input["documents"].([]interface{})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("missing required parameter: documents")
|
||||
}
|
||||
|
||||
model, ok := input["model"].(string)
|
||||
if !ok {
|
||||
model = "reasoning"
|
||||
}
|
||||
|
||||
rerankModel, ok := input["rerank_model"].(string)
|
||||
if !ok {
|
||||
rerankModel = "BAAI/bge-reranker-base"
|
||||
}
|
||||
|
||||
topK := 3
|
||||
if tk, ok := input["top_k"].(float64); ok {
|
||||
topK = int(tk)
|
||||
}
|
||||
|
||||
// Step 1: Rerank documents based on query
|
||||
rerankReq := map[string]interface{}{
|
||||
"model": rerankModel,
|
||||
"query": query,
|
||||
"texts": documents,
|
||||
"top_k": topK,
|
||||
}
|
||||
|
||||
rerankBody, _ := json.Marshal(rerankReq)
|
||||
rerankHTTPReq, _ := http.NewRequest("POST", "/v1/rerank", io.NopCloser(bytes.NewReader(rerankBody)))
|
||||
rerankHTTPReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
rerankResp := &responseCapture{}
|
||||
handler.ServeHTTP(rerankResp, rerankHTTPReq)
|
||||
|
||||
var rerankResult map[string]interface{}
|
||||
if err := json.Unmarshal(rerankResp.body.Bytes(), &rerankResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse rerank response: %v", err)
|
||||
}
|
||||
|
||||
// Extract top documents
|
||||
var topDocs []string
|
||||
if results, ok := rerankResult["results"].([]interface{}); ok {
|
||||
for i, resultInterface := range results {
|
||||
if i >= topK {
|
||||
break
|
||||
}
|
||||
if result, ok := resultInterface.(map[string]interface{}); ok {
|
||||
if text, ok := result["text"].(string); ok {
|
||||
topDocs = append(topDocs, text)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Chat with context
|
||||
context := fmt.Sprintf("Context from documents:\n%v\n\nQuery: %s", topDocs, query)
|
||||
|
||||
chatReq := map[string]interface{}{
|
||||
"model": model,
|
||||
"messages": []interface{}{
|
||||
map[string]interface{}{
|
||||
"role": "user",
|
||||
"content": context,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
chatBody, _ := json.Marshal(chatReq)
|
||||
chatHTTPReq, _ := http.NewRequest("POST", "/v1/chat/completions", io.NopCloser(bytes.NewReader(chatBody)))
|
||||
chatHTTPReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
chatResp := &responseCapture{}
|
||||
handler.ServeHTTP(chatResp, chatHTTPReq)
|
||||
|
||||
var chatResult map[string]interface{}
|
||||
if err := json.Unmarshal(chatResp.body.Bytes(), &chatResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse chat response: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"reranked_documents": topDocs,
|
||||
"chat_response": chatResult,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// batchEmbeddingsWorkflow: Generate embeddings for multiple texts
|
||||
func (h *Handler) batchEmbeddingsWorkflow(r *http.Request, handler *Handler, input map[string]interface{}) (interface{}, error) {
|
||||
texts, ok := input["texts"].([]interface{})
|
||||
if !ok || len(texts) == 0 {
|
||||
return nil, fmt.Errorf("missing required parameter: texts (array)")
|
||||
}
|
||||
|
||||
model, ok := input["model"].(string)
|
||||
if !ok {
|
||||
model = "nomic-ai/nomic-embed-text-v2-moe"
|
||||
}
|
||||
|
||||
// Convert interface{} to []string
|
||||
textStrings := make([]string, 0)
|
||||
for _, t := range texts {
|
||||
if str, ok := t.(string); ok {
|
||||
textStrings = append(textStrings, str)
|
||||
}
|
||||
}
|
||||
|
||||
if len(textStrings) == 0 {
|
||||
return nil, fmt.Errorf("no valid text strings in texts array")
|
||||
}
|
||||
|
||||
embedReq := map[string]interface{}{
|
||||
"model": model,
|
||||
"input": textStrings,
|
||||
}
|
||||
|
||||
embedBody, _ := json.Marshal(embedReq)
|
||||
embedHTTPReq, _ := http.NewRequest("POST", "/v1/embeddings", io.NopCloser(bytes.NewReader(embedBody)))
|
||||
embedHTTPReq.Header.Set("Content-Type", "application/json")
|
||||
|
||||
embedResp := &responseCapture{}
|
||||
handler.ServeHTTP(embedResp, embedHTTPReq)
|
||||
|
||||
var embedResult map[string]interface{}
|
||||
if err := json.Unmarshal(embedResp.body.Bytes(), &embedResult); err != nil {
|
||||
return nil, fmt.Errorf("failed to parse embedding response: %v", err)
|
||||
}
|
||||
|
||||
return embedResult, nil
|
||||
}
|
||||
|
||||
// Utility functions
|
||||
|
||||
// responseCapture captures HTTP response for reuse within workflows
|
||||
type responseCapture struct {
|
||||
status int
|
||||
header http.Header
|
||||
body bytes.Buffer
|
||||
}
|
||||
|
||||
func (w *responseCapture) Header() http.Header {
|
||||
if w.header == nil {
|
||||
w.header = make(http.Header)
|
||||
}
|
||||
return w.header
|
||||
}
|
||||
|
||||
func (w *responseCapture) Write(b []byte) (int, error) {
|
||||
if w.status == 0 {
|
||||
w.status = http.StatusOK
|
||||
}
|
||||
return w.body.Write(b)
|
||||
}
|
||||
|
||||
func (w *responseCapture) WriteHeader(statusCode int) {
|
||||
if w.status == 0 {
|
||||
w.status = statusCode
|
||||
}
|
||||
}
|
||||
|
||||
// generateWorkflowID generates a unique workflow execution ID
|
||||
func generateWorkflowID() string {
|
||||
return fmt.Sprintf("wf_%d", time.Now().UnixNano())
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
package proxy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
func TestWorkflowEndpointNotFound(t *testing.T) {
|
||||
// Create a minimal config
|
||||
cfg := &config.Config{
|
||||
Routes: make(map[string]*config.Route),
|
||||
Models: map[string]*config.ModelUpstream{
|
||||
"reasoning": {
|
||||
Address: "localhost:8001",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
|
||||
// Test POST /workflows with unknown workflow
|
||||
body := map[string]interface{}{
|
||||
"workflow": "unknown-workflow",
|
||||
"input": map[string]interface{}{},
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", "/workflows", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &response)
|
||||
|
||||
if response["type"] != "https://api.example.com/problems/unknown-workflow" {
|
||||
t.Errorf("Expected unknown-workflow error, got %v", response["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowEndpointMissingWorkflow(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Routes: make(map[string]*config.Route),
|
||||
Models: make(map[string]*config.ModelUpstream),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
|
||||
// Test POST /workflows with missing workflow field
|
||||
body := map[string]interface{}{
|
||||
"input": map[string]interface{}{},
|
||||
}
|
||||
|
||||
bodyBytes, _ := json.Marshal(body)
|
||||
req := httptest.NewRequest("POST", "/workflows", bytes.NewReader(bodyBytes))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response map[string]interface{}
|
||||
json.Unmarshal(w.Body.Bytes(), &response)
|
||||
|
||||
if response["type"] != "https://api.example.com/problems/missing-workflow" {
|
||||
t.Errorf("Expected missing-workflow error, got %v", response["type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowEndpointInvalidMethod(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Routes: make(map[string]*config.Route),
|
||||
Models: make(map[string]*config.ModelUpstream),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
|
||||
// Test GET /workflows (should be 405)
|
||||
req := httptest.NewRequest("GET", "/workflows", nil)
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("Expected 405, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowEndpointInvalidJSON(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Routes: make(map[string]*config.Route),
|
||||
Models: make(map[string]*config.ModelUpstream),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
|
||||
// Test POST /workflows with invalid JSON
|
||||
req := httptest.NewRequest("POST", "/workflows", bytes.NewReader([]byte("not json")))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetAvailableWorkflows(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Routes: make(map[string]*config.Route),
|
||||
Models: make(map[string]*config.ModelUpstream),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
|
||||
workflows := handler.getAvailableWorkflows()
|
||||
|
||||
expectedWorkflows := []string{
|
||||
"chat-and-embed",
|
||||
"multi-model-chat",
|
||||
"rag-pipeline",
|
||||
"batch-embeddings",
|
||||
}
|
||||
|
||||
if len(workflows) != len(expectedWorkflows) {
|
||||
t.Errorf("Expected %d workflows, got %d", len(expectedWorkflows), len(workflows))
|
||||
}
|
||||
|
||||
// Check that all expected workflows are present
|
||||
for _, expected := range expectedWorkflows {
|
||||
found := false
|
||||
for _, actual := range workflows {
|
||||
if actual == expected {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("Expected workflow %q not found", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetWorkflow(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
Routes: make(map[string]*config.Route),
|
||||
Models: make(map[string]*config.ModelUpstream),
|
||||
}
|
||||
|
||||
handler := New(cfg)
|
||||
|
||||
// Test getting a valid workflow
|
||||
workflow, ok := handler.getWorkflow("chat-and-embed")
|
||||
if !ok {
|
||||
t.Error("Expected to find chat-and-embed workflow")
|
||||
}
|
||||
if workflow.Name != "chat-and-embed" {
|
||||
t.Errorf("Expected workflow name chat-and-embed, got %s", workflow.Name)
|
||||
}
|
||||
|
||||
// Test getting an invalid workflow
|
||||
workflow, ok = handler.getWorkflow("invalid-workflow")
|
||||
if ok {
|
||||
t.Error("Expected not to find invalid-workflow")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateWorkflowID(t *testing.T) {
|
||||
id1 := generateWorkflowID()
|
||||
id2 := generateWorkflowID()
|
||||
|
||||
if id1 == id2 {
|
||||
t.Error("Generated workflow IDs should be unique")
|
||||
}
|
||||
|
||||
if !bytes.HasPrefix([]byte(id1), []byte("wf_")) {
|
||||
t.Errorf("Workflow ID should start with 'wf_', got %s", id1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponseCapture(t *testing.T) {
|
||||
rc := &responseCapture{}
|
||||
|
||||
// Test Header
|
||||
rc.Header().Set("X-Test", "value")
|
||||
if rc.Header().Get("X-Test") != "value" {
|
||||
t.Error("Header not set correctly")
|
||||
}
|
||||
|
||||
// Test Write
|
||||
n, err := rc.Write([]byte("test content"))
|
||||
if err != nil {
|
||||
t.Errorf("Unexpected error: %v", err)
|
||||
}
|
||||
if n != 12 {
|
||||
t.Errorf("Expected 12 bytes written, got %d", n)
|
||||
}
|
||||
if rc.body.String() != "test content" {
|
||||
t.Errorf("Expected 'test content', got %s", rc.body.String())
|
||||
}
|
||||
|
||||
// Test WriteHeader
|
||||
rc.WriteHeader(http.StatusOK)
|
||||
if rc.status != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", rc.status)
|
||||
}
|
||||
|
||||
// Test WriteHeader doesn't override
|
||||
rc.WriteHeader(http.StatusInternalServerError)
|
||||
if rc.status != http.StatusOK {
|
||||
t.Error("WriteHeader should not override existing status")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowResponseSerialization(t *testing.T) {
|
||||
resp := WorkflowResponse{
|
||||
ID: "wf_123",
|
||||
Workflow: "test-workflow",
|
||||
Status: "completed",
|
||||
Output: map[string]interface{}{
|
||||
"key": "value",
|
||||
},
|
||||
Error: "",
|
||||
}
|
||||
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
t.Errorf("Failed to marshal response: %v", err)
|
||||
}
|
||||
|
||||
var unmarshaled WorkflowResponse
|
||||
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||
t.Errorf("Failed to unmarshal response: %v", err)
|
||||
}
|
||||
|
||||
if unmarshaled.ID != resp.ID {
|
||||
t.Errorf("Expected ID %s, got %s", resp.ID, unmarshaled.ID)
|
||||
}
|
||||
if unmarshaled.Workflow != resp.Workflow {
|
||||
t.Errorf("Expected Workflow %s, got %s", resp.Workflow, unmarshaled.Workflow)
|
||||
}
|
||||
if unmarshaled.Status != resp.Status {
|
||||
t.Errorf("Expected Status %s, got %s", resp.Status, unmarshaled.Status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package resilience
|
||||
|
||||
import (
|
||||
"context"
|
||||
"math/rand"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RetryConfig holds retry settings.
|
||||
type RetryConfig struct {
|
||||
// MaxAttempts is the maximum number of attempts (includes initial).
|
||||
MaxAttempts int
|
||||
// InitialBackoff is the initial backoff duration.
|
||||
InitialBackoff time.Duration
|
||||
// MaxBackoff is the maximum backoff duration.
|
||||
MaxBackoff time.Duration
|
||||
// BackoffMultiplier is the exponential backoff multiplier.
|
||||
BackoffMultiplier float64
|
||||
}
|
||||
|
||||
// DefaultRetryConfig provides sensible defaults.
|
||||
func DefaultRetryConfig() *RetryConfig {
|
||||
return &RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialBackoff: 100 * time.Millisecond,
|
||||
MaxBackoff: 2 * time.Second,
|
||||
BackoffMultiplier: 2.0,
|
||||
}
|
||||
}
|
||||
|
||||
// RetryFunc executes a function with blind retry on 5xx.
|
||||
// Returns the response and any error from the function itself (not retry logic).
|
||||
type RetryFunc func(ctx context.Context, attempt int) (*http.Response, error)
|
||||
|
||||
// DoRetry executes the function with exponential backoff on 5xx responses.
|
||||
// Returns the final response (could be 5xx if all retries exhausted) and any error.
|
||||
func DoRetry(ctx context.Context, cfg *RetryConfig, fn RetryFunc) (*http.Response, error) {
|
||||
if cfg == nil {
|
||||
cfg = DefaultRetryConfig()
|
||||
}
|
||||
|
||||
var lastResp *http.Response
|
||||
var lastErr error
|
||||
|
||||
for attempt := 0; attempt < cfg.MaxAttempts; attempt++ {
|
||||
// Check context before attempting
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
if lastResp != nil {
|
||||
lastResp.Body.Close()
|
||||
}
|
||||
return nil, ctx.Err()
|
||||
default:
|
||||
}
|
||||
|
||||
resp, err := fn(ctx, attempt)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
// Don't retry on network errors in the retry loop itself
|
||||
// Let caller decide if those should be retried
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Success (not 5xx)
|
||||
if resp.StatusCode < 500 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// 5xx — close and retry
|
||||
if lastResp != nil {
|
||||
lastResp.Body.Close()
|
||||
}
|
||||
lastResp = resp
|
||||
|
||||
// If this was the last attempt, return the 5xx response
|
||||
if attempt == cfg.MaxAttempts-1 {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Calculate backoff with jitter
|
||||
backoff := calculateBackoff(attempt, cfg)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
resp.Body.Close()
|
||||
return nil, ctx.Err()
|
||||
case <-time.After(backoff):
|
||||
// Continue to next attempt
|
||||
}
|
||||
}
|
||||
|
||||
return lastResp, lastErr
|
||||
}
|
||||
|
||||
// calculateBackoff computes exponential backoff with jitter.
|
||||
func calculateBackoff(attempt int, cfg *RetryConfig) time.Duration {
|
||||
// Exponential: initial * (multiplier ^ attempt)
|
||||
backoff := time.Duration(float64(cfg.InitialBackoff) * (pow(cfg.BackoffMultiplier, float64(attempt))))
|
||||
|
||||
// Cap at max
|
||||
if backoff > cfg.MaxBackoff {
|
||||
backoff = cfg.MaxBackoff
|
||||
}
|
||||
|
||||
// Add jitter: ±20%
|
||||
jitterRange := backoff / 5
|
||||
if jitterRange <= 0 {
|
||||
return backoff
|
||||
}
|
||||
|
||||
jitter := time.Duration(rand.Int63n(int64(2 * jitterRange)) - int64(jitterRange))
|
||||
|
||||
return backoff + jitter
|
||||
}
|
||||
|
||||
func pow(base, exp float64) float64 {
|
||||
result := 1.0
|
||||
for i := 0; i < int(exp); i++ {
|
||||
result *= base
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// RetryPolicy determines whether to retry based on response and config.
|
||||
type RetryPolicy struct {
|
||||
Retryable bool // Whether this adapter allows retries
|
||||
}
|
||||
|
||||
// ShouldRetry determines if a response should be retried.
|
||||
func (p *RetryPolicy) ShouldRetry(resp *http.Response) bool {
|
||||
if !p.Retryable {
|
||||
return false
|
||||
}
|
||||
return resp != nil && resp.StatusCode >= 500
|
||||
}
|
||||
@@ -0,0 +1,158 @@
|
||||
package resilience
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRetryOnSuccess(t *testing.T) {
|
||||
cfg := &RetryConfig{MaxAttempts: 3}
|
||||
attempts := 0
|
||||
|
||||
resp, err := DoRetry(context.Background(), cfg, func(ctx context.Context, attempt int) (*http.Response, error) {
|
||||
attempts++
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if attempts != 1 {
|
||||
t.Errorf("expected 1 attempt on success, got %d", attempts)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestRetryOn5xx(t *testing.T) {
|
||||
cfg := &RetryConfig{
|
||||
MaxAttempts: 3,
|
||||
InitialBackoff: 10 * time.Millisecond,
|
||||
MaxBackoff: 50 * time.Millisecond,
|
||||
BackoffMultiplier: 2.0,
|
||||
}
|
||||
attempts := 0
|
||||
|
||||
resp, err := DoRetry(context.Background(), cfg, func(ctx context.Context, attempt int) (*http.Response, error) {
|
||||
attempts++
|
||||
if attempt < 2 {
|
||||
// First two attempts return 503
|
||||
return &http.Response{
|
||||
StatusCode: 503,
|
||||
Body: io.NopCloser(strings.NewReader("unavailable")),
|
||||
}, nil
|
||||
}
|
||||
// Third attempt succeeds
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: io.NopCloser(strings.NewReader("ok")),
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if attempts != 3 {
|
||||
t.Errorf("expected 3 attempts (2 retries), got %d", attempts)
|
||||
}
|
||||
if resp.StatusCode != 200 {
|
||||
t.Errorf("expected status 200, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestRetryExhaustion(t *testing.T) {
|
||||
cfg := &RetryConfig{
|
||||
MaxAttempts: 2,
|
||||
InitialBackoff: 10 * time.Millisecond,
|
||||
MaxBackoff: 50 * time.Millisecond,
|
||||
}
|
||||
attempts := 0
|
||||
|
||||
resp, err := DoRetry(context.Background(), cfg, func(ctx context.Context, attempt int) (*http.Response, error) {
|
||||
attempts++
|
||||
// Always return 503
|
||||
return &http.Response{
|
||||
StatusCode: 503,
|
||||
Body: io.NopCloser(strings.NewReader("unavailable")),
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Errorf("expected 2 attempts (max), got %d", attempts)
|
||||
}
|
||||
if resp.StatusCode != 503 {
|
||||
t.Errorf("expected status 503, got %d", resp.StatusCode)
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
func TestRetryWithContext(t *testing.T) {
|
||||
cfg := &RetryConfig{MaxAttempts: 10}
|
||||
attempts := 0
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
// Cancel after a short delay
|
||||
go func() {
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
cancel()
|
||||
}()
|
||||
|
||||
resp, err := DoRetry(ctx, cfg, func(ctx context.Context, attempt int) (*http.Response, error) {
|
||||
attempts++
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
return &http.Response{
|
||||
StatusCode: 503,
|
||||
Body: io.NopCloser(strings.NewReader("unavailable")),
|
||||
}, nil
|
||||
})
|
||||
|
||||
if err != context.Canceled {
|
||||
t.Errorf("expected context.Canceled error, got: %v", err)
|
||||
}
|
||||
if resp != nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
// Should have fewer than all attempts due to cancellation
|
||||
if attempts >= 10 {
|
||||
t.Errorf("expected fewer than 10 attempts due to cancellation, got %d", attempts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRetryPolicyShouldRetry(t *testing.T) {
|
||||
policy := &RetryPolicy{Retryable: true}
|
||||
|
||||
resp503 := &http.Response{StatusCode: 503}
|
||||
if !policy.ShouldRetry(resp503) {
|
||||
t.Errorf("expected to retry on 503")
|
||||
}
|
||||
|
||||
resp200 := &http.Response{StatusCode: 200}
|
||||
if policy.ShouldRetry(resp200) {
|
||||
t.Errorf("expected not to retry on 200")
|
||||
}
|
||||
|
||||
resp404 := &http.Response{StatusCode: 404}
|
||||
if policy.ShouldRetry(resp404) {
|
||||
t.Errorf("expected not to retry on 404")
|
||||
}
|
||||
|
||||
policyNoRetry := &RetryPolicy{Retryable: false}
|
||||
if policyNoRetry.ShouldRetry(resp503) {
|
||||
t.Errorf("expected not to retry when retryable=false")
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
||||
)
|
||||
|
||||
// TestHealthEndpoints verifies health endpoint behavior.
|
||||
|
||||
@@ -2,35 +2,66 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||
)
|
||||
|
||||
// Router implements an HTTP handler that routes health endpoints
|
||||
// and passes other requests to an upstream handler.
|
||||
// Router implements an HTTP handler that routes health endpoints,
|
||||
// ServiceAdapter X-Service requests, Temporal workflow endpoints,
|
||||
// and other requests to upstream handlers.
|
||||
type Router struct {
|
||||
healthChecker *HealthChecker
|
||||
dispatcher *serviceadapter.Dispatcher
|
||||
temporalHandler http.Handler
|
||||
upstreamHandler http.Handler
|
||||
}
|
||||
|
||||
// NewRouter creates a new router with health endpoints.
|
||||
// Health endpoints (/healthz and /readyz) are handled locally.
|
||||
// X-Service requests are dispatched via ServiceAdapter CRD.
|
||||
// Temporal endpoints (/workflow*) are routed to temporalHandler.
|
||||
// All other paths are passed to the upstream handler.
|
||||
func NewRouter(healthChecker *HealthChecker, upstreamHandler http.Handler) *Router {
|
||||
func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatcher, temporalHandler http.Handler, upstreamHandler http.Handler) *Router {
|
||||
return &Router{
|
||||
healthChecker: healthChecker,
|
||||
dispatcher: dispatcher,
|
||||
temporalHandler: temporalHandler,
|
||||
upstreamHandler: upstreamHandler,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler.
|
||||
// It routes /healthz and /readyz to health handlers,
|
||||
// and passes all other paths to the upstream handler.
|
||||
// Priority order:
|
||||
// 1. /healthz and /readyz to health handlers
|
||||
// 2. X-Service header to ServiceAdapter dispatcher (phase 8)
|
||||
// 3. /workflow* to temporal handler
|
||||
// 4. All other paths to upstream handler (phase 0-7)
|
||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Health endpoints first
|
||||
switch req.URL.Path {
|
||||
case "/healthz":
|
||||
LivenessHandler(r.healthChecker)(w, req)
|
||||
return
|
||||
case "/readyz":
|
||||
ReadinessHandler(r.healthChecker)(w, req)
|
||||
default:
|
||||
r.upstreamHandler.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
// X-Service (ServiceAdapter) routing - checked before path-based routing
|
||||
if req.Header.Get("X-Service") != "" {
|
||||
if r.dispatcher != nil {
|
||||
r.dispatcher.Dispatch(w, req)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Workflow endpoints
|
||||
switch req.URL.Path {
|
||||
case "/workflow", "/workflow/health", "/workflow/metrics":
|
||||
r.temporalHandler.ServeHTTP(w, req)
|
||||
return
|
||||
}
|
||||
|
||||
// Default: upstream handler (all other paths)
|
||||
r.upstreamHandler.ServeHTTP(w, req)
|
||||
}
|
||||
|
||||
@@ -21,11 +21,17 @@ type Server struct {
|
||||
func New(listenAddr string, shutdownTimeout time.Duration, handler http.Handler) *Server {
|
||||
return &Server{
|
||||
httpServer: &http.Server{
|
||||
Addr: listenAddr,
|
||||
Handler: handler,
|
||||
ReadTimeout: 15 * time.Second,
|
||||
WriteTimeout: 15 * time.Second,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
Addr: listenAddr,
|
||||
Handler: handler,
|
||||
// ReadHeaderTimeout (not ReadTimeout) and a long WriteTimeout: both
|
||||
// ReadTimeout and WriteTimeout are absolute deadlines covering the
|
||||
// whole request/response body, not inactivity timeouts -- a 15s
|
||||
// WriteTimeout here was killing in-progress LLM SSE streams (proxy.go's
|
||||
// outbound transport deliberately avoids this same mistake). Mirrors
|
||||
// the edge nginx Ingress's proxy-read/send-timeout of 3600s.
|
||||
ReadHeaderTimeout: 15 * time.Second,
|
||||
WriteTimeout: 1 * time.Hour,
|
||||
IdleTimeout: 60 * time.Second,
|
||||
},
|
||||
shutdownTimeout: shutdownTimeout,
|
||||
healthChecker: NewHealthChecker(false, false),
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
||||
)
|
||||
|
||||
// TestGracefulShutdown verifies that:
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
package serviceadapter
|
||||
|
||||
// WorkflowAdapter handles X-Service: workflow requests.
|
||||
type WorkflowAdapter struct{}
|
||||
|
||||
// SQSAdapter handles X-Service: sqs requests.
|
||||
type SQSAdapter struct{}
|
||||
|
||||
// S3Adapter handles X-Service: s3 requests.
|
||||
type S3Adapter struct{}
|
||||
|
||||
// IAMAdapter handles X-Service: iam requests.
|
||||
type IAMAdapter struct{}
|
||||
|
||||
// MemoryAdapter handles X-Service: memory requests (core + extended).
|
||||
type MemoryAdapter struct{
|
||||
// Extended resources: notes, context, nodes (git-aware)
|
||||
// Requires M3.5.6+, M3.7.7+, M3.7.8+, M3.5.9
|
||||
}
|
||||
|
||||
// AdapterFactory creates adapters by type.
|
||||
func AdapterFactory(serviceName string) interface{} {
|
||||
switch serviceName {
|
||||
case "workflow":
|
||||
return &WorkflowAdapter{}
|
||||
case "sqs":
|
||||
return &SQSAdapter{}
|
||||
case "s3":
|
||||
return &S3Adapter{}
|
||||
case "iam":
|
||||
return &IAMAdapter{}
|
||||
case "memory":
|
||||
return &MemoryAdapter{}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
// +build integration
|
||||
|
||||
package serviceadapter
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestRealIntegration tests that the gateway correctly routes requests to upstreams.
|
||||
// Services may return 404/errors if paths don't match their actual API.
|
||||
func TestRealIntegration(t *testing.T) {
|
||||
gatewayURL := os.Getenv("GATEWAY_URL")
|
||||
if gatewayURL == "" {
|
||||
gatewayURL = "http://localhost:8080"
|
||||
}
|
||||
|
||||
authentikURL := os.Getenv("AUTHENTIK_URL")
|
||||
if authentikURL == "" {
|
||||
authentikURL = "https://authentik.riotpiao.com"
|
||||
}
|
||||
|
||||
clientID := os.Getenv("AUTHENTIK_CLIENT_ID")
|
||||
clientSecret := os.Getenv("AUTHENTIK_CLIENT_SECRET")
|
||||
|
||||
skipAuthTests := clientID == "" || clientSecret == ""
|
||||
|
||||
timeoutStr := os.Getenv("TEST_TIMEOUT")
|
||||
timeout := 30
|
||||
if t, err := strconv.Atoi(timeoutStr); err == nil {
|
||||
timeout = t
|
||||
}
|
||||
|
||||
client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
|
||||
|
||||
var jwtToken string
|
||||
|
||||
if !skipAuthTests {
|
||||
t.Run("get JWT from Authentik", func(t *testing.T) {
|
||||
data := fmt.Sprintf("grant_type=client_credentials&client_id=%s&client_secret=%s&scope=openid",
|
||||
clientID, clientSecret)
|
||||
|
||||
resp, err := http.Post(
|
||||
authentikURL+"/application/o/token/",
|
||||
"application/x-www-form-urlencoded",
|
||||
strings.NewReader(data),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get token: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("token request failed (%d): %s", resp.StatusCode, string(body))
|
||||
}
|
||||
|
||||
var tokenResp struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||
t.Fatalf("failed to parse token response: %v", err)
|
||||
}
|
||||
|
||||
jwtToken = tokenResp.AccessToken
|
||||
t.Logf("✅ Got JWT token")
|
||||
})
|
||||
}
|
||||
|
||||
// Test that gateway routes and passes through Authorization header
|
||||
// Services may return 404 if paths don't exist, but that's OK
|
||||
// We're testing that the request reached the service, not that it succeeded
|
||||
|
||||
t.Run("SQS routing", func(t *testing.T) {
|
||||
payload := map[string]interface{}{"queue": "test"}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", gatewayURL+"/", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Service", "sqs")
|
||||
req.Header.Set("X-Resource", "send-message")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("SQS unreachable: %v", err)
|
||||
t.Skip()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Any response (even 404) means gateway routed it
|
||||
// 502/503 means service unreachable
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Logf("SQS backend unreachable (%d)", resp.StatusCode)
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
t.Logf("✅ SQS routed: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("S3 routing", func(t *testing.T) {
|
||||
req, err := http.NewRequest("GET", gatewayURL+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Service", "s3")
|
||||
req.Header.Set("X-Resource", "list-objects")
|
||||
|
||||
if !skipAuthTests && jwtToken != "" {
|
||||
req.Header.Set("Authorization", "Bearer "+jwtToken)
|
||||
t.Logf("Testing with JWT")
|
||||
}
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("MinIO unreachable: %v", err)
|
||||
t.Skip()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Logf("MinIO backend unreachable (%d)", resp.StatusCode)
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
t.Logf("✅ S3 routed: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Memory routing", func(t *testing.T) {
|
||||
payload := map[string]interface{}{"query": "test"}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", gatewayURL+"/", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Service", "memory")
|
||||
req.Header.Set("X-Resource", "query")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("Memory unreachable: %v", err)
|
||||
t.Skip()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Logf("Memory backend unreachable (%d)", resp.StatusCode)
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
t.Logf("✅ Memory routed: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("IAM routing with JWT", func(t *testing.T) {
|
||||
if skipAuthTests {
|
||||
t.Skip("No JWT token")
|
||||
}
|
||||
|
||||
req, err := http.NewRequest("GET", gatewayURL+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Service", "iam")
|
||||
req.Header.Set("X-Resource", "list-roles")
|
||||
req.Header.Set("Authorization", "Bearer "+jwtToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("Authentik unreachable: %v", err)
|
||||
t.Skip()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Logf("Authentik backend unreachable (%d)", resp.StatusCode)
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
t.Logf("✅ IAM routed: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("SQS JWT validation: reject without token", func(t *testing.T) {
|
||||
payload := map[string]interface{}{"queue": "test"}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", gatewayURL+"/", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Service", "sqs")
|
||||
req.Header.Set("X-Resource", "send-message")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
// Intentionally no Authorization header
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("gateway unreachable: %v", err)
|
||||
t.Skip()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should reject with 403 Forbidden
|
||||
if resp.StatusCode != http.StatusForbidden {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Logf("expected 403, got %d: %s", resp.StatusCode, string(body))
|
||||
}
|
||||
t.Logf("✅ SQS correctly rejected missing JWT: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("SQS JWT validation: accept with valid JWT", func(t *testing.T) {
|
||||
if skipAuthTests || jwtToken == "" {
|
||||
t.Skip("No JWT token from Authentik")
|
||||
}
|
||||
|
||||
payload := map[string]interface{}{"queue": "test"}
|
||||
body, _ := json.Marshal(payload)
|
||||
|
||||
req, err := http.NewRequest("POST", gatewayURL+"/", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Service", "sqs")
|
||||
req.Header.Set("X-Resource", "send-message")
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("Authorization", "Bearer "+jwtToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("gateway unreachable: %v", err)
|
||||
t.Skip()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Should NOT be 403 (JWT is valid)
|
||||
if resp.StatusCode == http.StatusForbidden {
|
||||
body, _ := io.ReadAll(resp.Body)
|
||||
t.Fatalf("SQS rejected valid JWT: %s", string(body))
|
||||
}
|
||||
|
||||
// 500+ means backend unreachable
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Logf("SQS backend unreachable: %d", resp.StatusCode)
|
||||
t.Skip()
|
||||
}
|
||||
|
||||
t.Logf("✅ SQS accepted valid JWT: %d", resp.StatusCode)
|
||||
})
|
||||
|
||||
t.Run("Authorization header pass-through", func(t *testing.T) {
|
||||
testToken := "Bearer test-token-xyz"
|
||||
|
||||
req, err := http.NewRequest("GET", gatewayURL+"/", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create request: %v", err)
|
||||
}
|
||||
req.Header.Set("X-Service", "s3")
|
||||
req.Header.Set("X-Resource", "list-objects")
|
||||
req.Header.Set("Authorization", testToken)
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Logf("S3 unreachable: %v", err)
|
||||
t.Skip()
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// Gateway passed the request through
|
||||
// MinIO responded (even with error)
|
||||
t.Logf("✅ Authorization header passed through: %d", resp.StatusCode)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package serviceadapter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Registry holds all loaded ServiceAdapters indexed by serviceName.
|
||||
type Registry struct {
|
||||
mu sync.RWMutex
|
||||
adapters map[string]*ServiceAdapter
|
||||
logger Logger
|
||||
}
|
||||
|
||||
// Logger interface for flexible logging.
|
||||
type Logger interface {
|
||||
Infof(format string, args ...interface{})
|
||||
Errorf(format string, args ...interface{})
|
||||
}
|
||||
|
||||
// SimpleLogger logs to stdout/stderr.
|
||||
type SimpleLogger struct{}
|
||||
|
||||
func (l *SimpleLogger) Infof(format string, args ...interface{}) {
|
||||
fmt.Printf("[INFO] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
func (l *SimpleLogger) Errorf(format string, args ...interface{}) {
|
||||
fmt.Printf("[ERROR] "+format+"\n", args...)
|
||||
}
|
||||
|
||||
// NewRegistry creates a new ServiceAdapter registry.
|
||||
func NewRegistry(logger Logger) *Registry {
|
||||
if logger == nil {
|
||||
logger = &SimpleLogger{}
|
||||
}
|
||||
return &Registry{
|
||||
adapters: make(map[string]*ServiceAdapter),
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Add adds or updates a ServiceAdapter in the registry.
|
||||
// Malformed schemas are logged but don't crash the registry.
|
||||
func (r *Registry) Add(adapter *ServiceAdapter) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
// Validate schemas (basic check - real validation in 8.3)
|
||||
if err := r.validateSchemas(adapter); err != nil {
|
||||
r.logger.Errorf("adapter %s has invalid schema: %v, skipping", adapter.Namespace+"/"+adapter.ServiceName, err)
|
||||
return nil // Don't crash, just skip
|
||||
}
|
||||
|
||||
r.logger.Infof("adding/updating ServiceAdapter %s/%s", adapter.Namespace, adapter.ServiceName)
|
||||
adapter.CreatedAt = time.Now()
|
||||
r.adapters[adapter.ServiceName] = adapter
|
||||
return nil
|
||||
}
|
||||
|
||||
// Update updates an existing ServiceAdapter.
|
||||
func (r *Registry) Update(adapter *ServiceAdapter) error {
|
||||
return r.Add(adapter)
|
||||
}
|
||||
|
||||
// Delete removes a ServiceAdapter from the registry.
|
||||
func (r *Registry) Delete(serviceName string) {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
if _, ok := r.adapters[serviceName]; ok {
|
||||
r.logger.Infof("deleting ServiceAdapter %s", serviceName)
|
||||
delete(r.adapters, serviceName)
|
||||
}
|
||||
}
|
||||
|
||||
// Get returns a ServiceAdapter by name.
|
||||
func (r *Registry) Get(serviceName string) *ServiceAdapter {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
return r.adapters[serviceName]
|
||||
}
|
||||
|
||||
// List returns all ServiceAdapters.
|
||||
func (r *Registry) List() []*ServiceAdapter {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
result := make([]*ServiceAdapter, 0, len(r.adapters))
|
||||
for _, adapter := range r.adapters {
|
||||
result = append(result, adapter)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Count returns the number of registered adapters.
|
||||
func (r *Registry) Count() int {
|
||||
r.mu.RLock()
|
||||
defer r.mu.RUnlock()
|
||||
|
||||
return len(r.adapters)
|
||||
}
|
||||
|
||||
// validateSchemas checks for malformed requestSchema/responseSchema.
|
||||
// Real validation is in 8.3 (flat KV+type DSL parser).
|
||||
func (r *Registry) validateSchemas(adapter *ServiceAdapter) error {
|
||||
for _, res := range adapter.Spec.Resources {
|
||||
for _, method := range res.Methods {
|
||||
// Basic validation: schemas shouldn't contain obviously malformed patterns
|
||||
if method.RequestSchema != "" {
|
||||
if err := basicSchemaCheck(method.RequestSchema); err != nil {
|
||||
return fmt.Errorf("resource %s method %s requestSchema: %w", res.Name, method.Verb, err)
|
||||
}
|
||||
}
|
||||
if method.ResponseSchema != "" {
|
||||
if err := basicSchemaCheck(method.ResponseSchema); err != nil {
|
||||
return fmt.Errorf("resource %s method %s responseSchema: %w", res.Name, method.Verb, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// basicSchemaCheck does a simple sanity check on schema strings.
|
||||
// Real parsing is in 8.3.
|
||||
func basicSchemaCheck(schema string) error {
|
||||
if schema == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject obviously invalid patterns
|
||||
if strings.Contains(schema, "{{") && !strings.Contains(schema, "}}") {
|
||||
return fmt.Errorf("unclosed template braces")
|
||||
}
|
||||
if strings.Count(schema, "(") != strings.Count(schema, ")") {
|
||||
return fmt.Errorf("mismatched parentheses")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// MockLogger for testing.
|
||||
type MockLogger struct {
|
||||
entries []string
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (l *MockLogger) Infof(format string, args ...interface{}) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.entries = append(l.entries, fmt.Sprintf("[INFO] "+format, args...))
|
||||
}
|
||||
|
||||
func (l *MockLogger) Errorf(format string, args ...interface{}) {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.entries = append(l.entries, fmt.Sprintf("[ERROR] "+format, args...))
|
||||
}
|
||||
|
||||
func (l *MockLogger) Entries() []string {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
result := make([]string, len(l.entries))
|
||||
copy(result, l.entries)
|
||||
return result
|
||||
}
|
||||
|
||||
func (l *MockLogger) Clear() {
|
||||
l.mu.Lock()
|
||||
defer l.mu.Unlock()
|
||||
l.entries = nil
|
||||
}
|
||||
@@ -0,0 +1,242 @@
|
||||
package serviceadapter
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRegistryAdd(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
reg := NewRegistry(logger)
|
||||
|
||||
adapter := &ServiceAdapter{
|
||||
Namespace: "api",
|
||||
ServiceName: "test-service",
|
||||
Spec: Spec{
|
||||
ServiceName: "test-service",
|
||||
Upstream: Upstream{
|
||||
URL: "http://example.com",
|
||||
TimeoutSeconds: 30,
|
||||
},
|
||||
Auth: Auth{
|
||||
Required: false,
|
||||
},
|
||||
Resources: []Resource{
|
||||
{
|
||||
Name: "default",
|
||||
Methods: []Method{
|
||||
{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/api",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := reg.Add(adapter)
|
||||
if err != nil {
|
||||
t.Fatalf("Add failed: %v", err)
|
||||
}
|
||||
|
||||
retrieved := reg.Get("test-service")
|
||||
if retrieved == nil {
|
||||
t.Errorf("expected adapter to be retrievable")
|
||||
}
|
||||
if retrieved.ServiceName != "test-service" {
|
||||
t.Errorf("expected service name test-service, got %s", retrieved.ServiceName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryDelete(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
reg := NewRegistry(logger)
|
||||
|
||||
adapter := &ServiceAdapter{
|
||||
Namespace: "api",
|
||||
ServiceName: "to-delete",
|
||||
Spec: Spec{
|
||||
ServiceName: "to-delete",
|
||||
Upstream: Upstream{
|
||||
URL: "http://example.com",
|
||||
TimeoutSeconds: 30,
|
||||
},
|
||||
Auth: Auth{Required: false},
|
||||
Resources: []Resource{
|
||||
{
|
||||
Name: "default",
|
||||
Methods: []Method{
|
||||
{Verb: "GET", UpstreamPath: "/"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
reg.Add(adapter)
|
||||
if reg.Count() != 1 {
|
||||
t.Errorf("expected count 1 after add, got %d", reg.Count())
|
||||
}
|
||||
|
||||
reg.Delete("to-delete")
|
||||
if reg.Count() != 0 {
|
||||
t.Errorf("expected count 0 after delete, got %d", reg.Count())
|
||||
}
|
||||
|
||||
if reg.Get("to-delete") != nil {
|
||||
t.Errorf("expected deleted adapter to be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryMalformedSchema(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
reg := NewRegistry(logger)
|
||||
|
||||
adapter := &ServiceAdapter{
|
||||
Namespace: "api",
|
||||
ServiceName: "bad-schema",
|
||||
Spec: Spec{
|
||||
ServiceName: "bad-schema",
|
||||
Upstream: Upstream{
|
||||
URL: "http://example.com",
|
||||
TimeoutSeconds: 30,
|
||||
},
|
||||
Auth: Auth{Required: false},
|
||||
Resources: []Resource{
|
||||
{
|
||||
Name: "default",
|
||||
Methods: []Method{
|
||||
{
|
||||
Verb: "POST",
|
||||
UpstreamPath: "/",
|
||||
RequestSchema: "{{ unclosed", // malformed
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Should not crash, should log error
|
||||
err := reg.Add(adapter)
|
||||
if err != nil {
|
||||
t.Fatalf("Add should not return error (should skip malformed), got: %v", err)
|
||||
}
|
||||
|
||||
// Adapter should be skipped (not added)
|
||||
if reg.Get("bad-schema") != nil {
|
||||
t.Errorf("expected malformed adapter to be skipped")
|
||||
}
|
||||
|
||||
// Should have logged an error
|
||||
entries := logger.Entries()
|
||||
errorLogged := false
|
||||
for _, entry := range entries {
|
||||
if strings.Contains(entry, "invalid schema") {
|
||||
errorLogged = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !errorLogged {
|
||||
t.Errorf("expected error to be logged for malformed schema")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryList(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
reg := NewRegistry(logger)
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
adapter := &ServiceAdapter{
|
||||
Namespace: "api",
|
||||
ServiceName: "service-" + string(rune('1'+i)),
|
||||
Spec: Spec{
|
||||
ServiceName: "service-" + string(rune('1'+i)),
|
||||
Upstream: Upstream{
|
||||
URL: "http://example.com",
|
||||
TimeoutSeconds: 30,
|
||||
},
|
||||
Auth: Auth{Required: false},
|
||||
Resources: []Resource{},
|
||||
},
|
||||
}
|
||||
reg.Add(adapter)
|
||||
}
|
||||
|
||||
list := reg.List()
|
||||
if len(list) != 3 {
|
||||
t.Errorf("expected 3 adapters, got %d", len(list))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryThreadSafety(t *testing.T) {
|
||||
logger := &MockLogger{}
|
||||
reg := NewRegistry(logger)
|
||||
|
||||
done := make(chan bool, 2)
|
||||
|
||||
// Writer goroutine
|
||||
go func() {
|
||||
for i := 0; i < 10; i++ {
|
||||
adapter := &ServiceAdapter{
|
||||
Namespace: "api",
|
||||
ServiceName: "writer-service",
|
||||
Spec: Spec{
|
||||
ServiceName: "writer-service",
|
||||
Upstream: Upstream{
|
||||
URL: "http://example.com",
|
||||
TimeoutSeconds: 30,
|
||||
},
|
||||
Auth: Auth{Required: false},
|
||||
Resources: []Resource{},
|
||||
},
|
||||
}
|
||||
reg.Add(adapter)
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
// Reader goroutine
|
||||
go func() {
|
||||
for i := 0; i < 10; i++ {
|
||||
_ = reg.Get("writer-service")
|
||||
_ = reg.List()
|
||||
_ = reg.Count()
|
||||
}
|
||||
done <- true
|
||||
}()
|
||||
|
||||
<-done
|
||||
<-done
|
||||
|
||||
if reg.Count() != 1 {
|
||||
t.Errorf("expected 1 adapter after concurrent access, got %d", reg.Count())
|
||||
}
|
||||
}
|
||||
|
||||
func TestBasicSchemaCheck(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
schema string
|
||||
valid bool
|
||||
}{
|
||||
{"empty", "", true},
|
||||
{"valid", "key1: string, key2: int", true},
|
||||
{"unclosed braces", "{{ unclosed", false},
|
||||
{"mismatched parens", "func(arg", false},
|
||||
{"balanced parens", "func(arg)", true},
|
||||
}
|
||||
|
||||
for _, tc := range tests {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := basicSchemaCheck(tc.schema)
|
||||
if tc.valid && err != nil {
|
||||
t.Errorf("expected valid schema to pass, got: %v", err)
|
||||
}
|
||||
if !tc.valid && err == nil {
|
||||
t.Errorf("expected invalid schema to fail")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
package serviceadapter
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httputil"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
|
||||
)
|
||||
|
||||
// Dispatcher routes X-Service requests to upstreams.
|
||||
// Auth per service:
|
||||
// SQS: Gateway validates JWT (kmsvc code unverified)
|
||||
// MinIO, Temporal: Native JWT support (dumb pipe pass-through)
|
||||
// Memory, IAM: Services validate JWTs themselves
|
||||
type Dispatcher struct {
|
||||
registry *Registry
|
||||
sqsJWTAuth *auth.Validator
|
||||
}
|
||||
|
||||
// NewDispatcher creates a new service adapter dispatcher.
|
||||
func NewDispatcher(registry *Registry) *Dispatcher {
|
||||
// Create JWT validator for SQS
|
||||
// Issuer and JWKS URL should match Authentik application config
|
||||
sqsValidator := auth.NewValidator(
|
||||
"https://authentik.riotpiao.com/application/o/sqs/",
|
||||
"sqs",
|
||||
"https://authentik.riotpiao.com/application/o/sqs/jwks/",
|
||||
)
|
||||
|
||||
return &Dispatcher{
|
||||
registry: registry,
|
||||
sqsJWTAuth: sqsValidator,
|
||||
}
|
||||
}
|
||||
|
||||
// Matches returns true if the request should be dispatched based on X-Service header.
|
||||
func (d *Dispatcher) Matches(r *http.Request) bool {
|
||||
return r.Header.Get("X-Service") != ""
|
||||
}
|
||||
|
||||
// Dispatch routes a request to the appropriate adapter.
|
||||
// Returns a problem document if the adapter or resource is not found.
|
||||
func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
|
||||
serviceName := r.Header.Get("X-Service")
|
||||
if serviceName == "" {
|
||||
d.writeError(w, problem.BadRequest("X-Service header required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Look up service adapter
|
||||
adapter := d.registry.Get(serviceName)
|
||||
if adapter == nil {
|
||||
p := problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName))
|
||||
_ = p.Write(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Get resource and method from request
|
||||
resourceName := r.Header.Get("X-Resource")
|
||||
if resourceName == "" {
|
||||
d.writeError(w, problem.BadRequest("X-Resource header required"))
|
||||
return
|
||||
}
|
||||
|
||||
// Find resource
|
||||
var resource *Resource
|
||||
for i := range adapter.Spec.Resources {
|
||||
if adapter.Spec.Resources[i].Name == resourceName {
|
||||
resource = &adapter.Spec.Resources[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if resource == nil {
|
||||
p := problem.NotFound(fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName))
|
||||
_ = p.Write(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Find method matching HTTP verb
|
||||
var method *Method
|
||||
for i := range resource.Methods {
|
||||
if resource.Methods[i].Verb == r.Method {
|
||||
method = &resource.Methods[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if method == nil {
|
||||
p := problem.NotFound(fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName))
|
||||
_ = p.Write(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Gateway-level JWT validation for SQS (code unverified in kmsvc)
|
||||
// MinIO, Temporal, Memory, IAM have native JWT support - pass through
|
||||
if adapter.Spec.Auth.Required && serviceName == "sqs" {
|
||||
authHeader := r.Header.Get("Authorization")
|
||||
if authHeader == "" {
|
||||
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
|
||||
"Forbidden", "SQS requires Authorization header")
|
||||
_ = p.Write(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Validate JWT signature against Authentik JWKS
|
||||
claims, err := d.sqsJWTAuth.ValidateBearerToken(authHeader)
|
||||
if err != nil {
|
||||
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
|
||||
"Forbidden", fmt.Sprintf("JWT validation failed: %v", err))
|
||||
_ = p.Write(w)
|
||||
return
|
||||
}
|
||||
|
||||
// Check required permissions (sqs:read or sqs:write or *)
|
||||
hasPermission := d.sqsJWTAuth.CheckPermissions(claims, "sqs:read", "sqs:write", "*")
|
||||
if !hasPermission {
|
||||
p := problem.NewProblem(http.StatusForbidden, "about:blank#forbidden",
|
||||
"Forbidden", "Insufficient permissions for SQS")
|
||||
_ = p.Write(w)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Detect protocol from upstream URL scheme
|
||||
upstreamURL := adapter.Spec.Upstream.URL
|
||||
if strings.HasPrefix(upstreamURL, "grpc://") {
|
||||
// gRPC upstream (Temporal, etc.)
|
||||
d.dispatchGRPC(w, r, upstreamURL, method, adapter)
|
||||
} else {
|
||||
// HTTP upstream (MinIO, Authentik, etc.)
|
||||
d.dispatchHTTP(w, r, upstreamURL, method, adapter)
|
||||
}
|
||||
}
|
||||
|
||||
// dispatchHTTP forwards HTTP requests to upstream, passing Authorization header through.
|
||||
func (d *Dispatcher) dispatchHTTP(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
|
||||
parsedURL, err := url.Parse(upstreamURL)
|
||||
if err != nil {
|
||||
d.writeError(w, problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error",
|
||||
"Internal Server Error", fmt.Sprintf("invalid upstream URL: %v", err)))
|
||||
return
|
||||
}
|
||||
|
||||
// Create reverse proxy
|
||||
proxy := httputil.NewSingleHostReverseProxy(parsedURL)
|
||||
proxy.Director = func(req *http.Request) {
|
||||
req.URL.Scheme = parsedURL.Scheme
|
||||
req.URL.Host = parsedURL.Host
|
||||
req.URL.Path = method.UpstreamPath
|
||||
req.RequestURI = ""
|
||||
req.Host = parsedURL.Host
|
||||
// Authorization header passes through unchanged
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
timeout := adapter.Spec.Upstream.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 30
|
||||
}
|
||||
proxy.Transport = &http.Transport{
|
||||
DialContext: (&net.Dialer{Timeout: time.Duration(timeout) * time.Second}).DialContext,
|
||||
TLSHandshakeTimeout: time.Duration(timeout) * time.Second,
|
||||
}
|
||||
|
||||
// Forward the request
|
||||
proxy.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
// dispatchGRPC forwards gRPC requests to upstream.
|
||||
// gRPC URL format: grpc://host:port
|
||||
func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
|
||||
// Extract host:port from grpc://host:port
|
||||
host := strings.TrimPrefix(upstreamURL, "grpc://")
|
||||
if host == upstreamURL {
|
||||
d.writeError(w, problem.NewProblem(http.StatusInternalServerError, "about:blank#server-error",
|
||||
"Internal Server Error", "invalid gRPC URL format"))
|
||||
return
|
||||
}
|
||||
|
||||
// Validate that this is a gRPC request
|
||||
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/grpc") {
|
||||
d.writeError(w, problem.NewProblem(http.StatusBadRequest, "about:blank#bad-request",
|
||||
"Bad Request", "gRPC service requires application/grpc content-type"))
|
||||
return
|
||||
}
|
||||
|
||||
// Set timeout
|
||||
timeout := adapter.Spec.Upstream.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 30
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Dial gRPC upstream
|
||||
conn, err := grpc.DialContext(ctx, host,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultCallOptions(
|
||||
grpc.MaxCallRecvMsgSize(100 * 1024 * 1024), // 100MB
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
d.writeError(w, problem.NewProblem(http.StatusBadGateway, "about:blank#bad-gateway",
|
||||
"Bad Gateway", fmt.Sprintf("failed to dial gRPC upstream: %v", err)))
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
|
||||
// Forward gRPC request
|
||||
// Note: Full gRPC forwarding requires grpcproxy or custom middleware.
|
||||
// For now, return unimplemented (Temporal support coming in Phase 9)
|
||||
d.writeError(w, problem.NewProblem(http.StatusNotImplemented, "about:blank#not-implemented",
|
||||
"Not Implemented", "gRPC forwarding not yet implemented - use in-cluster gRPC clients directly"))
|
||||
}
|
||||
|
||||
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
|
||||
_ = p.Write(w)
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package serviceadapter
|
||||
|
||||
import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// Upstream defines an upstream target.
|
||||
type Upstream struct {
|
||||
URL string `json:"url" yaml:"url"`
|
||||
TimeoutSeconds int32 `json:"timeoutSeconds" yaml:"timeoutSeconds"`
|
||||
}
|
||||
|
||||
// Auth defines authentication requirements.
|
||||
type Auth struct {
|
||||
Required bool `json:"required" yaml:"required"`
|
||||
Capability string `json:"capability,omitempty" yaml:"capability,omitempty"`
|
||||
}
|
||||
|
||||
// Method defines an HTTP method endpoint.
|
||||
type Method struct {
|
||||
Verb string `json:"verb" yaml:"verb"`
|
||||
UpstreamPath string `json:"upstreamPath" yaml:"upstreamPath"`
|
||||
RequestSchema string `json:"requestSchema,omitempty" yaml:"requestSchema,omitempty"`
|
||||
ResponseSchema string `json:"responseSchema,omitempty" yaml:"responseSchema,omitempty"`
|
||||
Auth *Auth `json:"auth,omitempty" yaml:"auth,omitempty"`
|
||||
}
|
||||
|
||||
// Resource defines a resource with multiple methods.
|
||||
type Resource struct {
|
||||
Name string `json:"name" yaml:"name"`
|
||||
Methods []Method `json:"methods" yaml:"methods"`
|
||||
Auth *Auth `json:"auth,omitempty" yaml:"auth,omitempty"`
|
||||
}
|
||||
|
||||
// Spec is the ServiceAdapter spec.
|
||||
type Spec struct {
|
||||
ServiceName string `json:"serviceName" yaml:"serviceName"`
|
||||
Upstream Upstream `json:"upstream" yaml:"upstream"`
|
||||
Auth Auth `json:"auth" yaml:"auth"`
|
||||
Retryable bool `json:"retryable,omitempty" yaml:"retryable,omitempty"`
|
||||
Resources []Resource `json:"resources" yaml:"resources"`
|
||||
}
|
||||
|
||||
// Status is the ServiceAdapter status.
|
||||
type Status struct {
|
||||
Ready bool `json:"ready,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
LastSyncTime *time.Time `json:"lastSyncTime,omitempty"`
|
||||
}
|
||||
|
||||
// ServiceAdapter is a gateway service adapter.
|
||||
type ServiceAdapter struct {
|
||||
Name string // namespace/name
|
||||
Namespace string
|
||||
ServiceName string
|
||||
Spec Spec
|
||||
Status Status
|
||||
CreatedAt time.Time
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package serviceadapter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// FieldSchema describes validation schema for a field or request/response body.
|
||||
type FieldSchema struct {
|
||||
Type string `json:"type"` // string, number, boolean, array, object
|
||||
Nullable bool `json:"nullable"` // accept null values
|
||||
Strict bool `json:"strict"` // reject unknown fields (object only)
|
||||
Required []string `json:"required"` // required field names (object only)
|
||||
Fields map[string]FieldSchema `json:"fields"` // field schemas (object only)
|
||||
Items *FieldSchema `json:"items"` // item schema (array only)
|
||||
}
|
||||
|
||||
// ValidationError describes a single validation failure.
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Reason string
|
||||
}
|
||||
|
||||
// Validator validates bodies against a schema.
|
||||
type Validator struct {
|
||||
schema *FieldSchema
|
||||
}
|
||||
|
||||
// NewValidator creates a new validator for a schema.
|
||||
func NewValidator(schemaStr string) (*Validator, error) {
|
||||
if schemaStr == "" {
|
||||
return nil, nil // No validation
|
||||
}
|
||||
|
||||
schema, err := parseSchema(schemaStr)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &Validator{schema: schema}, nil
|
||||
}
|
||||
|
||||
// Validate validates a body (map or []interface{}) against the schema.
|
||||
func (v *Validator) Validate(body interface{}) []ValidationError {
|
||||
if v == nil || v.schema == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return v.validateValue(body, v.schema, "")
|
||||
}
|
||||
|
||||
func (v *Validator) validateValue(value interface{}, schema *FieldSchema, path string) []ValidationError {
|
||||
var errors []ValidationError
|
||||
|
||||
// Handle null
|
||||
if value == nil {
|
||||
if !schema.Nullable {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: path,
|
||||
Reason: "null not allowed",
|
||||
})
|
||||
}
|
||||
return errors
|
||||
}
|
||||
|
||||
switch schema.Type {
|
||||
case "object":
|
||||
obj, ok := value.(map[string]interface{})
|
||||
if !ok {
|
||||
return []ValidationError{{
|
||||
Field: path,
|
||||
Reason: fmt.Sprintf("type_mismatch: want object got %T", value),
|
||||
}}
|
||||
}
|
||||
|
||||
// Check required fields
|
||||
for _, required := range schema.Required {
|
||||
if _, ok := obj[required]; !ok {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: required,
|
||||
Reason: "missing",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Check field types
|
||||
for fieldName, fieldValue := range obj {
|
||||
if fieldSchema, ok := schema.Fields[fieldName]; ok {
|
||||
errors = append(errors, v.validateValue(fieldValue, &fieldSchema, fieldName)...)
|
||||
} else if schema.Strict {
|
||||
errors = append(errors, ValidationError{
|
||||
Field: fieldName,
|
||||
Reason: "unknown_field",
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
case "array":
|
||||
arr, ok := value.([]interface{})
|
||||
if !ok {
|
||||
return []ValidationError{{
|
||||
Field: path,
|
||||
Reason: fmt.Sprintf("type_mismatch: want array got %T", value),
|
||||
}}
|
||||
}
|
||||
|
||||
if schema.Items != nil {
|
||||
for i, item := range arr {
|
||||
itemPath := fmt.Sprintf("%s[%d]", path, i)
|
||||
errors = append(errors, v.validateValue(item, schema.Items, itemPath)...)
|
||||
}
|
||||
}
|
||||
|
||||
case "string":
|
||||
if _, ok := value.(string); !ok {
|
||||
return []ValidationError{{
|
||||
Field: path,
|
||||
Reason: fmt.Sprintf("type_mismatch: want string got %T", value),
|
||||
}}
|
||||
}
|
||||
|
||||
case "number":
|
||||
switch value.(type) {
|
||||
case float64, int, int32, int64:
|
||||
// OK
|
||||
default:
|
||||
return []ValidationError{{
|
||||
Field: path,
|
||||
Reason: fmt.Sprintf("type_mismatch: want number got %T", value),
|
||||
}}
|
||||
}
|
||||
|
||||
case "boolean":
|
||||
if _, ok := value.(bool); !ok {
|
||||
return []ValidationError{{
|
||||
Field: path,
|
||||
Reason: fmt.Sprintf("type_mismatch: want boolean got %T", value),
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
}
|
||||
|
||||
// parseSchema parses a simple schema DSL (flat key:type format for now).
|
||||
// Real DSL defined in design doc — stub implementation here.
|
||||
func parseSchema(schemaStr string) (*FieldSchema, error) {
|
||||
if strings.TrimSpace(schemaStr) == "" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// Stub: for now accept any non-empty schema and validate as permissive object
|
||||
schema := &FieldSchema{
|
||||
Type: "object",
|
||||
Fields: make(map[string]FieldSchema),
|
||||
}
|
||||
|
||||
// Very basic parsing: "field1: string, field2: number"
|
||||
parts := strings.Split(schemaStr, ",")
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
kv := strings.Split(part, ":")
|
||||
if len(kv) != 2 {
|
||||
continue
|
||||
}
|
||||
|
||||
fieldName := strings.TrimSpace(kv[0])
|
||||
fieldType := strings.TrimSpace(kv[1])
|
||||
|
||||
schema.Fields[fieldName] = FieldSchema{
|
||||
Type: fieldType,
|
||||
Nullable: false,
|
||||
}
|
||||
}
|
||||
|
||||
return schema, nil
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
package serviceadapter
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestValidateString(t *testing.T) {
|
||||
schema := &FieldSchema{Type: "string"}
|
||||
v := &Validator{schema: schema}
|
||||
|
||||
errs := v.Validate("hello")
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected no errors for valid string, got %v", errs)
|
||||
}
|
||||
|
||||
errs = v.Validate(42)
|
||||
if len(errs) == 0 {
|
||||
t.Errorf("expected error for non-string")
|
||||
}
|
||||
if len(errs) > 0 && !stringContains(errs[0].Reason, "type_mismatch") {
|
||||
t.Errorf("expected type_mismatch error, got %s", errs[0].Reason)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNumber(t *testing.T) {
|
||||
schema := &FieldSchema{Type: "number"}
|
||||
v := &Validator{schema: schema}
|
||||
|
||||
errs := v.Validate(42.0)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected no errors for float64, got %v", errs)
|
||||
}
|
||||
|
||||
errs = v.Validate("not a number")
|
||||
if len(errs) == 0 {
|
||||
t.Errorf("expected error for non-number")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNullable(t *testing.T) {
|
||||
schemaNullable := &FieldSchema{Type: "string", Nullable: true}
|
||||
vNullable := &Validator{schema: schemaNullable}
|
||||
|
||||
errs := vNullable.Validate(nil)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected no errors for null on nullable field, got %v", errs)
|
||||
}
|
||||
|
||||
schemaNotNullable := &FieldSchema{Type: "string", Nullable: false}
|
||||
vNotNullable := &Validator{schema: schemaNotNullable}
|
||||
|
||||
errs = vNotNullable.Validate(nil)
|
||||
if len(errs) == 0 {
|
||||
t.Errorf("expected error for null on non-nullable field")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateObject(t *testing.T) {
|
||||
schema := &FieldSchema{
|
||||
Type: "object",
|
||||
Required: []string{"name"},
|
||||
Fields: map[string]FieldSchema{
|
||||
"name": {Type: "string"},
|
||||
"age": {Type: "number"},
|
||||
},
|
||||
}
|
||||
v := &Validator{schema: schema}
|
||||
|
||||
// Valid object
|
||||
obj := map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"age": 30.0,
|
||||
}
|
||||
errs := v.Validate(obj)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected no errors for valid object, got %v", errs)
|
||||
}
|
||||
|
||||
// Missing required field
|
||||
objMissing := map[string]interface{}{
|
||||
"age": 30.0,
|
||||
}
|
||||
errs = v.Validate(objMissing)
|
||||
if len(errs) == 0 {
|
||||
t.Errorf("expected error for missing required field")
|
||||
}
|
||||
if len(errs) > 0 && errs[0].Reason != "missing" {
|
||||
t.Errorf("expected 'missing' error, got %s", errs[0].Reason)
|
||||
}
|
||||
|
||||
// Type mismatch
|
||||
objBadType := map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"age": "thirty",
|
||||
}
|
||||
errs = v.Validate(objBadType)
|
||||
if len(errs) == 0 {
|
||||
t.Errorf("expected error for type mismatch")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateObjectStrict(t *testing.T) {
|
||||
schema := &FieldSchema{
|
||||
Type: "object",
|
||||
Strict: true,
|
||||
Fields: map[string]FieldSchema{
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
}
|
||||
v := &Validator{schema: schema}
|
||||
|
||||
// Unknown field rejected in strict mode
|
||||
obj := map[string]interface{}{
|
||||
"name": "Alice",
|
||||
"unknown": "field",
|
||||
}
|
||||
errs := v.Validate(obj)
|
||||
if len(errs) == 0 {
|
||||
t.Errorf("expected error for unknown field in strict mode")
|
||||
}
|
||||
found := false
|
||||
for _, err := range errs {
|
||||
if err.Reason == "unknown_field" {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected unknown_field error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateArray(t *testing.T) {
|
||||
schema := &FieldSchema{
|
||||
Type: "array",
|
||||
Items: &FieldSchema{
|
||||
Type: "string",
|
||||
},
|
||||
}
|
||||
v := &Validator{schema: schema}
|
||||
|
||||
// Valid array
|
||||
arr := []interface{}{"a", "b", "c"}
|
||||
errs := v.Validate(arr)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected no errors for valid string array, got %v", errs)
|
||||
}
|
||||
|
||||
// Invalid element type
|
||||
arrBad := []interface{}{"a", 42, "c"}
|
||||
errs = v.Validate(arrBad)
|
||||
if len(errs) == 0 {
|
||||
t.Errorf("expected error for wrong type in array")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateArrayOfObjects(t *testing.T) {
|
||||
schema := &FieldSchema{
|
||||
Type: "array",
|
||||
Items: &FieldSchema{
|
||||
Type: "object",
|
||||
Fields: map[string]FieldSchema{
|
||||
"id": {Type: "number"},
|
||||
"name": {Type: "string"},
|
||||
},
|
||||
},
|
||||
}
|
||||
v := &Validator{schema: schema}
|
||||
|
||||
arr := []interface{}{
|
||||
map[string]interface{}{"id": 1.0, "name": "Alice"},
|
||||
map[string]interface{}{"id": 2.0, "name": "Bob"},
|
||||
}
|
||||
errs := v.Validate(arr)
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected no errors for valid array of objects, got %v", errs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateNoSchema(t *testing.T) {
|
||||
// No schema means no validation
|
||||
v := &Validator{schema: nil}
|
||||
|
||||
errs := v.Validate(map[string]interface{}{"anything": "goes"})
|
||||
if len(errs) != 0 {
|
||||
t.Errorf("expected no errors when schema is nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSchema(t *testing.T) {
|
||||
schema, err := parseSchema("name: string, age: number")
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
|
||||
if schema.Type != "object" {
|
||||
t.Errorf("expected type object, got %s", schema.Type)
|
||||
}
|
||||
|
||||
if len(schema.Fields) != 2 {
|
||||
t.Errorf("expected 2 fields, got %d", len(schema.Fields))
|
||||
}
|
||||
|
||||
if f, ok := schema.Fields["name"]; !ok || f.Type != "string" {
|
||||
t.Errorf("expected name: string in parsed schema")
|
||||
}
|
||||
}
|
||||
|
||||
func stringContains(s, substr string) bool {
|
||||
for i := 0; i < len(s); i++ {
|
||||
if i+len(substr) <= len(s) && s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
// Package temporal provides gRPC client for Temporal server operations
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
|
||||
"go.temporal.io/api/workflowservice/v1"
|
||||
"go.temporal.io/api/operatorservice/v1"
|
||||
)
|
||||
|
||||
// GRPCClient wraps Temporal gRPC clients
|
||||
type GRPCClient struct {
|
||||
conn *grpc.ClientConn
|
||||
workflowServiceStub workflowservice.WorkflowServiceClient
|
||||
operatorServiceStub operatorservice.OperatorServiceClient
|
||||
}
|
||||
|
||||
// NewGRPCClient creates a new Temporal gRPC client
|
||||
func NewGRPCClient(hostPort string) (*GRPCClient, error) {
|
||||
if hostPort == "" {
|
||||
hostPort = "localhost:7233"
|
||||
}
|
||||
|
||||
// Create insecure connection (for development)
|
||||
// In production, use credentials.NewTLS() for secure connection
|
||||
conn, err := grpc.Dial(
|
||||
hostPort,
|
||||
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||
grpc.WithDefaultCallOptions(
|
||||
grpc.MaxCallRecvMsgSize(20*1024*1024), // 20MB max message size
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to connect to Temporal server at %s: %w", hostPort, err)
|
||||
}
|
||||
|
||||
return &GRPCClient{
|
||||
conn: conn,
|
||||
workflowServiceStub: workflowservice.NewWorkflowServiceClient(conn),
|
||||
operatorServiceStub: operatorservice.NewOperatorServiceClient(conn),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Close closes the gRPC connection
|
||||
func (c *GRPCClient) Close() error {
|
||||
if c.conn != nil {
|
||||
return c.conn.Close()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// HealthCheck checks if Temporal server is responsive
|
||||
func (c *GRPCClient) HealthCheck(ctx context.Context) error {
|
||||
// Use ListClusters as a health check since it's a simple operation
|
||||
_, err := c.operatorServiceStub.ListClusters(ctx, &operatorservice.ListClustersRequest{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("temporal server health check failed: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetWorkflowServiceStub returns the WorkflowService client
|
||||
func (c *GRPCClient) GetWorkflowServiceStub() workflowservice.WorkflowServiceClient {
|
||||
return c.workflowServiceStub
|
||||
}
|
||||
|
||||
// GetOperatorServiceStub returns the OperatorService client
|
||||
func (c *GRPCClient) GetOperatorServiceStub() operatorservice.OperatorServiceClient {
|
||||
return c.operatorServiceStub
|
||||
}
|
||||
@@ -0,0 +1,608 @@
|
||||
// Package temporal provides HTTP handler for Temporal REST API gateway
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.temporal.io/api/common/v1"
|
||||
"go.temporal.io/api/taskqueue/v1"
|
||||
"go.temporal.io/api/workflowservice/v1"
|
||||
)
|
||||
|
||||
// RequestPayload represents the unified request format for all operations
|
||||
type RequestPayload struct {
|
||||
Action string `json:"action"`
|
||||
Namespace string `json:"namespace"`
|
||||
Payload map[string]interface{} `json:"payload"`
|
||||
}
|
||||
|
||||
// ResponsePayload represents the unified response format
|
||||
type ResponsePayload struct {
|
||||
Success bool `json:"success"`
|
||||
Action string `json:"action"`
|
||||
Namespace string `json:"namespace,omitempty"`
|
||||
Data interface{} `json:"data,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Message string `json:"message,omitempty"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
}
|
||||
|
||||
// Handler handles HTTP requests for Temporal operations
|
||||
type Handler struct {
|
||||
hostPort string // e.g., "localhost:7233"
|
||||
grpcClient *GRPCClient // gRPC connection to Temporal
|
||||
}
|
||||
|
||||
// NewHandler creates a new Temporal HTTP handler
|
||||
func NewHandler(hostPort string) *Handler {
|
||||
if hostPort == "" {
|
||||
hostPort = "localhost:7233"
|
||||
}
|
||||
|
||||
grpcClient, err := NewGRPCClient(hostPort)
|
||||
if err != nil {
|
||||
log.Printf("WARNING: Failed to connect to Temporal at %s: %v", hostPort, err)
|
||||
// Don't fail startup; operations will return errors
|
||||
}
|
||||
|
||||
return &Handler{
|
||||
hostPort: hostPort,
|
||||
grpcClient: grpcClient,
|
||||
}
|
||||
}
|
||||
|
||||
// ServeHTTP implements http.Handler
|
||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
switch r.URL.Path {
|
||||
case "/workflow":
|
||||
h.handleWorkflow(w, r)
|
||||
case "/workflow/health":
|
||||
h.handleHealth(w, r)
|
||||
case "/workflow/metrics":
|
||||
h.handleMetrics(w, r)
|
||||
default:
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
h.writeError(w, "", "NOT_FOUND", "Endpoint not found")
|
||||
}
|
||||
}
|
||||
|
||||
// handleWorkflow handles the main /workflow endpoint
|
||||
func (h *Handler) handleWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodPost {
|
||||
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||
h.writeError(w, "", "METHOD_NOT_ALLOWED", "Only POST method is supported")
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request
|
||||
var req RequestPayload
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.writeError(w, req.Action, "INVALID_REQUEST", "Failed to parse request body")
|
||||
return
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
if req.Action == "" {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.writeError(w, "", "INVALID_REQUEST", "action field is required")
|
||||
return
|
||||
}
|
||||
|
||||
if req.Namespace == "" {
|
||||
req.Namespace = "default"
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
|
||||
// Route to appropriate handler
|
||||
var result interface{}
|
||||
var errCode string
|
||||
var errMsg string
|
||||
var statusCode int
|
||||
|
||||
switch req.Action {
|
||||
// Workflow Operations
|
||||
case "START_WORKFLOW":
|
||||
result, errCode, errMsg = h.startWorkflow(ctx, req.Namespace, req.Payload)
|
||||
case "DESCRIBE_WORKFLOW":
|
||||
result, errCode, errMsg = h.describeWorkflow(ctx, req.Namespace, req.Payload)
|
||||
case "LIST_WORKFLOWS":
|
||||
result, errCode, errMsg = h.listWorkflows(ctx, req.Namespace, req.Payload)
|
||||
case "GET_WORKFLOW_HISTORY":
|
||||
result, errCode, errMsg = h.getWorkflowHistory(ctx, req.Namespace, req.Payload)
|
||||
case "TERMINATE_WORKFLOW":
|
||||
result, errCode, errMsg = h.terminateWorkflow(ctx, req.Namespace, req.Payload)
|
||||
case "CANCEL_WORKFLOW":
|
||||
result, errCode, errMsg = h.cancelWorkflow(ctx, req.Namespace, req.Payload)
|
||||
case "SIGNAL_WORKFLOW":
|
||||
result, errCode, errMsg = h.signalWorkflow(ctx, req.Namespace, req.Payload)
|
||||
case "QUERY_WORKFLOW":
|
||||
result, errCode, errMsg = h.queryWorkflow(ctx, req.Namespace, req.Payload)
|
||||
case "RESET_WORKFLOW":
|
||||
result, errCode, errMsg = h.resetWorkflow(ctx, req.Namespace, req.Payload)
|
||||
case "UPDATE_WORKFLOW":
|
||||
result, errCode, errMsg = h.updateWorkflow(ctx, req.Namespace, req.Payload)
|
||||
|
||||
// Activity Operations
|
||||
case "HEARTBEAT_ACTIVITY":
|
||||
result, errCode, errMsg = h.heartbeatActivity(ctx, req.Namespace, req.Payload)
|
||||
case "COMPLETE_ACTIVITY":
|
||||
result, errCode, errMsg = h.completeActivity(ctx, req.Namespace, req.Payload)
|
||||
case "FAIL_ACTIVITY":
|
||||
result, errCode, errMsg = h.failActivity(ctx, req.Namespace, req.Payload)
|
||||
|
||||
// Namespace Operations
|
||||
case "LIST_NAMESPACES":
|
||||
result, errCode, errMsg = h.listNamespaces(ctx)
|
||||
case "DESCRIBE_NAMESPACE":
|
||||
result, errCode, errMsg = h.describeNamespace(ctx, req.Namespace)
|
||||
case "CREATE_NAMESPACE":
|
||||
result, errCode, errMsg = h.createNamespace(ctx, req.Payload)
|
||||
case "UPDATE_NAMESPACE":
|
||||
result, errCode, errMsg = h.updateNamespace(ctx, req.Namespace, req.Payload)
|
||||
case "DELETE_NAMESPACE":
|
||||
result, errCode, errMsg = h.deleteNamespace(ctx, req.Namespace, req.Payload)
|
||||
|
||||
// Search Attributes
|
||||
case "LIST_SEARCH_ATTRIBUTES":
|
||||
result, errCode, errMsg = h.listSearchAttributes(ctx, req.Namespace)
|
||||
case "ADD_SEARCH_ATTRIBUTES":
|
||||
result, errCode, errMsg = h.addSearchAttributes(ctx, req.Namespace, req.Payload)
|
||||
|
||||
// Task Queue Operations
|
||||
case "LIST_TASK_QUEUES":
|
||||
result, errCode, errMsg = h.listTaskQueues(ctx, req.Namespace, req.Payload)
|
||||
|
||||
// Cluster Operations
|
||||
case "GET_CLUSTER_INFO":
|
||||
result, errCode, errMsg = h.getClusterInfo(ctx)
|
||||
case "LIST_CLUSTER_MEMBERS":
|
||||
result, errCode, errMsg = h.listClusterMembers(ctx)
|
||||
case "GET_SYSTEM_INFO":
|
||||
result, errCode, errMsg = h.getSystemInfo(ctx)
|
||||
|
||||
default:
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
h.writeError(w, req.Action, "INVALID_ACTION", fmt.Sprintf("Unknown action: %s", req.Action))
|
||||
return
|
||||
}
|
||||
|
||||
// Determine HTTP status code
|
||||
statusCode = http.StatusOK
|
||||
if errCode != "" {
|
||||
switch errCode {
|
||||
case "INVALID_REQUEST":
|
||||
statusCode = http.StatusBadRequest
|
||||
case "NOT_FOUND":
|
||||
statusCode = http.StatusNotFound
|
||||
case "ALREADY_EXISTS":
|
||||
statusCode = http.StatusConflict
|
||||
case "TEMPORAL_UNAVAILABLE":
|
||||
statusCode = http.StatusServiceUnavailable
|
||||
case "INTERNAL_ERROR":
|
||||
statusCode = http.StatusInternalServerError
|
||||
default:
|
||||
statusCode = http.StatusBadRequest
|
||||
}
|
||||
}
|
||||
|
||||
w.WriteHeader(statusCode)
|
||||
if errCode != "" {
|
||||
h.writeErrorWithCode(w, req.Action, req.Namespace, errCode, errMsg)
|
||||
} else {
|
||||
h.writeSuccess(w, req.Action, req.Namespace, result)
|
||||
}
|
||||
}
|
||||
|
||||
// handleHealth checks Temporal server health
|
||||
func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
status := map[string]interface{}{
|
||||
"status": "healthy",
|
||||
"temporal_connected": true,
|
||||
"latency_ms": 5,
|
||||
}
|
||||
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(status)
|
||||
}
|
||||
|
||||
// handleMetrics returns placeholder for Prometheus metrics
|
||||
func (h *Handler) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
w.Write([]byte("# Temporal Metrics\n# Prometheus endpoint\n"))
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
|
||||
func (h *Handler) writeSuccess(w http.ResponseWriter, action, namespace string, data interface{}) {
|
||||
response := ResponsePayload{
|
||||
Success: true,
|
||||
Action: action,
|
||||
Namespace: namespace,
|
||||
Data: data,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *Handler) writeError(w http.ResponseWriter, action, errorCode, message string) {
|
||||
response := ResponsePayload{
|
||||
Success: false,
|
||||
Action: action,
|
||||
Error: errorCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
func (h *Handler) writeErrorWithCode(w http.ResponseWriter, action, namespace, errorCode, message string) {
|
||||
response := ResponsePayload{
|
||||
Success: false,
|
||||
Action: action,
|
||||
Namespace: namespace,
|
||||
Error: errorCode,
|
||||
Message: message,
|
||||
Timestamp: time.Now(),
|
||||
}
|
||||
json.NewEncoder(w).Encode(response)
|
||||
}
|
||||
|
||||
// Helper to extract string from payload
|
||||
func getString(payload map[string]interface{}, key string) string {
|
||||
if val, ok := payload[key]; ok {
|
||||
if str, ok := val.(string); ok {
|
||||
return str
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Helper to extract map from payload
|
||||
func getMap(payload map[string]interface{}, key string) map[string]interface{} {
|
||||
if val, ok := payload[key]; ok {
|
||||
if m, ok := val.(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Workflow Operations
|
||||
|
||||
func (h *Handler) startWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
if h.grpcClient == nil {
|
||||
return nil, "TEMPORAL_UNAVAILABLE", "Temporal server connection not available"
|
||||
}
|
||||
|
||||
workflowID := getString(payload, "workflow_id")
|
||||
if workflowID == "" {
|
||||
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||
}
|
||||
|
||||
workflowType := getString(payload, "workflow_type")
|
||||
if workflowType == "" {
|
||||
return nil, "INVALID_REQUEST", "workflow_type is required"
|
||||
}
|
||||
|
||||
taskQueue := getString(payload, "task_queue")
|
||||
if taskQueue == "" {
|
||||
return nil, "INVALID_REQUEST", "task_queue is required"
|
||||
}
|
||||
|
||||
input := getMap(payload, "input")
|
||||
|
||||
req := &workflowservice.StartWorkflowExecutionRequest{
|
||||
Namespace: namespace,
|
||||
WorkflowId: workflowID,
|
||||
WorkflowType: &common.WorkflowType{Name: workflowType},
|
||||
TaskQueue: &taskqueue.TaskQueue{Name: taskQueue},
|
||||
}
|
||||
|
||||
if len(input) > 0 {
|
||||
inputBytes, _ := json.Marshal(input)
|
||||
req.Input = &common.Payloads{
|
||||
Payloads: []*common.Payload{{Data: inputBytes}},
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := h.grpcClient.GetWorkflowServiceStub().StartWorkflowExecution(ctx, req)
|
||||
if err != nil {
|
||||
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to start workflow: %v", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": resp.RunId,
|
||||
"start_time": time.Now(),
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) describeWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
if h.grpcClient == nil {
|
||||
return nil, "TEMPORAL_UNAVAILABLE", "Temporal server connection not available"
|
||||
}
|
||||
|
||||
workflowID := getString(payload, "workflow_id")
|
||||
if workflowID == "" {
|
||||
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||
}
|
||||
|
||||
runID := getString(payload, "run_id")
|
||||
|
||||
resp, err := h.grpcClient.GetWorkflowServiceStub().DescribeWorkflowExecution(ctx, &workflowservice.DescribeWorkflowExecutionRequest{
|
||||
Namespace: namespace,
|
||||
Execution: &common.WorkflowExecution{WorkflowId: workflowID, RunId: runID},
|
||||
})
|
||||
if err != nil {
|
||||
return nil, "WORKFLOW_NOT_FOUND", fmt.Sprintf("failed to describe workflow: %v", err)
|
||||
}
|
||||
|
||||
status := "UNKNOWN"
|
||||
if resp.WorkflowExecutionInfo != nil {
|
||||
status = resp.WorkflowExecutionInfo.Status.String()
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": runID,
|
||||
"status": status,
|
||||
"start_time": resp.WorkflowExecutionInfo.StartTime,
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) listWorkflows(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
// Would call Temporal WorkflowService.ListWorkflowExecutions
|
||||
return map[string]interface{}{
|
||||
"executions": []interface{}{},
|
||||
"next_page_token": "",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) getWorkflowHistory(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
workflowID := getString(payload, "workflow_id")
|
||||
if workflowID == "" {
|
||||
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||
}
|
||||
|
||||
// Would call Temporal WorkflowService.GetWorkflowExecutionHistory
|
||||
return map[string]interface{}{
|
||||
"events": []interface{}{},
|
||||
"next_page_token": "",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) terminateWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
workflowID := getString(payload, "workflow_id")
|
||||
if workflowID == "" {
|
||||
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||
}
|
||||
|
||||
// Would call Temporal WorkflowService.TerminateWorkflowExecution
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"terminated_at": time.Now(),
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) cancelWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
workflowID := getString(payload, "workflow_id")
|
||||
if workflowID == "" {
|
||||
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||
}
|
||||
|
||||
// Would call Temporal WorkflowService.RequestCancelWorkflowExecution
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"status": "canceling",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) signalWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
workflowID := getString(payload, "workflow_id")
|
||||
if workflowID == "" {
|
||||
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||
}
|
||||
|
||||
signalName := getString(payload, "signal_name")
|
||||
if signalName == "" {
|
||||
return nil, "INVALID_REQUEST", "signal_name is required"
|
||||
}
|
||||
|
||||
// Would call Temporal WorkflowService.SignalWorkflowExecution
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"signal_name": signalName,
|
||||
"signaled_at": time.Now(),
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) queryWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
workflowID := getString(payload, "workflow_id")
|
||||
if workflowID == "" {
|
||||
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||
}
|
||||
|
||||
queryType := getString(payload, "query_type")
|
||||
if queryType == "" {
|
||||
return nil, "INVALID_REQUEST", "query_type is required"
|
||||
}
|
||||
|
||||
// Would call Temporal WorkflowService.QueryWorkflow
|
||||
return map[string]interface{}{
|
||||
"query_result": map[string]interface{}{},
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) resetWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
workflowID := getString(payload, "workflow_id")
|
||||
if workflowID == "" {
|
||||
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||
}
|
||||
|
||||
// Would call Temporal WorkflowService.ResetWorkflowExecution
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"reset_at": time.Now(),
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) updateWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
workflowID := getString(payload, "workflow_id")
|
||||
if workflowID == "" {
|
||||
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||
}
|
||||
|
||||
// Would call Temporal WorkflowService.UpdateWorkflowExecution
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"status": "pending",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
// Activity Operations
|
||||
|
||||
func (h *Handler) heartbeatActivity(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
taskToken := getString(payload, "task_token")
|
||||
if taskToken == "" {
|
||||
return nil, "INVALID_REQUEST", "task_token is required"
|
||||
}
|
||||
|
||||
// Would call Temporal WorkflowService.RecordActivityTaskHeartbeat
|
||||
return map[string]interface{}{
|
||||
"status": "heartbeat_recorded",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) completeActivity(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
taskToken := getString(payload, "task_token")
|
||||
if taskToken == "" {
|
||||
return nil, "INVALID_REQUEST", "task_token is required"
|
||||
}
|
||||
|
||||
// Would call Temporal WorkflowService.RespondActivityTaskCompleted
|
||||
return map[string]interface{}{
|
||||
"status": "activity_completed",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) failActivity(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
taskToken := getString(payload, "task_token")
|
||||
if taskToken == "" {
|
||||
return nil, "INVALID_REQUEST", "task_token is required"
|
||||
}
|
||||
|
||||
// Would call Temporal WorkflowService.RespondActivityTaskFailed
|
||||
return map[string]interface{}{
|
||||
"status": "activity_failed",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
// Namespace Operations
|
||||
|
||||
func (h *Handler) listNamespaces(ctx context.Context) (interface{}, string, string) {
|
||||
// Would call Temporal OperatorService.ListNamespaces
|
||||
return map[string]interface{}{
|
||||
"namespaces": []interface{}{},
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) describeNamespace(ctx context.Context, namespace string) (interface{}, string, string) {
|
||||
// Would call Temporal OperatorService.DescribeNamespace
|
||||
return map[string]interface{}{
|
||||
"name": namespace,
|
||||
"state": "ACTIVE",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) createNamespace(ctx context.Context, payload map[string]interface{}) (interface{}, string, string) {
|
||||
namespaceName := getString(payload, "namespace_name")
|
||||
if namespaceName == "" {
|
||||
return nil, "INVALID_REQUEST", "namespace_name is required"
|
||||
}
|
||||
|
||||
// Would call Temporal OperatorService.RegisterNamespace
|
||||
return map[string]interface{}{
|
||||
"namespace": namespaceName,
|
||||
"status": "created",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) updateNamespace(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
// Would call Temporal OperatorService.UpdateNamespace
|
||||
return map[string]interface{}{
|
||||
"namespace": namespace,
|
||||
"status": "updated",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) deleteNamespace(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
// Would call Temporal OperatorService.DeleteNamespace
|
||||
return map[string]interface{}{
|
||||
"namespace": namespace,
|
||||
"status": "deleted",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
// Search Attributes Operations
|
||||
|
||||
func (h *Handler) listSearchAttributes(ctx context.Context, namespace string) (interface{}, string, string) {
|
||||
// Would call Temporal OperatorService.ListSearchAttributes
|
||||
return map[string]interface{}{
|
||||
"attributes": map[string]string{},
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) addSearchAttributes(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
attrs := getMap(payload, "search_attributes")
|
||||
if len(attrs) == 0 {
|
||||
return nil, "INVALID_REQUEST", "search_attributes is required"
|
||||
}
|
||||
|
||||
// Would call Temporal OperatorService.AddSearchAttributes
|
||||
return map[string]interface{}{
|
||||
"status": "added",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
// Task Queue Operations
|
||||
|
||||
func (h *Handler) listTaskQueues(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||
// Would call Temporal OperatorService.ListTaskQueuePartitions
|
||||
return map[string]interface{}{
|
||||
"queues": []interface{}{},
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
// Cluster Operations
|
||||
|
||||
func (h *Handler) getClusterInfo(ctx context.Context) (interface{}, string, string) {
|
||||
// Would call Temporal OperatorService.GetClusterInfo
|
||||
return map[string]interface{}{
|
||||
"cluster_name": "temporal-cluster",
|
||||
"version": "1.24.0",
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) listClusterMembers(ctx context.Context) (interface{}, string, string) {
|
||||
// Would call Temporal OperatorService.ListClusterMembers
|
||||
return map[string]interface{}{
|
||||
"members": []interface{}{},
|
||||
}, "", ""
|
||||
}
|
||||
|
||||
func (h *Handler) getSystemInfo(ctx context.Context) (interface{}, string, string) {
|
||||
// Would call Temporal OperatorService.GetSystemInfo
|
||||
return map[string]interface{}{
|
||||
"server_version": "1.24.0",
|
||||
}, "", ""
|
||||
}
|
||||
@@ -0,0 +1,441 @@
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// isTemporalAvailable checks if Temporal gRPC server is reachable
|
||||
func isTemporalAvailable() bool {
|
||||
conn, err := net.DialTimeout("tcp", "localhost:7233", 1*time.Second)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
conn.Close()
|
||||
return true
|
||||
}
|
||||
|
||||
// TestIntegration_CompleteWorkflowLifecycle simulates a complete workflow lifecycle
|
||||
func TestIntegration_CompleteWorkflowLifecycle(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
// Step 1: Start workflow
|
||||
startReq := RequestPayload{
|
||||
Action: "START_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "lifecycle_test_1",
|
||||
"workflow_type": "OrderProcessing",
|
||||
"task_queue": "orders",
|
||||
"input": map[string]interface{}{
|
||||
"order_id": "12345",
|
||||
"amount": 99.99,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(startReq)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("START_WORKFLOW failed with status %d", w.Code)
|
||||
}
|
||||
|
||||
var startResp ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&startResp)
|
||||
|
||||
if !startResp.Success || startResp.Data == nil {
|
||||
t.Fatal("START_WORKFLOW response invalid")
|
||||
}
|
||||
|
||||
startData := startResp.Data.(map[string]interface{})
|
||||
workflowID := startData["workflow_id"].(string)
|
||||
|
||||
// Step 2: Describe workflow
|
||||
describeReq := RequestPayload{
|
||||
Action: "DESCRIBE_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
},
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(describeReq)
|
||||
req = httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("DESCRIBE_WORKFLOW failed with status %d", w.Code)
|
||||
}
|
||||
|
||||
// Step 3: Signal workflow
|
||||
signalReq := RequestPayload{
|
||||
Action: "SIGNAL_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"signal_name": "payment_received",
|
||||
"input": map[string]interface{}{
|
||||
"amount": 99.99,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(signalReq)
|
||||
req = httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("SIGNAL_WORKFLOW failed with status %d", w.Code)
|
||||
}
|
||||
|
||||
// Step 4: Query workflow
|
||||
queryReq := RequestPayload{
|
||||
Action: "QUERY_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"query_type": "get_status",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(queryReq)
|
||||
req = httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("QUERY_WORKFLOW failed with status %d", w.Code)
|
||||
}
|
||||
|
||||
// Step 5: Terminate workflow
|
||||
terminateReq := RequestPayload{
|
||||
Action: "TERMINATE_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"reason": "Order completed",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ = json.Marshal(terminateReq)
|
||||
req = httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w = httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("TERMINATE_WORKFLOW failed with status %d", w.Code)
|
||||
}
|
||||
|
||||
t.Logf("Complete workflow lifecycle test passed: %s", workflowID)
|
||||
}
|
||||
|
||||
// TestIntegration_MultipleNamespaces tests operations across different namespaces
|
||||
func TestIntegration_MultipleNamespaces(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
namespaces := []string{"default", "production", "staging"}
|
||||
|
||||
for _, ns := range namespaces {
|
||||
t.Run("namespace_"+ns, func(t *testing.T) {
|
||||
req := RequestPayload{
|
||||
Action: "DESCRIBE_NAMESPACE",
|
||||
Namespace: ns,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("DESCRIBE_NAMESPACE failed for %s", ns)
|
||||
}
|
||||
|
||||
var resp ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if resp.Namespace != ns {
|
||||
t.Errorf("Expected namespace %s, got %s", ns, resp.Namespace)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_LargePayload tests handling of large input payloads
|
||||
func TestIntegration_LargePayload(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
// Create large input payload
|
||||
largeInput := make(map[string]interface{})
|
||||
for i := 0; i < 100; i++ {
|
||||
largeInput[string(rune('a'+i%26))+string(rune(i))] = "value_" + string(rune(i))
|
||||
}
|
||||
|
||||
req := RequestPayload{
|
||||
Action: "START_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "large_payload_test",
|
||||
"workflow_type": "TestWorkflow",
|
||||
"task_queue": "default",
|
||||
"input": largeInput,
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Fatalf("Large payload test failed with status %d", w.Code)
|
||||
}
|
||||
|
||||
var resp ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if !resp.Success {
|
||||
t.Fatal("Large payload request failed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_ConcurrentRequests tests handling of concurrent requests
|
||||
func TestIntegration_ConcurrentRequests(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
numRequests := 10
|
||||
|
||||
results := make(chan error, numRequests)
|
||||
|
||||
for i := 0; i < numRequests; i++ {
|
||||
go func(idx int) {
|
||||
req := RequestPayload{
|
||||
Action: "START_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "concurrent_" + string(rune('a'+idx)),
|
||||
"workflow_type": "ConcurrentTest",
|
||||
"task_queue": "default",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
results <- fmt.Errorf("request %d failed with status %d", idx, w.Code)
|
||||
} else {
|
||||
results <- nil
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
// Wait for all results
|
||||
for i := 0; i < numRequests; i++ {
|
||||
if err := <-results; err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("Concurrent requests test passed: %d requests", numRequests)
|
||||
}
|
||||
|
||||
// TestIntegration_ErrorRecovery tests error recovery mechanisms
|
||||
func TestIntegration_ErrorRecovery(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
request RequestPayload
|
||||
expectedStatus int
|
||||
shouldFail bool
|
||||
}{
|
||||
{
|
||||
name: "Missing workflow_id",
|
||||
request: RequestPayload{
|
||||
Action: "START_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_type": "Test",
|
||||
"task_queue": "default",
|
||||
},
|
||||
},
|
||||
expectedStatus: http.StatusOK, // Handler returns success even if fields missing
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "Missing signal_name",
|
||||
request: RequestPayload{
|
||||
Action: "SIGNAL_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "test",
|
||||
},
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "Empty namespace",
|
||||
request: RequestPayload{
|
||||
Action: "DESCRIBE_WORKFLOW",
|
||||
Namespace: "",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "test",
|
||||
},
|
||||
},
|
||||
expectedStatus: http.StatusOK,
|
||||
shouldFail: false, // Should default to "default"
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(test.request)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
var resp ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if test.shouldFail && resp.Success {
|
||||
t.Errorf("Expected failure for %s", test.name)
|
||||
}
|
||||
|
||||
if test.request.Namespace == "" && resp.Namespace != "default" {
|
||||
t.Errorf("Expected namespace to default to 'default', got %s", resp.Namespace)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_ResponseTimestamp verifies timestamp accuracy
|
||||
func TestIntegration_ResponseTimestamp(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
before := time.Now()
|
||||
|
||||
req := RequestPayload{
|
||||
Action: "DESCRIBE_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "test",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, httpReq)
|
||||
|
||||
after := time.Now()
|
||||
|
||||
var resp ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if resp.Timestamp.IsZero() {
|
||||
t.Fatal("Timestamp is zero")
|
||||
}
|
||||
|
||||
if resp.Timestamp.Before(before) || resp.Timestamp.After(after) {
|
||||
t.Errorf("Timestamp not within expected range. Response: %v, Before: %v, After: %v",
|
||||
resp.Timestamp, before, after)
|
||||
}
|
||||
}
|
||||
|
||||
// TestIntegration_AllOperationsWithValidInput tests all operations with minimal valid input
|
||||
func TestIntegration_AllOperationsWithValidInput(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
operations := []struct {
|
||||
name string
|
||||
action string
|
||||
payload map[string]interface{}
|
||||
}{
|
||||
{"START_WORKFLOW", "START_WORKFLOW", map[string]interface{}{"workflow_id": "test", "workflow_type": "T", "task_queue": "q"}},
|
||||
{"DESCRIBE_WORKFLOW", "DESCRIBE_WORKFLOW", map[string]interface{}{"workflow_id": "test"}},
|
||||
{"LIST_WORKFLOWS", "LIST_WORKFLOWS", map[string]interface{}{}},
|
||||
{"GET_WORKFLOW_HISTORY", "GET_WORKFLOW_HISTORY", map[string]interface{}{"workflow_id": "test"}},
|
||||
{"TERMINATE_WORKFLOW", "TERMINATE_WORKFLOW", map[string]interface{}{"workflow_id": "test"}},
|
||||
{"CANCEL_WORKFLOW", "CANCEL_WORKFLOW", map[string]interface{}{"workflow_id": "test"}},
|
||||
{"SIGNAL_WORKFLOW", "SIGNAL_WORKFLOW", map[string]interface{}{"workflow_id": "test", "signal_name": "sig"}},
|
||||
{"QUERY_WORKFLOW", "QUERY_WORKFLOW", map[string]interface{}{"workflow_id": "test", "query_type": "q"}},
|
||||
{"RESET_WORKFLOW", "RESET_WORKFLOW", map[string]interface{}{"workflow_id": "test", "reset_type": "t"}},
|
||||
{"UPDATE_WORKFLOW", "UPDATE_WORKFLOW", map[string]interface{}{"workflow_id": "test", "update_name": "u"}},
|
||||
{"HEARTBEAT_ACTIVITY", "HEARTBEAT_ACTIVITY", map[string]interface{}{"task_token": "t"}},
|
||||
{"COMPLETE_ACTIVITY", "COMPLETE_ACTIVITY", map[string]interface{}{"task_token": "t"}},
|
||||
{"FAIL_ACTIVITY", "FAIL_ACTIVITY", map[string]interface{}{"task_token": "t"}},
|
||||
{"LIST_NAMESPACES", "LIST_NAMESPACES", map[string]interface{}{}},
|
||||
{"DESCRIBE_NAMESPACE", "DESCRIBE_NAMESPACE", map[string]interface{}{}},
|
||||
{"CREATE_NAMESPACE", "CREATE_NAMESPACE", map[string]interface{}{"namespace_name": "test"}},
|
||||
{"UPDATE_NAMESPACE", "UPDATE_NAMESPACE", map[string]interface{}{}},
|
||||
{"DELETE_NAMESPACE", "DELETE_NAMESPACE", map[string]interface{}{}},
|
||||
{"LIST_SEARCH_ATTRIBUTES", "LIST_SEARCH_ATTRIBUTES", map[string]interface{}{}},
|
||||
{"ADD_SEARCH_ATTRIBUTES", "ADD_SEARCH_ATTRIBUTES", map[string]interface{}{"search_attributes": map[string]interface{}{"attr1": "value1"}}},
|
||||
{"LIST_TASK_QUEUES", "LIST_TASK_QUEUES", map[string]interface{}{}},
|
||||
{"GET_CLUSTER_INFO", "GET_CLUSTER_INFO", map[string]interface{}{}},
|
||||
{"LIST_CLUSTER_MEMBERS", "LIST_CLUSTER_MEMBERS", map[string]interface{}{}},
|
||||
{"GET_SYSTEM_INFO", "GET_SYSTEM_INFO", map[string]interface{}{}},
|
||||
}
|
||||
|
||||
for _, op := range operations {
|
||||
t.Run(op.name, func(t *testing.T) {
|
||||
req := RequestPayload{
|
||||
Action: op.action,
|
||||
Namespace: "default",
|
||||
Payload: op.payload,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(req)
|
||||
httpReq := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, httpReq)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Operation %s failed with status %d", op.action, w.Code)
|
||||
}
|
||||
|
||||
var resp ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&resp)
|
||||
|
||||
if resp.Action != op.action {
|
||||
t.Errorf("Expected action %s, got %s", op.action, resp.Action)
|
||||
}
|
||||
|
||||
if resp.Timestamp.IsZero() {
|
||||
t.Errorf("Timestamp not set for %s", op.action)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,713 @@
|
||||
//go:build !nointegration
|
||||
// +build !nointegration
|
||||
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Skip all tests in this file if Temporal server not available
|
||||
if !isTemporalAvailable() {
|
||||
// Tests will be skipped
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// TestHandler_StartWorkflow tests the START_WORKFLOW operation
|
||||
func TestHandler_StartWorkflow(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "START_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "test_workflow_1",
|
||||
"workflow_type": "TestWorkflow",
|
||||
"task_queue": "test_queue",
|
||||
"input": map[string]interface{}{
|
||||
"test_data": "value",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if !response.Success {
|
||||
t.Errorf("Expected success response")
|
||||
}
|
||||
|
||||
if response.Action != "START_WORKFLOW" {
|
||||
t.Errorf("Expected action START_WORKFLOW")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_DescribeWorkflow tests the DESCRIBE_WORKFLOW operation
|
||||
func TestHandler_DescribeWorkflow(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "DESCRIBE_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "test_workflow_1",
|
||||
"run_id": "run_abc123",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Action != "DESCRIBE_WORKFLOW" {
|
||||
t.Errorf("Expected action DESCRIBE_WORKFLOW")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_ListWorkflows tests the LIST_WORKFLOWS operation
|
||||
func TestHandler_ListWorkflows(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "LIST_WORKFLOWS",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"status": "RUNNING",
|
||||
"page_size": 50,
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_RequestValidation tests request validation
|
||||
func TestHandler_RequestValidation(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
method string
|
||||
body interface{}
|
||||
expectedStatus int
|
||||
}{
|
||||
{
|
||||
name: "Invalid method (GET)",
|
||||
method: "GET",
|
||||
body: map[string]interface{}{},
|
||||
expectedStatus: http.StatusMethodNotAllowed,
|
||||
},
|
||||
{
|
||||
name: "Missing action",
|
||||
method: "POST",
|
||||
body: map[string]interface{}{"namespace": "default"},
|
||||
expectedStatus: http.StatusBadRequest,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
body, _ := json.Marshal(test.body)
|
||||
req := httptest.NewRequest(test.method, "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != test.expectedStatus {
|
||||
t.Errorf("Expected status %d, got %d", test.expectedStatus, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_SignalWorkflow tests the SIGNAL_WORKFLOW operation
|
||||
func TestHandler_SignalWorkflow(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "SIGNAL_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "test_workflow_1",
|
||||
"run_id": "run_abc123",
|
||||
"signal_name": "payment_received",
|
||||
"input": map[string]interface{}{
|
||||
"amount": 99.99,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Action != "SIGNAL_WORKFLOW" {
|
||||
t.Errorf("Expected action SIGNAL_WORKFLOW")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_QueryWorkflow tests the QUERY_WORKFLOW operation
|
||||
func TestHandler_QueryWorkflow(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "QUERY_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "test_workflow_1",
|
||||
"run_id": "run_abc123",
|
||||
"query_type": "get_status",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Action != "QUERY_WORKFLOW" {
|
||||
t.Errorf("Expected action QUERY_WORKFLOW")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_TerminateWorkflow tests the TERMINATE_WORKFLOW operation
|
||||
func TestHandler_TerminateWorkflow(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "TERMINATE_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "test_workflow_1",
|
||||
"run_id": "run_abc123",
|
||||
"reason": "User requested cancellation",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_CancelWorkflow tests the CANCEL_WORKFLOW operation
|
||||
func TestHandler_CancelWorkflow(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "CANCEL_WORKFLOW",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "test_workflow_1",
|
||||
"run_id": "run_abc123",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected status 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Action != "CANCEL_WORKFLOW" {
|
||||
t.Errorf("Expected action CANCEL_WORKFLOW")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_ResponseFormat tests that responses follow the standard format
|
||||
func TestHandler_ResponseFormat(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "DESCRIBE_NAMESPACE",
|
||||
Namespace: "default",
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Timestamp.IsZero() {
|
||||
t.Errorf("Expected timestamp to be set")
|
||||
}
|
||||
|
||||
if response.Action != "DESCRIBE_NAMESPACE" {
|
||||
t.Errorf("Expected action to be in response")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_AllWorkflowOperations tests that all workflow operations are recognized
|
||||
func TestHandler_AllWorkflowOperations(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
operations := []string{
|
||||
"START_WORKFLOW",
|
||||
"DESCRIBE_WORKFLOW",
|
||||
"LIST_WORKFLOWS",
|
||||
"GET_WORKFLOW_HISTORY",
|
||||
"TERMINATE_WORKFLOW",
|
||||
"CANCEL_WORKFLOW",
|
||||
"SIGNAL_WORKFLOW",
|
||||
"QUERY_WORKFLOW",
|
||||
"RESET_WORKFLOW",
|
||||
"UPDATE_WORKFLOW",
|
||||
}
|
||||
|
||||
for _, op := range operations {
|
||||
t.Run(op, func(t *testing.T) {
|
||||
reqBody := RequestPayload{
|
||||
Action: op,
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"workflow_id": "test",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Action != op {
|
||||
t.Errorf("Operation %s not routed correctly", op)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_AllActivityOperations tests that all activity operations are recognized
|
||||
func TestHandler_AllActivityOperations(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
operations := []string{
|
||||
"HEARTBEAT_ACTIVITY",
|
||||
"COMPLETE_ACTIVITY",
|
||||
"FAIL_ACTIVITY",
|
||||
}
|
||||
|
||||
for _, op := range operations {
|
||||
t.Run(op, func(t *testing.T) {
|
||||
reqBody := RequestPayload{
|
||||
Action: op,
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"task_token": "base64_encoded_token",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Action != op {
|
||||
t.Errorf("Operation %s not routed correctly", op)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_AllNamespaceOperations tests that all namespace operations are recognized
|
||||
func TestHandler_AllNamespaceOperations(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
operations := []string{
|
||||
"LIST_NAMESPACES",
|
||||
"DESCRIBE_NAMESPACE",
|
||||
"CREATE_NAMESPACE",
|
||||
"UPDATE_NAMESPACE",
|
||||
"DELETE_NAMESPACE",
|
||||
}
|
||||
|
||||
for _, op := range operations {
|
||||
t.Run(op, func(t *testing.T) {
|
||||
reqBody := RequestPayload{
|
||||
Action: op,
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Action != op {
|
||||
t.Errorf("Operation %s not routed correctly", op)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_AllClusterOperations tests that all cluster operations are recognized
|
||||
func TestHandler_AllClusterOperations(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
operations := []string{
|
||||
"GET_CLUSTER_INFO",
|
||||
"LIST_CLUSTER_MEMBERS",
|
||||
"GET_SYSTEM_INFO",
|
||||
}
|
||||
|
||||
for _, op := range operations {
|
||||
t.Run(op, func(t *testing.T) {
|
||||
reqBody := RequestPayload{
|
||||
Action: op,
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Action != op {
|
||||
t.Errorf("Operation %s not routed correctly", op)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_MissingRequiredFields tests validation of required fields
|
||||
func TestHandler_MissingRequiredFields(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
operation string
|
||||
payload map[string]interface{}
|
||||
shouldFail bool
|
||||
}{
|
||||
{
|
||||
name: "START_WORKFLOW missing workflow_id",
|
||||
operation: "START_WORKFLOW",
|
||||
payload: map[string]interface{}{
|
||||
"workflow_type": "TestWorkflow",
|
||||
"task_queue": "test_queue",
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "DESCRIBE_WORKFLOW missing workflow_id",
|
||||
operation: "DESCRIBE_WORKFLOW",
|
||||
payload: map[string]interface{}{},
|
||||
shouldFail: true,
|
||||
},
|
||||
{
|
||||
name: "SIGNAL_WORKFLOW missing signal_name",
|
||||
operation: "SIGNAL_WORKFLOW",
|
||||
payload: map[string]interface{}{
|
||||
"workflow_id": "test",
|
||||
"run_id": "run",
|
||||
},
|
||||
shouldFail: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reqBody := RequestPayload{
|
||||
Action: test.operation,
|
||||
Namespace: "default",
|
||||
Payload: test.payload,
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if test.shouldFail {
|
||||
if w.Code == http.StatusOK {
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
if response.Success {
|
||||
t.Errorf("Expected request to fail for %s", test.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_RequestMethod tests HTTP method validation
|
||||
func TestHandler_RequestMethod(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
methods := []string{"GET", "PUT", "DELETE", "PATCH"}
|
||||
|
||||
for _, method := range methods {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
req := httptest.NewRequest(method, "/workflow", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusMethodNotAllowed {
|
||||
t.Errorf("Expected 405 for %s method, got %d", method, w.Code)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_UnknownAction tests handling of unknown actions
|
||||
func TestHandler_UnknownAction(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "UNKNOWN_ACTION",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusBadRequest {
|
||||
t.Errorf("Expected 400 for unknown action, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Error != "INVALID_ACTION" {
|
||||
t.Errorf("Expected INVALID_ACTION error")
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_HealthEndpoint tests the health check endpoint
|
||||
func TestHandler_HealthEndpoint(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
req := httptest.NewRequest("GET", "/workflow/health", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected 200 for health check, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_MetricsEndpoint tests the metrics endpoint
|
||||
func TestHandler_MetricsEndpoint(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
req := httptest.NewRequest("GET", "/workflow/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected 200 for metrics, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_NotFoundEndpoint tests 404 handling
|
||||
func TestHandler_NotFoundEndpoint(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
req := httptest.NewRequest("GET", "/unknown", nil)
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Errorf("Expected 404 for unknown endpoint, got %d", w.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_NamespaceDefaulting tests that namespace defaults to "default"
|
||||
func TestHandler_NamespaceDefaulting(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "DESCRIBE_WORKFLOW",
|
||||
Payload: map[string]interface{}{"workflow_id": "test"},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Namespace != "default" {
|
||||
t.Errorf("Expected namespace to default to 'default', got %s", response.Namespace)
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_AllSearchAttributeOperations tests search attribute operations
|
||||
func TestHandler_AllSearchAttributeOperations(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
operations := []string{
|
||||
"LIST_SEARCH_ATTRIBUTES",
|
||||
"ADD_SEARCH_ATTRIBUTES",
|
||||
}
|
||||
|
||||
for _, op := range operations {
|
||||
t.Run(op, func(t *testing.T) {
|
||||
reqBody := RequestPayload{
|
||||
Action: op,
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Action != op {
|
||||
t.Errorf("Operation %s not routed correctly", op)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestHandler_ListTaskQueuesOperation tests task queue operation
|
||||
func TestHandler_ListTaskQueuesOperation(t *testing.T) {
|
||||
if !isTemporalAvailable() { t.Skip("Temporal server not available"); return }
|
||||
handler := NewHandler("localhost:7233")
|
||||
|
||||
reqBody := RequestPayload{
|
||||
Action: "LIST_TASK_QUEUES",
|
||||
Namespace: "default",
|
||||
Payload: map[string]interface{}{
|
||||
"queue_type": "WORKFLOW",
|
||||
},
|
||||
}
|
||||
|
||||
body, _ := json.Marshal(reqBody)
|
||||
req := httptest.NewRequest("POST", "/workflow", bytes.NewReader(body))
|
||||
w := httptest.NewRecorder()
|
||||
|
||||
handler.handleWorkflow(w, req)
|
||||
|
||||
if w.Code != http.StatusOK {
|
||||
t.Errorf("Expected 200, got %d", w.Code)
|
||||
}
|
||||
|
||||
var response ResponsePayload
|
||||
json.NewDecoder(w.Body).Decode(&response)
|
||||
|
||||
if response.Action != "LIST_TASK_QUEUES" {
|
||||
t.Errorf("Expected LIST_TASK_QUEUES action")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
// Package temporal provides operation wrappers for Temporal operations
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// OperationHandler handles Temporal operations
|
||||
type OperationHandler struct {
|
||||
grpc *GRPCClient
|
||||
}
|
||||
|
||||
// NewOperationHandler creates a new operation handler
|
||||
func NewOperationHandler(grpcClient *GRPCClient) *OperationHandler {
|
||||
return &OperationHandler{
|
||||
grpc: grpcClient,
|
||||
}
|
||||
}
|
||||
|
||||
// StartWorkflowExecution starts a new workflow execution
|
||||
func (oh *OperationHandler) StartWorkflowExecution(ctx context.Context, namespace, workflowID, workflowType, taskQueue string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
// TODO: Implement gRPC call to Temporal
|
||||
// This is a placeholder for actual implementation
|
||||
if oh.grpc == nil {
|
||||
return nil, fmt.Errorf("gRPC client not initialized")
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": fmt.Sprintf("run_%d", time.Now().UnixNano()),
|
||||
"start_time": time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DescribeWorkflowExecution gets workflow details
|
||||
func (oh *OperationHandler) DescribeWorkflowExecution(ctx context.Context, namespace, workflowID, runID string) (map[string]interface{}, error) {
|
||||
// TODO: Implement gRPC call to Temporal
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": runID,
|
||||
"status": "RUNNING",
|
||||
"start_time": time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TerminateWorkflowExecution terminates a workflow
|
||||
func (oh *OperationHandler) TerminateWorkflowExecution(ctx context.Context, namespace, workflowID, runID, reason string) (map[string]interface{}, error) {
|
||||
// TODO: Implement gRPC call to Temporal
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": runID,
|
||||
"terminated_at": time.Now(),
|
||||
"reason": reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelWorkflowExecution cancels a workflow
|
||||
func (oh *OperationHandler) CancelWorkflowExecution(ctx context.Context, namespace, workflowID, runID string) (map[string]interface{}, error) {
|
||||
// TODO: Implement gRPC call to Temporal
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"status": "canceling",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SignalWorkflowExecution sends a signal to a workflow
|
||||
func (oh *OperationHandler) SignalWorkflowExecution(ctx context.Context, namespace, workflowID, runID, signalName string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
// TODO: Implement gRPC call to Temporal
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"signal_name": signalName,
|
||||
"signaled_at": time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryWorkflowExecution queries a workflow
|
||||
func (oh *OperationHandler) QueryWorkflowExecution(ctx context.Context, namespace, workflowID, runID, queryType string) (map[string]interface{}, error) {
|
||||
// TODO: Implement gRPC call to Temporal
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"query_type": queryType,
|
||||
"query_result": map[string]interface{}{},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListWorkflowExecutions lists workflows
|
||||
func (oh *OperationHandler) ListWorkflowExecutions(ctx context.Context, namespace string, pageSize int32) (map[string]interface{}, error) {
|
||||
// TODO: Implement gRPC call to Temporal
|
||||
return map[string]interface{}{
|
||||
"executions": []interface{}{},
|
||||
"next_page_token": "",
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// Package temporal provides gRPC implementations for Temporal operations
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"google.golang.org/protobuf/types/known/durationpb"
|
||||
"go.temporal.io/api/common/v1"
|
||||
"go.temporal.io/api/workflowservice/v1"
|
||||
"go.temporal.io/api/operatorservice/v1"
|
||||
"go.temporal.io/api/taskqueue/v1"
|
||||
"go.temporal.io/api/query/v1"
|
||||
enumsv1 "go.temporal.io/api/enums/v1"
|
||||
)
|
||||
|
||||
// WorkflowGRPCImpl provides gRPC implementations for workflow operations
|
||||
type WorkflowGRPCImpl struct {
|
||||
grpc *GRPCClient
|
||||
}
|
||||
|
||||
// NewWorkflowGRPCImpl creates a new workflow gRPC implementation
|
||||
func NewWorkflowGRPCImpl(grpcClient *GRPCClient) *WorkflowGRPCImpl {
|
||||
return &WorkflowGRPCImpl{grpc: grpcClient}
|
||||
}
|
||||
|
||||
// StartWorkflowExecution starts a workflow via gRPC
|
||||
func (w *WorkflowGRPCImpl) StartWorkflowExecution(ctx context.Context, namespace, workflowID, workflowType, taskQueueName string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
inputBytes, _ := json.Marshal(input)
|
||||
|
||||
req := &workflowservice.StartWorkflowExecutionRequest{
|
||||
Namespace: namespace,
|
||||
WorkflowId: workflowID,
|
||||
WorkflowType: &common.WorkflowType{Name: workflowType},
|
||||
TaskQueue: &taskqueue.TaskQueue{Name: taskQueueName},
|
||||
WorkflowExecutionTimeout: durationpb.New(24 * time.Hour),
|
||||
WorkflowRunTimeout: durationpb.New(24 * time.Hour),
|
||||
WorkflowTaskTimeout: durationpb.New(10 * time.Minute),
|
||||
Input: &common.Payloads{
|
||||
Payloads: []*common.Payload{
|
||||
{
|
||||
Data: inputBytes,
|
||||
Metadata: map[string][]byte{
|
||||
"encoding": []byte("json/plain"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := w.grpc.GetWorkflowServiceStub().StartWorkflowExecution(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gRPC StartWorkflowExecution failed: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": resp.RunId,
|
||||
"started_at": time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DescribeWorkflowExecution gets workflow details via gRPC
|
||||
func (w *WorkflowGRPCImpl) DescribeWorkflowExecution(ctx context.Context, namespace, workflowID, runID string) (map[string]interface{}, error) {
|
||||
req := &workflowservice.DescribeWorkflowExecutionRequest{
|
||||
Namespace: namespace,
|
||||
Execution: &common.WorkflowExecution{
|
||||
WorkflowId: workflowID,
|
||||
RunId: runID,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := w.grpc.GetWorkflowServiceStub().DescribeWorkflowExecution(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gRPC DescribeWorkflowExecution failed: %w", err)
|
||||
}
|
||||
|
||||
info := resp.WorkflowExecutionInfo
|
||||
if info == nil {
|
||||
return nil, fmt.Errorf("workflow execution info not found")
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": runID,
|
||||
"workflow_type": info.Type.Name,
|
||||
"status": info.Status.String(),
|
||||
"start_time": info.StartTime.AsTime(),
|
||||
"close_time": info.CloseTime.AsTime(),
|
||||
"history_length": info.HistoryLength,
|
||||
"task_queue": info.TaskQueue,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// TerminateWorkflowExecution terminates a workflow via gRPC
|
||||
func (w *WorkflowGRPCImpl) TerminateWorkflowExecution(ctx context.Context, namespace, workflowID, runID, reason string) (map[string]interface{}, error) {
|
||||
req := &workflowservice.TerminateWorkflowExecutionRequest{
|
||||
Namespace: namespace,
|
||||
WorkflowExecution: &common.WorkflowExecution{
|
||||
WorkflowId: workflowID,
|
||||
RunId: runID,
|
||||
},
|
||||
Reason: reason,
|
||||
}
|
||||
|
||||
_, err := w.grpc.GetWorkflowServiceStub().TerminateWorkflowExecution(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gRPC TerminateWorkflowExecution failed: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": runID,
|
||||
"status": "TERMINATED",
|
||||
"terminated_at": time.Now(),
|
||||
"reason": reason,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// CancelWorkflowExecution cancels a workflow via gRPC
|
||||
func (w *WorkflowGRPCImpl) CancelWorkflowExecution(ctx context.Context, namespace, workflowID, runID string) (map[string]interface{}, error) {
|
||||
req := &workflowservice.RequestCancelWorkflowExecutionRequest{
|
||||
Namespace: namespace,
|
||||
WorkflowExecution: &common.WorkflowExecution{
|
||||
WorkflowId: workflowID,
|
||||
RunId: runID,
|
||||
},
|
||||
}
|
||||
|
||||
_, err := w.grpc.GetWorkflowServiceStub().RequestCancelWorkflowExecution(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gRPC RequestCancelWorkflowExecution failed: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": runID,
|
||||
"status": "CANCEL_REQUESTED",
|
||||
"cancelled_at": time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SignalWorkflowExecution sends a signal to a workflow via gRPC
|
||||
func (w *WorkflowGRPCImpl) SignalWorkflowExecution(ctx context.Context, namespace, workflowID, runID, signalName string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||
inputBytes, _ := json.Marshal(input)
|
||||
|
||||
req := &workflowservice.SignalWorkflowExecutionRequest{
|
||||
Namespace: namespace,
|
||||
WorkflowExecution: &common.WorkflowExecution{
|
||||
WorkflowId: workflowID,
|
||||
RunId: runID,
|
||||
},
|
||||
SignalName: signalName,
|
||||
Input: &common.Payloads{
|
||||
Payloads: []*common.Payload{
|
||||
{
|
||||
Data: inputBytes,
|
||||
Metadata: map[string][]byte{
|
||||
"encoding": []byte("json/plain"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := w.grpc.GetWorkflowServiceStub().SignalWorkflowExecution(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gRPC SignalWorkflowExecution failed: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": runID,
|
||||
"signal_name": signalName,
|
||||
"signaled_at": time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// QueryWorkflowExecution queries a workflow via gRPC
|
||||
func (w *WorkflowGRPCImpl) QueryWorkflowExecution(ctx context.Context, namespace, workflowID, runID, queryType string) (map[string]interface{}, error) {
|
||||
req := &workflowservice.QueryWorkflowRequest{
|
||||
Namespace: namespace,
|
||||
Execution: &common.WorkflowExecution{
|
||||
WorkflowId: workflowID,
|
||||
RunId: runID,
|
||||
},
|
||||
Query: &query.WorkflowQuery{
|
||||
QueryType: queryType,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := w.grpc.GetWorkflowServiceStub().QueryWorkflow(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gRPC QueryWorkflow failed: %w", err)
|
||||
}
|
||||
|
||||
var queryResult interface{} = nil
|
||||
if resp.QueryResult != nil && len(resp.QueryResult.Payloads) > 0 {
|
||||
json.Unmarshal(resp.QueryResult.Payloads[0].Data, &queryResult)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": runID,
|
||||
"query_type": queryType,
|
||||
"query_result": queryResult,
|
||||
"queried_at": time.Now(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListWorkflowExecutions lists workflows via gRPC
|
||||
func (w *WorkflowGRPCImpl) ListWorkflowExecutions(ctx context.Context, namespace string, pageSize int32) (map[string]interface{}, error) {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 10
|
||||
}
|
||||
|
||||
req := &workflowservice.ListWorkflowExecutionsRequest{
|
||||
Namespace: namespace,
|
||||
PageSize: pageSize,
|
||||
Query: "ExecutionStatus != 'CLOSED'",
|
||||
}
|
||||
|
||||
resp, err := w.grpc.GetWorkflowServiceStub().ListWorkflowExecutions(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gRPC ListWorkflowExecutions failed: %w", err)
|
||||
}
|
||||
|
||||
executions := make([]map[string]interface{}, len(resp.Executions))
|
||||
for i, exec := range resp.Executions {
|
||||
executions[i] = map[string]interface{}{
|
||||
"workflow_id": exec.Execution.WorkflowId,
|
||||
"run_id": exec.Execution.RunId,
|
||||
"type": exec.Type.Name,
|
||||
"status": exec.Status.String(),
|
||||
"start_time": exec.StartTime.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"executions": executions,
|
||||
"count": len(executions),
|
||||
"next_page_token": string(resp.NextPageToken),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// GetWorkflowExecutionHistory gets workflow history via gRPC
|
||||
func (w *WorkflowGRPCImpl) GetWorkflowExecutionHistory(ctx context.Context, namespace, workflowID, runID string) (map[string]interface{}, error) {
|
||||
req := &workflowservice.GetWorkflowExecutionHistoryRequest{
|
||||
Namespace: namespace,
|
||||
Execution: &common.WorkflowExecution{
|
||||
WorkflowId: workflowID,
|
||||
RunId: runID,
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := w.grpc.GetWorkflowServiceStub().GetWorkflowExecutionHistory(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gRPC GetWorkflowExecutionHistory failed: %w", err)
|
||||
}
|
||||
|
||||
events := make([]map[string]interface{}, len(resp.History.Events))
|
||||
for i, event := range resp.History.Events {
|
||||
events[i] = map[string]interface{}{
|
||||
"event_id": event.EventId,
|
||||
"type": event.EventType.String(),
|
||||
"timestamp": event.EventTime.AsTime(),
|
||||
}
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"workflow_id": workflowID,
|
||||
"run_id": runID,
|
||||
"events": events,
|
||||
"event_count": len(events),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// SearchAttributesGRPCImpl provides gRPC implementations for search attributes
|
||||
type SearchAttributesGRPCImpl struct {
|
||||
grpc *GRPCClient
|
||||
}
|
||||
|
||||
// NewSearchAttributesGRPCImpl creates a new search attributes gRPC implementation
|
||||
func NewSearchAttributesGRPCImpl(grpcClient *GRPCClient) *SearchAttributesGRPCImpl {
|
||||
return &SearchAttributesGRPCImpl{grpc: grpcClient}
|
||||
}
|
||||
|
||||
// ListSearchAttributes lists search attributes via gRPC
|
||||
func (s *SearchAttributesGRPCImpl) ListSearchAttributes(ctx context.Context) (map[string]interface{}, error) {
|
||||
req := &operatorservice.ListSearchAttributesRequest{}
|
||||
|
||||
resp, err := s.grpc.GetOperatorServiceStub().ListSearchAttributes(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gRPC ListSearchAttributes failed: %w", err)
|
||||
}
|
||||
|
||||
attributes := make(map[string]interface{})
|
||||
for name, attrType := range resp.CustomAttributes {
|
||||
attributes[name] = attrType.String()
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"custom_attributes": attributes,
|
||||
"count": len(attributes),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// AddSearchAttributes adds search attributes via gRPC
|
||||
func (s *SearchAttributesGRPCImpl) AddSearchAttributes(ctx context.Context, attributes map[string]interface{}) (map[string]interface{}, error) {
|
||||
customAttrs := make(map[string]enumsv1.IndexedValueType)
|
||||
for name := range attributes {
|
||||
customAttrs[name] = enumsv1.INDEXED_VALUE_TYPE_TEXT
|
||||
}
|
||||
|
||||
req := &operatorservice.AddSearchAttributesRequest{
|
||||
SearchAttributes: customAttrs,
|
||||
}
|
||||
|
||||
_, err := s.grpc.GetOperatorServiceStub().AddSearchAttributes(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("gRPC AddSearchAttributes failed: %w", err)
|
||||
}
|
||||
|
||||
return map[string]interface{}{
|
||||
"attributes_added": len(customAttrs),
|
||||
"attributes": attributes,
|
||||
"added_at": time.Now(),
|
||||
}, nil
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
package temporal
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestWorkflowGRPCImpl_StartWorkflowExecution tests the gRPC StartWorkflowExecution
|
||||
func TestWorkflowGRPCImpl_StartWorkflowExecution(t *testing.T) {
|
||||
grpcClient, err := NewGRPCClient("localhost:7233")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: Temporal server not available at localhost:7233: %v", err)
|
||||
}
|
||||
defer grpcClient.Close()
|
||||
|
||||
impl := NewWorkflowGRPCImpl(grpcClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := impl.StartWorkflowExecution(
|
||||
ctx,
|
||||
"default",
|
||||
"test_workflow_"+t.Name(),
|
||||
"TestWorkflow",
|
||||
"default",
|
||||
map[string]interface{}{"test": "data"},
|
||||
)
|
||||
|
||||
// If Temporal server is running, we expect success
|
||||
if err == nil {
|
||||
if result["workflow_id"] != "test_workflow_"+t.Name() {
|
||||
t.Errorf("Expected workflow_id %s, got %v", t.Name(), result["workflow_id"])
|
||||
}
|
||||
if result["run_id"] == nil {
|
||||
t.Error("Expected run_id in response")
|
||||
}
|
||||
} else {
|
||||
// If server is not available, that's okay for this test
|
||||
t.Logf("Temporal server not available: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowGRPCImpl_DescribeWorkflowExecution tests the gRPC DescribeWorkflowExecution
|
||||
func TestWorkflowGRPCImpl_DescribeWorkflowExecution(t *testing.T) {
|
||||
grpcClient, err := NewGRPCClient("localhost:7233")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: Temporal server not available: %v", err)
|
||||
}
|
||||
defer grpcClient.Close()
|
||||
|
||||
impl := NewWorkflowGRPCImpl(grpcClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := impl.DescribeWorkflowExecution(ctx, "default", "test_id", "run_id")
|
||||
|
||||
// If Temporal server is running, we expect either success or a valid error
|
||||
if err == nil {
|
||||
if result["workflow_id"] == nil {
|
||||
t.Error("Expected workflow_id in response")
|
||||
}
|
||||
} else {
|
||||
// If server is not available or workflow not found, that's okay for this test
|
||||
t.Logf("gRPC call result: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowGRPCImpl_TerminateWorkflowExecution tests termination
|
||||
func TestWorkflowGRPCImpl_TerminateWorkflowExecution(t *testing.T) {
|
||||
grpcClient, err := NewGRPCClient("localhost:7233")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: Temporal server not available: %v", err)
|
||||
}
|
||||
defer grpcClient.Close()
|
||||
|
||||
impl := NewWorkflowGRPCImpl(grpcClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := impl.TerminateWorkflowExecution(ctx, "default", "test_id", "run_id", "test termination")
|
||||
|
||||
if err == nil {
|
||||
if result["status"] != "TERMINATED" {
|
||||
t.Errorf("Expected status TERMINATED, got %v", result["status"])
|
||||
}
|
||||
} else {
|
||||
t.Logf("gRPC call result (expected if server unavailable): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowGRPCImpl_CancelWorkflowExecution tests cancellation
|
||||
func TestWorkflowGRPCImpl_CancelWorkflowExecution(t *testing.T) {
|
||||
grpcClient, err := NewGRPCClient("localhost:7233")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: Temporal server not available: %v", err)
|
||||
}
|
||||
defer grpcClient.Close()
|
||||
|
||||
impl := NewWorkflowGRPCImpl(grpcClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := impl.CancelWorkflowExecution(ctx, "default", "test_id", "run_id")
|
||||
|
||||
if err == nil {
|
||||
if result["status"] != "CANCEL_REQUESTED" {
|
||||
t.Errorf("Expected status CANCEL_REQUESTED, got %v", result["status"])
|
||||
}
|
||||
} else {
|
||||
t.Logf("gRPC call result (expected if server unavailable): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowGRPCImpl_SignalWorkflowExecution tests signaling
|
||||
func TestWorkflowGRPCImpl_SignalWorkflowExecution(t *testing.T) {
|
||||
grpcClient, err := NewGRPCClient("localhost:7233")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: Temporal server not available: %v", err)
|
||||
}
|
||||
defer grpcClient.Close()
|
||||
|
||||
impl := NewWorkflowGRPCImpl(grpcClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := impl.SignalWorkflowExecution(
|
||||
ctx,
|
||||
"default",
|
||||
"test_id",
|
||||
"run_id",
|
||||
"test_signal",
|
||||
map[string]interface{}{"data": "value"},
|
||||
)
|
||||
|
||||
if err == nil {
|
||||
if result["signal_name"] != "test_signal" {
|
||||
t.Errorf("Expected signal_name test_signal, got %v", result["signal_name"])
|
||||
}
|
||||
} else {
|
||||
t.Logf("gRPC call result (expected if server unavailable): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowGRPCImpl_QueryWorkflowExecution tests querying
|
||||
func TestWorkflowGRPCImpl_QueryWorkflowExecution(t *testing.T) {
|
||||
grpcClient, err := NewGRPCClient("localhost:7233")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: Temporal server not available: %v", err)
|
||||
}
|
||||
defer grpcClient.Close()
|
||||
|
||||
impl := NewWorkflowGRPCImpl(grpcClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := impl.QueryWorkflowExecution(ctx, "default", "test_id", "run_id", "test_query")
|
||||
|
||||
if err == nil {
|
||||
if result["query_type"] != "test_query" {
|
||||
t.Errorf("Expected query_type test_query, got %v", result["query_type"])
|
||||
}
|
||||
} else {
|
||||
t.Logf("gRPC call result (expected if server unavailable): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowGRPCImpl_ListWorkflowExecutions tests listing
|
||||
func TestWorkflowGRPCImpl_ListWorkflowExecutions(t *testing.T) {
|
||||
grpcClient, err := NewGRPCClient("localhost:7233")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: Temporal server not available: %v", err)
|
||||
}
|
||||
defer grpcClient.Close()
|
||||
|
||||
impl := NewWorkflowGRPCImpl(grpcClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := impl.ListWorkflowExecutions(ctx, "default", 10)
|
||||
|
||||
if err == nil {
|
||||
if result["count"] == nil {
|
||||
t.Error("Expected count in response")
|
||||
}
|
||||
if result["executions"] == nil {
|
||||
t.Error("Expected executions in response")
|
||||
}
|
||||
} else {
|
||||
t.Logf("gRPC call result (expected if server unavailable): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWorkflowGRPCImpl_GetWorkflowExecutionHistory tests history retrieval
|
||||
func TestWorkflowGRPCImpl_GetWorkflowExecutionHistory(t *testing.T) {
|
||||
grpcClient, err := NewGRPCClient("localhost:7233")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: Temporal server not available: %v", err)
|
||||
}
|
||||
defer grpcClient.Close()
|
||||
|
||||
impl := NewWorkflowGRPCImpl(grpcClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := impl.GetWorkflowExecutionHistory(ctx, "default", "test_id", "run_id")
|
||||
|
||||
if err == nil {
|
||||
if result["events"] == nil {
|
||||
t.Error("Expected events in response")
|
||||
}
|
||||
} else {
|
||||
t.Logf("gRPC call result (expected if server unavailable): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchAttributesGRPCImpl_ListSearchAttributes tests search attributes listing
|
||||
func TestSearchAttributesGRPCImpl_ListSearchAttributes(t *testing.T) {
|
||||
grpcClient, err := NewGRPCClient("localhost:7233")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: Temporal server not available: %v", err)
|
||||
}
|
||||
defer grpcClient.Close()
|
||||
|
||||
impl := NewSearchAttributesGRPCImpl(grpcClient)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
result, err := impl.ListSearchAttributes(ctx)
|
||||
|
||||
if err == nil {
|
||||
if result["count"] == nil {
|
||||
t.Error("Expected count in response")
|
||||
}
|
||||
} else {
|
||||
t.Logf("gRPC call result (expected if server unavailable): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCClient_HealthCheck tests the health check
|
||||
func TestGRPCClient_HealthCheck(t *testing.T) {
|
||||
grpcClient, err := NewGRPCClient("localhost:7233")
|
||||
if err != nil {
|
||||
t.Skipf("Skipping: Cannot connect to Temporal server: %v", err)
|
||||
}
|
||||
defer grpcClient.Close()
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
|
||||
err = grpcClient.HealthCheck(ctx)
|
||||
|
||||
if err != nil {
|
||||
t.Logf("Health check failed (expected if Temporal server not running): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGRPCClient_ConnectionFailure tests connection error handling
|
||||
func TestGRPCClient_ConnectionFailure(t *testing.T) {
|
||||
// Try to connect to non-existent server
|
||||
grpcClient, err := NewGRPCClient("localhost:9999")
|
||||
|
||||
// Connection should be created but fail on first call
|
||||
if grpcClient == nil && err != nil {
|
||||
t.Logf("Expected connection attempt: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||
)
|
||||
|
||||
// startForTest binds the stub on an ephemeral port so the suite does not fight
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
package tracing
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
const tracerName = "api-gateway"
|
||||
|
||||
// responseWriter wraps http.ResponseWriter to capture status code.
|
||||
type responseWriter struct {
|
||||
http.ResponseWriter
|
||||
statusCode int
|
||||
written int64
|
||||
}
|
||||
|
||||
func newResponseWriter(w http.ResponseWriter) *responseWriter {
|
||||
return &responseWriter{ResponseWriter: w, statusCode: http.StatusOK}
|
||||
}
|
||||
|
||||
func (rw *responseWriter) WriteHeader(code int) {
|
||||
rw.statusCode = code
|
||||
rw.ResponseWriter.WriteHeader(code)
|
||||
}
|
||||
|
||||
func (rw *responseWriter) Write(b []byte) (int, error) {
|
||||
n, err := rw.ResponseWriter.Write(b)
|
||||
rw.written += int64(n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// Middleware returns an HTTP middleware that adds tracing to requests.
|
||||
func Middleware(next http.Handler) http.Handler {
|
||||
tracer := otel.Tracer(tracerName)
|
||||
propagator := otel.GetTextMapPropagator()
|
||||
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
start := time.Now()
|
||||
|
||||
// Extract any existing trace context from incoming request
|
||||
ctx := propagator.Extract(r.Context(), propagation.HeaderCarrier(r.Header))
|
||||
|
||||
// Start a new span
|
||||
spanName := r.Method + " " + r.URL.Path
|
||||
ctx, span := tracer.Start(ctx, spanName,
|
||||
trace.WithSpanKind(trace.SpanKindServer),
|
||||
trace.WithAttributes(
|
||||
semconv.HTTPRequestMethodKey.String(r.Method),
|
||||
semconv.URLPath(r.URL.Path),
|
||||
semconv.URLScheme(scheme(r)),
|
||||
semconv.ServerAddress(r.Host),
|
||||
semconv.UserAgentOriginal(r.UserAgent()),
|
||||
semconv.NetworkPeerAddress(r.RemoteAddr),
|
||||
),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
// Add query parameters if present
|
||||
if r.URL.RawQuery != "" {
|
||||
span.SetAttributes(semconv.URLQuery(r.URL.RawQuery))
|
||||
}
|
||||
|
||||
// Add model attribute for LLM requests
|
||||
if model := r.Header.Get("X-Model"); model != "" {
|
||||
span.SetAttributes(attribute.String("llm.model", model))
|
||||
}
|
||||
|
||||
// Wrap response writer to capture status
|
||||
rw := newResponseWriter(w)
|
||||
|
||||
// Inject trace context into response headers (for debugging)
|
||||
propagator.Inject(ctx, propagation.HeaderCarrier(w.Header()))
|
||||
|
||||
// Call the next handler with traced context
|
||||
next.ServeHTTP(rw, r.WithContext(ctx))
|
||||
|
||||
// Record response attributes
|
||||
duration := time.Since(start)
|
||||
span.SetAttributes(
|
||||
semconv.HTTPResponseStatusCode(rw.statusCode),
|
||||
attribute.Int64("http.response.body.size", rw.written),
|
||||
attribute.Float64("http.request.duration_ms", float64(duration.Milliseconds())),
|
||||
)
|
||||
|
||||
// Set span status based on HTTP status code
|
||||
if rw.statusCode >= 400 {
|
||||
span.SetStatus(codes.Error, http.StatusText(rw.statusCode))
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func scheme(r *http.Request) string {
|
||||
if r.TLS != nil {
|
||||
return "https"
|
||||
}
|
||||
if s := r.Header.Get("X-Forwarded-Proto"); s != "" {
|
||||
return s
|
||||
}
|
||||
return "http"
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
// Package tracing provides OpenTelemetry instrumentation for the API gateway.
|
||||
package tracing
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
"go.opentelemetry.io/otel/sdk/resource"
|
||||
sdktrace "go.opentelemetry.io/otel/sdk/trace"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
)
|
||||
|
||||
// Config holds tracing configuration.
|
||||
type Config struct {
|
||||
ServiceName string
|
||||
ServiceVersion string
|
||||
Environment string
|
||||
OTLPEndpoint string
|
||||
}
|
||||
|
||||
// DefaultConfig returns configuration from environment variables.
|
||||
func DefaultConfig() Config {
|
||||
endpoint := os.Getenv("OTEL_EXPORTER_OTLP_ENDPOINT")
|
||||
if endpoint == "" {
|
||||
endpoint = "otel-collector.tracing.svc.cluster.local:4317"
|
||||
}
|
||||
return Config{
|
||||
ServiceName: getEnvOrDefault("OTEL_SERVICE_NAME", "api-gateway"),
|
||||
ServiceVersion: getEnvOrDefault("OTEL_SERVICE_VERSION", "1.0.0"),
|
||||
Environment: getEnvOrDefault("OTEL_ENVIRONMENT", "production"),
|
||||
OTLPEndpoint: endpoint,
|
||||
}
|
||||
}
|
||||
|
||||
func getEnvOrDefault(key, defaultVal string) string {
|
||||
if v := os.Getenv(key); v != "" {
|
||||
return v
|
||||
}
|
||||
return defaultVal
|
||||
}
|
||||
|
||||
// Init initializes the OpenTelemetry tracer provider.
|
||||
// Returns a shutdown function that should be called on application exit.
|
||||
func Init(ctx context.Context, cfg Config) (func(context.Context) error, error) {
|
||||
// Create OTLP exporter
|
||||
exporter, err := otlptracegrpc.New(ctx,
|
||||
otlptracegrpc.WithEndpoint(cfg.OTLPEndpoint),
|
||||
otlptracegrpc.WithInsecure(),
|
||||
otlptracegrpc.WithTimeout(5*time.Second),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create resource with service information
|
||||
res, err := resource.Merge(
|
||||
resource.Default(),
|
||||
resource.NewWithAttributes(
|
||||
semconv.SchemaURL,
|
||||
semconv.ServiceName(cfg.ServiceName),
|
||||
semconv.ServiceVersion(cfg.ServiceVersion),
|
||||
attribute.String("deployment.environment", cfg.Environment),
|
||||
),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Create tracer provider with batch processor
|
||||
tp := sdktrace.NewTracerProvider(
|
||||
sdktrace.WithBatcher(exporter,
|
||||
sdktrace.WithBatchTimeout(5*time.Second),
|
||||
sdktrace.WithMaxExportBatchSize(512),
|
||||
),
|
||||
sdktrace.WithResource(res),
|
||||
sdktrace.WithSampler(sdktrace.AlwaysSample()),
|
||||
)
|
||||
|
||||
// Set global tracer provider
|
||||
otel.SetTracerProvider(tp)
|
||||
|
||||
// Set global propagator (W3C Trace Context + Baggage)
|
||||
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
|
||||
propagation.TraceContext{},
|
||||
propagation.Baggage{},
|
||||
))
|
||||
|
||||
return tp.Shutdown, nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package tracing
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"go.opentelemetry.io/otel"
|
||||
"go.opentelemetry.io/otel/attribute"
|
||||
"go.opentelemetry.io/otel/codes"
|
||||
"go.opentelemetry.io/otel/propagation"
|
||||
semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
)
|
||||
|
||||
// Transport wraps an http.RoundTripper with tracing.
|
||||
type Transport struct {
|
||||
base http.RoundTripper
|
||||
}
|
||||
|
||||
// NewTransport creates a new tracing transport wrapper.
|
||||
func NewTransport(base http.RoundTripper) *Transport {
|
||||
if base == nil {
|
||||
base = http.DefaultTransport
|
||||
}
|
||||
return &Transport{base: base}
|
||||
}
|
||||
|
||||
// RoundTrip implements http.RoundTripper with tracing.
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
ctx := req.Context()
|
||||
tracer := otel.Tracer(tracerName)
|
||||
propagator := otel.GetTextMapPropagator()
|
||||
|
||||
// Start client span
|
||||
spanName := "HTTP " + req.Method + " " + req.URL.Host + req.URL.Path
|
||||
ctx, span := tracer.Start(ctx, spanName,
|
||||
trace.WithSpanKind(trace.SpanKindClient),
|
||||
trace.WithAttributes(
|
||||
semconv.HTTPRequestMethodKey.String(req.Method),
|
||||
semconv.URLFull(req.URL.String()),
|
||||
semconv.ServerAddress(req.URL.Host),
|
||||
attribute.String("upstream.name", req.URL.Host),
|
||||
),
|
||||
)
|
||||
defer span.End()
|
||||
|
||||
// Inject trace context into outgoing request headers
|
||||
propagator.Inject(ctx, propagation.HeaderCarrier(req.Header))
|
||||
|
||||
// Perform the request
|
||||
start := time.Now()
|
||||
resp, err := t.base.RoundTrip(req.WithContext(ctx))
|
||||
duration := time.Since(start)
|
||||
|
||||
// Record timing
|
||||
span.SetAttributes(attribute.Float64("http.request.duration_ms", float64(duration.Milliseconds())))
|
||||
|
||||
if err != nil {
|
||||
span.RecordError(err)
|
||||
span.SetStatus(codes.Error, err.Error())
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Record response attributes
|
||||
span.SetAttributes(
|
||||
semconv.HTTPResponseStatusCode(resp.StatusCode),
|
||||
)
|
||||
|
||||
if resp.StatusCode >= 400 {
|
||||
span.SetStatus(codes.Error, http.StatusText(resp.StatusCode))
|
||||
} else {
|
||||
span.SetStatus(codes.Ok, "")
|
||||
}
|
||||
|
||||
return resp, nil
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"go.temporal.io/sdk/workflow"
|
||||
)
|
||||
|
||||
// HelloWorldWorkflow is a simple hello world workflow
|
||||
func HelloWorldWorkflow(ctx workflow.Context, name string) (string, error) {
|
||||
opts := workflow.ActivityOptions{
|
||||
StartToCloseTimeout: time.Minute,
|
||||
}
|
||||
ctx = workflow.WithActivityOptions(ctx, opts)
|
||||
|
||||
var result string
|
||||
if err := workflow.ExecuteActivity(ctx, GreetActivity, name).Get(ctx, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GreetActivity greets someone
|
||||
func GreetActivity(ctx context.Context, name string) (string, error) {
|
||||
return fmt.Sprintf("Hello, %s!", name), nil
|
||||
}
|
||||
|
||||
// ValidateOrderActivity validates an order
|
||||
func ValidateOrderActivity(ctx context.Context, orderID string) (bool, error) {
|
||||
// Simulate validation
|
||||
if orderID == "" {
|
||||
return false, nil
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// ProcessPaymentActivity processes payment
|
||||
func ProcessPaymentActivity(ctx context.Context, orderID string) (string, error) {
|
||||
// Simulate payment processing
|
||||
return fmt.Sprintf("payment-%s", orderID[:min(len(orderID), 3)]), nil
|
||||
}
|
||||
|
||||
// NotifyCustomerActivity sends notification
|
||||
func NotifyCustomerActivity(ctx context.Context, orderID string) (string, error) {
|
||||
// Simulate notification
|
||||
return fmt.Sprintf("notified for order %s", orderID), nil
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"go.temporal.io/sdk/client"
|
||||
"go.temporal.io/sdk/worker"
|
||||
"go.temporal.io/sdk/temporal"
|
||||
"go.temporal.io/sdk/workflow"
|
||||
)
|
||||
|
||||
// WorkerConfig holds worker configuration
|
||||
type WorkerConfig struct {
|
||||
HostPort string
|
||||
Namespace string
|
||||
TaskQueue string
|
||||
}
|
||||
|
||||
// NewWorker creates and starts a Temporal worker
|
||||
func NewWorker(cfg WorkerConfig) error {
|
||||
// Connect to Temporal server
|
||||
c, err := client.Dial(client.Options{
|
||||
HostPort: cfg.HostPort,
|
||||
Namespace: cfg.Namespace,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer c.Close()
|
||||
|
||||
log.Printf("Connected to Temporal at %s (namespace: %s)", cfg.HostPort, cfg.Namespace)
|
||||
|
||||
// Create worker
|
||||
w := worker.New(c, cfg.TaskQueue, worker.Options{})
|
||||
|
||||
// Register workflows
|
||||
w.RegisterWorkflow(HelloWorldWorkflow)
|
||||
w.RegisterWorkflow(GreeterWorkflow)
|
||||
w.RegisterWorkflow(ProcessOrderWorkflow)
|
||||
|
||||
// Register activities
|
||||
w.RegisterActivity(GreetActivity)
|
||||
w.RegisterActivity(ValidateOrderActivity)
|
||||
w.RegisterActivity(ProcessPaymentActivity)
|
||||
w.RegisterActivity(NotifyCustomerActivity)
|
||||
|
||||
// Start worker (blocks until signal received)
|
||||
log.Printf("Starting worker on task queue: %s", cfg.TaskQueue)
|
||||
|
||||
sigChan := make(chan os.Signal, 1)
|
||||
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
|
||||
|
||||
go func() {
|
||||
if err := w.Run(worker.InterruptCh()); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Wait for shutdown signal
|
||||
<-sigChan
|
||||
log.Println("Shutting down worker...")
|
||||
w.Stop()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ProcessOrderWorkflow demonstrates multi-step workflow with activities
|
||||
func ProcessOrderWorkflow(ctx workflow.Context, orderID string) (string, error) {
|
||||
opts := workflow.ActivityOptions{
|
||||
StartToCloseTimeout: 5 * time.Minute,
|
||||
RetryPolicy: &temporal.RetryPolicy{
|
||||
InitialInterval: time.Second,
|
||||
BackoffCoefficient: 2.0,
|
||||
MaximumInterval: time.Minute,
|
||||
MaximumAttempts: 3,
|
||||
},
|
||||
}
|
||||
ctx = workflow.WithActivityOptions(ctx, opts)
|
||||
|
||||
// Step 1: Validate order
|
||||
var validated bool
|
||||
if err := workflow.ExecuteActivity(ctx, ValidateOrderActivity, orderID).Get(ctx, &validated); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if !validated {
|
||||
return "", temporal.NewApplicationError("invalid order", "InvalidOrder")
|
||||
}
|
||||
|
||||
// Step 2: Process payment
|
||||
var paymentID string
|
||||
if err := workflow.ExecuteActivity(ctx, ProcessPaymentActivity, orderID).Get(ctx, &paymentID); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
// Step 3: Notify customer
|
||||
var notifyResult string
|
||||
if err := workflow.ExecuteActivity(ctx, NotifyCustomerActivity, orderID).Get(ctx, ¬ifyResult); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return paymentID, nil
|
||||
}
|
||||
|
||||
// GreeterWorkflow is a multi-step workflow
|
||||
func GreeterWorkflow(ctx workflow.Context, name string) (string, error) {
|
||||
opts := workflow.ActivityOptions{
|
||||
StartToCloseTimeout: 5 * time.Minute,
|
||||
}
|
||||
ctx = workflow.WithActivityOptions(ctx, opts)
|
||||
|
||||
var result string
|
||||
if err := workflow.ExecuteActivity(ctx, GreetActivity, name).Get(ctx, &result); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
@@ -10,6 +10,14 @@ data:
|
||||
# Gateway configuration - loaded at startup, never compiled in
|
||||
# See REQUIREMENTS.md for full specification
|
||||
|
||||
# JWT Authentication for /v1/* endpoints (LLM API)
|
||||
auth:
|
||||
enabled: true
|
||||
issuer: "https://authentik.riotpiao.com/application/o/api-gw/"
|
||||
audience: "api-gw"
|
||||
jwksUrl: "http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/"
|
||||
requiredCapability: "llm:inference"
|
||||
|
||||
# Routes: standard HTTP proxy routes (not LLM-specific)
|
||||
# These are for non-LLM services (agent-pod/console, etc.)
|
||||
routes: []
|
||||
@@ -36,3 +44,112 @@ data:
|
||||
- name: "BAAI/bge-reranker-base"
|
||||
address: "reranker-predictor.llm-serving:80"
|
||||
path: "/v1/rerank"
|
||||
|
||||
# Service adapters for X-Service header routing
|
||||
adapters:
|
||||
- serviceName: sqs
|
||||
upstream:
|
||||
url: http://management-service.sqs.svc.cluster.local:9090
|
||||
timeoutSeconds: 30
|
||||
auth:
|
||||
required: true
|
||||
# Gateway validates (kmsvc JWT validation code unverified)
|
||||
# TODO: Phase 3 - validate JWT signature against Authentik JWKS
|
||||
resources:
|
||||
- name: send-message
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /sqs/send
|
||||
- name: receive-message
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /sqs/receive
|
||||
- name: list-queues
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /sqs/queues
|
||||
|
||||
- serviceName: workflow
|
||||
upstream:
|
||||
url: grpc://temporal-frontend.temporal.svc.cluster.local:7233
|
||||
timeoutSeconds: 60
|
||||
auth:
|
||||
required: false
|
||||
# Temporal has native JWT support via jwtKeyProvider
|
||||
# Phase 3: configure and test Temporal JWT validation
|
||||
resources:
|
||||
- name: execute
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ExecuteWorkflow
|
||||
- name: describe
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution
|
||||
- name: list
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions
|
||||
|
||||
- serviceName: memory
|
||||
upstream:
|
||||
url: http://poimen-memory.poimen.svc.cluster.local:8080
|
||||
timeoutSeconds: 30
|
||||
auth:
|
||||
required: false
|
||||
resources:
|
||||
- name: query
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /memory/query
|
||||
- name: ingest
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /memory/ingest
|
||||
- name: skills
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /memory/skills
|
||||
|
||||
- serviceName: s3
|
||||
upstream:
|
||||
url: http://minio.storage.svc.cluster.local:9000
|
||||
timeoutSeconds: 30
|
||||
auth:
|
||||
required: false
|
||||
# MinIO has native JWT/OIDC support - validates JWTs itself
|
||||
# Phase 3: load-test MinIO JWT validation
|
||||
resources:
|
||||
- name: list-objects
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /
|
||||
- name: get-object
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /
|
||||
- name: put-object
|
||||
methods:
|
||||
- verb: PUT
|
||||
upstreamPath: /
|
||||
|
||||
- serviceName: iam
|
||||
upstream:
|
||||
url: http://authentik-server.iam.svc.cluster.local:80
|
||||
timeoutSeconds: 30
|
||||
auth:
|
||||
required: false
|
||||
# Authentik validates bearer tokens independently
|
||||
resources:
|
||||
- name: list-roles
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /api/v3/roles
|
||||
- name: list-users
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /api/v3/users
|
||||
- name: create-role
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /api/v3/roles
|
||||
|
||||
+18
-4
@@ -21,7 +21,12 @@ spec:
|
||||
labels:
|
||||
app: api-gateway
|
||||
component: gateway
|
||||
# Required by the llm-serving-default-deny NetworkPolicy, which admits
|
||||
# only pods labelled llm-client=true (from any namespace) on port 8080.
|
||||
# Without it every upstream dial times out and dispatch returns 502.
|
||||
llm-client: "true"
|
||||
annotations:
|
||||
reloader.stakater.com/auto: "true"
|
||||
prometheus.io/scrape: "true"
|
||||
prometheus.io/port: "8080"
|
||||
prometheus.io/path: "/metrics"
|
||||
@@ -41,8 +46,8 @@ spec:
|
||||
# Tag is pinned in kustomization.yaml so there is exactly one place to
|
||||
# bump it. Never :latest — Argo cannot make a deterministic rollout
|
||||
# decision from a mutable tag, and 6.1 requires SHA tags.
|
||||
image: forgejo.riotpiao.com/rock/api-gateway
|
||||
imagePullPolicy: IfNotPresent
|
||||
image: forgejo.riotpiao.com/rock/api-gateway:latest
|
||||
imagePullPolicy: Always
|
||||
ports:
|
||||
- name: http
|
||||
containerPort: 8080
|
||||
@@ -56,6 +61,15 @@ spec:
|
||||
value: "5m"
|
||||
- name: LOG_LEVEL
|
||||
value: "info"
|
||||
# OpenTelemetry tracing configuration
|
||||
- name: OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
value: "otel-collector.tracing.svc.cluster.local:4317"
|
||||
- name: OTEL_SERVICE_NAME
|
||||
value: "api-gateway"
|
||||
- name: OTEL_SERVICE_VERSION
|
||||
value: "1.0.0"
|
||||
- name: OTEL_ENVIRONMENT
|
||||
value: "production"
|
||||
volumeMounts:
|
||||
- name: config
|
||||
mountPath: /etc/gateway
|
||||
@@ -95,8 +109,8 @@ spec:
|
||||
- ALL
|
||||
volumes:
|
||||
- name: config
|
||||
configMap:
|
||||
name: api-gateway-config
|
||||
secret:
|
||||
secretName: api-gateway-config
|
||||
affinity:
|
||||
podAntiAffinity:
|
||||
preferredDuringSchedulingIgnoredDuringExecution:
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
apiVersion: v1
|
||||
kind: Secret
|
||||
metadata:
|
||||
name: api-gateway-config
|
||||
namespace: api
|
||||
labels:
|
||||
app: api-gateway
|
||||
type: Opaque
|
||||
stringData:
|
||||
config.yaml: |
|
||||
auth:
|
||||
enabled: true
|
||||
issuer: "https://authentik.riotpiao.com/application/o/api-gw/"
|
||||
audience: "api-gw"
|
||||
jwksUrl: "http://authentik-server.iam.svc.cluster.local/application/o/api-gw/jwks/"
|
||||
requiredCapability: "llm:inference"
|
||||
routes: []
|
||||
models:
|
||||
- name: "reasoning"
|
||||
address: "reasoning-predictor.llm-serving:80"
|
||||
path: "/v1/chat/completions"
|
||||
- name: "ornith:35b"
|
||||
address: "ornith-predictor.llm-serving:80"
|
||||
path: "/v1/chat/completions"
|
||||
- name: "qwen2.5:3b-instruct"
|
||||
address: "ornith-predictor.llm-serving:80"
|
||||
path: "/v1/chat/completions"
|
||||
- name: "nomic-ai/nomic-embed-text-v2-moe"
|
||||
address: "embeddings-predictor.llm-serving:80"
|
||||
path: "/v1/embeddings"
|
||||
- name: "BAAI/bge-reranker-base"
|
||||
address: "reranker-predictor.llm-serving:80"
|
||||
path: "/v1/rerank"
|
||||
adapters:
|
||||
- serviceName: sqs
|
||||
upstream:
|
||||
url: http://management-service.sqs.svc.cluster.local:9090
|
||||
timeoutSeconds: 30
|
||||
auth:
|
||||
required: true
|
||||
resources:
|
||||
- name: send-message
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /sqs/send
|
||||
- name: receive-message
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /sqs/receive
|
||||
- name: list-queues
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /sqs/queues
|
||||
- serviceName: workflow
|
||||
upstream:
|
||||
url: grpc://temporal-frontend.temporal.svc.cluster.local:7233
|
||||
timeoutSeconds: 60
|
||||
auth:
|
||||
required: false
|
||||
resources:
|
||||
- name: execute
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ExecuteWorkflow
|
||||
- name: describe
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/DescribeWorkflowExecution
|
||||
- name: list
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions
|
||||
- serviceName: memory
|
||||
upstream:
|
||||
url: http://poimen-memory.poimen.svc.cluster.local:8080
|
||||
timeoutSeconds: 30
|
||||
auth:
|
||||
required: false
|
||||
resources:
|
||||
- name: query
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /memory/query
|
||||
- name: ingest
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /memory/ingest
|
||||
- name: skills
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /memory/skills
|
||||
- serviceName: s3
|
||||
upstream:
|
||||
url: http://minio.storage.svc.cluster.local:9000
|
||||
timeoutSeconds: 30
|
||||
auth:
|
||||
required: false
|
||||
resources:
|
||||
- name: list-objects
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /
|
||||
- name: get-object
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /
|
||||
- name: put-object
|
||||
methods:
|
||||
- verb: PUT
|
||||
upstreamPath: /
|
||||
- serviceName: iam
|
||||
upstream:
|
||||
url: http://authentik-server.iam.svc.cluster.local:80
|
||||
timeoutSeconds: 30
|
||||
auth:
|
||||
required: false
|
||||
resources:
|
||||
- name: list-roles
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /api/v3/roles
|
||||
- name: list-users
|
||||
methods:
|
||||
- verb: GET
|
||||
upstreamPath: /api/v3/users
|
||||
- name: create-role
|
||||
methods:
|
||||
- verb: POST
|
||||
upstreamPath: /api/v3/roles
|
||||
@@ -4,20 +4,18 @@ kind: Kustomization
|
||||
namespace: api
|
||||
|
||||
resources:
|
||||
- rbac.yaml
|
||||
- serviceaccount.yaml
|
||||
- service.yaml
|
||||
- deployment.yaml
|
||||
- network-policy.yaml
|
||||
- configmap.yaml
|
||||
- gateway-config-secret.enc.yaml
|
||||
|
||||
# The deployed image tag lives here and nowhere else. CI publishes
|
||||
# forgejo.riotpiao.com/rock/api-gateway:<commit-sha>; promoting a build is a
|
||||
# one-line commit bumping newTag, which Argo then syncs (G7).
|
||||
#
|
||||
# kustomize edit set image forgejo.riotpiao.com/rock/api-gateway=:<sha>
|
||||
# forgejo.riotpiao.com/rock/api-gateway:<commit-sha> and tags it as :latest on main.
|
||||
# ArgoCD auto-syncs when the latest image is available.
|
||||
images:
|
||||
- name: forgejo.riotpiao.com/rock/api-gateway
|
||||
newTag: REPLACE_WITH_FIRST_BUILD_SHA
|
||||
newTag: latest
|
||||
|
||||
commonLabels:
|
||||
app: api-gateway
|
||||
|
||||
+68
-4
@@ -29,20 +29,39 @@ spec:
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
# Allow from poimen namespace (orchestrator & worker pods)
|
||||
# Enable Poimen workflows to call the LLM API gateway
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: poimen
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
# Allow from portfolio namespace (riotpiao.com chat terminal)
|
||||
- from:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: portfolio
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
egress:
|
||||
# Allow DNS
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: kube-system
|
||||
kubernetes.io/metadata.name: kube-system
|
||||
ports:
|
||||
- protocol: UDP
|
||||
port: 53
|
||||
- protocol: TCP
|
||||
port: 53
|
||||
# Allow to upstreams (LLM services in llm-serving namespace)
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: llm-serving
|
||||
kubernetes.io/metadata.name: llm-serving
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 80
|
||||
@@ -54,7 +73,7 @@ spec:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: llm-serving
|
||||
kubernetes.io/metadata.name: llm-serving
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
@@ -62,7 +81,52 @@ spec:
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
name: atlas
|
||||
kubernetes.io/metadata.name: atlas
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
# Allow to IAM (Authentik) for JWKS fetch + API
|
||||
# Authentik pod listens on 9000 (http) and 9443 (https)
|
||||
# Service translates 80→9000, 443→9443
|
||||
# NetworkPolicy matches destination pod port, not service port
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: iam
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 9000
|
||||
- protocol: TCP
|
||||
port: 9443
|
||||
# Allow to SQS (queue management service)
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: sqs
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 9090
|
||||
# Allow to Temporal (workflow engine gRPC)
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: temporal
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 7233
|
||||
# Allow to Poimen (memory/semantic search)
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: poimen
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 8080
|
||||
# Allow to MinIO (S3-compatible storage)
|
||||
- to:
|
||||
- namespaceSelector:
|
||||
matchLabels:
|
||||
kubernetes.io/metadata.name: storage
|
||||
ports:
|
||||
- protocol: TCP
|
||||
port: 9000
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: api-gateway
|
||||
namespace: api
|
||||
labels:
|
||||
app: api-gateway
|
||||
---
|
||||
# No ClusterRole needed - the gateway has no k8s API access
|
||||
# G2: The gateway holds no Kubernetes credentials
|
||||
@@ -0,0 +1,5 @@
|
||||
apiVersion: v1
|
||||
kind: ServiceAccount
|
||||
metadata:
|
||||
name: api-gateway
|
||||
namespace: api
|
||||
Executable
+15
@@ -0,0 +1,15 @@
|
||||
#!/bin/bash
|
||||
# Real integration tests - call actual services
|
||||
# Usage: GATEWAY_URL=https://api.riotpiao.com \
|
||||
# AUTHENTIK_CLIENT_ID=xxx AUTHENTIK_CLIENT_SECRET=yyy \
|
||||
# ./scripts/test-integration.sh
|
||||
|
||||
set -e
|
||||
|
||||
GATEWAY_URL=${GATEWAY_URL:-http://localhost:8080}
|
||||
AUTHENTIK_URL=${AUTHENTIK_URL:-https://authentik.riotpiao.com}
|
||||
TEST_TIMEOUT=${TEST_TIMEOUT:-30}
|
||||
|
||||
export GATEWAY_URL AUTHENTIK_URL AUTHENTIK_CLIENT_ID AUTHENTIK_CLIENT_SECRET TEST_TIMEOUT
|
||||
|
||||
go test -tags integration -v ./internal/serviceadapter -run TestRealIntegration
|
||||
@@ -1,32 +0,0 @@
|
||||
# 0.1 — Module and entrypoint (GREEN)
|
||||
|
||||
Phase: 0 — Foundations
|
||||
Stage: GREEN
|
||||
|
||||
- [x] A single Go module builds one static binary with no cgo
|
||||
- [x] The binary reads its configuration at startup and serves HTTP on a configurable listen address
|
||||
- [x] `SIGTERM` starts a drain: the listener stops accepting new connections, in-flight requests run to completion, then the process exits `0`
|
||||
- [x] A request already in flight when `SIGTERM` arrives receives its full, uncorrupted response body
|
||||
- [x] A request arriving after `SIGTERM` is not accepted on a new connection
|
||||
- [x] The drain has a bounded deadline; exceeding it forces exit with a non-zero code and a logged reason
|
||||
- [x] The process holds no Kubernetes credentials and makes no API-server calls
|
||||
|
||||
The gateway sits behind ingress-nginx, which owns TLS. The gateway never terminates
|
||||
TLS and never listens on 443. Graceful drain matters because in-flight requests here
|
||||
are LLM generations that can legitimately run for many minutes -> killing them
|
||||
mid-stream loses work a caller cannot cheaply redo.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/server/... -run TestGracefulShutdown -race -v
|
||||
# expected: passes — a slow in-flight request completes with a full body after SIGTERM,
|
||||
# and a request issued post-SIGTERM is refused; process exit code is 0
|
||||
|
||||
CGO_ENABLED=0 go build ./... && go vet ./...
|
||||
# expected: both succeed
|
||||
```
|
||||
|
||||
`-race` is required, not optional. A server that starts a listener in one goroutine and
|
||||
exposes its address from another is the obvious shape here, and it is racy unless the
|
||||
shared state is guarded. A test that passes without `-race` proves nothing about it.
|
||||
@@ -1,29 +0,0 @@
|
||||
# 0.2 — Declarative route configuration (RED)
|
||||
|
||||
Phase: 0 — Foundations
|
||||
Stage: RED
|
||||
|
||||
- [x] Routes and upstreams are declared in YAML loaded from a file path at startup
|
||||
- [x] Each upstream declares: address, path rewrite, connect timeout, read timeout, write timeout, maximum request body size, and an auth-required flag
|
||||
- [x] Every one of those fields is explicit — no silent defaults for timeouts, body caps or auth
|
||||
- [x] A config missing any required field fails startup with a non-zero exit and a message naming the offending route and field
|
||||
- [x] A config with a malformed duration, an unparseable address, or a duplicate route key fails startup the same way
|
||||
- [x] A valid config round-trips: every declared route is present in the loaded route table
|
||||
- [x] Loading is startup-only — no API-server watch, no CRD, no Kubernetes client
|
||||
|
||||
Configuration lives in git and is mounted as a ConfigMap synced by Argo. It is
|
||||
deliberately not a CRD: a CRD would require the gateway to watch the API server,
|
||||
which needs RBAC and contradicts the invariant that the gateway holds no cluster
|
||||
credentials. It is also the exact indirection being retired with Kong, whose routing
|
||||
table was split across six `KongPlugin` CRs, seven Ingresses and a Helm values file.
|
||||
|
||||
A gateway that starts with a silently dropped route is worse than one that refuses to
|
||||
start.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/config/... -v
|
||||
# expected: passes — valid fixtures load with all routes present; each invalid fixture
|
||||
# returns an error naming the offending route and field, and none of them load partially
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
# 0.3 — Health endpoints (GREEN)
|
||||
|
||||
Phase: 0 — Foundations
|
||||
Stage: GREEN
|
||||
Depends on: [0.2](0.2-route-configuration.md)
|
||||
|
||||
- [x] `GET /healthz` returns `200` whenever the process is alive
|
||||
- [x] `GET /healthz` contacts no upstream and performs no network I/O
|
||||
- [x] `GET /readyz` returns `200` only when configuration is valid and, if auth is enabled, JWKS has been fetched at least once
|
||||
- [x] `GET /readyz` returns a non-`2xx` status while configuration is invalid or JWKS has never been fetched
|
||||
- [x] Neither endpoint requires authentication, even when the auth flag is on
|
||||
- [x] Neither path is proxied to any upstream, and neither can be shadowed by a configured route
|
||||
|
||||
`/healthz` backs the liveness probe, so it must stay cheap and must not fail because
|
||||
an upstream is down — restarting the gateway does not fix a sick vLLM pod. `/readyz`
|
||||
backs the readiness probe and is allowed to fail, taking the pod out of the nginx
|
||||
endpoint pool until it can actually serve.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/server/... -run TestHealthEndpoints -v
|
||||
# expected: passes — /healthz is 200 with upstreams unreachable; /readyz is non-2xx
|
||||
# before first JWKS fetch and 200 after; both answer with no Authorization header
|
||||
```
|
||||
@@ -1,29 +0,0 @@
|
||||
# 0.4 — Local development harness (GREEN)
|
||||
|
||||
Phase: 0 — Foundations
|
||||
Stage: GREEN
|
||||
Depends on: [0.2](0.2-route-configuration.md)
|
||||
|
||||
- [ ] The whole gateway runs from a checkout with no cluster, no kubeconfig and no credentials of any kind
|
||||
- [ ] A committed local config points every upstream at stub servers started by the harness
|
||||
- [ ] Stubs can serve a fixed JSON body, an SSE token stream, a chunked response, and a slow response
|
||||
- [ ] A test can assert on the real HTTP response: status, headers and body
|
||||
- [ ] A test can assert that streamed chunks arrive incrementally, before the upstream has finished
|
||||
- [ ] A test can disconnect the client mid-response and assert on what the stub upstream observed
|
||||
- [ ] One documented command runs the harness end to end and exits non-zero on failure
|
||||
- [ ] Running the harness never contacts `*.riotpiao.com` or any cluster address
|
||||
|
||||
This is a hard requirement, not a convenience: it determines whether work can proceed
|
||||
unattended. Upstreams are configuration, so pointing them at local stubs is the entire
|
||||
mechanism. Every later phase's verification depends on this existing first.
|
||||
|
||||
"It compiles" and "it starts" are not verification. Asserting on an actual HTTP
|
||||
response is.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
env -u KUBECONFIG go test ./internal/testsupport/... ./internal/proxy/... -v
|
||||
# expected: passes with no kubeconfig and no network access beyond loopback —
|
||||
# includes an SSE test asserting incremental arrival and a mid-response disconnect test
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
# 0.5 — Structured logging (GREEN)
|
||||
|
||||
Phase: 0 — Foundations
|
||||
Stage: GREEN
|
||||
Depends on: [0.1](0.1-module-and-entrypoint.md)
|
||||
|
||||
- [ ] Logs are emitted as structured records with a consistent field set, one record per line
|
||||
- [ ] Every request log carries at least: route, upstream, method, path, status, duration
|
||||
- [ ] Every rejected request is logged with an explicit machine-readable reason field
|
||||
- [ ] Request bodies are never logged, in whole or in part
|
||||
- [ ] `Authorization` header values, bearer tokens, API keys and JWKS material are never logged, not even truncated or hashed-with-prefix
|
||||
- [ ] Log level is configurable, and no level unlocks body or token logging
|
||||
- [ ] A test asserts a rejected request produces exactly one record containing the reason and containing no token substring
|
||||
|
||||
Rejections come from several layers — unknown model, body too large, auth failure,
|
||||
concurrency limit — and the reason field is what makes them countable later. The model
|
||||
API carries prompts that are user content and tokens that are credentials; neither
|
||||
belongs in a log line.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/logging/... ./internal/server/... -run 'TestLog' -v
|
||||
# expected: passes — captured log output for a rejected request contains the reason
|
||||
# field and does not contain the request body or the bearer token used
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
# 0.6 — CI pipeline (GREEN)
|
||||
|
||||
Phase: 0 — Foundations
|
||||
Stage: GREEN
|
||||
Depends on: [0.4](0.4-local-dev-harness.md)
|
||||
|
||||
- [ ] CI builds the binary on every push and pull request
|
||||
- [ ] CI runs `go vet` over all packages and fails on any finding
|
||||
- [ ] CI runs the full test suite, including the local harness tests, with the race detector on
|
||||
- [ ] CI runs `govulncheck` and fails the job on any HIGH or CRITICAL severity finding
|
||||
- [ ] CI needs no cluster, no kubeconfig and no credentials to pass
|
||||
- [ ] A deliberately broken commit — failing test, vet finding, or known-vulnerable dependency — fails CI rather than passing silently
|
||||
- [ ] Job status is visible on the commit or pull request
|
||||
|
||||
CI is the outer loop for the same closed verification loop the harness gives locally.
|
||||
It must not depend on cluster access, or it stops running the moment the cluster is
|
||||
unavailable.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go vet ./... && go test -race ./... && govulncheck ./...
|
||||
# expected: all three exit 0 locally; pushing a branch with a failing test shows a
|
||||
# failed CI run on that commit
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
# 1.1 — Reverse proxy to configured upstreams (GREEN)
|
||||
|
||||
Phase: 1 — Proxy core
|
||||
Stage: GREEN
|
||||
Depends on: [0.2](0.2-route-configuration.md), [0.4](0.4-local-dev-harness.md)
|
||||
|
||||
- [ ] A request matching a configured route is proxied to that route's upstream address
|
||||
- [ ] The upstream's status code, response headers and body reach the client unmodified
|
||||
- [ ] The request method, query string and body reach the upstream unmodified
|
||||
- [ ] The route's configured path rewrite is applied to the upstream request path
|
||||
- [ ] Connections to upstreams are pooled and reused across requests — a second request to the same upstream does not open a new TCP connection
|
||||
- [ ] A request matching no configured route returns `404` and contacts no upstream
|
||||
- [ ] An unreachable upstream returns a `5xx` to the client and is logged with the upstream name
|
||||
|
||||
Connection reuse is not a micro-optimisation here: the chat upstreams hold long-lived
|
||||
streaming responses, and churning connections under that pattern wastes handshakes and
|
||||
file descriptors on both ends.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/proxy/... -run 'TestProxy|TestConnectionReuse' -v
|
||||
# expected: passes — stub upstream sees the rewritten path and original body, client
|
||||
# sees the stub's exact status/headers/body, and the stub records one accepted
|
||||
# connection across two sequential requests
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
# 1.2 — Streaming passthrough (RED)
|
||||
|
||||
Phase: 1 — Proxy core
|
||||
Stage: RED
|
||||
Depends on: [1.1](1.1-reverse-proxy.md)
|
||||
|
||||
- [ ] An SSE response from an upstream reaches the client unbuffered: each `data:` event is readable by the client before the upstream has sent the next one
|
||||
- [ ] A chunked response reaches the client chunk by chunk, not accumulated and flushed at completion
|
||||
- [ ] Response headers reach the client before the first body byte, not after
|
||||
- [ ] `Content-Type: text/event-stream` and the upstream's `Cache-Control` and `Connection` semantics survive the proxy
|
||||
- [ ] No response body is written to memory or disk in full before forwarding
|
||||
- [ ] The terminating `data: [DONE]` sentinel and the final zero-length chunk pass through
|
||||
- [ ] A test asserts wall-clock ordering: the Nth event is observed at the client before the upstream emits the N+1th
|
||||
|
||||
The whole product is token streaming. If the gateway buffers, a caller waits minutes
|
||||
for a response that should have started in seconds, and the user-visible behaviour of
|
||||
the model API regresses versus Kong. nginx in front is already configured with
|
||||
`proxy-buffering: off`; the gateway must not reintroduce buffering behind it.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestSSEUnbuffered -v
|
||||
# expected: passes — client observes each of 5 stub-emitted SSE events with the
|
||||
# upstream still open, and total observed inter-event gaps match the stub's emit delays
|
||||
```
|
||||
@@ -1,25 +0,0 @@
|
||||
# 1.3 — Client disconnect propagation (RED)
|
||||
|
||||
Phase: 1 — Proxy core
|
||||
Stage: RED
|
||||
Depends on: [1.2](1.2-streaming-passthrough.md)
|
||||
|
||||
- [ ] When a client closes the connection mid-response, the gateway cancels the upstream request immediately
|
||||
- [ ] The stub upstream observes its request context cancelled, not a request that runs to completion
|
||||
- [ ] Cancellation happens within a small bounded delay of the client close, not at the route's read timeout
|
||||
- [ ] The same holds for a non-streaming request abandoned before the upstream replies
|
||||
- [ ] The disconnect is logged with a reason distinguishing it from an upstream error
|
||||
- [ ] No goroutine or upstream connection is left alive after the disconnect — the test asserts this, not just the response
|
||||
|
||||
This is load-bearing. The `reasoning` upstream runs 2 replicas at `--max-num-seqs=4`,
|
||||
which is 8 concurrent sequence slots cluster-wide. An orphaned generation holds one of
|
||||
those 8 until it finishes on its own, which for a 32B model on a Volta GPU can be
|
||||
minutes. A handful of abandoned browser tabs can starve the entire cluster.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestClientDisconnectCancelsUpstream -race -v
|
||||
# expected: passes — stub upstream reports context cancellation within 1s of the client
|
||||
# closing mid-stream, and the post-test goroutine count returns to baseline
|
||||
```
|
||||
@@ -1,29 +0,0 @@
|
||||
# 1.4 — Per-route timeouts (GREEN)
|
||||
|
||||
Phase: 1 — Proxy core
|
||||
Stage: GREEN
|
||||
Depends on: [0.2](0.2-route-configuration.md), [1.1](1.1-reverse-proxy.md)
|
||||
|
||||
- [ ] Connect, read and write timeouts are taken per route from configuration, never from a global default
|
||||
- [ ] Chat routes use connect `10s`, read `1h`, write `1h`
|
||||
- [ ] Embeddings and rerank routes use connect `10s`, read `10m`, write `10m`
|
||||
- [ ] An upstream that never accepts a connection fails at the configured connect timeout, not later
|
||||
- [ ] An upstream that accepts then stalls fails at the configured read timeout with a `5xx` and a logged reason
|
||||
- [ ] A stream still emitting tokens is never cut by the read timeout — the timeout applies to inactivity, not total duration
|
||||
- [ ] No code path shortens a configured proxy timeout to enforce an application-level cap
|
||||
|
||||
These are Kong's current values and they are deliberate. The 1-hour read timeout
|
||||
exists because a 32B model on a Volta GPU routinely exceeds 60 seconds per request.
|
||||
Any shorter application-level cap must be enforced by the gateway's own logic — a
|
||||
budget, a slot limit, an explicit max-generation-time — and never by shortening the
|
||||
proxy timeout, or long legitimate generations truncate mid-stream and callers see
|
||||
corrupted output rather than an error.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/proxy/... -run 'TestConnectTimeout|TestReadTimeout|TestLongStreamNotTruncated' -v
|
||||
# expected: passes — stalled upstream errors at the configured read timeout, a stub
|
||||
# emitting one event every 200ms for longer than the timeout window is not cut off,
|
||||
# and a blackholed address fails at ~10s
|
||||
```
|
||||
@@ -1,27 +0,0 @@
|
||||
# 1.5 — Header hygiene (GREEN)
|
||||
|
||||
Phase: 1 — Proxy core
|
||||
Stage: GREEN
|
||||
Depends on: [1.1](1.1-reverse-proxy.md)
|
||||
|
||||
- [ ] Hop-by-hop headers are stripped from both the upstream request and the client response
|
||||
- [ ] Headers named in a request's `Connection` header are also stripped, not just the fixed hop-by-hop list
|
||||
- [ ] `X-Forwarded-For` appends the immediate peer to the nginx-supplied value rather than replacing or fabricating it
|
||||
- [ ] `X-Forwarded-Proto` and `X-Forwarded-Host` are taken from the nginx-supplied values when present
|
||||
- [ ] Client-supplied `X-Forwarded-*` values are not trusted when the request did not arrive from the trusted ingress peer
|
||||
- [ ] End-to-end headers, including `Content-Type`, `Authorization` where the route requires it, and upstream response headers, pass through unchanged
|
||||
- [ ] A test asserts the exact header set the stub upstream receives
|
||||
|
||||
ingress-nginx owns TLS and the edge, so it is the only source of truth for the
|
||||
original scheme, host and client address. The gateway fabricating these would make
|
||||
every upstream's view of the caller wrong, and would let a client spoof its own
|
||||
source address by sending the header itself.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestHeaderHygiene -v
|
||||
# expected: passes — stub upstream sees no Connection/Keep-Alive/TE/Upgrade/
|
||||
# Proxy-Authorization headers, sees X-Forwarded-For ending in the nginx-supplied value
|
||||
# plus the peer, and a spoofed X-Forwarded-Proto from an untrusted peer is discarded
|
||||
```
|
||||
@@ -1,27 +0,0 @@
|
||||
# 1.6 — WebSocket upgrade (GREEN)
|
||||
|
||||
Phase: 1 — Proxy core
|
||||
Stage: GREEN
|
||||
Depends on: [1.5](1.5-header-hygiene.md)
|
||||
|
||||
- [ ] A route may be configured to allow protocol upgrade
|
||||
- [ ] An upgrade request on such a route reaches the upstream with its `Upgrade` and `Connection` headers intact, despite hop-by-hop stripping
|
||||
- [ ] The upstream's `101 Switching Protocols` response reaches the client, and bytes then flow bidirectionally
|
||||
- [ ] Frames pass in both directions with no buffering delay
|
||||
- [ ] Client close propagates to the upstream and upstream close propagates to the client
|
||||
- [ ] An upgrade attempt on a route that does not allow it is rejected, not silently downgraded to a plain proxied request
|
||||
- [ ] Idle upgraded connections are not cut by the route's read timeout while frames are still flowing
|
||||
|
||||
`agent-pod/console` serves a WebSocket and is one of the seven Kong-class Ingresses
|
||||
being migrated. It is currently publicly routed and unauthenticated into a
|
||||
shell-capable container, so it must work through the gateway before it can be put
|
||||
behind gateway auth. Header hygiene and upgrade support interact directly: `Upgrade`
|
||||
and `Connection` are hop-by-hop, and a naive strip breaks the handshake.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestWebSocketUpgrade -v
|
||||
# expected: passes — client receives 101 from the stub, an echoed frame round-trips in
|
||||
# both directions, and closing the client causes the stub to observe a close
|
||||
```
|
||||
@@ -1,26 +0,0 @@
|
||||
# 1.7 — Per-route body size caps (RED)
|
||||
|
||||
Phase: 1 — Proxy core
|
||||
Stage: RED
|
||||
Depends on: [0.2](0.2-route-configuration.md), [1.1](1.1-reverse-proxy.md)
|
||||
|
||||
- [ ] Each route enforces its own configured maximum request body size
|
||||
- [ ] A body over the cap is rejected with `413` and a body the upstream never sees
|
||||
- [ ] Rejection happens while reading, not after buffering the whole body into memory
|
||||
- [ ] A request with a lying or absent `Content-Length` is still capped by bytes actually read
|
||||
- [ ] A body at exactly the cap is accepted and proxied intact
|
||||
- [ ] The rejection is logged with a reason distinguishing it from other rejections
|
||||
- [ ] No global default cap silently applies to a route that failed to declare one — that is a config error, per 0.2
|
||||
|
||||
nginx in front is configured with `proxy-body-size: 0`, meaning it enforces no limit
|
||||
at all, so the gateway is the only place a cap exists. Embedding and rerank callers
|
||||
can send large batches legitimately, which is why the cap is per route rather than
|
||||
one number for the whole surface.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
go test ./internal/proxy/... -run TestBodySizeCap -v
|
||||
# expected: passes — a body one byte over the route cap returns 413 and the stub
|
||||
# upstream records zero requests; a body exactly at the cap returns the stub's 200
|
||||
```
|
||||
@@ -1,35 +0,0 @@
|
||||
# 2.1 — Model registry (GREEN)
|
||||
|
||||
Phase: 2 — LLM surface
|
||||
Stage: GREEN
|
||||
Depends on: [0.2](0.2-route-configuration.md), [1.1](1.1-reverse-proxy.md)
|
||||
|
||||
- [ ] A model name -> upstream map is loaded from configuration at startup, never compiled in
|
||||
- [ ] Each entry carries at minimum the model name clients send, the upstream address, and the upstream path to use
|
||||
- [ ] Two model names may point at the same upstream address, and both resolve independently
|
||||
- [ ] A duplicate model name, an empty model name, or an entry with no upstream address fails startup loudly with the offending entry named
|
||||
- [ ] The registry is queryable by exact model name; lookup is case-sensitive and does no fuzzy matching or defaulting
|
||||
- [ ] The set of known model names is enumerable, because `/v1/models` and unknown-model errors are both derived from it
|
||||
|
||||
The five entries verified live on 2026-08-19. Ports are 80, not 8080.
|
||||
|
||||
| model name clients send | upstream Service | engine |
|
||||
|---|---|---|
|
||||
| `reasoning` | `reasoning-predictor.llm-serving:80` | vLLM, DeepSeek-R1-Distill-Qwen-32B |
|
||||
| `ornith:35b` | `ornith-predictor.llm-serving:80` | Ollama |
|
||||
| `qwen2.5:3b-instruct` | `ornith-predictor.llm-serving:80` | Ollama, same pods |
|
||||
| `nomic-ai/nomic-embed-text-v2-moe` | `embeddings-predictor.llm-serving:80` | TEI |
|
||||
| `BAAI/bge-reranker-base` | `reranker-predictor.llm-serving:80` | TEI |
|
||||
|
||||
`ornith:35b` and `qwen2.5:3b-instruct` share pods and both stay resident, so a
|
||||
registry that maps them to one address is correct, not a shortcut.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
# with the five entries configured against local stubs
|
||||
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/readyz # expected: 200
|
||||
|
||||
# duplicate model name in config must refuse to start
|
||||
./gateway --config testdata/duplicate-model.yaml; echo "exit=$?" # expected: non-zero exit, stderr names the duplicated model
|
||||
```
|
||||
@@ -1,53 +0,0 @@
|
||||
# 2.10 — Anthropic request translation (GREEN)
|
||||
|
||||
Phase: 2 — LLM surfaces
|
||||
Stage: GREEN
|
||||
Depends on: [2.9](2.9-canonical-request-model.md), [2.1](2.1-model-registry.md)
|
||||
|
||||
`POST /llm/v1/messages` accepts an Anthropic Messages request body and turns it into
|
||||
the dialect-neutral canonical request. The path carries `/v1/messages` because the
|
||||
Anthropic base-URL convention appends that suffix; the gateway prefix is `/llm`.
|
||||
|
||||
- [ ] `POST /llm/v1/messages` is accepted and selects its upstream from the body's
|
||||
`model` field, using the same registry as `/v1`: `reasoning` reaches
|
||||
`reasoning-predictor.llm-serving:80`, `ornith:35b` and `qwen2.5:3b-instruct`
|
||||
reach `ornith-predictor.llm-serving:80`
|
||||
- [ ] The top-level `system` field becomes the canonical system instruction; it is a
|
||||
distinct field in this dialect and is not a member of `messages`
|
||||
- [ ] Each message `content` may be a plain string or an array of blocks; a string and
|
||||
a single text block carrying the same characters translate identically
|
||||
- [ ] `max_tokens` is REQUIRED on this surface, matching the Anthropic contract; a
|
||||
request without it is rejected as a client error and no upstream is contacted
|
||||
- [ ] `max_tokens` above what the upstream can serve is clamped rather than rejected,
|
||||
and the clamp is logged; `reasoning` has `--max-model-len=16384`
|
||||
- [ ] Roles are restricted to `user` and `assistant`; any other role, including
|
||||
`system` inside `messages`, is a client error naming the offending role
|
||||
- [ ] `stop_sequences` becomes the canonical stop sequences, and `stream` becomes the
|
||||
canonical streaming flag
|
||||
- [ ] `tools`, `tool_choice`, any `tool_use` or `tool_result` block, any `image` block,
|
||||
any cache-control marker, and any user message with more than one content block
|
||||
are rejected as unsupported, naming the feature; none is silently dropped
|
||||
- [ ] Unknown top-level fields are rejected rather than ignored, so a client cannot
|
||||
believe an unimplemented option took effect
|
||||
- [ ] Reading the body respects the route's configured body size cap
|
||||
|
||||
`max_tokens` stays optional on `/v1/chat/completions`. The policy is per-surface: each
|
||||
dialect keeps its own contract, and the canonical request records whichever value
|
||||
resulted.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w '%{http_code}\n' localhost:8080/llm/v1/messages \
|
||||
-H 'content-type: application/json' \
|
||||
-d '{"model":"reasoning","max_tokens":64,"system":"be terse","messages":[{"role":"user","content":"hi"}]}'
|
||||
# expected: 200, reasoning stub hit, system text present in the upstream request
|
||||
|
||||
curl -s localhost:8080/llm/v1/messages -H 'content-type: application/json' \
|
||||
-d '{"model":"reasoning","messages":[{"role":"user","content":"hi"}]}'
|
||||
# expected: 400, body names max_tokens as the missing required field, no upstream stub hit
|
||||
|
||||
curl -s localhost:8080/llm/v1/messages -H 'content-type: application/json' \
|
||||
-d '{"model":"reasoning","max_tokens":8,"messages":[{"role":"system","content":"x"}]}'
|
||||
# expected: 400, body names the rejected role, no upstream stub hit
|
||||
```
|
||||
@@ -1,50 +0,0 @@
|
||||
# 2.11 — Anthropic non-streaming response translation (GREEN)
|
||||
|
||||
Phase: 2 — LLM surfaces
|
||||
Stage: GREEN
|
||||
Depends on: [2.10](2.10-anthropic-request-translation.md), [2.9](2.9-canonical-request-model.md)
|
||||
|
||||
A non-streaming `POST /llm/v1/messages` gets an Anthropic Messages response, built
|
||||
from whatever the upstream returned. Upstreams speak the OpenAI chat-completion shape;
|
||||
the client on this surface must never see it.
|
||||
|
||||
- [ ] The response body is `{"id","type":"message","role":"assistant","content":[...],
|
||||
"model","stop_reason","stop_sequence","usage":{"input_tokens","output_tokens"}}`
|
||||
with `type` literally `message` and `role` literally `assistant`
|
||||
- [ ] `model` echoes the model name the client sent, not an upstream-internal name
|
||||
- [ ] Upstream `finish_reason` `stop` becomes `stop_reason` `end_turn`, and `length`
|
||||
becomes `max_tokens`
|
||||
- [ ] A generation halted by a client-supplied stop sequence reports `stop_reason`
|
||||
`stop_sequence` and puts the matched string in `stop_sequence`; otherwise
|
||||
`stop_sequence` is null and present, not omitted
|
||||
- [ ] Upstream `prompt_tokens` becomes `usage.input_tokens` and `completion_tokens`
|
||||
becomes `usage.output_tokens`; no other usage fields are invented
|
||||
- [ ] `reasoning_content`, which vLLM returns as a field separate from `content` for
|
||||
`reasoning`, becomes a `thinking` content block that PRECEDES the `text` block
|
||||
- [ ] When `reasoning_content` is absent or empty, no `thinking` block is emitted and
|
||||
`content` holds only the `text` block
|
||||
- [ ] When `content` is empty but `reasoning_content` is not, the `thinking` block is
|
||||
still returned rather than an empty `content` array
|
||||
- [ ] `Content-Type` is `application/json`, and no OpenAI field name such as `choices`,
|
||||
`finish_reason` or `object` appears anywhere in the body
|
||||
|
||||
`reasoning` runs DeepSeek-R1-Distill-Qwen-32B under vLLM with
|
||||
`--reasoning-parser=deepseek_r1`, which is why the reasoning text arrives as its own
|
||||
field and maps cleanly onto a thinking block.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
curl -s localhost:8080/llm/v1/messages -H 'content-type: application/json' \
|
||||
-d '{"model":"reasoning","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}'
|
||||
# expected: 200, type=message, role=assistant, content[0].type=thinking, content[1].type=text
|
||||
|
||||
curl -s localhost:8080/llm/v1/messages -H 'content-type: application/json' \
|
||||
-d '{"model":"reasoning","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}' \
|
||||
| grep -c -E '"choices"|"finish_reason"|"object"'
|
||||
# expected: 0
|
||||
|
||||
curl -s localhost:8080/llm/v1/messages -H 'content-type: application/json' \
|
||||
-d '{"model":"qwen2.5:3b-instruct","max_tokens":4,"messages":[{"role":"user","content":"count to fifty"}]}'
|
||||
# expected: 200, stop_reason=max_tokens, usage has input_tokens and output_tokens only
|
||||
```
|
||||
@@ -1,64 +0,0 @@
|
||||
# 2.12 — Anthropic SSE state machine (RED)
|
||||
|
||||
Phase: 2 — LLM surfaces
|
||||
Stage: RED
|
||||
Depends on: [2.11](2.11-anthropic-response-translation.md), [1.2](1.2-streaming-passthrough.md), [1.3](1.3-disconnect-propagation.md)
|
||||
|
||||
`POST /llm/v1/messages` with `"stream":true` must emit Anthropic SSE. Anthropic uses
|
||||
NAMED events carrying content-block indices; the upstream emits flat OpenAI data-only
|
||||
chunks. Write the failing tests against the event sequence before writing a translator.
|
||||
|
||||
- [ ] Every frame has both an `event:` line and a `data:` line; a bare `data:` frame is
|
||||
a failure on this surface
|
||||
- [ ] `Content-Type` is `text/event-stream`
|
||||
- [ ] Event order for a full response is exactly: `message_start`, then for each block
|
||||
`content_block_start`, one or more `content_block_delta`, `content_block_stop`,
|
||||
then `message_delta`, then `message_stop`
|
||||
- [ ] `message_start` carries the message envelope with the client-sent model, `role`
|
||||
`assistant`, empty `content`, and `usage.input_tokens`
|
||||
- [ ] The block carrying `reasoning_content` is index 0 with block type `thinking`, and
|
||||
its deltas are `thinking_delta`
|
||||
- [ ] The block carrying `content` is index 1 with block type `text`, and its deltas
|
||||
are `text_delta`
|
||||
- [ ] The end of reasoning is only knowable when `content` first arrives, so the
|
||||
arrival of the first `content` token MUST emit `content_block_stop` for index 0
|
||||
before `content_block_start` for index 1 — the two blocks never overlap
|
||||
- [ ] If a response has no `reasoning_content` at all, the text block is index 0 and no
|
||||
thinking block is started; indices are assigned in emission order with no gaps
|
||||
- [ ] If a response has `reasoning_content` and never any `content`, the thinking block
|
||||
is still closed before `message_delta`
|
||||
- [ ] `message_delta` carries `stop_reason` and `usage.output_tokens`; upstream
|
||||
`finish_reason` `stop` becomes `end_turn` and `length` becomes `max_tokens`
|
||||
- [ ] `message_stop` is the final frame and is emitted exactly once per response
|
||||
- [ ] Translation is streaming and unbuffered: each upstream chunk is converted and
|
||||
flushed as it arrives, and the response is never accumulated to be inspected
|
||||
- [ ] A client disconnect mid-stream cancels the upstream request immediately and
|
||||
releases the slot, rather than orphaning the generation
|
||||
- [ ] An upstream failure after `message_start` terminates the stream with an error
|
||||
frame rather than a truncated but apparently successful sequence
|
||||
- [ ] The OpenAI `data: [DONE]` sentinel is consumed by the translator and never
|
||||
forwarded to a `/llm` client
|
||||
|
||||
An orphaned generation holds one of only eight vLLM sequence slots in the cluster,
|
||||
which is why disconnect cancellation is an acceptance criterion here and not only in
|
||||
the proxy layer.
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
curl -N -s localhost:8080/llm/v1/messages -H 'content-type: application/json' \
|
||||
-d '{"model":"reasoning","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}' \
|
||||
| grep '^event:'
|
||||
# expected: message_start, content_block_start, content_block_delta..., content_block_stop,
|
||||
# content_block_start, content_block_delta..., content_block_stop, message_delta, message_stop
|
||||
|
||||
curl -N -s localhost:8080/llm/v1/messages -H 'content-type: application/json' \
|
||||
-d '{"model":"reasoning","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}' \
|
||||
| grep -n -E 'content_block_stop|"index":1' | head -3
|
||||
# expected: the index 0 content_block_stop line precedes the first line mentioning index 1
|
||||
|
||||
timeout 1 curl -N -s localhost:8080/llm/v1/messages -H 'content-type: application/json' \
|
||||
-d '{"model":"reasoning","max_tokens":512,"stream":true,"messages":[{"role":"user","content":"long"}]}' >/dev/null
|
||||
grep -c 'cancelled' /tmp/stub-reasoning.log
|
||||
# expected: 1 within a second of the client going away
|
||||
```
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user