Compare commits
142
Commits
v0.1.0
...
d71c88498b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d71c88498b | ||
|
|
c5a91cf647 | ||
|
|
aac68386d8 | ||
|
|
28753d11ed | ||
|
|
3068c68730 | ||
|
|
da361f2a21 | ||
|
|
1e17706367 | ||
|
|
49c9071a59 | ||
|
|
16ddd6d4ad | ||
|
|
fadc658ac0 | ||
|
|
bb7a490f5c | ||
|
|
4a3768ac35 | ||
|
|
f8882e86ae | ||
|
|
f9093fe713 | ||
|
|
ecaaaa8792 | ||
|
|
6d304a3e5a | ||
|
|
3ca654513f | ||
|
|
23fff1006a | ||
|
|
50bcd19634 | ||
|
|
a24e4f7174 | ||
|
|
0bde5dd8fe | ||
|
|
ef5e736673 | ||
|
|
aaa9a5d1cb | ||
|
|
ea7bd8336b | ||
|
|
ad5ba583ac | ||
|
|
63ec2ddf68 | ||
|
|
e4fed02809 | ||
|
|
eab6bc50c9 | ||
|
|
a8e20f437b | ||
|
|
4487ac99b4 | ||
|
|
23f909b9d8 | ||
|
|
6b089ff223 | ||
|
|
f2cff13629 | ||
|
|
bd8c64862c | ||
|
|
2171424220 | ||
|
|
3c6c855761 | ||
|
|
7237e47854 | ||
|
|
f18e6331ea | ||
|
|
bb792d463f | ||
|
|
d439536ca9 | ||
|
|
30e0a83a50 | ||
|
|
7de71180b3 | ||
|
|
0943df8a42 | ||
|
|
d7e1cbc62b | ||
|
|
f888df8be2 | ||
|
|
4341b1109b | ||
|
|
4a00312906 | ||
|
|
b8f95506ca | ||
|
|
67f24ea763 | ||
|
|
d53b7632cf | ||
|
|
d82cc5a697 | ||
|
|
04619a269f | ||
|
|
45254a48b0 | ||
|
|
e61885254b | ||
|
|
8177f8b92f | ||
|
|
05d6321302 | ||
|
|
1c64d8ff0e | ||
|
|
74ecfe7107 | ||
|
|
97707aa2f2 | ||
|
|
2e4e7e4855 | ||
|
|
c2fa3445bd | ||
|
|
0605754445 | ||
|
|
fe6bc67ec3 | ||
|
|
fbcb8989cd | ||
|
|
a23f5b3f31 | ||
|
|
3faed02dbf | ||
|
|
4effbf47bc | ||
|
|
619dc62de6 | ||
|
|
0ff38e2e7a | ||
|
|
3836835dd0 | ||
|
|
afd548c9bb | ||
|
|
e09e2270e2 | ||
|
|
dd9356c669 | ||
|
|
2bcf6c82fc | ||
|
|
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 |
@@ -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,189 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
branches: [main]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
env:
|
||||||
|
REGISTRY: forgejo.riotpiao.com
|
||||||
|
IMAGE: forgejo.riotpiao.com/rock/api-gateway
|
||||||
|
DOCKER_HOST: tcp://localhost:2375
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
ci:
|
||||||
|
name: CI
|
||||||
|
runs-on: golang
|
||||||
|
steps:
|
||||||
|
- name: Install dependencies
|
||||||
|
run: |
|
||||||
|
apt-get update
|
||||||
|
apt-get install -y docker.io curl nodejs
|
||||||
|
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Go vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Go test
|
||||||
|
run: go test -short ./...
|
||||||
|
|
||||||
|
- name: Get short SHA
|
||||||
|
id: sha
|
||||||
|
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Registry login
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \
|
||||||
|
--username "${REGISTRY_USER}" --password-stdin
|
||||||
|
env:
|
||||||
|
REGISTRY_USER: ${{ secrets.FORGEJO_REGISTRY_USER }}
|
||||||
|
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
|
- name: Build Docker image
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
docker build --no-cache \
|
||||||
|
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
||||||
|
-f Dockerfile .
|
||||||
|
|
||||||
|
- name: Push image (SHA tag)
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
|
|
||||||
|
# ── Deploy canary pod + integration test ────────────────
|
||||||
|
# Canary pod runs in-cluster with real secrets and real upstreams
|
||||||
|
# (Temporal, Gotify). Tests run against its pod IP directly.
|
||||||
|
# Zero downtime: production pods untouched until tests pass.
|
||||||
|
- name: Setup kubeconfig
|
||||||
|
if: false # SKIP: only needed for canary deploy, RBAC not ready
|
||||||
|
run: |
|
||||||
|
mkdir -p ~/.kube
|
||||||
|
echo "${KUBECONFIG_B64}" | base64 -d > ~/.kube/config
|
||||||
|
kubectl get namespaces --no-headers | head -1
|
||||||
|
echo '✓ kubeconfig works'
|
||||||
|
env:
|
||||||
|
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
|
||||||
|
|
||||||
|
- name: Deploy canary pod
|
||||||
|
if: false # SKIP: SA lacks pod create RBAC. Re-enable when fixed.
|
||||||
|
id: canary
|
||||||
|
run: |
|
||||||
|
SHA="${{ steps.sha.outputs.short_sha }}"
|
||||||
|
POD_NAME="api-gateway-canary-${SHA}"
|
||||||
|
|
||||||
|
# Remove any stale canary
|
||||||
|
kubectl delete pod "${POD_NAME}" -n api --ignore-not-found
|
||||||
|
|
||||||
|
# Create canary pod with real secrets + config
|
||||||
|
kubectl run "${POD_NAME}" \
|
||||||
|
--image="${IMAGE}:${SHA}" \
|
||||||
|
--restart=Never \
|
||||||
|
--namespace=api \
|
||||||
|
--labels="app=api-gateway-canary,sha=${SHA}" \
|
||||||
|
--overrides="$(cat <<JSON
|
||||||
|
{
|
||||||
|
"spec": {
|
||||||
|
"imagePullSecrets": [{"name": "forgejo-registry"}],
|
||||||
|
"volumes": [
|
||||||
|
{"name": "config", "secret": {"secretName": "api-gateway-config"}}
|
||||||
|
],
|
||||||
|
"containers": [{
|
||||||
|
"name": "${POD_NAME}",
|
||||||
|
"image": "${IMAGE}:${SHA}",
|
||||||
|
"imagePullPolicy": "Always",
|
||||||
|
"env": [
|
||||||
|
{"name": "LISTEN_ADDR", "value": "0.0.0.0:8080"},
|
||||||
|
{"name": "CONFIG_PATH", "value": "/etc/gateway/config.yaml"},
|
||||||
|
{"name": "LOG_LEVEL", "value": "info"},
|
||||||
|
{"name": "GOTIFY_URL", "valueFrom": {"secretKeyRef": {"name": "gotify-webhook-secret", "key": "gotify-url", "optional": true}}},
|
||||||
|
{"name": "GOTIFY_APP_TOKEN", "valueFrom": {"secretKeyRef": {"name": "gotify-webhook-secret", "key": "gotify-app-token", "optional": true}}},
|
||||||
|
{"name": "FORGEJO_WEBHOOK_SECRET","valueFrom": {"secretKeyRef": {"name": "gotify-webhook-secret", "key": "forgejo-webhook-secret", "optional": true}}},
|
||||||
|
{"name": "AUTH_CLIENT_SECRET", "valueFrom": {"secretKeyRef": {"name": "api-gw-client-secret", "key": "client-secret", "optional": true}}}
|
||||||
|
],
|
||||||
|
"volumeMounts": [
|
||||||
|
{"name": "config", "mountPath": "/etc/gateway", "readOnly": true}
|
||||||
|
],
|
||||||
|
"readinessProbe": {
|
||||||
|
"httpGet": {"path": "/healthz", "port": 8080},
|
||||||
|
"initialDelaySeconds": 3,
|
||||||
|
"periodSeconds": 2
|
||||||
|
}
|
||||||
|
}]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
JSON
|
||||||
|
)"
|
||||||
|
|
||||||
|
echo "✓ Canary pod created: ${POD_NAME}"
|
||||||
|
|
||||||
|
# Wait for pod to be ready (real secrets, real upstreams)
|
||||||
|
kubectl wait pod/"${POD_NAME}" -n api \
|
||||||
|
--for=condition=Ready --timeout=90s
|
||||||
|
|
||||||
|
# Capture pod IP for test runner
|
||||||
|
POD_IP=$(kubectl get pod "${POD_NAME}" -n api \
|
||||||
|
-o jsonpath='{.status.podIP}')
|
||||||
|
echo "pod_name=${POD_NAME}" >> $GITHUB_OUTPUT
|
||||||
|
echo "pod_ip=${POD_IP}" >> $GITHUB_OUTPUT
|
||||||
|
echo "✓ Canary ready at ${POD_IP}:8080"
|
||||||
|
|
||||||
|
- name: Run integration tests against canary
|
||||||
|
if: false # SKIP: depends on canary pod
|
||||||
|
id: tests
|
||||||
|
run: |
|
||||||
|
POD_IP="${{ steps.canary.outputs.pod_ip }}"
|
||||||
|
GW="http://${POD_IP}:8080"
|
||||||
|
RESULTS_DIR="/tmp/test-results"
|
||||||
|
mkdir -p "${RESULTS_DIR}"
|
||||||
|
|
||||||
|
echo "Running integration tests against canary at ${GW}..."
|
||||||
|
sh k8s/tekton/scripts/integration-test.sh
|
||||||
|
|
||||||
|
RESULT=$(cat "${RESULTS_DIR}/result" 2>/dev/null || echo "fail")
|
||||||
|
SUMMARY=$(cat "${RESULTS_DIR}/summary" 2>/dev/null || echo "unknown")
|
||||||
|
echo "Result: ${RESULT}"
|
||||||
|
echo "Summary: ${SUMMARY}"
|
||||||
|
echo "result=${RESULT}" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
|
- name: Cleanup canary pod
|
||||||
|
if: false # SKIP: depends on canary pod
|
||||||
|
run: |
|
||||||
|
POD_NAME="${{ steps.canary.outputs.pod_name }}"
|
||||||
|
if [ -n "${POD_NAME}" ]; then
|
||||||
|
kubectl delete pod "${POD_NAME}" -n api --ignore-not-found || true
|
||||||
|
echo "✓ Canary pod removed"
|
||||||
|
else
|
||||||
|
echo "⚠ No canary pod to clean up (name was empty)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Gate on test result
|
||||||
|
if: false # SKIP: depends on canary pod
|
||||||
|
run: |
|
||||||
|
echo "✗ Integration tests FAILED — production deployment unchanged"
|
||||||
|
exit 1
|
||||||
|
|
||||||
|
# ── Zero-downtime promotion ──────────────────────────────
|
||||||
|
# kubectl set image triggers a rolling update: new pods come up
|
||||||
|
# before old pods are terminated. Production traffic uninterrupted.
|
||||||
|
# TODO: Re-enable rolling update when kubeconfig/RBAC is ready
|
||||||
|
# - name: Promote — rolling update
|
||||||
|
# if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
# run: |
|
||||||
|
# kubectl set image deployment/api-gateway api-gateway="${IMAGE}:${SHA}" -n api
|
||||||
|
# kubectl rollout status deployment/api-gateway -n api --timeout=120s
|
||||||
|
|
||||||
|
- name: Tag :latest (informational)
|
||||||
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
|
run: |
|
||||||
|
docker tag "${IMAGE}:${{ steps.sha.outputs.short_sha }}" "${IMAGE}:latest"
|
||||||
|
docker push "${IMAGE}:latest"
|
||||||
|
echo "✓ Tagged :latest"
|
||||||
|
|
||||||
|
- name: Cleanup docker images
|
||||||
|
if: always()
|
||||||
|
run: docker image prune -af 2>&1 | tail -3 || true
|
||||||
+34
@@ -0,0 +1,34 @@
|
|||||||
|
# SOPS Configuration for secrets encryption
|
||||||
|
# Public keys are safe to commit; private keys stay in cluster
|
||||||
|
|
||||||
|
creation_rules:
|
||||||
|
# Encrypt secrets, configs, and sensitive files
|
||||||
|
# Multiple public keys for key rotation support
|
||||||
|
# Files matching these patterns will be encrypted automatically with `sops -e`
|
||||||
|
- path_regex: k8s/(.*secret.*|.*config.*|.*deployment.*\.ya?ml)
|
||||||
|
age:
|
||||||
|
- age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||||
|
- age1ryxmuwhecmdru786eqgek4cf8ppq585j2uqr7e87phya42w9s5wscn6tgp
|
||||||
|
encrypted_regex: '^data|^stringData' # Only encrypt data fields, keep structure readable
|
||||||
|
|
||||||
|
# Fallback rule for .enc.yaml files
|
||||||
|
- path_regex: '.*\.enc\.ya?ml'
|
||||||
|
age:
|
||||||
|
- age1e5fq3hwxy78psus2nfvmtmua36g0u3suk78ephw6246l974d2utsvn0hla
|
||||||
|
- age1ryxmuwhecmdru786eqgek4cf8ppq585j2uqr7e87phya42w9s5wscn6tgp
|
||||||
|
encrypted_regex: '^data|^stringData'
|
||||||
|
|
||||||
|
# To encrypt a file locally:
|
||||||
|
# sops --encrypt k8s/configmap.yaml > k8s/configmap.yaml
|
||||||
|
#
|
||||||
|
# To decrypt and view:
|
||||||
|
# sops k8s/configmap.yaml
|
||||||
|
#
|
||||||
|
# To decrypt to stdout:
|
||||||
|
# sops --decrypt k8s/configmap.yaml
|
||||||
|
#
|
||||||
|
# The private age keys are stored in the cluster at:
|
||||||
|
# kubectl -n argocd get secret sops-age -o jsonpath='{.data.key\.txt}' | base64 -d
|
||||||
|
#
|
||||||
|
# Key rotation: Multiple public keys can coexist for decryption
|
||||||
|
# Only private keys MUST be kept secret (in cluster only)
|
||||||
+5
-1
@@ -7,7 +7,7 @@
|
|||||||
# --platform=$BUILDPLATFORM pins the build stage to the machine doing the
|
# --platform=$BUILDPLATFORM pins the build stage to the machine doing the
|
||||||
# building, then Go cross-compiles to $TARGETARCH. Without it, building an
|
# building, then Go cross-compiles to $TARGETARCH. Without it, building an
|
||||||
# amd64 image from an arm64 workstation runs the whole toolchain under QEMU.
|
# 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
|
WORKDIR /src
|
||||||
|
|
||||||
@@ -46,6 +46,10 @@ FROM gcr.io/distroless/static-debian12:nonroot
|
|||||||
# securityContext; if one changes, both must.
|
# securityContext; if one changes, both must.
|
||||||
USER 65532:65532
|
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
|
COPY --from=build /out/gateway /gateway
|
||||||
|
|
||||||
EXPOSE 8080
|
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,322 @@
|
|||||||
# homelab-frontend
|
# homelab-frontend
|
||||||
|
|
||||||
A Go API gateway for the homelab cluster. One capability per subdomain, one auth
|
Production API gateway for the homelab cluster. Single entry point (`api.riotpiao.com`) for all services: LLM inference, workflows, queues, memory, and cluster operations.
|
||||||
implementation, one routing table.
|
|
||||||
|
|
||||||
Replaces Kong OSS entirely — see
|
**Status:** Live in production. Replaced Kong OSS entirely.
|
||||||
[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.
|
|
||||||
|
|
||||||
## 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)
|
┌─────────────┬──────────────┬────────────┐
|
||||||
│
|
│ Browser │ SDK │ CLI │
|
||||||
▼
|
└──────┬──────┴──────┬───────┴────┬───────┘
|
||||||
┌─────────────────────────────┐
|
│ │ │
|
||||||
│ homelab-frontend │
|
└─────────────┼────────────┘
|
||||||
│ routing · authn · budgets │
|
│
|
||||||
└──────────────┬──────────────┘
|
HTTPS/TLS
|
||||||
│
|
│
|
||||||
/v1 /sqs /workflow /cluster
|
┌─────────────┼────────────┐
|
||||||
│ │ │ │
|
│ Cloudflare Edge │
|
||||||
▼ ▼ ▼ ▼
|
│ (DDoS, caching) │
|
||||||
llm-serving kmsvc/Kafka temporal atlas
|
└──────────┬──────────────┘
|
||||||
(predictors) (sqs ns) (temporal ns) (riotpiao-backend)
|
│
|
||||||
|
ingress-nginx
|
||||||
└──── in-cluster Services ────┘
|
(SSL termination)
|
||||||
|
│
|
||||||
|
┌─────────────────────────────┐
|
||||||
|
│ homelab-frontend Gateway │
|
||||||
|
│ (routing, auth, limits) │
|
||||||
|
└──────┬───────────────────────┘
|
||||||
|
│
|
||||||
|
┌──────┴───────────────────────────────────┐
|
||||||
|
│ │
|
||||||
|
/v1/* X-Service header routing /
|
||||||
|
(LLM) (workflow, sqs, s3, iam, memory) /
|
||||||
|
│ │ │
|
||||||
|
▼ ▼ ▼
|
||||||
|
llm-serving temporal:7233 kmsvc/Kafka, MinIO,
|
||||||
|
(vLLM, Ollama) (gRPC) Authentik, poimen-memory
|
||||||
|
(TEI)
|
||||||
```
|
```
|
||||||
|
|
||||||
ingress-nginx keeps TLS and the edge. The gateway owns everything after it.
|
**Design principles:**
|
||||||
|
- ✅ Single hostname, unified X-Service + X-Resource header routing
|
||||||
|
- ✅ HTTP REST gateway → gRPC Temporal bridge (via X-Service: workflow)
|
||||||
|
- ✅ Bearer token auth via Authentik (JWT + RBAC)
|
||||||
|
- ✅ Streaming unbuffered (SSE, WebSocket, HTTP/2 multiplexing)
|
||||||
|
- ✅ 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 | Method | Upstream | Status |
|
||||||
|
|---------|--------|----------|--------|
|
||||||
|
| **LLM Chat** | `POST /v1/chat/completions` | llm-serving (vLLM) | ✅ Live |
|
||||||
|
| **Embeddings** | `POST /v1/embeddings` | llm-serving (TEI) | ✅ Live |
|
||||||
|
| **Reranking** | `POST /v1/rerank` | llm-serving (TEI) | ✅ Live |
|
||||||
|
| **Workflows** | `X-Service: workflow` + `X-Resource: {action}` | Temporal gRPC (7233) | ✅ Live (START, DESCRIBE, SIGNAL, QUERY, etc) |
|
||||||
|
| **Queues** | `X-Service: sqs` + `X-Resource: {action}` | kmsvc/Kafka | ✅ Live |
|
||||||
|
| **Memory** | `X-Service: memory` + `X-Resource: {action}` | poimen-memory | ✅ Live |
|
||||||
|
| **IAM** | `X-Service: iam` + `X-Resource: {action}` | Authentik API | ✅ Live |
|
||||||
|
| **S3** | `X-Service: s3` + `X-Resource: {action}` | 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
|
## How to Use
|
||||||
`/v1/chat/completions` at the base URL, so that prefix cannot be repurposed.
|
|
||||||
|
|
||||||
Paths rather than subdomains: one DNS record, one tunnel hostname, one Ingress.
|
### 1. Get a token
|
||||||
Promoting a prefix to its own subdomain later is additive and can run alongside the
|
|
||||||
path — the reverse is not, because clients hardcode hostnames.
|
|
||||||
|
|
||||||
atlas lives in its own repo (`riotpiao-backend`) and keeps its own informers and
|
**Human (OIDC device code):**
|
||||||
RBAC. The gateway routes to it; it does not absorb it. Cluster-read permissions
|
```bash
|
||||||
stay out of the public edge process.
|
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
|
### 2. Call any service
|
||||||
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.
|
|
||||||
|
|
||||||
## 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 (via X-Service header):**
|
||||||
|
```bash
|
||||||
|
curl -X POST https://api.riotpiao.com/ \
|
||||||
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
|
-H "X-Service: workflow" \
|
||||||
|
-H "X-Resource: start" \
|
||||||
|
-d '{
|
||||||
|
"namespace": "default",
|
||||||
|
"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/
|
internal/
|
||||||
auth/ Authentik OIDC, JWKS cache, service-account tokens
|
server/ Router, health checks
|
||||||
llm/ model registry, body-based dispatch, upstream map
|
proxy/ Reverse proxy, streaming, timeouts
|
||||||
queue/ sqs.riotpiao.com surface
|
temporal/ Workflow handler + gRPC bridge
|
||||||
workflow/ workflow.riotpiao.com surface
|
serviceadapter/ X-Service dispatcher (CRD-driven)
|
||||||
proxy/ reverse proxy, streaming, timeouts, disconnect propagation
|
config/ Route + upstream configuration
|
||||||
config/ upstream + route configuration
|
auth/ Authentik JWT validation
|
||||||
observability/ Prometheus metrics, structured logging
|
observability/ Metrics, structured logging
|
||||||
deploy/
|
k8s/
|
||||||
base/ Kubernetes manifests
|
configmap.yaml Route definitions
|
||||||
argocd/ Argo Application
|
rbac.yaml Service account, roles
|
||||||
docs/adr/ architecture decision records
|
deployment.yaml Pod spec
|
||||||
tasks/ task board — see tasks/INDEX.md
|
networkpolicy.yaml Ingress/egress rules
|
||||||
testdata/ fixtures for offline tests
|
testdata/ Fixtures for offline tests
|
||||||
```
|
```
|
||||||
|
|
||||||
## Local development
|
---
|
||||||
|
|
||||||
The gateway must be runnable with no cluster, no kubeconfig and no credentials, so
|
## Deployment
|
||||||
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).
|
|
||||||
|
|
||||||
## Status
|
Deployed to Kubernetes via ArgoCD:
|
||||||
|
|
||||||
Scaffolded 2026-08-19. Nothing is wired yet. Kong is still serving live traffic on
|
```bash
|
||||||
`api.riotpiao.com`.
|
# 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.
|
||||||
@@ -1,560 +0,0 @@
|
|||||||
# API Testing Guide
|
|
||||||
|
|
||||||
Quick reference for testing the homelab-frontend gateway API.
|
|
||||||
|
|
||||||
## Setup
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Set base URL
|
|
||||||
export GATEWAY="https://api.riotpiao.com"
|
|
||||||
|
|
||||||
# Or for local testing
|
|
||||||
export GATEWAY="http://localhost:8080"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Quick Tests (Copy & Paste)
|
|
||||||
|
|
||||||
### 1. Health Checks ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Liveness
|
|
||||||
curl $GATEWAY/healthz | jq .
|
|
||||||
|
|
||||||
# Readiness
|
|
||||||
curl $GATEWAY/readyz | jq .
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: Both return `{"status":"..."}` with HTTP 200
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 2. List Models ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl $GATEWAY/v1/models | jq '.data[] | .id'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected Output**:
|
|
||||||
```
|
|
||||||
"reasoning"
|
|
||||||
"ornith:35b"
|
|
||||||
"qwen2.5:3b-instruct"
|
|
||||||
"nomic-ai/nomic-embed-text-v2-moe"
|
|
||||||
"BAAI/bge-reranker-base"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 3. Chat - Basic ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "reasoning",
|
|
||||||
"messages": [
|
|
||||||
{"role": "user", "content": "What is 2+2?"}
|
|
||||||
]
|
|
||||||
}' | jq '.choices[0].message.content'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: Model responds with an answer
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 4. Chat - Ornith Model ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "ornith:35b",
|
|
||||||
"messages": [
|
|
||||||
{"role": "user", "content": "Hello"}
|
|
||||||
]
|
|
||||||
}' | jq '.choices[0].message.content'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: Routes to ornith model, returns response
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 5. Chat - Qwen Model ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "qwen2.5:3b-instruct",
|
|
||||||
"messages": [
|
|
||||||
{"role": "user", "content": "Hi"}
|
|
||||||
]
|
|
||||||
}' | jq '.choices[0].message.content'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: Routes to qwen model, returns response
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 6. Chat - Unknown Model (Should Error) ❌→✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "gpt-4-turbo",
|
|
||||||
"messages": []
|
|
||||||
}' | jq '.'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: HTTP 400 with problem+json:
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"type": "https://api.example.com/problems/unknown-model",
|
|
||||||
"title": "Unknown Model",
|
|
||||||
"status": 400,
|
|
||||||
"detail": "Model \"gpt-4-turbo\" is not available. See valid_models for available options.",
|
|
||||||
"valid_models": ["reasoning", "ornith:35b", ...]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 7. Chat - Missing Model (Should Error) ❌→✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"messages": [{"role": "user", "content": "test"}]
|
|
||||||
}' | jq '.'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: HTTP 400 with problem+json (missing model)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 8. Chat - Streaming ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -N -X POST $GATEWAY/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "reasoning",
|
|
||||||
"messages": [{"role": "user", "content": "count to 3"}],
|
|
||||||
"stream": true
|
|
||||||
}' | head -20
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**:
|
|
||||||
- Multiple `data: {...}` lines (SSE chunks)
|
|
||||||
- Final `data: [DONE]`
|
|
||||||
- Chunks arrive incrementally (observable with `-N` flag)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 9. Chat - Tool Calling ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "reasoning",
|
|
||||||
"messages": [
|
|
||||||
{"role": "user", "content": "What is the weather in SF?"}
|
|
||||||
],
|
|
||||||
"tools": [
|
|
||||||
{
|
|
||||||
"type": "function",
|
|
||||||
"function": {
|
|
||||||
"name": "get_weather",
|
|
||||||
"description": "Get weather for a location",
|
|
||||||
"parameters": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"location": {"type": "string"}
|
|
||||||
},
|
|
||||||
"required": ["location"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}' | jq '.choices[0].message.tool_calls'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: Array of tool calls (if model decides to call them), or null (if not)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 10. Embeddings ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/embeddings \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "nomic-ai/nomic-embed-text-v2-moe",
|
|
||||||
"input": "hello world"
|
|
||||||
}' | jq '.data | length'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: `1` (one embedding vector)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 11. Embeddings - Multiple ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/embeddings \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "nomic-ai/nomic-embed-text-v2-moe",
|
|
||||||
"input": ["text 1", "text 2", "text 3"]
|
|
||||||
}' | jq '.data | length'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: `3` (three embedding vectors)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 12. Embeddings - Unknown Model (Should Error) ❌→✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/embeddings \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "unknown-embed",
|
|
||||||
"input": "test"
|
|
||||||
}' | jq '.status'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: `400` (client error)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 13. Rerank ✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/rerank \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "BAAI/bge-reranker-base",
|
|
||||||
"query": "machine learning",
|
|
||||||
"texts": [
|
|
||||||
"Machine learning is AI",
|
|
||||||
"Python is a language",
|
|
||||||
"Deep learning is ML"
|
|
||||||
]
|
|
||||||
}' | jq '.results'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: Array of ranked results with scores:
|
|
||||||
```json
|
|
||||||
[
|
|
||||||
{"index": 0, "score": 0.95},
|
|
||||||
{"index": 2, "score": 0.85},
|
|
||||||
{"index": 1, "score": 0.15}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 14. Rerank - Unknown Model (Should Error) ❌→✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/rerank \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "unknown-rerank",
|
|
||||||
"query": "test",
|
|
||||||
"texts": ["a"]
|
|
||||||
}' | jq '.status'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: `400` (client error)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 15. Invalid JSON (Should Error) ❌→✅
|
|
||||||
|
|
||||||
```bash
|
|
||||||
curl -X POST $GATEWAY/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d 'not json' | jq '.title'
|
|
||||||
```
|
|
||||||
|
|
||||||
**Expected**: `"Invalid Request Body"` (HTTP 400)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Testing Checklist
|
|
||||||
|
|
||||||
Complete this checklist to verify all endpoints:
|
|
||||||
|
|
||||||
### Health Endpoints
|
|
||||||
- [ ] GET /healthz → 200, `{"status":"alive"}`
|
|
||||||
- [ ] GET /readyz → 200, `{"status":"ready"}`
|
|
||||||
|
|
||||||
### Model Discovery
|
|
||||||
- [ ] GET /v1/models → 200, returns all 5 models
|
|
||||||
- [ ] All advertised models can be called (none 400)
|
|
||||||
|
|
||||||
### Chat Completions
|
|
||||||
- [ ] POST /v1/chat/completions (reasoning) → 200, response
|
|
||||||
- [ ] POST /v1/chat/completions (ornith:35b) → 200, response
|
|
||||||
- [ ] POST /v1/chat/completions (qwen2.5:3b-instruct) → 200, response
|
|
||||||
- [ ] POST /v1/chat/completions (unknown model) → 400, problem+json
|
|
||||||
- [ ] POST /v1/chat/completions (missing model) → 400, problem+json
|
|
||||||
- [ ] POST /v1/chat/completions (invalid JSON) → 400, problem+json
|
|
||||||
- [ ] POST /v1/chat/completions (streaming) → 200, SSE chunks
|
|
||||||
- [ ] POST /v1/chat/completions (with tools) → 200, tool_calls present/absent
|
|
||||||
|
|
||||||
### Embeddings
|
|
||||||
- [ ] POST /v1/embeddings (single input) → 200, embedding
|
|
||||||
- [ ] POST /v1/embeddings (multiple inputs) → 200, embeddings array
|
|
||||||
- [ ] POST /v1/embeddings (unknown model) → 400, problem+json
|
|
||||||
|
|
||||||
### Reranking
|
|
||||||
- [ ] POST /v1/rerank → 200, ranked results
|
|
||||||
- [ ] POST /v1/rerank (unknown model) → 400, problem+json
|
|
||||||
- [ ] Verify path is rewritten to /rerank on upstream
|
|
||||||
|
|
||||||
### Error Handling
|
|
||||||
- [ ] Unknown model lists valid_models
|
|
||||||
- [ ] Error responses are problem+json
|
|
||||||
- [ ] No 5xx for client errors (validation errors)
|
|
||||||
- [ ] Upstream errors pass through
|
|
||||||
|
|
||||||
### Streaming
|
|
||||||
- [ ] Chunks arrive incrementally
|
|
||||||
- [ ] Final `[DONE]` sentinel present
|
|
||||||
- [ ] Works for chat completions
|
|
||||||
|
|
||||||
### Tool Calling
|
|
||||||
- [ ] Tool definitions forward to upstream
|
|
||||||
- [ ] Tool calls in response
|
|
||||||
- [ ] Multi-turn with tool results
|
|
||||||
- [ ] Parallel tool calls
|
|
||||||
- [ ] Complex nested arguments preserved
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### 404 Responses
|
|
||||||
|
|
||||||
**Symptom**: All endpoints return `"not found"`
|
|
||||||
|
|
||||||
**Cause**: ConfigMap with models/routes not deployed
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
```bash
|
|
||||||
kubectl -n api create configmap homelab-frontend-config \
|
|
||||||
--from-file=config.yaml=k8s/configmap.yaml
|
|
||||||
kubectl -n api rollout restart deployment/homelab-frontend
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### 503 (Not Ready)
|
|
||||||
|
|
||||||
**Symptom**: `/readyz` returns 503
|
|
||||||
|
|
||||||
**Cause**: Configuration not loaded or JWKS fetch failed
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
```bash
|
|
||||||
# Check logs
|
|
||||||
kubectl -n api logs deployment/homelab-frontend
|
|
||||||
|
|
||||||
# Check config
|
|
||||||
kubectl -n api get configmap homelab-frontend-config
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Connection Refused
|
|
||||||
|
|
||||||
**Symptom**: `Connection refused` or `Temporary failure in name resolution`
|
|
||||||
|
|
||||||
**Cause**:
|
|
||||||
- Gateway not running
|
|
||||||
- Wrong URL/hostname
|
|
||||||
- Network issue
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
```bash
|
|
||||||
# Verify gateway is running
|
|
||||||
kubectl -n api get pods -l app=homelab-frontend
|
|
||||||
|
|
||||||
# Check service
|
|
||||||
kubectl -n api get svc homelab-frontend
|
|
||||||
|
|
||||||
# Verify ingress
|
|
||||||
kubectl -n api get ingress api
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Upstream Connection Errors
|
|
||||||
|
|
||||||
**Symptom**: `502 Bad Gateway` or `connection refused to upstream`
|
|
||||||
|
|
||||||
**Cause**: Model upstream service not reachable
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
```bash
|
|
||||||
# Check upstreams are running
|
|
||||||
kubectl -n llm-serving get pods
|
|
||||||
|
|
||||||
# Verify addresses in ConfigMap
|
|
||||||
kubectl -n api get configmap homelab-frontend-config -o yaml
|
|
||||||
|
|
||||||
# Test connectivity from gateway pod
|
|
||||||
kubectl -n api exec deployment/homelab-frontend -- \
|
|
||||||
curl -s reasoning-predictor.llm-serving:80/healthz
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Streaming Doesn't Work
|
|
||||||
|
|
||||||
**Symptom**: Chunks arrive all at once (buffered) instead of incrementally
|
|
||||||
|
|
||||||
**Cause**: nginx buffering or client not using `-N` flag
|
|
||||||
|
|
||||||
**Solution**:
|
|
||||||
```bash
|
|
||||||
# Use -N flag
|
|
||||||
curl -N https://api.riotpiao.com/v1/chat/completions ...
|
|
||||||
|
|
||||||
# Verify nginx has buffering disabled
|
|
||||||
# Should have: proxy-buffering: off in Ingress annotations
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Performance Testing
|
|
||||||
|
|
||||||
### Load Test (Simple)
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Send 10 requests in parallel
|
|
||||||
for i in {1..10}; do
|
|
||||||
curl -X POST $GATEWAY/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{"model":"reasoning","messages":[{"role":"user","content":"Hi"}]}' &
|
|
||||||
done
|
|
||||||
wait
|
|
||||||
|
|
||||||
echo "Completed 10 requests"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Concurrency Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Use Apache Bench (if installed)
|
|
||||||
ab -n 100 -c 10 \
|
|
||||||
-p request.json \
|
|
||||||
-T application/json \
|
|
||||||
$GATEWAY/v1/chat/completions
|
|
||||||
|
|
||||||
# Create request.json:
|
|
||||||
# {"model":"reasoning","messages":[{"role":"user","content":"test"}]}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Latency Test
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Measure response time
|
|
||||||
curl -w "\nTotal time: %{time_total}s\n" \
|
|
||||||
-X POST $GATEWAY/v1/chat/completions \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"model": "reasoning",
|
|
||||||
"messages": [{"role": "user", "content": "What is AI?"}]
|
|
||||||
}' > /dev/null
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Integration Testing
|
|
||||||
|
|
||||||
### Test with Python
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pip install requests
|
|
||||||
|
|
||||||
cat > test_api.py << 'EOF'
|
|
||||||
import requests
|
|
||||||
import json
|
|
||||||
|
|
||||||
gateway = "https://api.riotpiao.com"
|
|
||||||
|
|
||||||
# Test health
|
|
||||||
r = requests.get(f"{gateway}/healthz")
|
|
||||||
assert r.status_code == 200
|
|
||||||
print("✓ Health check passed")
|
|
||||||
|
|
||||||
# Test models
|
|
||||||
r = requests.get(f"{gateway}/v1/models")
|
|
||||||
assert r.status_code == 200
|
|
||||||
models = [m['id'] for m in r.json()['data']]
|
|
||||||
print(f"✓ Models: {models}")
|
|
||||||
|
|
||||||
# Test chat
|
|
||||||
r = requests.post(
|
|
||||||
f"{gateway}/v1/chat/completions",
|
|
||||||
json={"model": "reasoning", "messages": [{"role": "user", "content": "Hi"}]}
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
print("✓ Chat works")
|
|
||||||
|
|
||||||
# Test unknown model error
|
|
||||||
r = requests.post(
|
|
||||||
f"{gateway}/v1/chat/completions",
|
|
||||||
json={"model": "gpt-4", "messages": []}
|
|
||||||
)
|
|
||||||
assert r.status_code == 400
|
|
||||||
assert "unknown" in r.json()['detail'].lower()
|
|
||||||
print("✓ Unknown model error correct")
|
|
||||||
|
|
||||||
# Test embeddings
|
|
||||||
r = requests.post(
|
|
||||||
f"{gateway}/v1/embeddings",
|
|
||||||
json={"model": "nomic-ai/nomic-embed-text-v2-moe", "input": "test"}
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
print("✓ Embeddings work")
|
|
||||||
|
|
||||||
# Test rerank
|
|
||||||
r = requests.post(
|
|
||||||
f"{gateway}/v1/rerank",
|
|
||||||
json={"model": "BAAI/bge-reranker-base", "query": "test", "texts": ["a", "b"]}
|
|
||||||
)
|
|
||||||
assert r.status_code == 200
|
|
||||||
print("✓ Reranking works")
|
|
||||||
|
|
||||||
print("\n✅ All tests passed!")
|
|
||||||
EOF
|
|
||||||
|
|
||||||
python test_api.py
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Summary
|
|
||||||
|
|
||||||
| Category | Tests | Expected |
|
|
||||||
|----------|-------|----------|
|
|
||||||
| Health | 2 | ✅ Both 200 |
|
|
||||||
| Models | 1 | ✅ 5 models listed |
|
|
||||||
| Chat | 8 | ✅ 6 success + 2 error |
|
|
||||||
| Embeddings | 3 | ✅ 2 success + 1 error |
|
|
||||||
| Rerank | 2 | ✅ 1 success + 1 error |
|
|
||||||
| Streaming | 1 | ✅ Incremental chunks |
|
|
||||||
| Tools | 1 | ✅ Tool calls present |
|
|
||||||
| **TOTAL** | **18+** | **✅ ALL PASS** |
|
|
||||||
|
|
||||||
Once all tests pass, the gateway is production-ready! 🚀
|
|
||||||
+169
-6
@@ -4,17 +4,47 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
|
"math"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"runtime"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
|
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/proxy"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/notification"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
||||||
|
"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() {
|
func main() {
|
||||||
|
// Size the Go scheduler to the container's CPU quota before any server
|
||||||
|
// goroutine starts.
|
||||||
|
configureGOMAXPROCS()
|
||||||
|
|
||||||
|
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
|
// Load configuration
|
||||||
cfg, err := config.Load()
|
cfg, err := config.Load()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -34,6 +64,15 @@ func main() {
|
|||||||
// Create the reverse proxy handler that routes requests based on configuration
|
// Create the reverse proxy handler that routes requests based on configuration
|
||||||
upstreamHandler := proxy.New(cfg)
|
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
|
// Create server with health checker
|
||||||
srv := server.New(cfg.ListenAddr, cfg.ShutdownTimeout, nil)
|
srv := server.New(cfg.ListenAddr, cfg.ShutdownTimeout, nil)
|
||||||
|
|
||||||
@@ -41,9 +80,83 @@ func main() {
|
|||||||
healthChecker := server.NewHealthChecker(true, authEnabled)
|
healthChecker := server.NewHealthChecker(true, authEnabled)
|
||||||
srv.SetHealthChecker(healthChecker)
|
srv.SetHealthChecker(healthChecker)
|
||||||
|
|
||||||
// Create router that handles health endpoints and passes others to upstream
|
// Create ServiceAdapter registry and dispatcher (phase 8)
|
||||||
router := server.NewRouter(healthChecker, upstreamHandler)
|
registry := serviceadapter.NewRegistry(nil)
|
||||||
srv.SetHandler(router)
|
|
||||||
|
// Add workflow service adapter (uses Temporal handler for gRPC forwarding)
|
||||||
|
workflowSpec := serviceadapter.GetWorkflowSpec()
|
||||||
|
workflowAdapterHandler := serviceadapter.NewWorkflowAdapter(temporalHandler)
|
||||||
|
workflowAdapter := &serviceadapter.ServiceAdapter{
|
||||||
|
Namespace: "temporal",
|
||||||
|
ServiceName: "workflow",
|
||||||
|
Handler: workflowAdapterHandler,
|
||||||
|
Spec: *workflowSpec,
|
||||||
|
}
|
||||||
|
_ = registry.Add(workflowAdapter)
|
||||||
|
|
||||||
|
// Add notification service adapter (internal handler, no upstream proxy)
|
||||||
|
notifHandler := notification.NewHandler()
|
||||||
|
notifAdapter := &serviceadapter.ServiceAdapter{
|
||||||
|
Namespace: "notification",
|
||||||
|
ServiceName: "notification",
|
||||||
|
Handler: notifHandler,
|
||||||
|
Spec: serviceadapter.Spec{
|
||||||
|
ServiceName: "notification",
|
||||||
|
Auth: serviceadapter.Auth{Required: true},
|
||||||
|
Resources: []serviceadapter.Resource{
|
||||||
|
{Name: "send-email", Methods: []serviceadapter.Method{{Verb: "POST", UpstreamPath: "/send-email"}}},
|
||||||
|
{Name: "send-message", Methods: []serviceadapter.Method{{Verb: "POST", UpstreamPath: "/send-message"}}},
|
||||||
|
{Name: "list-messages", Methods: []serviceadapter.Method{{Verb: "GET", UpstreamPath: "/list-messages"}}},
|
||||||
|
{Name: "delete-message", Methods: []serviceadapter.Method{{Verb: "DELETE", UpstreamPath: "/delete-message"}}},
|
||||||
|
{Name: "delete-all-messages", Methods: []serviceadapter.Method{{Verb: "DELETE", UpstreamPath: "/delete-all-messages"}}},
|
||||||
|
{Name: "list-applications", Methods: []serviceadapter.Method{{Verb: "GET", UpstreamPath: "/list-applications"}}},
|
||||||
|
{Name: "create-application", Methods: []serviceadapter.Method{{Verb: "POST", UpstreamPath: "/create-application"}}},
|
||||||
|
{Name: "delete-application", Methods: []serviceadapter.Method{{Verb: "DELETE", UpstreamPath: "/delete-application"}}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_ = registry.Add(notifAdapter)
|
||||||
|
|
||||||
|
// Add Poimen orchestrator adapter (typed REST for AI orchestrator frontend)
|
||||||
|
poimenAdapter := serviceadapter.NewPoimenAdapter(temporalHandler)
|
||||||
|
_ = registry.Add(poimenAdapter)
|
||||||
|
|
||||||
|
// Add memory service adapter (code-registered; config memory entry is skipped).
|
||||||
|
// Includes standing-context resource for CAG support (issue #44).
|
||||||
|
_ = registry.Add(serviceadapter.NewMemoryAdapter())
|
||||||
|
|
||||||
|
// Add sessions service adapter — REST CRUD bridged to poimen-memory ingest
|
||||||
|
// and poimen-graphql reads. Issue #50.
|
||||||
|
_ = registry.Add(serviceadapter.NewSessionAdapter())
|
||||||
|
|
||||||
|
// Add other adapters from config (skip if already registered in code)
|
||||||
|
for _, a := range cfg.Adapters {
|
||||||
|
if existing := registry.Get(a.ServiceName); existing != nil {
|
||||||
|
log.Printf("skip config adapter '%s': already registered with internal handler", a.ServiceName)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
_ = registry.Add(a)
|
||||||
|
}
|
||||||
|
log.Printf("%d service adapters loaded", registry.Count())
|
||||||
|
|
||||||
|
// Create shared JWT validator for X-Service auth enforcement
|
||||||
|
var jwtValidator *auth.Validator
|
||||||
|
if cfg.Auth.Enabled && cfg.Auth.JWKSURL != "" {
|
||||||
|
jwtValidator = auth.NewValidator(cfg.Auth.Issuer, cfg.Auth.Audience, cfg.Auth.JWKSURL)
|
||||||
|
}
|
||||||
|
dispatcher := serviceadapter.NewDispatcher(registry, jwtValidator)
|
||||||
|
|
||||||
|
// Wire workflow adapter to temporal handler for proper request forwarding
|
||||||
|
// workflowAdapterHandler (above) handles JSON-to-gRPC translation for workflow service
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
router.SetMetricsHandler(observability.NewMetricsHandler(observability.Global()))
|
||||||
|
|
||||||
|
// Wrap router with tracing middleware
|
||||||
|
tracedRouter := tracing.Middleware(router)
|
||||||
|
srv.SetHandler(tracedRouter)
|
||||||
|
|
||||||
// Set up signal handling
|
// Set up signal handling
|
||||||
sigChan := make(chan os.Signal, 1)
|
sigChan := make(chan os.Signal, 1)
|
||||||
@@ -72,3 +185,53 @@ func main() {
|
|||||||
log.Printf("gateway shutdown complete")
|
log.Printf("gateway shutdown complete")
|
||||||
os.Exit(0)
|
os.Exit(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// configureGOMAXPROCS sets GOMAXPROCS from the cgroup CPU quota so the runtime
|
||||||
|
// sizes its scheduler to the container limit rather than the host CPU count.
|
||||||
|
// The default is left alone when no quota is configured.
|
||||||
|
func configureGOMAXPROCS() {
|
||||||
|
quota, period := readCPUQuota()
|
||||||
|
if quota <= 0 || period <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
n := int(math.Ceil(float64(quota) / float64(period)))
|
||||||
|
if n < 1 {
|
||||||
|
n = 1
|
||||||
|
}
|
||||||
|
runtime.GOMAXPROCS(n)
|
||||||
|
log.Printf("GOMAXPROCS set to %d (cgroup cpu quota %d/%d)", n, quota, period)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readCPUQuota returns the CPU quota and period from cgroup v2, falling back to
|
||||||
|
// cgroup v1. A quota of 0 means no limit.
|
||||||
|
func readCPUQuota() (int64, int64) {
|
||||||
|
// cgroup v2: "<quota> <period>" or "max <period>".
|
||||||
|
if data, err := os.ReadFile("/sys/fs/cgroup/cpu.max"); err == nil {
|
||||||
|
fields := strings.Fields(string(data))
|
||||||
|
if len(fields) >= 2 {
|
||||||
|
period, perr := strconv.ParseInt(fields[1], 10, 64)
|
||||||
|
if perr == nil && period > 0 {
|
||||||
|
if fields[0] == "max" {
|
||||||
|
return 0, period
|
||||||
|
}
|
||||||
|
if quota, qerr := strconv.ParseInt(fields[0], 10, 64); qerr == nil {
|
||||||
|
return quota, period
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// cgroup v1: cpu.cfs_quota_us is -1 when unlimited.
|
||||||
|
period := int64(100000)
|
||||||
|
if data, err := os.ReadFile("/sys/fs/cgroup/cpu/cpu.cfs_period_us"); err == nil {
|
||||||
|
if v, perr := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64); perr == nil && v > 0 {
|
||||||
|
period = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile("/sys/fs/cgroup/cpu/cpu.cfs_quota_us"); err == nil {
|
||||||
|
if v, qerr := strconv.ParseInt(strings.TrimSpace(string(data)), 10, 64); qerr == nil {
|
||||||
|
return v, period
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,62 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Example: Gotify CRUD operations via notification service (X-Service routing)
|
||||||
|
|
||||||
|
BASE_URL="${1:-https://api.riotpiao.com}"
|
||||||
|
AUTH_TOKEN="${2:-}"
|
||||||
|
AUTH="-H \"Authorization: Bearer $AUTH_TOKEN\""
|
||||||
|
|
||||||
|
echo "=== Send Gotify Message ==="
|
||||||
|
curl -s -X POST "$BASE_URL" \
|
||||||
|
-H "X-Service: notification" \
|
||||||
|
-H "X-Resource: send-message" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"title": "Deployment Complete",
|
||||||
|
"message": "homelab-frontend v1.2.0 deployed to production",
|
||||||
|
"priority": 5
|
||||||
|
}' | jq .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== List Messages ==="
|
||||||
|
curl -s -X GET "$BASE_URL?limit=10" \
|
||||||
|
-H "X-Service: notification" \
|
||||||
|
-H "X-Resource: list-messages" \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" | jq .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== List Applications ==="
|
||||||
|
curl -s -X GET "$BASE_URL" \
|
||||||
|
-H "X-Service: notification" \
|
||||||
|
-H "X-Resource: list-applications" \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" | jq .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Create Application ==="
|
||||||
|
curl -s -X POST "$BASE_URL" \
|
||||||
|
-H "X-Service: notification" \
|
||||||
|
-H "X-Resource: create-application" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"name": "my-monitor",
|
||||||
|
"description": "Monitoring alerts"
|
||||||
|
}' | jq .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Delete Message (by ID) ==="
|
||||||
|
curl -s -X DELETE "$BASE_URL" \
|
||||||
|
-H "X-Service: notification" \
|
||||||
|
-H "X-Resource: delete-message" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||||
|
-d '{"id": 1}' | jq .
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "=== Delete Application (by ID) ==="
|
||||||
|
curl -s -X DELETE "$BASE_URL" \
|
||||||
|
-H "X-Service: notification" \
|
||||||
|
-H "X-Resource: delete-application" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||||
|
-d '{"id": 1}' | jq .
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Example: Send email via notification service (X-Service routing)
|
||||||
|
|
||||||
|
BASE_URL="${1:-https://api.riotpiao.com}"
|
||||||
|
AUTH_TOKEN="${2:-}"
|
||||||
|
|
||||||
|
# Send email
|
||||||
|
curl -s -X POST "$BASE_URL" \
|
||||||
|
-H "X-Service: notification" \
|
||||||
|
-H "X-Resource: send-email" \
|
||||||
|
-H "Content-Type: application/json" \
|
||||||
|
-H "Authorization: Bearer $AUTH_TOKEN" \
|
||||||
|
-d '{
|
||||||
|
"to": "[email protected]",
|
||||||
|
"cc": "[email protected]",
|
||||||
|
"subject": "System Alert",
|
||||||
|
"body": "CPU usage exceeded 90% threshold"
|
||||||
|
}' | jq .
|
||||||
|
|
||||||
|
echo ""
|
||||||
@@ -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 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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
|||||||
@@ -0,0 +1,198 @@
|
|||||||
|
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.
|
||||||
|
// Supports multi-issuer: any Authentik service account provider is accepted
|
||||||
|
// (portfolio-agent, memory-agent, api-gw, etc.) because all share the same
|
||||||
|
// JWKS signing key.
|
||||||
|
type Validator struct {
|
||||||
|
issuer string // Not used for validation (kept for logging); issuer regex check is sufficient
|
||||||
|
audience string // Not used for validation; any audience from valid Authentik issuer is accepted
|
||||||
|
jwksURL string
|
||||||
|
jwks *keyfunc.JWKS
|
||||||
|
mu sync.Mutex
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidator creates a new JWT validator for a service.
|
||||||
|
// issuer and audience params are deprecated (ignored for validation) but kept
|
||||||
|
// for backward compatibility. Multi-issuer validation via isValidIssuer() is used instead.
|
||||||
|
// JWKS fetching is lazy (deferred until first validation).
|
||||||
|
func NewValidator(issuer, audience, jwksURL string) *Validator {
|
||||||
|
return &Validator{
|
||||||
|
issuer: issuer, // deprecated param, kept for compat
|
||||||
|
audience: audience, // deprecated param, kept for compat
|
||||||
|
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) - accept any Authentik-provided audience
|
||||||
|
// since all Authentik service accounts use the same signing key.
|
||||||
|
// The issuer check above is sufficient to ensure JWT came from Authentik.
|
||||||
|
if aud, ok := claims["aud"].(string); !ok {
|
||||||
|
return nil, fmt.Errorf("missing audience claim")
|
||||||
|
} else if aud == "" {
|
||||||
|
return nil, fmt.Errorf("empty audience claim")
|
||||||
|
}
|
||||||
|
// Note: Not hardcoding expected audience. Any audience from a valid Authentik
|
||||||
|
// issuer is accepted, since all service accounts are under the same trust boundary.
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
package config_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLoadAuthConfig_TokenURLAndClientID(t *testing.T) {
|
||||||
|
yaml := `
|
||||||
|
routes: []
|
||||||
|
models: []
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
issuer: "https://authentik.example.com/application/o/api-gw/"
|
||||||
|
audience: "api-gw"
|
||||||
|
jwksUrl: "https://authentik.example.com/application/o/api-gw/jwks/"
|
||||||
|
requiredCapability: "llm:inference"
|
||||||
|
tokenUrl: "https://authentik.example.com/application/o/token/"
|
||||||
|
clientId: "api-gw"
|
||||||
|
`
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
if err := os.WriteFile(path, []byte(yaml), 0644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set env for client secret
|
||||||
|
t.Setenv("AUTH_CLIENT_SECRET", "test-secret-value")
|
||||||
|
|
||||||
|
_, _, _, auth, err := config.LoadRoutesAndModelsFromFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !auth.Enabled {
|
||||||
|
t.Error("auth should be enabled")
|
||||||
|
}
|
||||||
|
if auth.TokenURL != "https://authentik.example.com/application/o/token/" {
|
||||||
|
t.Errorf("tokenUrl = %q, want authentik token endpoint", auth.TokenURL)
|
||||||
|
}
|
||||||
|
if auth.ClientID != "api-gw" {
|
||||||
|
t.Errorf("clientId = %q, want api-gw", auth.ClientID)
|
||||||
|
}
|
||||||
|
if auth.ClientSecret != "test-secret-value" {
|
||||||
|
t.Errorf("clientSecret = %q, want test-secret-value", auth.ClientSecret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAuthConfig_ClientSecretFromEnvOnly(t *testing.T) {
|
||||||
|
yaml := `
|
||||||
|
routes: []
|
||||||
|
models: []
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
tokenUrl: "https://example.com/token/"
|
||||||
|
clientId: "test"
|
||||||
|
`
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
os.WriteFile(path, []byte(yaml), 0644)
|
||||||
|
|
||||||
|
// No AUTH_CLIENT_SECRET env set
|
||||||
|
t.Setenv("AUTH_CLIENT_SECRET", "")
|
||||||
|
|
||||||
|
_, _, _, auth, err := config.LoadRoutesAndModelsFromFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.ClientSecret != "" {
|
||||||
|
t.Errorf("clientSecret should be empty when env not set, got %q", auth.ClientSecret)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadAuthConfig_BackwardCompatible(t *testing.T) {
|
||||||
|
// Config without tokenUrl/clientId should still load (zero values)
|
||||||
|
yaml := `
|
||||||
|
routes: []
|
||||||
|
models: []
|
||||||
|
auth:
|
||||||
|
enabled: true
|
||||||
|
issuer: "https://example.com/"
|
||||||
|
jwksUrl: "https://example.com/jwks/"
|
||||||
|
requiredCapability: "llm:inference"
|
||||||
|
`
|
||||||
|
dir := t.TempDir()
|
||||||
|
path := filepath.Join(dir, "config.yaml")
|
||||||
|
os.WriteFile(path, []byte(yaml), 0644)
|
||||||
|
|
||||||
|
_, _, _, auth, err := config.LoadRoutesAndModelsFromFile(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if auth.TokenURL != "" {
|
||||||
|
t.Errorf("tokenUrl should be empty, got %q", auth.TokenURL)
|
||||||
|
}
|
||||||
|
if auth.ClientID != "" {
|
||||||
|
t.Errorf("clientId should be empty, got %q", auth.ClientID)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package config_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"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:
|
// TestLoadRoutesMissingAuthRequired tests that the auth-required flag is not a silent default:
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Config holds the gateway configuration.
|
// Config holds the gateway configuration.
|
||||||
@@ -18,6 +20,12 @@ type Config struct {
|
|||||||
// Models maps model names to their upstream configuration.
|
// Models maps model names to their upstream configuration.
|
||||||
// Multiple models can point to the same upstream address.
|
// Multiple models can point to the same upstream address.
|
||||||
Models map[string]*ModelUpstream
|
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
|
||||||
|
// Temporal holds Temporal server configuration.
|
||||||
|
Temporal TemporalConfig
|
||||||
}
|
}
|
||||||
|
|
||||||
// ModelUpstream holds upstream configuration for a specific model.
|
// ModelUpstream holds upstream configuration for a specific model.
|
||||||
@@ -25,9 +33,45 @@ type ModelUpstream struct {
|
|||||||
// Name is the model name clients send (e.g., "reasoning", "ornith:35b").
|
// Name is the model name clients send (e.g., "reasoning", "ornith:35b").
|
||||||
Name string
|
Name string
|
||||||
// Address is the upstream server address (host:port).
|
// Address is the upstream server address (host:port).
|
||||||
|
// When Addresses is set, this holds Addresses[0] for backward compatibility.
|
||||||
Address string
|
Address string
|
||||||
|
// Addresses holds all backend addresses for multi-backend cache-aware routing.
|
||||||
|
// Populated from the 'addresses' YAML field; backward-compat with single 'address'.
|
||||||
|
Addresses []string
|
||||||
// Path is the upstream path for this model (e.g., "/v1/chat/completions").
|
// Path is the upstream path for this model (e.g., "/v1/chat/completions").
|
||||||
Path string
|
Path string
|
||||||
|
// UpstreamModel is the model name to send to the upstream server.
|
||||||
|
// If empty, the client-provided model name (Name) is used as-is.
|
||||||
|
// Use this when the upstream expects a different model name than clients send.
|
||||||
|
UpstreamModel string
|
||||||
|
// AuthRequired indicates whether this model requires JWT authentication.
|
||||||
|
AuthRequired bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// TemporalConfig holds Temporal server configuration.
|
||||||
|
type TemporalConfig struct {
|
||||||
|
// HostPort is the address of the Temporal server (host:port).
|
||||||
|
HostPort string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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
|
||||||
|
// TokenURL is the Authentik token endpoint for password/refresh grants.
|
||||||
|
TokenURL string
|
||||||
|
// ClientID is the OAuth2 client ID for token exchange.
|
||||||
|
ClientID string
|
||||||
|
// ClientSecret is the OAuth2 client secret (loaded from env, never from config file).
|
||||||
|
ClientSecret string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route represents a single route and its upstream configuration.
|
// Route represents a single route and its upstream configuration.
|
||||||
@@ -76,6 +120,18 @@ func (c *Config) ModelNames() []string {
|
|||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AllAddresses returns all configured backend addresses for the model.
|
||||||
|
// Returns Addresses if non-empty; otherwise wraps Address in a slice.
|
||||||
|
func (m *ModelUpstream) AllAddresses() []string {
|
||||||
|
if len(m.Addresses) > 0 {
|
||||||
|
return m.Addresses
|
||||||
|
}
|
||||||
|
if m.Address != "" {
|
||||||
|
return []string{m.Address}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Load reads configuration from environment variables with defaults.
|
// Load reads configuration from environment variables with defaults.
|
||||||
func Load() (*Config, error) {
|
func Load() (*Config, error) {
|
||||||
listenAddr := "127.0.0.1:8080"
|
listenAddr := "127.0.0.1:8080"
|
||||||
@@ -94,16 +150,26 @@ func Load() (*Config, error) {
|
|||||||
shutdownTimeout = d
|
shutdownTimeout = d
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load routes and models from config file
|
// Load routes, models, adapters, and auth from config file
|
||||||
routes := make(map[string]*Route)
|
routes := make(map[string]*Route)
|
||||||
models := make(map[string]*ModelUpstream)
|
models := make(map[string]*ModelUpstream)
|
||||||
|
var adapters []*serviceadapter.ServiceAdapter
|
||||||
|
var authConfig AuthConfig
|
||||||
if configPath, ok := os.LookupEnv("CONFIG_PATH"); ok {
|
if configPath, ok := os.LookupEnv("CONFIG_PATH"); ok {
|
||||||
loadedRoutes, loadedModels, err := LoadRoutesAndModelsFromFile(configPath)
|
loadedRoutes, loadedModels, loadedAdapters, loadedAuth, err := LoadRoutesAndModelsFromFile(configPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
routes = loadedRoutes
|
routes = loadedRoutes
|
||||||
models = loadedModels
|
models = loadedModels
|
||||||
|
adapters = loadedAdapters
|
||||||
|
authConfig = loadedAuth
|
||||||
|
}
|
||||||
|
|
||||||
|
temporalHostPort := "localhost:7233"
|
||||||
|
// Allow override via environment variable
|
||||||
|
if hostPort, ok := os.LookupEnv("TEMPORAL_HOST_PORT"); ok {
|
||||||
|
temporalHostPort = hostPort
|
||||||
}
|
}
|
||||||
|
|
||||||
return &Config{
|
return &Config{
|
||||||
@@ -111,5 +177,10 @@ func Load() (*Config, error) {
|
|||||||
ShutdownTimeout: shutdownTimeout,
|
ShutdownTimeout: shutdownTimeout,
|
||||||
Routes: routes,
|
Routes: routes,
|
||||||
Models: models,
|
Models: models,
|
||||||
|
Adapters: adapters,
|
||||||
|
Auth: authConfig,
|
||||||
|
Temporal: TemporalConfig{
|
||||||
|
HostPort: temporalHostPort,
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestLoadRoutesValidConfig tests that a valid configuration loads correctly.
|
// TestLoadRoutesValidConfig tests that a valid configuration loads correctly.
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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
|
// TestLoadIntegration tests the full Load function with CONFIG_PATH env var
|
||||||
|
|||||||
+122
-33
@@ -6,13 +6,27 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||||
"gopkg.in/yaml.v3"
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
// rawConfig represents the structure of the YAML configuration file.
|
// rawConfig represents the structure of the YAML configuration file.
|
||||||
type rawConfig struct {
|
type rawConfig struct {
|
||||||
Routes []rawRoute `yaml:"routes"`
|
Routes []rawRoute `yaml:"routes"`
|
||||||
Models []rawModel `yaml:"models"`
|
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"`
|
||||||
|
TokenURL string `yaml:"tokenUrl"`
|
||||||
|
ClientID string `yaml:"clientId"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// rawRoute represents a single route in the YAML configuration.
|
// rawRoute represents a single route in the YAML configuration.
|
||||||
@@ -23,9 +37,35 @@ type rawRoute struct {
|
|||||||
|
|
||||||
// rawModel represents a single model entry in the YAML configuration.
|
// rawModel represents a single model entry in the YAML configuration.
|
||||||
type rawModel struct {
|
type rawModel struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
Path string `yaml:"path"`
|
Addresses []string `yaml:"addresses"`
|
||||||
|
Path string `yaml:"path"`
|
||||||
|
UpstreamModel string `yaml:"upstreamModel"`
|
||||||
|
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.
|
// rawUpstream represents upstream configuration in YAML.
|
||||||
@@ -39,32 +79,32 @@ type rawUpstream struct {
|
|||||||
AuthRequired *bool `yaml:"authRequired"`
|
AuthRequired *bool `yaml:"authRequired"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadRoutesAndModelsFromFile loads both route and model configuration from a YAML file.
|
// LoadRoutesAndModelsFromFile loads route, model, adapter, and auth configuration from a YAML file.
|
||||||
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, error) {
|
func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*ModelUpstream, []*serviceadapter.ServiceAdapter, AuthConfig, error) {
|
||||||
data, err := os.ReadFile(path)
|
data, err := os.ReadFile(path)
|
||||||
if err != nil {
|
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
|
var raw rawConfig
|
||||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
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
|
// Load routes
|
||||||
routes := make(map[string]*Route)
|
routes := make(map[string]*Route)
|
||||||
for _, rawRoute := range raw.Routes {
|
for _, rawRoute := range raw.Routes {
|
||||||
if rawRoute.Name == "" {
|
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 {
|
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)
|
upstream, err := parseUpstream(rawRoute.Name, rawRoute.Upstream)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, nil, AuthConfig{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
routes[rawRoute.Name] = &Route{
|
routes[rawRoute.Name] = &Route{
|
||||||
@@ -76,42 +116,91 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
|
|||||||
// Load models
|
// Load models
|
||||||
models := make(map[string]*ModelUpstream)
|
models := make(map[string]*ModelUpstream)
|
||||||
for _, rawModel := range raw.Models {
|
for _, rawModel := range raw.Models {
|
||||||
// Validate model name is not empty
|
|
||||||
if rawModel.Name == "" {
|
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 {
|
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)
|
||||||
}
|
}
|
||||||
|
// Support both 'address' (single) and 'addresses' (multi-backend) YAML fields.
|
||||||
// Validate address is not empty
|
addresses := rawModel.Addresses
|
||||||
if rawModel.Address == "" {
|
if len(addresses) == 0 && rawModel.Address != "" {
|
||||||
return nil, nil, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
|
addresses = []string{rawModel.Address}
|
||||||
}
|
}
|
||||||
|
if len(addresses) == 0 {
|
||||||
// Validate address format (host:port)
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": 'address' or 'addresses' is required", rawModel.Name)
|
||||||
if _, _, err := net.SplitHostPort(rawModel.Address); err != nil {
|
}
|
||||||
return nil, nil, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err)
|
for _, addr := range addresses {
|
||||||
|
if _, _, err := net.SplitHostPort(addr); err != nil {
|
||||||
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, addr, 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{
|
models[rawModel.Name] = &ModelUpstream{
|
||||||
Name: rawModel.Name,
|
Name: rawModel.Name,
|
||||||
Address: rawModel.Address,
|
Address: addresses[0],
|
||||||
Path: rawModel.Path,
|
Addresses: addresses,
|
||||||
|
Path: rawModel.Path,
|
||||||
|
UpstreamModel: rawModel.UpstreamModel,
|
||||||
|
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,
|
||||||
|
TokenURL: raw.Auth.TokenURL,
|
||||||
|
ClientID: raw.Auth.ClientID,
|
||||||
|
ClientSecret: os.Getenv("AUTH_CLIENT_SECRET"),
|
||||||
|
}
|
||||||
|
|
||||||
|
return routes, models, adapters, authConfig, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadRoutesFromFile loads route configuration from a YAML file.
|
// 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.
|
// Deprecated: Use LoadRoutesAndModelsFromFile instead.
|
||||||
func LoadRoutesFromFile(path string) (map[string]*Route, error) {
|
func LoadRoutesFromFile(path string) (map[string]*Route, error) {
|
||||||
routes, _, err := LoadRoutesAndModelsFromFile(path)
|
routes, _, _, _, err := LoadRoutesAndModelsFromFile(path)
|
||||||
return routes, err
|
return routes, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ models:
|
|||||||
tmpFile.WriteString(data)
|
tmpFile.WriteString(data)
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
|
||||||
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
_, models, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to load config: %v", err)
|
t.Fatalf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -94,7 +94,7 @@ models:
|
|||||||
tmpFile.WriteString(data)
|
tmpFile.WriteString(data)
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
|
||||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
_, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("expected error for duplicate model name, got nil")
|
t.Errorf("expected error for duplicate model name, got nil")
|
||||||
}
|
}
|
||||||
@@ -127,7 +127,7 @@ models:
|
|||||||
tmpFile.WriteString(data)
|
tmpFile.WriteString(data)
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
|
||||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
_, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("expected error for empty model name, got nil")
|
t.Errorf("expected error for empty model name, got nil")
|
||||||
}
|
}
|
||||||
@@ -157,11 +157,11 @@ models:
|
|||||||
tmpFile.WriteString(data)
|
tmpFile.WriteString(data)
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
|
||||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
_, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("expected error for missing address, got nil")
|
t.Errorf("expected error for missing address, got nil")
|
||||||
}
|
}
|
||||||
if err.Error() != "model \"reasoning\": field 'address' is required" {
|
if err.Error() != "model \"reasoning\": 'address' or 'addresses' is required" {
|
||||||
t.Errorf("expected missing address error, got: %v", err)
|
t.Errorf("expected missing address error, got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,7 +190,7 @@ models:
|
|||||||
tmpFile.WriteString(data)
|
tmpFile.WriteString(data)
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
|
||||||
_, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
_, _, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||||
if err == nil {
|
if err == nil {
|
||||||
t.Errorf("expected error for invalid address, got nil")
|
t.Errorf("expected error for invalid address, got nil")
|
||||||
}
|
}
|
||||||
@@ -219,7 +219,7 @@ models:
|
|||||||
tmpFile.WriteString(data)
|
tmpFile.WriteString(data)
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
|
||||||
_, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
_, models, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to load config: %v", err)
|
t.Fatalf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -261,7 +261,7 @@ models:
|
|||||||
tmpFile.WriteString(data)
|
tmpFile.WriteString(data)
|
||||||
tmpFile.Close()
|
tmpFile.Close()
|
||||||
|
|
||||||
routes, models, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
routes, models, _, _, err := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("failed to load config: %v", err)
|
t.Fatalf("failed to load config: %v", err)
|
||||||
}
|
}
|
||||||
@@ -283,3 +283,139 @@ models:
|
|||||||
t.Errorf("expected different ModelUpstream objects even for same address")
|
t.Errorf("expected different ModelUpstream objects even for same address")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// minimalRouteYAML is a valid routes block required by LoadRoutesAndModelsFromFile.
|
||||||
|
const minimalRouteYAML = `
|
||||||
|
routes:
|
||||||
|
- name: test-route
|
||||||
|
upstream:
|
||||||
|
address: "localhost:8000"
|
||||||
|
pathRewrite: ""
|
||||||
|
connectTimeout: "10s"
|
||||||
|
readTimeout: "30s"
|
||||||
|
writeTimeout: "30s"
|
||||||
|
maxBodySize: 1048576
|
||||||
|
authRequired: false
|
||||||
|
`
|
||||||
|
|
||||||
|
// TestLoadModelsAddressesField verifies address / addresses YAML field handling.
|
||||||
|
func TestLoadModelsAddressesField(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
modelsYAML string
|
||||||
|
wantErr bool
|
||||||
|
errContains string
|
||||||
|
wantAddresses []string
|
||||||
|
wantAddress string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "single address field",
|
||||||
|
modelsYAML: `
|
||||||
|
models:
|
||||||
|
- name: "mymodel"
|
||||||
|
address: "host:8080"
|
||||||
|
path: "/v1/chat"
|
||||||
|
`,
|
||||||
|
wantErr: false,
|
||||||
|
wantAddresses: []string{"host:8080"},
|
||||||
|
wantAddress: "host:8080",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multi addresses field",
|
||||||
|
modelsYAML: `
|
||||||
|
models:
|
||||||
|
- name: "mymodel"
|
||||||
|
addresses:
|
||||||
|
- "a:80"
|
||||||
|
- "b:80"
|
||||||
|
path: "/v1/chat"
|
||||||
|
`,
|
||||||
|
wantErr: false,
|
||||||
|
wantAddresses: []string{"a:80", "b:80"},
|
||||||
|
wantAddress: "a:80",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "neither field",
|
||||||
|
modelsYAML: `
|
||||||
|
models:
|
||||||
|
- name: "mymodel"
|
||||||
|
path: "/v1/chat"
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "mymodel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "invalid address format",
|
||||||
|
modelsYAML: `
|
||||||
|
models:
|
||||||
|
- name: "mymodel"
|
||||||
|
address: "notavalidhostport"
|
||||||
|
path: "/v1/chat"
|
||||||
|
`,
|
||||||
|
wantErr: true,
|
||||||
|
errContains: "mymodel",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
data := minimalRouteYAML + tc.modelsYAML
|
||||||
|
|
||||||
|
tmpFile, err := os.CreateTemp("", "config-*.yaml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateTemp: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
tmpFile.WriteString(data)
|
||||||
|
tmpFile.Close()
|
||||||
|
|
||||||
|
_, models, _, _, loadErr := LoadRoutesAndModelsFromFile(tmpFile.Name())
|
||||||
|
|
||||||
|
if tc.wantErr {
|
||||||
|
if loadErr == nil {
|
||||||
|
t.Fatalf("expected error, got nil")
|
||||||
|
}
|
||||||
|
if tc.errContains != "" && !containsStr(loadErr.Error(), tc.errContains) {
|
||||||
|
t.Errorf("error %q does not contain %q", loadErr.Error(), tc.errContains)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if loadErr != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", loadErr)
|
||||||
|
}
|
||||||
|
|
||||||
|
m, ok := models["mymodel"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("model 'mymodel' not found in result")
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Address != tc.wantAddress {
|
||||||
|
t.Errorf("Address: want %q, got %q", tc.wantAddress, m.Address)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.Addresses) != len(tc.wantAddresses) {
|
||||||
|
t.Errorf("Addresses len: want %d, got %d (%v)", len(tc.wantAddresses), len(m.Addresses), m.Addresses)
|
||||||
|
} else {
|
||||||
|
for i, a := range tc.wantAddresses {
|
||||||
|
if m.Addresses[i] != a {
|
||||||
|
t.Errorf("Addresses[%d]: want %q, got %q", i, a, m.Addresses[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsStr is a helper to avoid importing strings just for Contains.
|
||||||
|
func containsStr(s, sub string) bool {
|
||||||
|
return len(s) >= len(sub) && (s == sub || len(sub) == 0 ||
|
||||||
|
func() bool {
|
||||||
|
for i := 0; i <= len(s)-len(sub); i++ {
|
||||||
|
if s[i:i+len(sub)] == sub {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}())
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Package identity extracts authenticated user identity from JWT claims
|
||||||
|
// and injects forwarding headers into proxied requests.
|
||||||
|
//
|
||||||
|
// Headers injected after JWT validation:
|
||||||
|
//
|
||||||
|
// X-Forwarded-User: subject (sub claim)
|
||||||
|
// X-Forwarded-Roles: comma-separated roles or permissions
|
||||||
|
// X-Acting-Service: authorized party (azp claim), only for service accounts
|
||||||
|
// X-Auth-Verified: "true" when gateway validated the JWT
|
||||||
|
//
|
||||||
|
// Security contract: downstream services MUST only accept traffic from the
|
||||||
|
// gateway (enforced by NetworkPolicy). They trust these headers because the
|
||||||
|
// gateway is the sole ingress path.
|
||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Headers that the gateway controls. Incoming values from clients are
|
||||||
|
// stripped to prevent spoofing.
|
||||||
|
const (
|
||||||
|
HeaderUser = "X-Forwarded-User"
|
||||||
|
HeaderRoles = "X-Forwarded-Roles"
|
||||||
|
HeaderActingService = "X-Acting-Service"
|
||||||
|
HeaderAuthVerified = "X-Auth-Verified"
|
||||||
|
)
|
||||||
|
|
||||||
|
// managed lists all headers this package owns. Used for stripping and cleanup.
|
||||||
|
var managed = []string{
|
||||||
|
HeaderUser,
|
||||||
|
HeaderRoles,
|
||||||
|
HeaderActingService,
|
||||||
|
HeaderAuthVerified,
|
||||||
|
}
|
||||||
|
|
||||||
|
// StripIncoming removes all gateway-managed identity headers from an
|
||||||
|
// inbound request, preventing clients from spoofing identity.
|
||||||
|
// Call this early in the handler chain, before any routing.
|
||||||
|
func StripIncoming(r *http.Request) {
|
||||||
|
for _, h := range managed {
|
||||||
|
r.Header.Del(h)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject extracts identity from validated JWT claims and sets the
|
||||||
|
// corresponding forwarding headers on the request. Only call this
|
||||||
|
// after successful JWT validation.
|
||||||
|
func Inject(r *http.Request, claims jwt.MapClaims) {
|
||||||
|
r.Header.Set(HeaderAuthVerified, "true")
|
||||||
|
|
||||||
|
if sub := claimString(claims, "sub"); sub != "" {
|
||||||
|
r.Header.Set(HeaderUser, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
if roles := claimStringSlice(claims, "roles"); len(roles) > 0 {
|
||||||
|
r.Header.Set(HeaderRoles, strings.Join(roles, ","))
|
||||||
|
} else if perms := claimStringSlice(claims, "permissions"); len(perms) > 0 {
|
||||||
|
r.Header.Set(HeaderRoles, strings.Join(perms, ","))
|
||||||
|
}
|
||||||
|
|
||||||
|
if azp := claimString(claims, "azp"); azp != "" {
|
||||||
|
sub := claimString(claims, "sub")
|
||||||
|
// Only set acting-service when azp differs from sub
|
||||||
|
// (i.e., a service account acting, not the user themselves)
|
||||||
|
if azp != sub {
|
||||||
|
r.Header.Set(HeaderActingService, azp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimString extracts a string value from claims, returning "" if
|
||||||
|
// the key is missing or not a string.
|
||||||
|
func claimString(claims jwt.MapClaims, key string) string {
|
||||||
|
val, ok := claims[key]
|
||||||
|
if !ok || val == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, ok := val.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimStringSlice extracts a []string from claims. JWT libraries
|
||||||
|
// deserialize JSON arrays as []interface{}, so each element is
|
||||||
|
// type-asserted individually. Non-string elements are skipped.
|
||||||
|
func claimStringSlice(claims jwt.MapClaims, key string) []string {
|
||||||
|
val, ok := claims[key]
|
||||||
|
if !ok || val == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, ok := val.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, v := range raw {
|
||||||
|
if s, ok := v.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
package identity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStripIncoming_RemovesSpoofedHeaders(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set(HeaderUser, "evil-spoof")
|
||||||
|
r.Header.Set(HeaderRoles, "admin:*")
|
||||||
|
r.Header.Set(HeaderActingService, "fake-service")
|
||||||
|
r.Header.Set(HeaderAuthVerified, "true")
|
||||||
|
|
||||||
|
StripIncoming(r)
|
||||||
|
|
||||||
|
for _, h := range managed {
|
||||||
|
if got := r.Header.Get(h); got != "" {
|
||||||
|
t.Errorf("header %s should be stripped, got %q", h, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripIncoming_PreservesOtherHeaders(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("Authorization", "Bearer token")
|
||||||
|
r.Header.Set("Content-Type", "application/json")
|
||||||
|
r.Header.Set(HeaderUser, "spoof")
|
||||||
|
|
||||||
|
StripIncoming(r)
|
||||||
|
|
||||||
|
if got := r.Header.Get("Authorization"); got != "Bearer token" {
|
||||||
|
t.Errorf("Authorization should be preserved, got %q", got)
|
||||||
|
}
|
||||||
|
if got := r.Header.Get("Content-Type"); got != "application/json" {
|
||||||
|
t.Errorf("Content-Type should be preserved, got %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInject_ServiceAccount(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "abc123-hashed-id",
|
||||||
|
"azp": "portfolio-agent",
|
||||||
|
"roles": []interface{}{"llm:inference", "memory:read"},
|
||||||
|
}
|
||||||
|
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
assertHeader(t, r, HeaderAuthVerified, "true")
|
||||||
|
assertHeader(t, r, HeaderUser, "abc123-hashed-id")
|
||||||
|
assertHeader(t, r, HeaderRoles, "llm:inference,memory:read")
|
||||||
|
assertHeader(t, r, HeaderActingService, "portfolio-agent")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInject_HumanUser(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "user-hash-456",
|
||||||
|
"azp": "api-gw",
|
||||||
|
"permissions": []interface{}{"*"},
|
||||||
|
}
|
||||||
|
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
assertHeader(t, r, HeaderAuthVerified, "true")
|
||||||
|
assertHeader(t, r, HeaderUser, "user-hash-456")
|
||||||
|
assertHeader(t, r, HeaderRoles, "*")
|
||||||
|
// azp != sub, so acting-service is set
|
||||||
|
assertHeader(t, r, HeaderActingService, "api-gw")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInject_SameSubAndAzp_NoActingService(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "portfolio-agent",
|
||||||
|
"azp": "portfolio-agent",
|
||||||
|
"roles": []interface{}{"llm:inference"},
|
||||||
|
}
|
||||||
|
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
assertHeader(t, r, HeaderActingService, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInject_RolesOverPermissions(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "user-1",
|
||||||
|
"roles": []interface{}{"llm:inference"},
|
||||||
|
"permissions": []interface{}{"admin:*"},
|
||||||
|
}
|
||||||
|
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
// roles takes precedence over permissions
|
||||||
|
assertHeader(t, r, HeaderRoles, "llm:inference")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInject_PermissionsFallback(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "user-1",
|
||||||
|
"permissions": []interface{}{"grafana:read", "grafana:write"},
|
||||||
|
}
|
||||||
|
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
assertHeader(t, r, HeaderRoles, "grafana:read,grafana:write")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInject_EmptyClaims(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
claims := jwt.MapClaims{}
|
||||||
|
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
assertHeader(t, r, HeaderAuthVerified, "true")
|
||||||
|
assertHeader(t, r, HeaderUser, "")
|
||||||
|
assertHeader(t, r, HeaderRoles, "")
|
||||||
|
assertHeader(t, r, HeaderActingService, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInject_NilValuesInClaims(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": nil,
|
||||||
|
"azp": nil,
|
||||||
|
"roles": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
assertHeader(t, r, HeaderAuthVerified, "true")
|
||||||
|
assertHeader(t, r, HeaderUser, "")
|
||||||
|
assertHeader(t, r, HeaderRoles, "")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInject_WildcardPermission(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "admin-user",
|
||||||
|
"permissions": []interface{}{"*"},
|
||||||
|
}
|
||||||
|
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
// Wildcard passed as literal, never expanded
|
||||||
|
assertHeader(t, r, HeaderRoles, "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInject_MixedTypeRolesArray(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "user-1",
|
||||||
|
"roles": []interface{}{"llm:inference", 42, nil, "", "memory:read"},
|
||||||
|
}
|
||||||
|
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
// Non-string and empty elements skipped
|
||||||
|
assertHeader(t, r, HeaderRoles, "llm:inference,memory:read")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInject_EmptyRolesArray(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "user-1",
|
||||||
|
"roles": []interface{}{},
|
||||||
|
"permissions": []interface{}{"backup:read"},
|
||||||
|
}
|
||||||
|
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
// Empty roles falls through to permissions
|
||||||
|
assertHeader(t, r, HeaderRoles, "backup:read")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStripThenInject_OverwritesSpoof(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set(HeaderUser, "evil-spoof")
|
||||||
|
r.Header.Set(HeaderAuthVerified, "true")
|
||||||
|
|
||||||
|
StripIncoming(r)
|
||||||
|
|
||||||
|
claims := jwt.MapClaims{
|
||||||
|
"sub": "real-user",
|
||||||
|
"roles": []interface{}{"llm:inference"},
|
||||||
|
}
|
||||||
|
Inject(r, claims)
|
||||||
|
|
||||||
|
assertHeader(t, r, HeaderUser, "real-user")
|
||||||
|
assertHeader(t, r, HeaderAuthVerified, "true")
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertHeader(t *testing.T, r *http.Request, key, want string) {
|
||||||
|
t.Helper()
|
||||||
|
got := r.Header.Get(key)
|
||||||
|
if got != want {
|
||||||
|
t.Errorf("header %s = %q, want %q", key, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,299 @@
|
|||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
gatewayBaseURL = "http://api-gateway:8080"
|
||||||
|
timeout = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestIntegrationMemoryService tests memory adapter (ingest, query)
|
||||||
|
func TestIntegrationMemoryService(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: timeout}
|
||||||
|
|
||||||
|
// Test 1: Ingest memory
|
||||||
|
t.Log("Testing memory ingest...")
|
||||||
|
ingestPayload := map[string]interface{}{
|
||||||
|
"ingest_id": "test-ingest-" + fmt.Sprintf("%d", time.Now().Unix()),
|
||||||
|
"project": "test-project",
|
||||||
|
"title": "Integration Test Memory",
|
||||||
|
"content": "This is a test memory entry from integration test",
|
||||||
|
"tags": []string{"integration", "test"},
|
||||||
|
"source": "integration-test",
|
||||||
|
}
|
||||||
|
|
||||||
|
ingestBody, _ := json.Marshal(ingestPayload)
|
||||||
|
req, _ := http.NewRequest("POST", gatewayBaseURL+"/memory/ingest", bytes.NewReader(ingestBody))
|
||||||
|
req.Header.Set("X-Service", "memory")
|
||||||
|
req.Header.Set("X-Resource", "ingest")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("memory ingest request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Logf("Ingest response: %d - %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
t.Fatalf("memory ingest failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Memory ingest successful")
|
||||||
|
|
||||||
|
// Test 2: Query memory
|
||||||
|
t.Log("Testing memory query...")
|
||||||
|
queryPayload := map[string]interface{}{
|
||||||
|
"query": "integration test",
|
||||||
|
}
|
||||||
|
|
||||||
|
queryBody, _ := json.Marshal(queryPayload)
|
||||||
|
req, _ = http.NewRequest("POST", gatewayBaseURL+"/memory/query", bytes.NewReader(queryBody))
|
||||||
|
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.Fatalf("memory query request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ = io.ReadAll(resp.Body)
|
||||||
|
t.Logf("Query response: %d - %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("memory query failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ Memory query successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationS3Service tests S3 adapter (list, put, get)
|
||||||
|
func TestIntegrationS3Service(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: timeout}
|
||||||
|
|
||||||
|
// Test 1: List objects
|
||||||
|
t.Log("Testing S3 list objects...")
|
||||||
|
req, _ := http.NewRequest("GET", gatewayBaseURL+"/", nil)
|
||||||
|
req.Header.Set("X-Service", "s3")
|
||||||
|
req.Header.Set("X-Resource", "list-objects")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("S3 list request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Logf("List response: %d - %s", resp.StatusCode, string(body)[:100])
|
||||||
|
|
||||||
|
// S3 should respond with either 200 (list) or 403 (access denied) - both mean routing works
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Fatalf("S3 list failed with unexpected status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ S3 list objects successful")
|
||||||
|
|
||||||
|
// Test 2: Put object to dedicated test bucket
|
||||||
|
t.Log("Testing S3 put object...")
|
||||||
|
testContent := fmt.Sprintf("Integration test data - %d", time.Now().Unix())
|
||||||
|
testKey := "test-file-" + fmt.Sprintf("%d", time.Now().Unix()) + ".txt"
|
||||||
|
|
||||||
|
req, _ = http.NewRequest("PUT", gatewayBaseURL+"/"+testKey, bytes.NewReader([]byte(testContent)))
|
||||||
|
req.Header.Set("X-Service", "s3")
|
||||||
|
req.Header.Set("X-Resource", "put-object")
|
||||||
|
req.Header.Set("Content-Type", "text/plain")
|
||||||
|
|
||||||
|
resp, err = client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("S3 put request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ = io.ReadAll(resp.Body)
|
||||||
|
t.Logf("Put response: %d - %s", resp.StatusCode, string(body)[:100])
|
||||||
|
|
||||||
|
// Put should respond - either success or S3 error (both mean routing works)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 600 {
|
||||||
|
t.Fatalf("S3 put failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Log("✓ S3 put object successful")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationSQSService tests SQS adapter (create queue, send, receive)
|
||||||
|
func TestIntegrationSQSService(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: timeout}
|
||||||
|
|
||||||
|
// Test 1: List queues (no auth required in test, auth error is ok)
|
||||||
|
t.Log("Testing SQS list queues...")
|
||||||
|
req, _ := http.NewRequest("GET", gatewayBaseURL+"/sqs/queues", nil)
|
||||||
|
req.Header.Set("X-Service", "sqs")
|
||||||
|
req.Header.Set("X-Resource", "list-queues")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("SQS list request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Logf("List queues response: %d - %s", resp.StatusCode, string(body))
|
||||||
|
|
||||||
|
// SQS requires auth, so 401 is expected but proves routing works
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized {
|
||||||
|
t.Log("✓ SQS correctly requires authorization (routing works)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
t.Log("✓ SQS list queues successful")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("SQS list failed with unexpected status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationWorkflowService tests workflow adapter (list, describe)
|
||||||
|
func TestIntegrationWorkflowService(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: timeout}
|
||||||
|
|
||||||
|
// Test 1: List workflows with gRPC
|
||||||
|
t.Log("Testing workflow list (gRPC)...")
|
||||||
|
req, _ := http.NewRequest("GET",
|
||||||
|
gatewayBaseURL+"/temporal.api.workflowservice.v1.WorkflowService/ListWorkflowExecutions",
|
||||||
|
nil)
|
||||||
|
req.Header.Set("X-Service", "workflow")
|
||||||
|
req.Header.Set("X-Resource", "list")
|
||||||
|
req.Header.Set("Content-Type", "application/grpc")
|
||||||
|
req.Header.Set("TE", "trailers")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("workflow list request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
|
||||||
|
// gRPC responses are binary, but we can check status code
|
||||||
|
t.Logf("List workflows response: %d (body length: %d bytes)", resp.StatusCode, len(body))
|
||||||
|
|
||||||
|
// Status 200 with gRPC binary data, or 501 if not yet implemented
|
||||||
|
if resp.StatusCode == http.StatusOK {
|
||||||
|
t.Log("✓ Workflow list successful (gRPC forwarding working)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode == http.StatusNotImplemented {
|
||||||
|
t.Log("⚠ Workflow list: gRPC forwarding not yet implemented")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||||
|
// Client error might indicate routing works but request format issue
|
||||||
|
t.Logf("✓ Workflow adapter routing confirmed (status %d)", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("Workflow list failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationIAMService tests IAM adapter
|
||||||
|
func TestIntegrationIAMService(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: timeout}
|
||||||
|
|
||||||
|
t.Log("Testing IAM list users...")
|
||||||
|
req, _ := http.NewRequest("GET", gatewayBaseURL+"/api/v3/users", nil)
|
||||||
|
req.Header.Set("X-Service", "iam")
|
||||||
|
req.Header.Set("X-Resource", "list-users")
|
||||||
|
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("IAM list request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
_, _ = io.ReadAll(resp.Body)
|
||||||
|
t.Logf("IAM list users response: %d", resp.StatusCode)
|
||||||
|
|
||||||
|
// IAM (Authentik) should respond - 200, 404, or auth error all prove routing works
|
||||||
|
if resp.StatusCode >= 200 && resp.StatusCode < 600 {
|
||||||
|
t.Log("✓ IAM adapter routing successful")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Fatalf("IAM list failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIntegrationHealthChecks tests gateway health endpoints
|
||||||
|
func TestIntegrationHealthChecks(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("skipping integration test")
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: timeout}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
endpoint string
|
||||||
|
}{
|
||||||
|
{"liveness", "/healthz"},
|
||||||
|
{"readiness", "/readyz"},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
req, _ := http.NewRequest("GET", gatewayBaseURL+tt.endpoint, nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("health check request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("health check failed with status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var health map[string]string
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&health); err != nil {
|
||||||
|
t.Fatalf("failed to decode health response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("✓ %s: %s", tt.name, health["status"])
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -198,3 +198,11 @@ func FromContext(ctx context.Context) *Logger {
|
|||||||
func WithContext(ctx context.Context, l *Logger) context.Context {
|
func WithContext(ctx context.Context, l *Logger) context.Context {
|
||||||
return context.WithValue(ctx, "logger", l)
|
return context.WithValue(ctx, "logger", l)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Event emits arbitrary fields as a structured JSON line.
|
||||||
|
// Use when field values include non-string types (numerics, floats).
|
||||||
|
func Event(fields map[string]interface{}) {
|
||||||
|
ensure()
|
||||||
|
data, _ := json.Marshal(fields)
|
||||||
|
_, _ = global.out.Write(append(data, '\n'))
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,260 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GotifyClient is a CRUD client for the Gotify API.
|
||||||
|
type GotifyClient struct {
|
||||||
|
baseURL string
|
||||||
|
appToken string // token for sending messages (application token)
|
||||||
|
clientToken string // token for reading/managing (client token)
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewGotifyClient creates a Gotify API client.
|
||||||
|
// appToken is used for sending messages.
|
||||||
|
// clientToken is used for listing/deleting messages and managing applications.
|
||||||
|
func NewGotifyClient(baseURL, appToken, clientToken string) *GotifyClient {
|
||||||
|
return &GotifyClient{
|
||||||
|
baseURL: baseURL,
|
||||||
|
appToken: appToken,
|
||||||
|
clientToken: clientToken,
|
||||||
|
httpClient: &http.Client{
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Message Types ---
|
||||||
|
|
||||||
|
// GotifyMessage represents a Gotify message.
|
||||||
|
type GotifyMessage struct {
|
||||||
|
ID int `json:"id,omitempty"`
|
||||||
|
AppID int `json:"appid,omitempty"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
Priority int `json:"priority,omitempty"`
|
||||||
|
Date string `json:"date,omitempty"`
|
||||||
|
Extras map[string]interface{} `json:"extras,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GotifyMessageList is a paginated list of messages.
|
||||||
|
type GotifyMessageList struct {
|
||||||
|
Messages []GotifyMessage `json:"messages"`
|
||||||
|
Paging GotifyPaging `json:"paging"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GotifyPaging represents pagination info.
|
||||||
|
type GotifyPaging struct {
|
||||||
|
Size int `json:"size"`
|
||||||
|
Since int `json:"since"`
|
||||||
|
Limit int `json:"limit"`
|
||||||
|
Next string `json:"next,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Application Types ---
|
||||||
|
|
||||||
|
// GotifyApplication represents a Gotify application.
|
||||||
|
type GotifyApplication struct {
|
||||||
|
ID int `json:"id,omitempty"`
|
||||||
|
Token string `json:"token,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
Image string `json:"image,omitempty"`
|
||||||
|
Internal bool `json:"internal,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Message CRUD ---
|
||||||
|
|
||||||
|
// SendMessage sends a message via Gotify (uses app token).
|
||||||
|
func (c *GotifyClient) SendMessage(msg GotifyMessage) (*GotifyMessage, error) {
|
||||||
|
body, err := json.Marshal(msg)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal message: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/message", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Gotify-Key", c.appToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("send message: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return nil, c.readError(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result GotifyMessage
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListMessages lists messages (uses client token).
|
||||||
|
func (c *GotifyClient) ListMessages(limit int) (*GotifyMessageList, error) {
|
||||||
|
url := fmt.Sprintf("%s/message?limit=%d", c.baseURL, limit)
|
||||||
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Gotify-Key", c.clientToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list messages: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, c.readError(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result GotifyMessageList
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteMessage deletes a message by ID (uses client token).
|
||||||
|
func (c *GotifyClient) DeleteMessage(id int) error {
|
||||||
|
url := fmt.Sprintf("%s/message/%d", c.baseURL, id)
|
||||||
|
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Gotify-Key", c.clientToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete message: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
return c.readError(resp)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteAllMessages deletes all messages (uses client token).
|
||||||
|
func (c *GotifyClient) DeleteAllMessages() error {
|
||||||
|
req, err := http.NewRequest(http.MethodDelete, c.baseURL+"/message", nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Gotify-Key", c.clientToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete all messages: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
return c.readError(resp)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Application CRUD ---
|
||||||
|
|
||||||
|
// ListApplications lists all applications (uses client token).
|
||||||
|
func (c *GotifyClient) ListApplications() ([]GotifyApplication, error) {
|
||||||
|
req, err := http.NewRequest(http.MethodGet, c.baseURL+"/application", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Gotify-Key", c.clientToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("list applications: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, c.readError(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []GotifyApplication
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateApplication creates a new application (uses client token).
|
||||||
|
func (c *GotifyClient) CreateApplication(app GotifyApplication) (*GotifyApplication, error) {
|
||||||
|
body, err := json.Marshal(app)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal application: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequest(http.MethodPost, c.baseURL+"/application", bytes.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Gotify-Key", c.clientToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("create application: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
|
||||||
|
return nil, c.readError(resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result GotifyApplication
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode response: %w", err)
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeleteApplication deletes an application by ID (uses client token).
|
||||||
|
func (c *GotifyClient) DeleteApplication(id int) error {
|
||||||
|
url := fmt.Sprintf("%s/application/%d", c.baseURL, id)
|
||||||
|
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("create request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("X-Gotify-Key", c.clientToken)
|
||||||
|
|
||||||
|
resp, err := c.httpClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("delete application: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusNoContent {
|
||||||
|
return c.readError(resp)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func (c *GotifyClient) readError(resp *http.Response) error {
|
||||||
|
body, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("gotify API error (HTTP %d): failed to read body: %w", resp.StatusCode, err)
|
||||||
|
}
|
||||||
|
return fmt.Errorf("gotify API error (HTTP %d): %s", resp.StatusCode, string(body))
|
||||||
|
}
|
||||||
@@ -0,0 +1,291 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"net/smtp"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler handles notification requests routed via X-Resource header.
|
||||||
|
// Supports: send-email, send-gotify, list-messages, delete-message,
|
||||||
|
// delete-all-messages, list-applications, create-application, delete-application.
|
||||||
|
type Handler struct {
|
||||||
|
smtpHost string
|
||||||
|
smtpPort string
|
||||||
|
smtpFrom string
|
||||||
|
smtpUser string
|
||||||
|
smtpPass string
|
||||||
|
gotify *GotifyClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a notification handler from environment variables.
|
||||||
|
func NewHandler() *Handler {
|
||||||
|
var gotify *GotifyClient
|
||||||
|
gotifyURL := os.Getenv("GOTIFY_URL")
|
||||||
|
if gotifyURL != "" {
|
||||||
|
gotify = NewGotifyClient(
|
||||||
|
gotifyURL,
|
||||||
|
os.Getenv("GOTIFY_APP_TOKEN"),
|
||||||
|
os.Getenv("GOTIFY_CLIENT_TOKEN"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Handler{
|
||||||
|
smtpHost: os.Getenv("SMTP_HOST"),
|
||||||
|
smtpPort: os.Getenv("SMTP_PORT"),
|
||||||
|
smtpFrom: os.Getenv("SMTP_FROM"),
|
||||||
|
smtpUser: os.Getenv("SMTP_USER"),
|
||||||
|
smtpPass: os.Getenv("SMTP_PASS"),
|
||||||
|
gotify: gotify,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP routes requests by X-Upstream-Path (set by dispatcher after resource matching).
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resource := r.Header.Get("X-Resource")
|
||||||
|
|
||||||
|
switch resource {
|
||||||
|
// --- Email ---
|
||||||
|
case "send-email":
|
||||||
|
h.handleSendEmail(w, r)
|
||||||
|
|
||||||
|
// --- Gotify Messages ---
|
||||||
|
case "send-message":
|
||||||
|
h.handleSendGotify(w, r)
|
||||||
|
case "list-messages":
|
||||||
|
h.handleListMessages(w, r)
|
||||||
|
case "delete-message":
|
||||||
|
h.handleDeleteMessage(w, r)
|
||||||
|
case "delete-all-messages":
|
||||||
|
h.handleDeleteAllMessages(w, r)
|
||||||
|
|
||||||
|
// --- Gotify Applications ---
|
||||||
|
case "list-applications":
|
||||||
|
h.handleListApplications(w, r)
|
||||||
|
case "create-application":
|
||||||
|
h.handleCreateApplication(w, r)
|
||||||
|
case "delete-application":
|
||||||
|
h.handleDeleteApplication(w, r)
|
||||||
|
|
||||||
|
default:
|
||||||
|
h.writeJSON(w, http.StatusNotFound, map[string]string{
|
||||||
|
"error": fmt.Sprintf("unknown resource: %s", resource),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Email ---
|
||||||
|
|
||||||
|
type SendEmailRequest struct {
|
||||||
|
To string `json:"to"`
|
||||||
|
CC string `json:"cc,omitempty"`
|
||||||
|
Subject string `json:"subject"`
|
||||||
|
Body string `json:"body"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleSendEmail(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req SendEmailRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.To == "" {
|
||||||
|
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'to' field"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := req.Subject
|
||||||
|
if subject == "" {
|
||||||
|
subject = "Notification"
|
||||||
|
}
|
||||||
|
|
||||||
|
msg := fmt.Sprintf(
|
||||||
|
"From: %s\r\nTo: %s\r\nSubject: %s\r\nContent-Type: text/plain; charset=UTF-8\r\n\r\n%s",
|
||||||
|
h.smtpFrom, req.To, subject, req.Body,
|
||||||
|
)
|
||||||
|
|
||||||
|
smtpAddr := fmt.Sprintf("%s:%s", h.smtpHost, h.smtpPort)
|
||||||
|
auth := smtp.PlainAuth("", h.smtpUser, h.smtpPass, h.smtpHost)
|
||||||
|
|
||||||
|
if err := smtp.SendMail(smtpAddr, auth, h.smtpFrom, []string{req.To}, []byte(msg)); err != nil {
|
||||||
|
log.Printf("error sending email to %s: %v", req.To, err)
|
||||||
|
h.writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "failed to send email: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{
|
||||||
|
"status": "success",
|
||||||
|
"messageId": fmt.Sprintf("email-%s", req.To),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Gotify Messages ---
|
||||||
|
|
||||||
|
func (h *Handler) handleSendGotify(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.gotify == nil {
|
||||||
|
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg GotifyMessage
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||||
|
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.gotify.SendMessage(msg)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error sending gotify message: %v", err)
|
||||||
|
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleListMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.gotify == nil {
|
||||||
|
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limit := 50
|
||||||
|
if l := r.URL.Query().Get("limit"); l != "" {
|
||||||
|
if parsed, err := strconv.Atoi(l); err == nil && parsed > 0 {
|
||||||
|
limit = parsed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.gotify.ListMessages(limit)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error listing gotify messages: %v", err)
|
||||||
|
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleDeleteMessage(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.gotify == nil {
|
||||||
|
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ID == 0 {
|
||||||
|
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'id' field"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.gotify.DeleteMessage(req.ID); err != nil {
|
||||||
|
log.Printf("error deleting gotify message %d: %v", req.ID, err)
|
||||||
|
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleDeleteAllMessages(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.gotify == nil {
|
||||||
|
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.gotify.DeleteAllMessages(); err != nil {
|
||||||
|
log.Printf("error deleting all gotify messages: %v", err)
|
||||||
|
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"status": "all messages deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Gotify Applications ---
|
||||||
|
|
||||||
|
func (h *Handler) handleListApplications(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.gotify == nil {
|
||||||
|
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.gotify.ListApplications()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error listing gotify applications: %v", err)
|
||||||
|
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleCreateApplication(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.gotify == nil {
|
||||||
|
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var app GotifyApplication
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&app); err != nil {
|
||||||
|
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.gotify.CreateApplication(app)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("error creating gotify application: %v", err)
|
||||||
|
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusCreated, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleDeleteApplication(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if h.gotify == nil {
|
||||||
|
h.writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Gotify not configured"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid request: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.ID == 0 {
|
||||||
|
h.writeJSON(w, http.StatusBadRequest, map[string]string{"error": "missing 'id' field"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.gotify.DeleteApplication(req.ID); err != nil {
|
||||||
|
log.Printf("error deleting gotify application %d: %v", req.ID, err)
|
||||||
|
h.writeJSON(w, http.StatusBadGateway, map[string]string{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
h.writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func (h *Handler) writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data)
|
||||||
|
}
|
||||||
@@ -0,0 +1,513 @@
|
|||||||
|
package notification
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mockGotifyServer creates a test server that simulates the Gotify API.
|
||||||
|
func mockGotifyServer() *httptest.Server {
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// POST /message — send message
|
||||||
|
mux.HandleFunc("/message", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
var msg GotifyMessage
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&msg); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
msg.ID = 42
|
||||||
|
msg.AppID = 1
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(msg)
|
||||||
|
|
||||||
|
case http.MethodGet:
|
||||||
|
// list messages
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(GotifyMessageList{
|
||||||
|
Messages: []GotifyMessage{
|
||||||
|
{ID: 1, Title: "Test", Message: "hello", Priority: 3},
|
||||||
|
{ID: 2, Title: "Alert", Message: "world", Priority: 7},
|
||||||
|
},
|
||||||
|
Paging: GotifyPaging{Size: 2, Limit: 50},
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodDelete:
|
||||||
|
// delete all messages
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /message/{id}
|
||||||
|
mux.HandleFunc("/message/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
// GET/POST/DELETE /application
|
||||||
|
mux.HandleFunc("/application", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodGet:
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode([]GotifyApplication{
|
||||||
|
{ID: 1, Name: "app1", Token: "tok1"},
|
||||||
|
{ID: 2, Name: "app2", Token: "tok2"},
|
||||||
|
})
|
||||||
|
|
||||||
|
case http.MethodPost:
|
||||||
|
var app GotifyApplication
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&app); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
app.ID = 10
|
||||||
|
app.Token = "new-token"
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(app)
|
||||||
|
|
||||||
|
default:
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// DELETE /application/{id}
|
||||||
|
mux.HandleFunc("/application/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodDelete {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"status": "ok"})
|
||||||
|
})
|
||||||
|
|
||||||
|
return httptest.NewServer(mux)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestHandler(gotifyURL string) *Handler {
|
||||||
|
h := &Handler{}
|
||||||
|
if gotifyURL != "" {
|
||||||
|
h.gotify = NewGotifyClient(gotifyURL, "test-app-token", "test-client-token")
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func doRequest(h *Handler, method, resource string, body interface{}) *httptest.ResponseRecorder {
|
||||||
|
var reqBody io.Reader
|
||||||
|
if body != nil {
|
||||||
|
b, _ := json.Marshal(body)
|
||||||
|
reqBody = bytes.NewReader(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(method, "/", reqBody)
|
||||||
|
req.Header.Set("X-Resource", resource)
|
||||||
|
if body != nil {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
return w
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeResponse(t *testing.T, w *httptest.ResponseRecorder) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatalf("decode response: %v (body: %s)", err, w.Body.String())
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Handler routing tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestHandler_UnknownResource(t *testing.T) {
|
||||||
|
h := newTestHandler("")
|
||||||
|
w := doRequest(h, "GET", "unknown-resource", nil)
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
data := decodeResponse(t, w)
|
||||||
|
if _, ok := data["error"]; !ok {
|
||||||
|
t.Error("expected error in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_GotifyNotConfigured(t *testing.T) {
|
||||||
|
h := newTestHandler("") // no gotify
|
||||||
|
|
||||||
|
resources := []struct {
|
||||||
|
method string
|
||||||
|
resource string
|
||||||
|
body interface{}
|
||||||
|
}{
|
||||||
|
{"POST", "send-message", map[string]string{"title": "t", "message": "m"}},
|
||||||
|
{"GET", "list-messages", nil},
|
||||||
|
{"DELETE", "delete-message", map[string]int{"id": 1}},
|
||||||
|
{"DELETE", "delete-all-messages", nil},
|
||||||
|
{"GET", "list-applications", nil},
|
||||||
|
{"POST", "create-application", map[string]string{"name": "app"}},
|
||||||
|
{"DELETE", "delete-application", map[string]int{"id": 1}},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range resources {
|
||||||
|
t.Run(tc.resource, func(t *testing.T) {
|
||||||
|
w := doRequest(h, tc.method, tc.resource, tc.body)
|
||||||
|
if w.Code != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("expected 503, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Gotify message tests (via handler)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestHandler_SendMessage(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
h := newTestHandler(srv.URL)
|
||||||
|
|
||||||
|
w := doRequest(h, "POST", "send-message", map[string]interface{}{
|
||||||
|
"title": "Test Alert",
|
||||||
|
"message": "Something happened",
|
||||||
|
"priority": 5,
|
||||||
|
})
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
data := decodeResponse(t, w)
|
||||||
|
if data["title"] != "Test Alert" {
|
||||||
|
t.Errorf("expected title 'Test Alert', got %v", data["title"])
|
||||||
|
}
|
||||||
|
if int(data["id"].(float64)) != 42 {
|
||||||
|
t.Errorf("expected id 42, got %v", data["id"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_SendMessage_InvalidJSON(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
h := newTestHandler(srv.URL)
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/", bytes.NewReader([]byte("not json")))
|
||||||
|
req.Header.Set("X-Resource", "send-message")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_ListMessages(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
h := newTestHandler(srv.URL)
|
||||||
|
|
||||||
|
w := doRequest(h, "GET", "list-messages", nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var result GotifyMessageList
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &result); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Messages) != 2 {
|
||||||
|
t.Errorf("expected 2 messages, got %d", len(result.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_DeleteMessage(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
h := newTestHandler(srv.URL)
|
||||||
|
|
||||||
|
w := doRequest(h, "DELETE", "delete-message", map[string]int{"id": 1})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_DeleteMessage_MissingID(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
h := newTestHandler(srv.URL)
|
||||||
|
|
||||||
|
w := doRequest(h, "DELETE", "delete-message", map[string]int{"id": 0})
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_DeleteAllMessages(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
h := newTestHandler(srv.URL)
|
||||||
|
|
||||||
|
w := doRequest(h, "DELETE", "delete-all-messages", nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Gotify application tests (via handler)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestHandler_ListApplications(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
h := newTestHandler(srv.URL)
|
||||||
|
|
||||||
|
w := doRequest(h, "GET", "list-applications", nil)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
var apps []GotifyApplication
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &apps); err != nil {
|
||||||
|
t.Fatalf("decode: %v", err)
|
||||||
|
}
|
||||||
|
if len(apps) != 2 {
|
||||||
|
t.Errorf("expected 2 apps, got %d", len(apps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_CreateApplication(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
h := newTestHandler(srv.URL)
|
||||||
|
|
||||||
|
w := doRequest(h, "POST", "create-application", map[string]string{
|
||||||
|
"name": "my-app",
|
||||||
|
"description": "test app",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("expected 201, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
data := decodeResponse(t, w)
|
||||||
|
if data["name"] != "my-app" {
|
||||||
|
t.Errorf("expected name 'my-app', got %v", data["name"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_DeleteApplication(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
h := newTestHandler(srv.URL)
|
||||||
|
|
||||||
|
w := doRequest(h, "DELETE", "delete-application", map[string]int{"id": 1})
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_DeleteApplication_MissingID(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
h := newTestHandler(srv.URL)
|
||||||
|
|
||||||
|
w := doRequest(h, "DELETE", "delete-application", map[string]int{"id": 0})
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// Email tests (routing only, no SMTP)
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestHandler_SendEmail_MissingTo(t *testing.T) {
|
||||||
|
h := newTestHandler("")
|
||||||
|
|
||||||
|
w := doRequest(h, "POST", "send-email", map[string]string{
|
||||||
|
"subject": "Test",
|
||||||
|
"body": "Hello",
|
||||||
|
})
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandler_SendEmail_InvalidJSON(t *testing.T) {
|
||||||
|
h := newTestHandler("")
|
||||||
|
|
||||||
|
req := httptest.NewRequest("POST", "/", bytes.NewReader([]byte("{bad")))
|
||||||
|
req.Header.Set("X-Resource", "send-email")
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GotifyClient direct tests
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
func TestGotifyClient_SendMessage(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
client := NewGotifyClient(srv.URL, "app-token", "client-token")
|
||||||
|
|
||||||
|
msg, err := client.SendMessage(GotifyMessage{
|
||||||
|
Title: "Direct Test",
|
||||||
|
Message: "Hello",
|
||||||
|
Priority: 3,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("send: %v", err)
|
||||||
|
}
|
||||||
|
if msg.ID != 42 {
|
||||||
|
t.Errorf("expected id 42, got %d", msg.ID)
|
||||||
|
}
|
||||||
|
if msg.Title != "Direct Test" {
|
||||||
|
t.Errorf("expected title 'Direct Test', got %s", msg.Title)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGotifyClient_ListMessages(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
client := NewGotifyClient(srv.URL, "app-token", "client-token")
|
||||||
|
|
||||||
|
list, err := client.ListMessages(50)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(list.Messages) != 2 {
|
||||||
|
t.Errorf("expected 2 messages, got %d", len(list.Messages))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGotifyClient_DeleteMessage(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
client := NewGotifyClient(srv.URL, "app-token", "client-token")
|
||||||
|
|
||||||
|
if err := client.DeleteMessage(1); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGotifyClient_DeleteAllMessages(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
client := NewGotifyClient(srv.URL, "app-token", "client-token")
|
||||||
|
|
||||||
|
if err := client.DeleteAllMessages(); err != nil {
|
||||||
|
t.Fatalf("delete all: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGotifyClient_ListApplications(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
client := NewGotifyClient(srv.URL, "app-token", "client-token")
|
||||||
|
|
||||||
|
apps, err := client.ListApplications()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list: %v", err)
|
||||||
|
}
|
||||||
|
if len(apps) != 2 {
|
||||||
|
t.Errorf("expected 2 apps, got %d", len(apps))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGotifyClient_CreateApplication(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
client := NewGotifyClient(srv.URL, "app-token", "client-token")
|
||||||
|
|
||||||
|
app, err := client.CreateApplication(GotifyApplication{
|
||||||
|
Name: "new-app",
|
||||||
|
Description: "test",
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("create: %v", err)
|
||||||
|
}
|
||||||
|
if app.ID != 10 {
|
||||||
|
t.Errorf("expected id 10, got %d", app.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGotifyClient_DeleteApplication(t *testing.T) {
|
||||||
|
srv := mockGotifyServer()
|
||||||
|
defer srv.Close()
|
||||||
|
client := NewGotifyClient(srv.URL, "app-token", "client-token")
|
||||||
|
|
||||||
|
if err := client.DeleteApplication(1); err != nil {
|
||||||
|
t.Fatalf("delete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGotifyClient_ErrorResponse(t *testing.T) {
|
||||||
|
// Server that returns 500 for everything
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
fmt.Fprint(w, "internal error")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
client := NewGotifyClient(srv.URL, "app-token", "client-token")
|
||||||
|
|
||||||
|
_, err := client.SendMessage(GotifyMessage{Title: "test"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.ListMessages(10)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = client.ListApplications()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGotifyClient_RateLimited(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusTooManyRequests)
|
||||||
|
fmt.Fprint(w, "rate limited")
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
client := NewGotifyClient(srv.URL, "app-token", "client-token")
|
||||||
|
|
||||||
|
_, err := client.SendMessage(GotifyMessage{Title: "test"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected error on 429")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGotifyClient_InvalidURL(t *testing.T) {
|
||||||
|
client := NewGotifyClient("http://localhost:1", "app-token", "client-token")
|
||||||
|
|
||||||
|
_, err := client.SendMessage(GotifyMessage{Title: "test"})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected connection error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,28 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
)
|
||||||
|
|
||||||
|
// MetricsHandler serves Prometheus metrics
|
||||||
|
type MetricsHandler struct {
|
||||||
|
exporter *PrometheusExporter
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewMetricsHandler creates a new metrics handler
|
||||||
|
func NewMetricsHandler(m *Metrics) *MetricsHandler {
|
||||||
|
return &MetricsHandler{
|
||||||
|
exporter: NewPrometheusExporter(m),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler for Prometheus /metrics endpoint
|
||||||
|
func (h *MetricsHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate")
|
||||||
|
w.Header().Set("Pragma", "no-cache")
|
||||||
|
w.Header().Set("Expires", "0")
|
||||||
|
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte(h.exporter.Export()))
|
||||||
|
}
|
||||||
@@ -0,0 +1,454 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// LLM inference metrics (TTFT and ITL)
|
||||||
|
// ttftMs: Time-to-First-Token in milliseconds
|
||||||
|
ttftMs map[string][]int64 // samples for histogram
|
||||||
|
// itlMs: Inter-Token Latency in milliseconds
|
||||||
|
itlMs map[string][]int64 // samples for histogram
|
||||||
|
// Token counts
|
||||||
|
tokenCount map[string]int64
|
||||||
|
|
||||||
|
// CAG metrics
|
||||||
|
warmupRequestsDelayed int64
|
||||||
|
prefixCacheHitTokens int64
|
||||||
|
queueFullRequests int64
|
||||||
|
|
||||||
|
// Admission control metrics (#64)
|
||||||
|
admissionParked int64
|
||||||
|
admissionRejected map[string]int64
|
||||||
|
engineStateStale int64
|
||||||
|
|
||||||
|
// Engine gauge readings mirrored from vLLM (#64)
|
||||||
|
vllmRequestsRunning int64
|
||||||
|
vllmKVUsagePerc float64
|
||||||
|
observerDegraded int
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
ttftMs: make(map[string][]int64),
|
||||||
|
itlMs: make(map[string][]int64),
|
||||||
|
tokenCount: make(map[string]int64),
|
||||||
|
admissionRejected: 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,
|
||||||
|
"llm_ttft_ms": m.ttftMs,
|
||||||
|
"llm_itl_ms": m.itlMs,
|
||||||
|
"llm_token_count": m.tokenCount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordTTFT records Time-to-First-Token in milliseconds
|
||||||
|
func (m *Metrics) RecordTTFT(model string, ttftMs int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
key := fmt.Sprintf("llm:ttft:%s", model)
|
||||||
|
m.ttftMs[key] = append(m.ttftMs[key], ttftMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordITL records Inter-Token Latency in milliseconds
|
||||||
|
func (m *Metrics) RecordITL(model string, itlMs int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
key := fmt.Sprintf("llm:itl:%s", model)
|
||||||
|
m.itlMs[key] = append(m.itlMs[key], itlMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordTokenCount records number of tokens in response
|
||||||
|
func (m *Metrics) RecordTokenCount(model string, count int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
|
||||||
|
key := fmt.Sprintf("llm:tokens:%s", model)
|
||||||
|
m.tokenCount[key] += count
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTTFTMetrics returns TTFT statistics for Prometheus export
|
||||||
|
func (m *Metrics) GetTTFTMetrics() map[string]interface{} {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
for key, samples := range m.ttftMs {
|
||||||
|
if len(samples) > 0 {
|
||||||
|
result[key] = map[string]interface{}{
|
||||||
|
"count": len(samples),
|
||||||
|
"sum": sumInt64(samples),
|
||||||
|
"avg": sumInt64(samples) / int64(len(samples)),
|
||||||
|
"min": minInt64(samples),
|
||||||
|
"max": maxInt64(samples),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetITLMetrics returns ITL statistics for Prometheus export
|
||||||
|
func (m *Metrics) GetITLMetrics() map[string]interface{} {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
|
||||||
|
result := make(map[string]interface{})
|
||||||
|
for key, samples := range m.itlMs {
|
||||||
|
if len(samples) > 0 {
|
||||||
|
result[key] = map[string]interface{}{
|
||||||
|
"count": len(samples),
|
||||||
|
"sum": sumInt64(samples),
|
||||||
|
"avg": sumInt64(samples) / int64(len(samples)),
|
||||||
|
"min": minInt64(samples),
|
||||||
|
"max": maxInt64(samples),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func sumInt64(vals []int64) int64 {
|
||||||
|
var s int64
|
||||||
|
for _, v := range vals {
|
||||||
|
s += v
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
func minInt64(vals []int64) int64 {
|
||||||
|
if len(vals) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
min := vals[0]
|
||||||
|
for _, v := range vals {
|
||||||
|
if v < min {
|
||||||
|
min = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return min
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt64(vals []int64) int64 {
|
||||||
|
if len(vals) == 0 {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
max := vals[0]
|
||||||
|
for _, v := range vals {
|
||||||
|
if v > max {
|
||||||
|
max = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return max
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
m.ttftMs = make(map[string][]int64)
|
||||||
|
m.itlMs = make(map[string][]int64)
|
||||||
|
m.tokenCount = make(map[string]int64)
|
||||||
|
m.warmupRequestsDelayed = 0
|
||||||
|
m.prefixCacheHitTokens = 0
|
||||||
|
m.queueFullRequests = 0
|
||||||
|
m.admissionParked = 0
|
||||||
|
m.admissionRejected = make(map[string]int64)
|
||||||
|
m.engineStateStale = 0
|
||||||
|
m.vllmRequestsRunning = 0
|
||||||
|
m.vllmKVUsagePerc = 0
|
||||||
|
m.observerDegraded = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrQueueFull increments queue_full_requests_total.
|
||||||
|
func (m *Metrics) IncrQueueFull() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.queueFullRequests++
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetQueueFull returns queue_full_requests_total.
|
||||||
|
func (m *Metrics) GetQueueFull() int64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.queueFullRequests
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrWarmupDelayed increments warmup_requests_delayed_total.
|
||||||
|
func (m *Metrics) IncrWarmupDelayed() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.warmupRequestsDelayed++
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWarmupDelayed returns warmup_requests_delayed_total.
|
||||||
|
func (m *Metrics) GetWarmupDelayed() int64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.warmupRequestsDelayed
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrPrefixCacheHitTokens increments vllm_prefix_cache_hit_tokens_total by n.
|
||||||
|
func (m *Metrics) IncrPrefixCacheHitTokens(n int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.prefixCacheHitTokens += n
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPrefixCacheHitTokens returns vllm_prefix_cache_hit_tokens_total.
|
||||||
|
func (m *Metrics) GetPrefixCacheHitTokens() int64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.prefixCacheHitTokens
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrAdmissionParked increments admission_parked_total.
|
||||||
|
func (m *Metrics) IncrAdmissionParked() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.admissionParked++
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdmissionParked returns admission_parked_total.
|
||||||
|
func (m *Metrics) GetAdmissionParked() int64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.admissionParked
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrAdmissionRejected increments admission_rejected_total{reason=reason}.
|
||||||
|
func (m *Metrics) IncrAdmissionRejected(reason string) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.admissionRejected[reason]++
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAdmissionRejected returns a copy of admission_rejected_total by reason.
|
||||||
|
func (m *Metrics) GetAdmissionRejected() map[string]int64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
out := make(map[string]int64, len(m.admissionRejected))
|
||||||
|
for reason, count := range m.admissionRejected {
|
||||||
|
out[reason] = count
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// IncrEngineStateStale increments engine_state_stale_total.
|
||||||
|
func (m *Metrics) IncrEngineStateStale() {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.engineStateStale++
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetEngineStateStale returns engine_state_stale_total.
|
||||||
|
func (m *Metrics) GetEngineStateStale() int64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.engineStateStale
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVLLMRequestsRunning sets vllm_requests_running from the gateway's own reading.
|
||||||
|
func (m *Metrics) SetVLLMRequestsRunning(n int64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.vllmRequestsRunning = n
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVLLMRequestsRunning returns vllm_requests_running.
|
||||||
|
func (m *Metrics) GetVLLMRequestsRunning() int64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.vllmRequestsRunning
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetVLLMKVUsagePerc sets vllm_kv_usage_perc from the gateway's own reading.
|
||||||
|
func (m *Metrics) SetVLLMKVUsagePerc(v float64) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
m.vllmKVUsagePerc = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVLLMKVUsagePerc returns vllm_kv_usage_perc.
|
||||||
|
func (m *Metrics) GetVLLMKVUsagePerc() float64 {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.vllmKVUsagePerc
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetObserverDegraded sets observer_degraded (1 = startup self-check failed).
|
||||||
|
func (m *Metrics) SetObserverDegraded(degraded bool) {
|
||||||
|
m.mu.Lock()
|
||||||
|
defer m.mu.Unlock()
|
||||||
|
if degraded {
|
||||||
|
m.observerDegraded = 1
|
||||||
|
} else {
|
||||||
|
m.observerDegraded = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetObserverDegraded returns observer_degraded.
|
||||||
|
func (m *Metrics) GetObserverDegraded() int {
|
||||||
|
m.mu.RLock()
|
||||||
|
defer m.mu.RUnlock()
|
||||||
|
return m.observerDegraded
|
||||||
|
}
|
||||||
|
|
||||||
|
// globalMetrics is the singleton Metrics instance for gateway-wide telemetry.
|
||||||
|
var (
|
||||||
|
globalMetrics *Metrics
|
||||||
|
globalMetricsOnce sync.Once
|
||||||
|
)
|
||||||
|
|
||||||
|
// Global returns the singleton Metrics instance, initialising on first call.
|
||||||
|
func Global() *Metrics {
|
||||||
|
globalMetricsOnce.Do(func() { globalMetrics = NewMetrics() })
|
||||||
|
return globalMetrics
|
||||||
|
}
|
||||||
@@ -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,242 @@
|
|||||||
|
package observability
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PrometheusExporter exports metrics in Prometheus text format
|
||||||
|
type PrometheusExporter struct {
|
||||||
|
metrics *Metrics
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPrometheusExporter creates a new Prometheus exporter
|
||||||
|
func NewPrometheusExporter(m *Metrics) *PrometheusExporter {
|
||||||
|
return &PrometheusExporter{metrics: m}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Export returns metrics in Prometheus text format
|
||||||
|
func (p *PrometheusExporter) Export() string {
|
||||||
|
var lines []string
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP llm_ttft_seconds Time to first token for LLM inference (seconds)")
|
||||||
|
lines = append(lines, "# TYPE llm_ttft_seconds histogram")
|
||||||
|
p.exportTTFT(&lines)
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP llm_itl_seconds Inter-token latency for LLM inference (seconds)")
|
||||||
|
lines = append(lines, "# TYPE llm_itl_seconds histogram")
|
||||||
|
p.exportITL(&lines)
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP llm_tokens_total Total tokens generated")
|
||||||
|
lines = append(lines, "# TYPE llm_tokens_total counter")
|
||||||
|
p.exportTokens(&lines)
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP request_duration_seconds Request latency")
|
||||||
|
lines = append(lines, "# TYPE request_duration_seconds histogram")
|
||||||
|
p.exportRequestDuration(&lines)
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP queue_full_requests_total Requests rejected due to backend queue saturation")
|
||||||
|
lines = append(lines, "# TYPE queue_full_requests_total counter")
|
||||||
|
lines = append(lines, fmt.Sprintf("queue_full_requests_total %d", p.metrics.GetQueueFull()))
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP warmup_requests_delayed_total LLM warmup requests delayed for vLLM slot management")
|
||||||
|
lines = append(lines, "# TYPE warmup_requests_delayed_total counter")
|
||||||
|
lines = append(lines, fmt.Sprintf("warmup_requests_delayed_total %d", p.metrics.GetWarmupDelayed()))
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP vllm_prefix_cache_hit_tokens_total vLLM KV prefix cache hit tokens")
|
||||||
|
lines = append(lines, "# TYPE vllm_prefix_cache_hit_tokens_total counter")
|
||||||
|
lines = append(lines, fmt.Sprintf("vllm_prefix_cache_hit_tokens_total %d", p.metrics.GetPrefixCacheHitTokens()))
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP admission_parked_total Requests parked waiting for engine admission")
|
||||||
|
lines = append(lines, "# TYPE admission_parked_total counter")
|
||||||
|
lines = append(lines, fmt.Sprintf("admission_parked_total %d", p.metrics.GetAdmissionParked()))
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP admission_rejected_total Requests rejected by engine admission control")
|
||||||
|
lines = append(lines, "# TYPE admission_rejected_total counter")
|
||||||
|
rejected := p.metrics.GetAdmissionRejected()
|
||||||
|
reasons := make([]string, 0, len(rejected))
|
||||||
|
for reason := range rejected {
|
||||||
|
reasons = append(reasons, reason)
|
||||||
|
}
|
||||||
|
sort.Strings(reasons)
|
||||||
|
for _, reason := range reasons {
|
||||||
|
lines = append(lines, fmt.Sprintf(`admission_rejected_total{reason="%s"} %d`, reason, rejected[reason]))
|
||||||
|
}
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP engine_state_stale_total Times the engine metrics feed went stale")
|
||||||
|
lines = append(lines, "# TYPE engine_state_stale_total counter")
|
||||||
|
lines = append(lines, fmt.Sprintf("engine_state_stale_total %d", p.metrics.GetEngineStateStale()))
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP vllm_requests_running vLLM requests running as read by the gateway")
|
||||||
|
lines = append(lines, "# TYPE vllm_requests_running gauge")
|
||||||
|
lines = append(lines, fmt.Sprintf("vllm_requests_running %d", p.metrics.GetVLLMRequestsRunning()))
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP vllm_kv_usage_perc vLLM KV cache usage fraction as read by the gateway")
|
||||||
|
lines = append(lines, "# TYPE vllm_kv_usage_perc gauge")
|
||||||
|
lines = append(lines, fmt.Sprintf("vllm_kv_usage_perc %g", p.metrics.GetVLLMKVUsagePerc()))
|
||||||
|
|
||||||
|
lines = append(lines, "# HELP observer_degraded Engine metrics observer startup self-check failed (1) or healthy (0)")
|
||||||
|
lines = append(lines, "# TYPE observer_degraded gauge")
|
||||||
|
lines = append(lines, fmt.Sprintf("observer_degraded %d", p.metrics.GetObserverDegraded()))
|
||||||
|
|
||||||
|
return strings.Join(lines, "\n") + "\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PrometheusExporter) exportTTFT(lines *[]string) {
|
||||||
|
p.metrics.mu.RLock()
|
||||||
|
defer p.metrics.mu.RUnlock()
|
||||||
|
|
||||||
|
// Calculate statistics for each model
|
||||||
|
for key, samples := range p.metrics.ttftMs {
|
||||||
|
if len(samples) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
model := extractModel(key)
|
||||||
|
sum := sumInt64(samples)
|
||||||
|
|
||||||
|
// Export histogram buckets (in seconds)
|
||||||
|
buckets := []float64{0.001, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0}
|
||||||
|
for _, bucket := range buckets {
|
||||||
|
count := countLessOrEqual(samples, int64(bucket*1000))
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`llm_ttft_seconds_bucket{model="%s",le="%.3f"} %d`,
|
||||||
|
model, bucket, count,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`llm_ttft_seconds_bucket{model="%s",le="+Inf"} %d`,
|
||||||
|
model, len(samples),
|
||||||
|
))
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`llm_ttft_seconds_sum{model="%s"} %.3f`,
|
||||||
|
model, float64(sum)/1000,
|
||||||
|
))
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`llm_ttft_seconds_count{model="%s"} %d`,
|
||||||
|
model, len(samples),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PrometheusExporter) exportITL(lines *[]string) {
|
||||||
|
p.metrics.mu.RLock()
|
||||||
|
defer p.metrics.mu.RUnlock()
|
||||||
|
|
||||||
|
for key, samples := range p.metrics.itlMs {
|
||||||
|
if len(samples) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
model := extractModel(key)
|
||||||
|
sum := sumInt64(samples)
|
||||||
|
|
||||||
|
// Export histogram buckets (in seconds)
|
||||||
|
buckets := []float64{0.001, 0.01, 0.05, 0.1, 0.5, 1.0, 5.0}
|
||||||
|
for _, bucket := range buckets {
|
||||||
|
count := countLessOrEqual(samples, int64(bucket*1000))
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`llm_itl_seconds_bucket{model="%s",le="%.3f"} %d`,
|
||||||
|
model, bucket, count,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`llm_itl_seconds_bucket{model="%s",le="+Inf"} %d`,
|
||||||
|
model, len(samples),
|
||||||
|
))
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`llm_itl_seconds_sum{model="%s"} %.3f`,
|
||||||
|
model, float64(sum)/1000,
|
||||||
|
))
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`llm_itl_seconds_count{model="%s"} %d`,
|
||||||
|
model, len(samples),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PrometheusExporter) exportTokens(lines *[]string) {
|
||||||
|
p.metrics.mu.RLock()
|
||||||
|
defer p.metrics.mu.RUnlock()
|
||||||
|
|
||||||
|
// Sort keys for consistent output
|
||||||
|
var keys []string
|
||||||
|
for k := range p.metrics.tokenCount {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
model := extractModel(key)
|
||||||
|
count := p.metrics.tokenCount[key]
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`llm_tokens_total{model="%s"} %d`,
|
||||||
|
model, count,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PrometheusExporter) exportRequestDuration(lines *[]string) {
|
||||||
|
p.metrics.mu.RLock()
|
||||||
|
defer p.metrics.mu.RUnlock()
|
||||||
|
|
||||||
|
// Sort keys for consistent output
|
||||||
|
var keys []string
|
||||||
|
for k := range p.metrics.requestDuration {
|
||||||
|
keys = append(keys, k)
|
||||||
|
}
|
||||||
|
sort.Strings(keys)
|
||||||
|
|
||||||
|
for _, key := range keys {
|
||||||
|
route, upstream := parseKey(key)
|
||||||
|
totalMs := p.metrics.requestDuration[key]
|
||||||
|
count := int64(1) // We'd need to track count separately in real impl
|
||||||
|
|
||||||
|
if buckets, ok := p.metrics.requestDurationBuckets[key]; ok {
|
||||||
|
for bucket := range buckets {
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`request_duration_seconds_bucket{route="%s",upstream="%s",le="%.1f"} %d`,
|
||||||
|
route, upstream, bucket, buckets[bucket],
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`request_duration_seconds_sum{route="%s",upstream="%s"} %.3f`,
|
||||||
|
route, upstream, float64(totalMs)/1000,
|
||||||
|
))
|
||||||
|
*lines = append(*lines, fmt.Sprintf(
|
||||||
|
`request_duration_seconds_count{route="%s",upstream="%s"} %d`,
|
||||||
|
route, upstream, count,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func extractModel(key string) string {
|
||||||
|
parts := strings.Split(key, ":")
|
||||||
|
if len(parts) >= 3 {
|
||||||
|
return parts[2]
|
||||||
|
}
|
||||||
|
return key
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseKey(key string) (string, string) {
|
||||||
|
parts := strings.Split(key, ":")
|
||||||
|
if len(parts) >= 2 {
|
||||||
|
return parts[0], parts[1]
|
||||||
|
}
|
||||||
|
return key, ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func countLessOrEqual(samples []int64, threshold int64) int {
|
||||||
|
count := 0
|
||||||
|
for _, s := range samples {
|
||||||
|
if s <= threshold {
|
||||||
|
count++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// tokenRequest is the JSON body for POST /auth/token.
|
||||||
|
type tokenRequest struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Password string `json:"password"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// refreshRequest is the JSON body for POST /auth/refresh.
|
||||||
|
type refreshRequest struct {
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// authClient handles token exchange with the upstream identity provider.
|
||||||
|
// Extracted for testability — production uses http.DefaultClient,
|
||||||
|
// tests inject a stub.
|
||||||
|
type authClient interface {
|
||||||
|
PostForm(url string, data url.Values) (*http.Response, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// httpAuthClient wraps http.Client to implement authClient.
|
||||||
|
type httpAuthClient struct {
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *httpAuthClient) PostForm(url string, data url.Values) (*http.Response, error) {
|
||||||
|
return c.client.PostForm(url, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func newAuthClient() authClient {
|
||||||
|
return &httpAuthClient{
|
||||||
|
client: &http.Client{Timeout: 10 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuthToken exchanges username+password for a JWT via the upstream
|
||||||
|
// identity provider's token endpoint using grant_type=password.
|
||||||
|
func (h *Handler) handleAuthToken(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeProblemDetail(w, http.StatusMethodNotAllowed,
|
||||||
|
"about:blank#method-not-allowed", "Method Not Allowed",
|
||||||
|
"POST only", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.config.Auth.TokenURL == "" || h.config.Auth.ClientID == "" {
|
||||||
|
writeProblemDetail(w, http.StatusServiceUnavailable,
|
||||||
|
"about:blank#not-configured", "Not Configured",
|
||||||
|
"token endpoint not configured", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req tokenRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeProblemDetail(w, http.StatusBadRequest,
|
||||||
|
"about:blank#bad-request", "Bad Request",
|
||||||
|
"invalid JSON body", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Username == "" || req.Password == "" {
|
||||||
|
writeProblemDetail(w, http.StatusBadRequest,
|
||||||
|
"about:blank#bad-request", "Bad Request",
|
||||||
|
"username and password are required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
scope := req.Scope
|
||||||
|
if scope == "" {
|
||||||
|
scope = "openid roles permissions"
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"password"},
|
||||||
|
"username": {req.Username},
|
||||||
|
"password": {req.Password},
|
||||||
|
"client_id": {h.config.Auth.ClientID},
|
||||||
|
"client_secret": {h.config.Auth.ClientSecret},
|
||||||
|
"scope": {scope},
|
||||||
|
}
|
||||||
|
|
||||||
|
h.forwardTokenResponse(w, form, "/auth/token")
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuthRefresh exchanges a refresh token for a new JWT.
|
||||||
|
func (h *Handler) handleAuthRefresh(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeProblemDetail(w, http.StatusMethodNotAllowed,
|
||||||
|
"about:blank#method-not-allowed", "Method Not Allowed",
|
||||||
|
"POST only", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.config.Auth.TokenURL == "" || h.config.Auth.ClientID == "" {
|
||||||
|
writeProblemDetail(w, http.StatusServiceUnavailable,
|
||||||
|
"about:blank#not-configured", "Not Configured",
|
||||||
|
"token endpoint not configured", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req refreshRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeProblemDetail(w, http.StatusBadRequest,
|
||||||
|
"about:blank#bad-request", "Bad Request",
|
||||||
|
"invalid JSON body", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.RefreshToken == "" {
|
||||||
|
writeProblemDetail(w, http.StatusBadRequest,
|
||||||
|
"about:blank#bad-request", "Bad Request",
|
||||||
|
"refresh_token is required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"refresh_token"},
|
||||||
|
"refresh_token": {req.RefreshToken},
|
||||||
|
"client_id": {h.config.Auth.ClientID},
|
||||||
|
"client_secret": {h.config.Auth.ClientSecret},
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Scope != "" {
|
||||||
|
form.Set("scope", req.Scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
h.forwardTokenResponse(w, form, "/auth/refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardTokenResponse posts form data to the token endpoint and
|
||||||
|
// forwards the response verbatim to the client.
|
||||||
|
func (h *Handler) forwardTokenResponse(w http.ResponseWriter, form url.Values, logPath string) {
|
||||||
|
resp, err := h.authHTTP.PostForm(h.config.Auth.TokenURL, form)
|
||||||
|
if err != nil {
|
||||||
|
writeProblemDetail(w, http.StatusBadGateway,
|
||||||
|
"about:blank#bad-gateway", "Bad Gateway",
|
||||||
|
"identity provider unreachable", nil)
|
||||||
|
logging.Errorf("auth upstream error", err, map[string]string{
|
||||||
|
"path": logPath,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(resp.StatusCode)
|
||||||
|
io.Copy(w, resp.Body)
|
||||||
|
}
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubAuthClient captures the form data sent and returns a canned response.
|
||||||
|
type stubAuthClient struct {
|
||||||
|
lastForm url.Values
|
||||||
|
statusCode int
|
||||||
|
body string
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubAuthClient) PostForm(u string, data url.Values) (*http.Response, error) {
|
||||||
|
s.lastForm = data
|
||||||
|
if s.err != nil {
|
||||||
|
return nil, s.err
|
||||||
|
}
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: s.statusCode,
|
||||||
|
Body: io.NopCloser(strings.NewReader(s.body)),
|
||||||
|
Header: http.Header{"Content-Type": {"application/json"}},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newTestHandler(tokenURL, clientID, clientSecret string, client authClient) *Handler {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Auth: config.AuthConfig{
|
||||||
|
TokenURL: tokenURL,
|
||||||
|
ClientID: clientID,
|
||||||
|
ClientSecret: clientSecret,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := &Handler{
|
||||||
|
config: cfg,
|
||||||
|
authHTTP: client,
|
||||||
|
routes: make(map[string]*Route),
|
||||||
|
transports: make(map[string]*http.Transport),
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthToken_Success(t *testing.T) {
|
||||||
|
stub := &stubAuthClient{
|
||||||
|
statusCode: 200,
|
||||||
|
body: `{"access_token":"jwt.token.here","refresh_token":"refresh","expires_in":3600}`,
|
||||||
|
}
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
||||||
|
|
||||||
|
body := `{"username":"rock","password":"pass123"}`
|
||||||
|
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify form sent to upstream
|
||||||
|
if stub.lastForm.Get("grant_type") != "password" {
|
||||||
|
t.Errorf("grant_type = %q, want password", stub.lastForm.Get("grant_type"))
|
||||||
|
}
|
||||||
|
if stub.lastForm.Get("username") != "rock" {
|
||||||
|
t.Errorf("username = %q, want rock", stub.lastForm.Get("username"))
|
||||||
|
}
|
||||||
|
if stub.lastForm.Get("client_id") != "api-gw" {
|
||||||
|
t.Errorf("client_id = %q, want api-gw", stub.lastForm.Get("client_id"))
|
||||||
|
}
|
||||||
|
if stub.lastForm.Get("client_secret") != "secret" {
|
||||||
|
t.Errorf("client_secret = %q, want secret", stub.lastForm.Get("client_secret"))
|
||||||
|
}
|
||||||
|
if stub.lastForm.Get("scope") != "openid roles permissions" {
|
||||||
|
t.Errorf("scope = %q, want default scope", stub.lastForm.Get("scope"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify response forwarded
|
||||||
|
var resp map[string]interface{}
|
||||||
|
json.NewDecoder(w.Body).Decode(&resp)
|
||||||
|
if resp["access_token"] != "jwt.token.here" {
|
||||||
|
t.Errorf("access_token not forwarded")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthToken_CustomScope(t *testing.T) {
|
||||||
|
stub := &stubAuthClient{statusCode: 200, body: `{}`}
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
||||||
|
|
||||||
|
body := `{"username":"rock","password":"pass","scope":"openid roles"}`
|
||||||
|
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
|
||||||
|
if stub.lastForm.Get("scope") != "openid roles" {
|
||||||
|
t.Errorf("scope = %q, want custom scope", stub.lastForm.Get("scope"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthToken_MissingUsername(t *testing.T) {
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
||||||
|
|
||||||
|
body := `{"password":"pass"}`
|
||||||
|
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthToken_MissingPassword(t *testing.T) {
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
||||||
|
|
||||||
|
body := `{"username":"rock"}`
|
||||||
|
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthToken_InvalidJSON(t *testing.T) {
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
||||||
|
|
||||||
|
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader("not json"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthToken_WrongMethod(t *testing.T) {
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/auth/token", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
|
||||||
|
if w.Code != 405 {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthToken_NotConfigured(t *testing.T) {
|
||||||
|
h := newTestHandler("", "", "", &stubAuthClient{})
|
||||||
|
|
||||||
|
body := `{"username":"rock","password":"pass"}`
|
||||||
|
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Errorf("expected 503, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthToken_UpstreamError(t *testing.T) {
|
||||||
|
stub := &stubAuthClient{err: io.ErrUnexpectedEOF}
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
||||||
|
|
||||||
|
body := `{"username":"rock","password":"pass"}`
|
||||||
|
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
|
||||||
|
if w.Code != 502 {
|
||||||
|
t.Errorf("expected 502, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthToken_UpstreamRejectsCredentials(t *testing.T) {
|
||||||
|
stub := &stubAuthClient{
|
||||||
|
statusCode: 400,
|
||||||
|
body: `{"error":"invalid_grant","error_description":"bad password"}`,
|
||||||
|
}
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
||||||
|
|
||||||
|
body := `{"username":"rock","password":"wrong"}`
|
||||||
|
r := httptest.NewRequest("POST", "/auth/token", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
|
||||||
|
// Upstream error forwarded verbatim
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Errorf("expected 400 (forwarded), got %d", w.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(w.Body.String(), "invalid_grant") {
|
||||||
|
t.Errorf("expected upstream error forwarded, got %s", w.Body.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- /auth/refresh tests ---
|
||||||
|
|
||||||
|
func TestAuthRefresh_Success(t *testing.T) {
|
||||||
|
stub := &stubAuthClient{
|
||||||
|
statusCode: 200,
|
||||||
|
body: `{"access_token":"new.jwt","refresh_token":"new.refresh","expires_in":3600}`,
|
||||||
|
}
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
||||||
|
|
||||||
|
body := `{"refresh_token":"old.refresh"}`
|
||||||
|
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthRefresh(w, r)
|
||||||
|
|
||||||
|
if w.Code != 200 {
|
||||||
|
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if stub.lastForm.Get("grant_type") != "refresh_token" {
|
||||||
|
t.Errorf("grant_type = %q, want refresh_token", stub.lastForm.Get("grant_type"))
|
||||||
|
}
|
||||||
|
if stub.lastForm.Get("refresh_token") != "old.refresh" {
|
||||||
|
t.Errorf("refresh_token not sent")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRefresh_MissingToken(t *testing.T) {
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
||||||
|
|
||||||
|
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthRefresh(w, r)
|
||||||
|
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRefresh_WrongMethod(t *testing.T) {
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/auth/refresh", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthRefresh(w, r)
|
||||||
|
|
||||||
|
if w.Code != 405 {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRefresh_NotConfigured(t *testing.T) {
|
||||||
|
h := newTestHandler("", "", "", &stubAuthClient{})
|
||||||
|
|
||||||
|
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{"refresh_token":"x"}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthRefresh(w, r)
|
||||||
|
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Errorf("expected 503, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthRefresh_ExpiredToken(t *testing.T) {
|
||||||
|
stub := &stubAuthClient{
|
||||||
|
statusCode: 401,
|
||||||
|
body: `{"error":"invalid_grant","error_description":"token expired"}`,
|
||||||
|
}
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", stub)
|
||||||
|
|
||||||
|
r := httptest.NewRequest("POST", "/auth/refresh", strings.NewReader(`{"refresh_token":"expired"}`))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthRefresh(w, r)
|
||||||
|
|
||||||
|
if w.Code != 401 {
|
||||||
|
t.Errorf("expected 401 (forwarded), got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify no credentials are leaked in response bodies
|
||||||
|
func TestAuthToken_NoCredentialLeak(t *testing.T) {
|
||||||
|
stub := &stubAuthClient{statusCode: 200, body: `{"access_token":"tok"}`}
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "super-secret", stub)
|
||||||
|
|
||||||
|
body := `{"username":"rock","password":"my-password"}`
|
||||||
|
r := httptest.NewRequest("POST", "/auth/token", bytes.NewReader([]byte(body)))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
|
||||||
|
respBody := w.Body.String()
|
||||||
|
if strings.Contains(respBody, "super-secret") {
|
||||||
|
t.Error("client_secret leaked in response")
|
||||||
|
}
|
||||||
|
if strings.Contains(respBody, "my-password") {
|
||||||
|
t.Error("password leaked in response")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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.
|
// TestBodyBasedDispatch verifies that /v1/chat/completions routes based on model field.
|
||||||
@@ -381,3 +381,66 @@ func TestBodySizeCappedDispatch(t *testing.T) {
|
|||||||
t.Errorf("expected 200 for reasonable body, got %d", resp.StatusCode)
|
t.Errorf("expected 200 for reasonable body, got %d", resp.StatusCode)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestUpstreamModelRewrite verifies that the model field is rewritten when upstreamModel is set.
|
||||||
|
func TestUpstreamModelRewrite(t *testing.T) {
|
||||||
|
var receivedModel string
|
||||||
|
|
||||||
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
var payload map[string]interface{}
|
||||||
|
json.Unmarshal(body, &payload)
|
||||||
|
receivedModel = payload["model"].(string)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, `{}`)
|
||||||
|
}))
|
||||||
|
defer upstreamServer.Close()
|
||||||
|
|
||||||
|
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Models: map[string]*config.ModelUpstream{
|
||||||
|
// Client sends "ornith:35b", upstream expects "qwen2.5:72b-instruct"
|
||||||
|
"ornith:35b": {
|
||||||
|
Name: "ornith:35b",
|
||||||
|
Address: upstreamAddr,
|
||||||
|
UpstreamModel: "qwen2.5:72b-instruct",
|
||||||
|
},
|
||||||
|
// No rewrite - upstream model same as client model
|
||||||
|
"reasoning": {
|
||||||
|
Name: "reasoning",
|
||||||
|
Address: upstreamAddr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := New(cfg)
|
||||||
|
defer handler.Close()
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Test 1: Model should be rewritten
|
||||||
|
requestBody := `{"model":"ornith:35b","messages":[{"role":"user","content":"hi"}]}`
|
||||||
|
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 receivedModel != "qwen2.5:72b-instruct" {
|
||||||
|
t.Errorf("expected upstream to receive model 'qwen2.5:72b-instruct', got '%s'", receivedModel)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test 2: No rewrite when upstreamModel is empty
|
||||||
|
receivedModel = ""
|
||||||
|
requestBody = `{"model":"reasoning","messages":[{"role":"user","content":"hi"}]}`
|
||||||
|
resp, _ = http.Post(server.URL+"/v1/chat/completions", "application/json", strings.NewReader(requestBody))
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if receivedModel != "reasoning" {
|
||||||
|
t.Errorf("expected upstream to receive model 'reasoning', got '%s'", receivedModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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.
|
// TestBodySizeCapExact verifies that a body exactly at the cap is accepted.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestEmbeddingsPassthroughNoRewrite verifies /v1/embeddings is not rewritten
|
// TestEmbeddingsPassthroughNoRewrite verifies /v1/embeddings is not rewritten
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"math"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Metric names published by the vLLM engine. Admission is gated on these
|
||||||
|
// rather than on a per-pod semaphore: every gateway replica reads the same
|
||||||
|
// engine, so the limit is global by construction (#64).
|
||||||
|
const (
|
||||||
|
metricRequestsRunning = "vllm:num_requests_running"
|
||||||
|
metricRequestsWaiting = "vllm:num_requests_waiting"
|
||||||
|
metricKVCacheUsage = "vllm:kv_cache_usage_perc"
|
||||||
|
|
||||||
|
// enginePollInterval is how often each backend's /metrics is scraped.
|
||||||
|
enginePollInterval = 2 * time.Second
|
||||||
|
|
||||||
|
// engineStaleThreshold is the number of consecutive failed polls after
|
||||||
|
// which the state is marked unhealthy and admission fails open.
|
||||||
|
engineStaleThreshold = 3
|
||||||
|
|
||||||
|
// engineMetricsTimeout bounds a single scrape.
|
||||||
|
engineMetricsTimeout = 2 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
// EngineState is the gateway's view of one vLLM engine, mirrored from its
|
||||||
|
// own metrics endpoint.
|
||||||
|
type EngineState struct {
|
||||||
|
Running int64
|
||||||
|
Waiting int64
|
||||||
|
KVUsage float64
|
||||||
|
UpdatedAt time.Time
|
||||||
|
Healthy bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// InFlight returns the number of requests the engine currently holds:
|
||||||
|
// running plus waiting.
|
||||||
|
func (s EngineState) InFlight() int64 {
|
||||||
|
return s.Running + s.Waiting
|
||||||
|
}
|
||||||
|
|
||||||
|
// EngineObserver polls one vLLM backend's /metrics and publishes the gauges
|
||||||
|
// admission is gated on. Healthy is false until the first successful poll and
|
||||||
|
// after engineStaleThreshold consecutive failures.
|
||||||
|
type EngineObserver struct {
|
||||||
|
addr string
|
||||||
|
interval time.Duration
|
||||||
|
client *http.Client
|
||||||
|
|
||||||
|
mu sync.Mutex
|
||||||
|
state EngineState
|
||||||
|
failures int
|
||||||
|
degraded bool
|
||||||
|
tick chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewEngineObserver creates an observer for the backend at addr.
|
||||||
|
func NewEngineObserver(addr string, interval time.Duration) *EngineObserver {
|
||||||
|
return &EngineObserver{
|
||||||
|
addr: addr,
|
||||||
|
interval: interval,
|
||||||
|
// Own client: the proxy transports carry socket options and connect
|
||||||
|
// timeouts tuned for inference, not for a short metrics scrape.
|
||||||
|
client: &http.Client{Timeout: engineMetricsTimeout},
|
||||||
|
tick: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start launches the polling goroutine. It returns immediately; the goroutine
|
||||||
|
// exits when ctx is cancelled.
|
||||||
|
func (o *EngineObserver) Start(ctx context.Context) {
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(o.interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
// The first scrape waits one interval rather than firing at Start().
|
||||||
|
// Scraping immediately makes the gateway contact every observed backend
|
||||||
|
// before any request needs one, which is wasted work and a surprising
|
||||||
|
// side effect for anything asserting on upstream traffic. State is
|
||||||
|
// unknown until the first poll, and unknown fails open, so the deferral
|
||||||
|
// costs at most one interval of ungated admission. The startup
|
||||||
|
// self-check (decision Q21) still runs, on that first tick.
|
||||||
|
startup := true
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
o.poll(ctx, startup)
|
||||||
|
startup = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns the most recent engine state.
|
||||||
|
func (o *EngineObserver) Get() EngineState {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
return o.state
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tick returns a channel that is closed and replaced after every successful
|
||||||
|
// poll, waking requests parked on engine admission.
|
||||||
|
func (o *EngineObserver) Tick() <-chan struct{} {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
return o.tick
|
||||||
|
}
|
||||||
|
|
||||||
|
// Degraded reports whether the startup self-check found the metrics feed
|
||||||
|
// unusable (parse failure or missing series).
|
||||||
|
func (o *EngineObserver) Degraded() bool {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
return o.degraded
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns the latest state together with the tick channel that the
|
||||||
|
// next successful poll will close. Both are read under one lock on purpose:
|
||||||
|
// calling Get and Tick separately can miss the wakeup that lands between the
|
||||||
|
// two calls, parking a request for an extra poll interval.
|
||||||
|
func (o *EngineObserver) Snapshot() (EngineState, <-chan struct{}) {
|
||||||
|
o.mu.Lock()
|
||||||
|
defer o.mu.Unlock()
|
||||||
|
return o.state, o.tick
|
||||||
|
}
|
||||||
|
|
||||||
|
// markDegraded records whether the metrics feed is currently unusable and
|
||||||
|
// mirrors that into observer_degraded. It is cleared on the next parseable
|
||||||
|
// scrape, so one truncated response cannot pin the gauge for the process
|
||||||
|
// lifetime while metric-name drift after the first poll is still reported.
|
||||||
|
func (o *EngineObserver) markDegraded(v bool) {
|
||||||
|
o.mu.Lock()
|
||||||
|
changed := o.degraded != v
|
||||||
|
o.degraded = v
|
||||||
|
o.mu.Unlock()
|
||||||
|
if changed {
|
||||||
|
observability.Global().SetObserverDegraded(v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// poll scrapes /metrics once. startup marks the first poll, whose failure is a
|
||||||
|
// self-diagnosis signal rather than a transient blip.
|
||||||
|
func (o *EngineObserver) poll(ctx context.Context, startup bool) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://"+o.addr+"/metrics", nil)
|
||||||
|
if err != nil {
|
||||||
|
o.recordFailure()
|
||||||
|
logging.Warnf("engine metrics request build failed", map[string]string{
|
||||||
|
"address": o.addr,
|
||||||
|
"err": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
resp, err := o.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
o.recordFailure()
|
||||||
|
logging.Warnf("engine metrics scrape failed", map[string]string{
|
||||||
|
"address": o.addr,
|
||||||
|
"err": err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
o.recordFailure()
|
||||||
|
logging.Warnf("engine metrics scrape returned non-200", map[string]string{
|
||||||
|
"address": o.addr,
|
||||||
|
"status": strconv.Itoa(resp.StatusCode),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
running, waiting, kvUsage, err := ParseEngineMetrics(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
// Metric-name drift must be loud, never a silent fall-back to fail-open
|
||||||
|
// admission (decision Q21). The gauge tracks current state, so it clears
|
||||||
|
// on the next parseable scrape instead of pinning for the pod's life.
|
||||||
|
o.markDegraded(true)
|
||||||
|
if startup {
|
||||||
|
logging.Errorf("engine metrics unusable at startup", err, map[string]string{
|
||||||
|
"address": o.addr,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logging.Warnf("engine metrics parse failed", map[string]string{
|
||||||
|
"address": o.addr,
|
||||||
|
"err": err.Error(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
o.recordFailure()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
o.markDegraded(false)
|
||||||
|
o.recordSuccess(running, waiting, kvUsage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordSuccess stores a fresh reading and wakes parked waiters.
|
||||||
|
func (o *EngineObserver) recordSuccess(running, waiting int64, kvUsage float64) {
|
||||||
|
o.mu.Lock()
|
||||||
|
o.state = EngineState{
|
||||||
|
Running: running,
|
||||||
|
Waiting: waiting,
|
||||||
|
KVUsage: kvUsage,
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
Healthy: true,
|
||||||
|
}
|
||||||
|
o.failures = 0
|
||||||
|
close(o.tick)
|
||||||
|
o.tick = make(chan struct{})
|
||||||
|
o.mu.Unlock()
|
||||||
|
|
||||||
|
m := observability.Global()
|
||||||
|
m.SetVLLMRequestsRunning(running)
|
||||||
|
m.SetVLLMKVUsagePerc(kvUsage)
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordFailure counts a failed poll and, on the third consecutive failure,
|
||||||
|
// marks the state unhealthy and counts the staleness transition once — not
|
||||||
|
// once per failed poll. Last known values are kept.
|
||||||
|
func (o *EngineObserver) recordFailure() {
|
||||||
|
o.mu.Lock()
|
||||||
|
o.failures++
|
||||||
|
stale := o.failures == engineStaleThreshold
|
||||||
|
if o.failures >= engineStaleThreshold {
|
||||||
|
o.state.Healthy = false
|
||||||
|
}
|
||||||
|
failures := o.failures
|
||||||
|
o.mu.Unlock()
|
||||||
|
|
||||||
|
if stale {
|
||||||
|
observability.Global().IncrEngineStateStale()
|
||||||
|
logging.Warnf("engine metrics stale; admission is failing open", map[string]string{
|
||||||
|
"address": o.addr,
|
||||||
|
"failures": strconv.Itoa(failures),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseEngineMetrics extracts the running, waiting and KV-cache-usage gauges
|
||||||
|
// from a vLLM Prometheus exposition body. Comment lines, unrelated series and
|
||||||
|
// non-finite samples are ignored; values are summed across repeated label sets.
|
||||||
|
// It returns an error naming any of the three series that is absent.
|
||||||
|
func ParseEngineMetrics(r io.Reader) (running int64, waiting int64, kvUsage float64, err error) {
|
||||||
|
scanner := bufio.NewScanner(r)
|
||||||
|
// The real exposition body is large; give the scanner room per line.
|
||||||
|
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||||
|
|
||||||
|
var hasRunning, hasWaiting, hasKV bool
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
name, value, ok := splitMetricSample(line)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case metricRequestsRunning, metricRequestsWaiting, metricKVCacheUsage:
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
f, err := strconv.ParseFloat(value, 64)
|
||||||
|
if err != nil || math.IsNaN(f) || math.IsInf(f, 0) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch name {
|
||||||
|
case metricRequestsRunning:
|
||||||
|
running += int64(f)
|
||||||
|
hasRunning = true
|
||||||
|
case metricRequestsWaiting:
|
||||||
|
waiting += int64(f)
|
||||||
|
hasWaiting = true
|
||||||
|
case metricKVCacheUsage:
|
||||||
|
kvUsage += f
|
||||||
|
hasKV = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := scanner.Err(); err != nil {
|
||||||
|
return 0, 0, 0, fmt.Errorf("read engine metrics: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var missing []string
|
||||||
|
if !hasRunning {
|
||||||
|
missing = append(missing, metricRequestsRunning)
|
||||||
|
}
|
||||||
|
if !hasWaiting {
|
||||||
|
missing = append(missing, metricRequestsWaiting)
|
||||||
|
}
|
||||||
|
if !hasKV {
|
||||||
|
missing = append(missing, metricKVCacheUsage)
|
||||||
|
}
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return 0, 0, 0, fmt.Errorf("engine metrics missing series: %s", strings.Join(missing, ", "))
|
||||||
|
}
|
||||||
|
return running, waiting, kvUsage, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// splitMetricSample splits one Prometheus exposition line into its metric name
|
||||||
|
// and its first sample value. Lines without a value are reported as not ok.
|
||||||
|
func splitMetricSample(line string) (name, value string, ok bool) {
|
||||||
|
end := strings.IndexAny(line, "{ \t")
|
||||||
|
if end <= 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
name = line[:end]
|
||||||
|
rest := line[end:]
|
||||||
|
if rest[0] == '{' {
|
||||||
|
closeBrace := strings.LastIndex(rest, "}")
|
||||||
|
if closeBrace < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
rest = rest[closeBrace+1:]
|
||||||
|
}
|
||||||
|
fields := strings.Fields(rest)
|
||||||
|
if len(fields) == 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return name, fields[0], true
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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.
|
// TestHeaderHygiene verifies that headers are properly filtered and forwarded.
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ResponseWriterWrapper wraps an http.ResponseWriter to capture per-request LLM
|
||||||
|
// latency metrics (TTFT, ITL, generated tokens) while a streaming response is
|
||||||
|
// copied from the upstream by httputil.ReverseProxy.
|
||||||
|
//
|
||||||
|
// Only server-sent-event responses are measured. A non-streaming completion is
|
||||||
|
// written in a single Write, so recording it would emit one "TTFT" sample equal
|
||||||
|
// to the whole request latency and no ITL samples at all — numbers that look
|
||||||
|
// plausible but mean nothing.
|
||||||
|
type ResponseWriterWrapper struct {
|
||||||
|
writer http.ResponseWriter
|
||||||
|
statusCode int
|
||||||
|
metrics *observability.Metrics
|
||||||
|
model string
|
||||||
|
startTime time.Time
|
||||||
|
firstByteTime time.Time
|
||||||
|
lastWriteTime time.Time
|
||||||
|
ttftRecorded bool
|
||||||
|
streamChecked bool
|
||||||
|
streaming bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewResponseWriterWrapper creates a wrapper for response writer.
|
||||||
|
// startTime must be the time the request entered the gateway, not the time this
|
||||||
|
// wrapper was constructed, otherwise TTFT measures the wrong interval.
|
||||||
|
func NewResponseWriterWrapper(w http.ResponseWriter, model string, metrics *observability.Metrics, startTime time.Time) *ResponseWriterWrapper {
|
||||||
|
return &ResponseWriterWrapper{
|
||||||
|
writer: w,
|
||||||
|
model: model,
|
||||||
|
metrics: metrics,
|
||||||
|
startTime: startTime,
|
||||||
|
statusCode: 200,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Header implements http.ResponseWriter
|
||||||
|
func (w *ResponseWriterWrapper) Header() http.Header {
|
||||||
|
return w.writer.Header()
|
||||||
|
}
|
||||||
|
|
||||||
|
// isStreaming reports whether this is an SSE response. Resolved once from the
|
||||||
|
// Content-Type header, which ReverseProxy has already set before the first write.
|
||||||
|
func (w *ResponseWriterWrapper) isStreaming() bool {
|
||||||
|
if !w.streamChecked {
|
||||||
|
w.streamChecked = true
|
||||||
|
w.streaming = strings.Contains(w.writer.Header().Get("Content-Type"), "text/event-stream")
|
||||||
|
}
|
||||||
|
return w.streaming
|
||||||
|
}
|
||||||
|
|
||||||
|
// countSSETokens counts generated tokens in a chunk of an SSE stream. These
|
||||||
|
// upstreams emit one `data:` event per token, so the event count is the token
|
||||||
|
// count; the `[DONE]` terminator is not a token.
|
||||||
|
func countSSETokens(b []byte) int64 {
|
||||||
|
var n int64
|
||||||
|
for _, line := range strings.Split(string(b), "\n") {
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "data:") && !strings.HasPrefix(line, "data: [DONE]") {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write implements http.ResponseWriter
|
||||||
|
func (w *ResponseWriterWrapper) Write(b []byte) (int, error) {
|
||||||
|
if len(b) > 0 && w.isStreaming() {
|
||||||
|
now := time.Now()
|
||||||
|
if !w.ttftRecorded {
|
||||||
|
w.metrics.RecordTTFT(w.model, now.Sub(w.startTime).Milliseconds())
|
||||||
|
w.ttftRecorded = true
|
||||||
|
w.firstByteTime = now
|
||||||
|
} else if itl := now.Sub(w.lastWriteTime).Milliseconds(); itl > 0 {
|
||||||
|
// Only record positive ITL: a zero means two chunks landed in the
|
||||||
|
// same millisecond, which carries no timing information.
|
||||||
|
w.metrics.RecordITL(w.model, itl)
|
||||||
|
}
|
||||||
|
w.lastWriteTime = now
|
||||||
|
|
||||||
|
if n := countSSETokens(b); n > 0 {
|
||||||
|
w.metrics.RecordTokenCount(w.model, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return w.writer.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteHeader implements http.ResponseWriter
|
||||||
|
func (w *ResponseWriterWrapper) WriteHeader(statusCode int) {
|
||||||
|
w.statusCode = statusCode
|
||||||
|
w.writer.WriteHeader(statusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Flush implements http.Flusher
|
||||||
|
func (w *ResponseWriterWrapper) Flush() {
|
||||||
|
if flusher, ok := w.writer.(http.Flusher); ok {
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hijack implements http.Hijacker for streaming
|
||||||
|
func (w *ResponseWriterWrapper) Hijack() (net.Conn, *bufio.ReadWriter, error) {
|
||||||
|
if hijacker, ok := w.writer.(http.Hijacker); ok {
|
||||||
|
return hijacker.Hijack()
|
||||||
|
}
|
||||||
|
return nil, nil, fmt.Errorf("response writer does not implement Hijacker")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Unwrap exposes the underlying writer to http.ResponseController so optional
|
||||||
|
// capabilities (Flush, Hijack, deadlines) keep working through the wrapper.
|
||||||
|
func (w *ResponseWriterWrapper) Unwrap() http.ResponseWriter {
|
||||||
|
return w.writer
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exportMetrics renders the Prometheus text exposition for m.
|
||||||
|
func exportMetrics(m *observability.Metrics) string {
|
||||||
|
return observability.NewPrometheusExporter(m).Export()
|
||||||
|
}
|
||||||
|
|
||||||
|
// requireSeries asserts the exporter emitted a sample line with the exact value.
|
||||||
|
func requireSeries(t *testing.T, out, want string) {
|
||||||
|
t.Helper()
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
if strings.TrimSpace(line) == want {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Errorf("missing metrics series %q\n--- got ---\n%s", want, out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResponseWriterWrapperRecordsSSEMetrics guards the regression where the
|
||||||
|
// LLM latency wrapper existed but was never constructed, leaving
|
||||||
|
// llm_ttft_seconds / llm_itl_seconds / llm_tokens_total as HELP-only stubs.
|
||||||
|
func TestResponseWriterWrapperRecordsSSEMetrics(t *testing.T) {
|
||||||
|
m := observability.NewMetrics()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
rec.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
|
||||||
|
w := NewResponseWriterWrapper(rec, "reasoning", m, time.Now().Add(-50*time.Millisecond))
|
||||||
|
|
||||||
|
if _, err := w.Write([]byte("data: {\"token\":\"a\"}\n\n")); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
if _, err := w.Write([]byte("data: {\"token\":\"b\"}\n\n")); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
// The terminator is not a generated token.
|
||||||
|
if _, err := w.Write([]byte("data: [DONE]\n\n")); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := exportMetrics(m)
|
||||||
|
requireSeries(t, out, `llm_ttft_seconds_count{model="reasoning"} 1`)
|
||||||
|
requireSeries(t, out, `llm_itl_seconds_count{model="reasoning"} 1`)
|
||||||
|
requireSeries(t, out, `llm_tokens_total{model="reasoning"} 2`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResponseWriterWrapperIgnoresNonStreaming verifies a single-shot JSON
|
||||||
|
// response does not produce a bogus TTFT sample equal to full request latency.
|
||||||
|
func TestResponseWriterWrapperIgnoresNonStreaming(t *testing.T) {
|
||||||
|
m := observability.NewMetrics()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
rec.Header().Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := NewResponseWriterWrapper(rec, "reasoning", m, time.Now().Add(-50*time.Millisecond))
|
||||||
|
if _, err := w.Write([]byte(`{"choices":[]}`)); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := exportMetrics(m)
|
||||||
|
if strings.Contains(out, `llm_ttft_seconds_count{model="reasoning"}`) {
|
||||||
|
t.Errorf("non-streaming response recorded TTFT\n--- got ---\n%s", out)
|
||||||
|
}
|
||||||
|
if strings.Contains(out, `llm_tokens_total{model="reasoning"}`) {
|
||||||
|
t.Errorf("non-streaming response recorded tokens\n--- got ---\n%s", out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestResponseWriterWrapperPreservesStreaming verifies the wrapper stays
|
||||||
|
// transparent: bytes reach the client and Flush still works, so wrapping the
|
||||||
|
// writer cannot silently break SSE (issue #33).
|
||||||
|
func TestResponseWriterWrapperPreservesStreaming(t *testing.T) {
|
||||||
|
m := observability.NewMetrics()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
rec.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
|
||||||
|
w := NewResponseWriterWrapper(rec, "reasoning", m, time.Now())
|
||||||
|
payload := "data: {\"token\":\"a\"}\n\n"
|
||||||
|
if _, err := w.Write([]byte(payload)); err != nil {
|
||||||
|
t.Fatalf("write: %v", err)
|
||||||
|
}
|
||||||
|
w.Flush()
|
||||||
|
|
||||||
|
if got := rec.Body.String(); got != payload {
|
||||||
|
t.Errorf("body = %q, want %q", got, payload)
|
||||||
|
}
|
||||||
|
if !rec.Flushed {
|
||||||
|
t.Error("Flush did not reach the underlying writer")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestHandlerRecordsLLMMetricsForStreaming is the end-to-end guard: it drives a
|
||||||
|
// real streaming request through the Handler, so it fails if the wrapper is ever
|
||||||
|
// constructed nowhere (the original defect) rather than merely being broken.
|
||||||
|
func TestHandlerRecordsLLMMetricsForStreaming(t *testing.T) {
|
||||||
|
// Unique model name keeps the observations isolated in the global registry.
|
||||||
|
model := fmt.Sprintf("metrics-test-%d", time.Now().UnixNano())
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
rc := http.NewResponseController(w)
|
||||||
|
for _, tok := range []string{"a", "b", "c"} {
|
||||||
|
time.Sleep(2 * time.Millisecond) // guarantee distinct ITL samples
|
||||||
|
fmt.Fprintf(w, "data: {\"token\":%q}\n\n", tok)
|
||||||
|
if err := rc.Flush(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, "data: [DONE]\n\n")
|
||||||
|
_ = rc.Flush()
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Models: map[string]*config.ModelUpstream{
|
||||||
|
model: {
|
||||||
|
Name: model,
|
||||||
|
Address: strings.TrimPrefix(upstream.URL, "http://"),
|
||||||
|
Path: "/v1/chat/completions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := New(cfg)
|
||||||
|
defer handler.Close()
|
||||||
|
srv := httptest.NewServer(handler)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
body := fmt.Sprintf(`{"model":%q,"stream":true,"messages":[{"role":"user","content":"hi"}]}`, model)
|
||||||
|
resp, err := http.Post(srv.URL+"/v1/chat/completions", "application/json", strings.NewReader(body))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status = %d, want 200", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if _, err := io.ReadAll(resp.Body); err != nil {
|
||||||
|
t.Fatalf("read body: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
out := exportMetrics(observability.Global())
|
||||||
|
requireSeries(t, out, fmt.Sprintf(`llm_ttft_seconds_count{model=%q} 1`, model))
|
||||||
|
requireSeries(t, out, fmt.Sprintf(`llm_tokens_total{model=%q} 3`, model))
|
||||||
|
|
||||||
|
itlCount := regexp.MustCompile(fmt.Sprintf(`llm_itl_seconds_count\{model=%q\} (\d+)`, model))
|
||||||
|
match := itlCount.FindStringSubmatch(out)
|
||||||
|
if match == nil {
|
||||||
|
t.Errorf("no llm_itl_seconds samples recorded for %s\n--- got ---\n%s", model, out)
|
||||||
|
} else if match[1] == "0" {
|
||||||
|
t.Errorf("llm_itl_seconds_count = 0, want > 0")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ModelListResponse represents the response shape for GET /v1/models
|
// ModelListResponse represents the response shape for GET /v1/models
|
||||||
|
|||||||
+606
-86
@@ -2,19 +2,30 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net"
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httputil"
|
"net/http/httputil"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/logging"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Handler is a reverse proxy that routes requests to configured upstreams.
|
// Handler is a reverse proxy that routes requests to configured upstreams.
|
||||||
@@ -24,18 +35,33 @@ type Handler struct {
|
|||||||
transports map[string]*http.Transport
|
transports map[string]*http.Transport
|
||||||
// config holds the gateway configuration (for model registry, etc.)
|
// config holds the gateway configuration (for model registry, etc.)
|
||||||
config *config.Config
|
config *config.Config
|
||||||
|
// jwtValidator validates JWT tokens for authenticated endpoints
|
||||||
|
jwtValidator *auth.Validator
|
||||||
|
// authHTTP is the HTTP client for token exchange with the identity provider.
|
||||||
|
authHTTP authClient
|
||||||
// Default timeouts for synthesized routes (model-based dispatch)
|
// Default timeouts for synthesized routes (model-based dispatch)
|
||||||
defaultConnectTimeout time.Duration
|
defaultConnectTimeout time.Duration
|
||||||
defaultReadTimeout time.Duration
|
defaultReadTimeout time.Duration
|
||||||
defaultWriteTimeout time.Duration
|
defaultWriteTimeout time.Duration
|
||||||
defaultMaxBodySize int64
|
defaultMaxBodySize int64
|
||||||
|
// CAG: per-model backend schedulers for cache-aware routing (#45/#46)
|
||||||
|
schedulers map[string]*Scheduler
|
||||||
|
// CAG: delay applied to warmup requests before queue entry (#45)
|
||||||
|
warmupDelay time.Duration
|
||||||
|
// engineObservers polls each model backend's /metrics for admission (#64)
|
||||||
|
engineObservers map[string]*EngineObserver
|
||||||
|
// maxBodyBytes caps synthesized model-route request bodies (MAX_BODY_BYTES)
|
||||||
|
maxBodyBytes int64
|
||||||
|
// transportsMu guards h.transports for goroutine-safe reads/writes.
|
||||||
|
transportsMu sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
// Route represents a reverse proxy route.
|
// Route represents a reverse proxy route.
|
||||||
type Route struct {
|
type Route struct {
|
||||||
Name string
|
Name string
|
||||||
Upstream *config.Upstream
|
ModelName string // non-empty for model-based routes; used for scheduler lookup
|
||||||
Director func(*http.Request)
|
Upstream *config.Upstream
|
||||||
|
Director func(*http.Request)
|
||||||
Transport *http.Transport
|
Transport *http.Transport
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -72,11 +98,127 @@ func New(cfg *config.Config) *Handler {
|
|||||||
defaultMaxBodySize: 100 * 1024 * 1024,
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.Auth.TokenURL != "" {
|
||||||
|
h.authHTTP = newAuthClient()
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAG: warmup delay from env (#45)
|
||||||
|
warmupMs := int64(500)
|
||||||
|
if v := os.Getenv("WARMUP_DELAY_MS"); v != "" {
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n >= 0 {
|
||||||
|
warmupMs = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.warmupDelay = time.Duration(warmupMs) * time.Millisecond
|
||||||
|
|
||||||
|
// CAG: per-model backend schedulers (#45/#46)
|
||||||
|
maxSlots := 4
|
||||||
|
if v := os.Getenv("VLLM_MAX_NUM_SEQS"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
maxSlots = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxQueue := 8
|
||||||
|
if v := os.Getenv("VLLM_MAX_QUEUE_DEPTH"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
maxQueue = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue #64: engine-wide admission thresholds. MaxInFlight defaults to the
|
||||||
|
// same value as VLLM_MAX_NUM_SEQS; the observers below read the engine's
|
||||||
|
// own gauges, which every replica shares.
|
||||||
|
admissionMaxInFlight := maxSlots
|
||||||
|
if v := os.Getenv("ADMISSION_MAX_INFLIGHT"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
admissionMaxInFlight = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
admissionMaxKVUsage := 0.90
|
||||||
|
if v := os.Getenv("ADMISSION_MAX_KV_USAGE"); v != "" {
|
||||||
|
if f, err := strconv.ParseFloat(v, 64); err == nil && f > 0 {
|
||||||
|
admissionMaxKVUsage = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
maxParkSeconds := 120
|
||||||
|
if v := os.Getenv("ADMISSION_MAX_PARK_SECONDS"); v != "" {
|
||||||
|
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
||||||
|
maxParkSeconds = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.maxBodyBytes = 1048576
|
||||||
|
if v := os.Getenv("MAX_BODY_BYTES"); v != "" {
|
||||||
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil && n > 0 {
|
||||||
|
h.maxBodyBytes = n
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
limits := AdmissionLimits{
|
||||||
|
MaxInFlight: admissionMaxInFlight,
|
||||||
|
MaxKVUsage: admissionMaxKVUsage,
|
||||||
|
MaxPark: time.Duration(maxParkSeconds) * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue #64: only backends that actually publish the vLLM scheduler gauges
|
||||||
|
// may be observed. `reasoning` is the only vLLM engine here; ornith and
|
||||||
|
// qwen-cpu are Ollama and embeddings/rerank are TEI, none of which export
|
||||||
|
// vllm:* series. Observing them would trip the startup self-check and hold
|
||||||
|
// observer_degraded at 1 forever. Unset (the default) observes nothing and
|
||||||
|
// leaves admission exactly as it was before this change.
|
||||||
|
observeModels := make(map[string]bool)
|
||||||
|
for _, name := range strings.Split(os.Getenv("ADMISSION_OBSERVE_MODELS"), ",") {
|
||||||
|
if name = strings.TrimSpace(name); name != "" {
|
||||||
|
observeModels[name] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h.engineObservers = make(map[string]*EngineObserver)
|
||||||
|
h.schedulers = make(map[string]*Scheduler)
|
||||||
|
for modelName, model := range cfg.Models {
|
||||||
|
addrs := model.AllAddresses()
|
||||||
|
if len(addrs) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if observeModels[modelName] {
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if _, ok := h.engineObservers[addr]; !ok {
|
||||||
|
h.engineObservers[addr] = NewEngineObserver(addr, enginePollInterval)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
h.schedulers[modelName] = NewScheduler(addrs, maxSlots, maxQueue, h.engineObservers, limits)
|
||||||
|
}
|
||||||
|
// The handler has no request-path lifecycle to hang cancellation on, so the
|
||||||
|
// observers poll for the lifetime of the process.
|
||||||
|
for _, o := range h.engineObservers {
|
||||||
|
o.Start(context.Background())
|
||||||
|
}
|
||||||
|
// Pre-populate transports for all scheduler backends at startup (single-threaded).
|
||||||
|
// getOrCreateTransport is not goroutine-safe; pre-populating avoids concurrent writes.
|
||||||
|
for _, model := range cfg.Models {
|
||||||
|
for _, addr := range model.AllAddresses() {
|
||||||
|
h.getOrCreateTransport(addr, &config.Upstream{
|
||||||
|
ConnectTimeout: h.defaultConnectTimeout,
|
||||||
|
ReadTimeout: h.defaultReadTimeout,
|
||||||
|
WriteTimeout: h.defaultWriteTimeout,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for name, route := range cfg.Routes {
|
for name, route := range cfg.Routes {
|
||||||
// Create a transport per unique upstream address for connection reuse
|
// Create a transport per unique upstream address for connection reuse
|
||||||
transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream)
|
transport := h.getOrCreateTransport(route.Upstream.Address, &route.Upstream)
|
||||||
|
|
||||||
upstreamURL, _ := url.Parse("http://" + route.Upstream.Address)
|
upstreamURL := &url.URL{Scheme: "http", Host: route.Upstream.Address}
|
||||||
|
|
||||||
r := &Route{
|
r := &Route{
|
||||||
Name: name,
|
Name: name,
|
||||||
@@ -95,7 +237,16 @@ func New(cfg *config.Config) *Handler {
|
|||||||
// getOrCreateTransport returns a shared http.Transport for the given upstream address.
|
// getOrCreateTransport returns a shared http.Transport for the given upstream address.
|
||||||
// This ensures connections are pooled and reused across requests to the same upstream.
|
// This ensures connections are pooled and reused across requests to the same upstream.
|
||||||
func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.Transport {
|
func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.Transport {
|
||||||
|
h.transportsMu.RLock()
|
||||||
if t, ok := h.transports[addr]; ok {
|
if t, ok := h.transports[addr]; ok {
|
||||||
|
h.transportsMu.RUnlock()
|
||||||
|
return t
|
||||||
|
}
|
||||||
|
h.transportsMu.RUnlock()
|
||||||
|
|
||||||
|
h.transportsMu.Lock()
|
||||||
|
defer h.transportsMu.Unlock()
|
||||||
|
if t, ok := h.transports[addr]; ok { // double-check after write lock
|
||||||
return t
|
return t
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -107,6 +258,15 @@ func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.T
|
|||||||
dialer := &net.Dialer{
|
dialer := &net.Dialer{
|
||||||
Timeout: up.ConnectTimeout,
|
Timeout: up.ConnectTimeout,
|
||||||
KeepAlive: 30 * time.Second,
|
KeepAlive: 30 * time.Second,
|
||||||
|
// Issue #31: TCP_NODELAY disables Nagle's algorithm, reducing latency
|
||||||
|
// for streaming responses by sending small packets immediately instead of
|
||||||
|
// waiting for larger batches. Critical for low-latency LLM token streaming.
|
||||||
|
Control: func(network, address string, c syscall.RawConn) error {
|
||||||
|
return c.Control(func(fd uintptr) {
|
||||||
|
// TCP_NODELAY disables Nagle's algorithm for immediate packet transmission
|
||||||
|
_ = syscall.SetsockoptInt(int(fd), syscall.IPPROTO_TCP, syscall.TCP_NODELAY, 1)
|
||||||
|
})
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
transport := &http.Transport{
|
transport := &http.Transport{
|
||||||
@@ -114,8 +274,15 @@ func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.T
|
|||||||
DialContext: dialer.DialContext,
|
DialContext: dialer.DialContext,
|
||||||
MaxIdleConns: 100,
|
MaxIdleConns: 100,
|
||||||
IdleConnTimeout: 90 * time.Second,
|
IdleConnTimeout: 90 * time.Second,
|
||||||
|
// Issue #32: Increase per-host connection limit to support HTTP/2 multiplexing.
|
||||||
|
// With HTTP/2, we can serve many concurrent streams over fewer connections,
|
||||||
|
// but we still allow more connections for better resource utilization.
|
||||||
|
MaxConnsPerHost: 10,
|
||||||
// Allow persistent connections
|
// Allow persistent connections
|
||||||
DisableKeepAlives: false,
|
DisableKeepAlives: false,
|
||||||
|
// Issue #31: Enable HTTP/2 for client connections to support multiplexing.
|
||||||
|
// This allows concurrent requests to stream simultaneously with better flow control.
|
||||||
|
ForceAttemptHTTP2: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store the upstream config for use in the handler
|
// Store the upstream config for use in the handler
|
||||||
@@ -128,7 +295,7 @@ func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.transports[addr] = transport
|
h.transports[addr] = transport
|
||||||
return transport
|
return transport // written under transportsMu write lock
|
||||||
}
|
}
|
||||||
|
|
||||||
// directorFunc modifies the request to be sent to the upstream.
|
// directorFunc modifies the request to be sent to the upstream.
|
||||||
@@ -214,63 +381,263 @@ func writeProblemDetail(w http.ResponseWriter, status int, problemType, title, d
|
|||||||
|
|
||||||
// ServeHTTP implements http.Handler.
|
// ServeHTTP implements http.Handler.
|
||||||
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Captured before any work so TTFT measures from the moment the gateway
|
||||||
|
// accepted the request, not from when the upstream response is copied.
|
||||||
|
requestStart := time.Now()
|
||||||
|
|
||||||
|
if h.handleAuthEndpoint(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
// Handle /v1/models endpoint (no routing needed, derived from config)
|
// Handle /v1/models endpoint (no routing needed, derived from config)
|
||||||
if r.URL.Path == "/v1/models" && r.Method == "GET" {
|
if r.URL.Path == "/v1/models" && r.Method == "GET" {
|
||||||
h.handleModelsEndpoint(w, r)
|
h.handleModelsEndpoint(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Try to find a matching route (including body-based dispatch for /v1/chat/completions)
|
// Resolve route; handles validation errors and 404.
|
||||||
|
route, ok := h.routeOrError(w, r)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Strip spoofed identity headers from all inbound requests.
|
||||||
|
// Must happen before any routing — even unauthenticated paths.
|
||||||
|
identity.StripIncoming(r)
|
||||||
|
|
||||||
|
// JWT Authentication for /v1/* endpoints.
|
||||||
|
if !h.authenticateV1(w, r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Enforce request body size cap for non-chat routes.
|
||||||
|
if !h.enforceBodyLimit(w, r, route) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAG: warmup deprioritisation + per-backend semaphore (issues #45/#46).
|
||||||
|
cacheKey := r.Header.Get("X-Poimen-Cache-Key")
|
||||||
|
sched := h.schedulers[route.ModelName]
|
||||||
|
proxy, backend, ok := h.setupCAGProxy(r.Context(), w, r, route, sched, cacheKey)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if backend != nil {
|
||||||
|
defer backend.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
// For streaming responses (SSE, chunked), disable buffering. Issue #33.
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
|
// Capture per-request LLM latency metrics (TTFT/ITL/tokens) while the
|
||||||
|
// response is copied from the upstream. Inert for non-SSE responses.
|
||||||
|
var respWriter http.ResponseWriter = w
|
||||||
|
if route.ModelName != "" {
|
||||||
|
respWriter = NewResponseWriterWrapper(w, route.ModelName, observability.Global(), requestStart)
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.ServeHTTP(respWriter, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
// applyWarmup strips the X-Poimen-Warmup header, delays the request while
|
||||||
|
// honouring context cancellation, and records the metric.
|
||||||
|
// Returns false if the context was cancelled during the delay (caller must abort).
|
||||||
|
func (h *Handler) applyWarmup(ctx context.Context, r *http.Request) bool {
|
||||||
|
if r.Header.Get("X-Poimen-Warmup") != "true" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
r.Header.Del("X-Poimen-Warmup")
|
||||||
|
r.Header.Set("X-Priority", "low")
|
||||||
|
select {
|
||||||
|
case <-time.After(h.warmupDelay):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
observability.Global().IncrWarmupDelayed()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquireScheduledBackend selects a backend from sched, applies warmup
|
||||||
|
// deprioritisation, acquires a concurrency slot, and patches route when the
|
||||||
|
// selected backend differs from the config default. It returns the backend
|
||||||
|
// to Release (caller must defer), the (possibly patched) route, whether SSE
|
||||||
|
// headers were already written while parking, and ok=true.
|
||||||
|
// If the slot cannot be acquired it writes a 503 and returns ok=false.
|
||||||
|
func (h *Handler) acquireScheduledBackend(ctx context.Context, w http.ResponseWriter, r *http.Request, sched *Scheduler, route *Route, cacheKey string) (*Backend, *Route, bool, bool) {
|
||||||
|
backend := sched.Select(cacheKey)
|
||||||
|
if !h.applyWarmup(ctx, r) {
|
||||||
|
return nil, nil, false, false
|
||||||
|
}
|
||||||
|
if backend == nil {
|
||||||
|
return nil, route, false, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decision Q22: a request that actually parks on the engine gate and wants
|
||||||
|
// an SSE stream gets its headers pre-written plus a 15 s keep-alive, so no
|
||||||
|
// intermediary times the connection out before the engine admits it.
|
||||||
|
var (
|
||||||
|
prewritten bool
|
||||||
|
hbStop chan struct{}
|
||||||
|
hbDone chan struct{}
|
||||||
|
)
|
||||||
|
onPark := func() {
|
||||||
|
if !requestWantsStream(r) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
flusher, ok := w.(http.Flusher)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prewritten = true
|
||||||
|
hbStop = make(chan struct{})
|
||||||
|
hbDone = make(chan struct{})
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("Cache-Control", "no-cache")
|
||||||
|
w.Header().Set("Connection", "keep-alive")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
fmt.Fprint(w, ": keep-alive\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
go func() {
|
||||||
|
defer close(hbDone)
|
||||||
|
ticker := time.NewTicker(15 * time.Second)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-hbStop:
|
||||||
|
return
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
fmt.Fprint(w, ": keep-alive\n\n")
|
||||||
|
flusher.Flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
err := backend.acquire(ctx, onPark)
|
||||||
|
if prewritten {
|
||||||
|
close(hbStop)
|
||||||
|
<-hbDone
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
// Once the SSE status line is spent, report the rejection as an event
|
||||||
|
// instead of a second WriteHeader.
|
||||||
|
reject := func(body string) {
|
||||||
|
if prewritten {
|
||||||
|
fmt.Fprintf(w, "event: error\ndata: {\"error\":%q}\n\n", body)
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusServiceUnavailable)
|
||||||
|
fmt.Fprintf(w, "%s", body)
|
||||||
|
}
|
||||||
|
if errors.Is(err, ErrAdmissionTimeout) {
|
||||||
|
observability.Global().IncrAdmissionRejected("park_timeout")
|
||||||
|
logging.Warnf("admission timeout", map[string]string{
|
||||||
|
"backend": backend.Address,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"model": route.ModelName,
|
||||||
|
})
|
||||||
|
if !prewritten {
|
||||||
|
w.Header().Set("Retry-After", "5")
|
||||||
|
}
|
||||||
|
reject("admission timeout")
|
||||||
|
} else if errors.Is(err, ErrQueueFull) {
|
||||||
|
observability.Global().IncrQueueFull()
|
||||||
|
observability.Global().IncrAdmissionRejected("queue_full")
|
||||||
|
logging.Warnf("backend queue full", map[string]string{
|
||||||
|
"backend": backend.Address,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"model": route.ModelName,
|
||||||
|
})
|
||||||
|
if !prewritten {
|
||||||
|
w.Header().Set("Retry-After", "5")
|
||||||
|
}
|
||||||
|
reject("queue full")
|
||||||
|
} else {
|
||||||
|
logging.Warnf("backend acquire failed", map[string]string{
|
||||||
|
"backend": backend.Address,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"model": route.ModelName,
|
||||||
|
"err": err.Error(),
|
||||||
|
})
|
||||||
|
reject("upstream busy")
|
||||||
|
}
|
||||||
|
return nil, nil, prewritten, false
|
||||||
|
}
|
||||||
|
if backend.Address != route.Upstream.Address {
|
||||||
|
patchedCfg := *route.Upstream
|
||||||
|
patchedCfg.Address = backend.Address
|
||||||
|
patchedURL := &url.URL{Scheme: "http", Host: backend.Address}
|
||||||
|
route = &Route{
|
||||||
|
Name: route.Name,
|
||||||
|
ModelName: route.ModelName,
|
||||||
|
Upstream: &patchedCfg,
|
||||||
|
Transport: h.getOrCreateTransport(backend.Address, &patchedCfg),
|
||||||
|
Director: func(req *http.Request) {
|
||||||
|
directorFunc(req, patchedURL, &patchedCfg)
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return backend, route, prewritten, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestWantsStream reports whether the JSON request body asks for an SSE
|
||||||
|
// stream ("stream": true). The body is restored for the upstream.
|
||||||
|
func requestWantsStream(r *http.Request) bool {
|
||||||
|
if r.Body == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
var probe struct {
|
||||||
|
Stream bool `json:"stream"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(body, &probe); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return probe.Stream
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuthEndpoint routes /auth/* requests and returns true if handled.
|
||||||
|
func (h *Handler) handleAuthEndpoint(w http.ResponseWriter, r *http.Request) bool {
|
||||||
|
switch r.URL.Path {
|
||||||
|
case "/auth/token":
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
return true
|
||||||
|
case "/auth/refresh":
|
||||||
|
h.handleAuthRefresh(w, r)
|
||||||
|
return true
|
||||||
|
case "/auth/exchange":
|
||||||
|
h.handleAuthExchange(w, r)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// routeOrError resolves a route for r, writing error responses on failure.
|
||||||
|
// Returns (route, true) on success or (nil, false) when a response has been written.
|
||||||
|
func (h *Handler) routeOrError(w http.ResponseWriter, r *http.Request) (*Route, bool) {
|
||||||
route, err := h.RouteRequest(r)
|
route, err := h.RouteRequest(r)
|
||||||
|
|
||||||
// Check if this is a model validation error (from body-based dispatch)
|
|
||||||
if validationErr, ok := err.(*modelValidationError); ok {
|
if validationErr, ok := err.(*modelValidationError); ok {
|
||||||
// This is a client error, not a routing error
|
h.writeModelValidationError(w, validationErr, h.getValidModels())
|
||||||
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{
|
logging.Errorf("client error", validationErr, map[string]string{
|
||||||
"path": r.URL.Path,
|
"path": r.URL.Path,
|
||||||
"method": r.Method,
|
"method": r.Method,
|
||||||
"reason": validationErr.Kind,
|
"reason": validationErr.Kind,
|
||||||
})
|
})
|
||||||
return
|
return nil, false
|
||||||
}
|
}
|
||||||
|
|
||||||
if err != nil || route == nil {
|
if err != nil || route == nil {
|
||||||
// Route not found or error determining route
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
w.WriteHeader(http.StatusNotFound)
|
||||||
fmt.Fprintf(w, "not found")
|
fmt.Fprintf(w, "not found")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -281,68 +648,221 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
} else {
|
} else {
|
||||||
logging.Errorf("no route matches", fmt.Errorf("path=%s method=%s", r.URL.Path, r.Method), nil)
|
logging.Errorf("no route matches", fmt.Errorf("path=%s method=%s", r.URL.Path, r.Method), nil)
|
||||||
}
|
}
|
||||||
return
|
return nil, false
|
||||||
|
}
|
||||||
|
return route, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeModelValidationError writes a problem-detail response for model validation errors.
|
||||||
|
func (h *Handler) writeModelValidationError(w http.ResponseWriter, validationErr *modelValidationError, validModels []string) {
|
||||||
|
var status int
|
||||||
|
var problemType, title, detail string
|
||||||
|
var models []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"
|
||||||
|
models = validModels
|
||||||
|
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)
|
||||||
|
models = validModels
|
||||||
|
default:
|
||||||
|
status = http.StatusBadRequest
|
||||||
|
problemType = "https://api.example.com/problems/invalid-request"
|
||||||
|
title = "Invalid Request"
|
||||||
|
detail = validationErr.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Body size checking already happened in RouteRequest (body was read for model dispatch).
|
writeProblemDetail(w, status, problemType, title, detail, models)
|
||||||
// For other paths, we still need to enforce the cap.
|
}
|
||||||
// For /v1/chat/completions, the body was already read and validated.
|
|
||||||
|
// authenticateV1 validates JWT for /v1/* paths.
|
||||||
// Enforce request body size cap for non-chat routes
|
// Returns false when auth fails (response already written).
|
||||||
if r.URL.Path != "/v1/chat/completions" {
|
func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
||||||
if route.Upstream.MaxBodySize > 0 && r.ContentLength > route.Upstream.MaxBodySize {
|
if h.jwtValidator == nil {
|
||||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
logging.Warnf("JWT auth disabled; /v1/* request unauthenticated", map[string]string{"path": r.URL.Path})
|
||||||
fmt.Fprintf(w, "request body too large")
|
return true
|
||||||
logging.Errorf("request rejected", fmt.Errorf("body_too_large"), map[string]string{
|
}
|
||||||
"reason": "body_too_large",
|
if !strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||||
"route": route.Name,
|
return true
|
||||||
"upstream": route.Upstream.Address,
|
}
|
||||||
"content_length": fmt.Sprintf("%d", r.ContentLength),
|
authHeader := r.Header.Get("Authorization")
|
||||||
"max_body_size": fmt.Sprintf("%d", route.Upstream.MaxBodySize),
|
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 false
|
||||||
|
}
|
||||||
|
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 false
|
||||||
|
}
|
||||||
|
identity.Inject(r, claims)
|
||||||
|
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
|
return false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
// Wrap request body with size limiter
|
// enforceBodyLimit caps request body size for non-chat routes.
|
||||||
// This enforces the cap at read time, not after buffering
|
// Returns false when the request is rejected (response already written).
|
||||||
if route.Upstream.MaxBodySize > 0 && r.Body != nil {
|
func (h *Handler) enforceBodyLimit(w http.ResponseWriter, r *http.Request, route *Route) bool {
|
||||||
r.Body = io.NopCloser(io.LimitReader(r.Body, route.Upstream.MaxBodySize))
|
if r.URL.Path == "/v1/chat/completions" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if route.Upstream.MaxBodySize > 0 && r.ContentLength > route.Upstream.MaxBodySize {
|
||||||
|
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||||
|
fmt.Fprintf(w, "request body too large")
|
||||||
|
logging.Errorf("request rejected", fmt.Errorf("body_too_large"), map[string]string{
|
||||||
|
"reason": "body_too_large",
|
||||||
|
"route": route.Name,
|
||||||
|
"upstream": route.Upstream.Address,
|
||||||
|
"content_length": fmt.Sprintf("%d", r.ContentLength),
|
||||||
|
"max_body_size": fmt.Sprintf("%d", route.Upstream.MaxBodySize),
|
||||||
|
})
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if route.Upstream.MaxBodySize > 0 && r.Body != nil {
|
||||||
|
r.Body = io.NopCloser(io.LimitReader(r.Body, route.Upstream.MaxBodySize))
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// setupCAGProxy acquires a scheduled backend (if any), builds a reverse proxy,
|
||||||
|
// wires ErrorHandler and ModifyResponse, and returns (proxy, backend, true).
|
||||||
|
// Returns (nil, nil, false) when the backend slot cannot be acquired (response written).
|
||||||
|
func (h *Handler) setupCAGProxy(ctx context.Context, w http.ResponseWriter, r *http.Request, route *Route, sched *Scheduler, cacheKey string) (*httputil.ReverseProxy, *Backend, bool) {
|
||||||
|
var selectedBackend *Backend
|
||||||
|
var headersPrewritten bool
|
||||||
|
if sched != nil {
|
||||||
|
var ok bool
|
||||||
|
selectedBackend, route, headersPrewritten, ok = h.acquireScheduledBackend(ctx, w, r, sched, route, cacheKey)
|
||||||
|
if !ok {
|
||||||
|
return nil, nil, false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create the reverse proxy
|
|
||||||
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
proxy := httputil.NewSingleHostReverseProxy(&url.URL{
|
||||||
Scheme: "http",
|
Scheme: "http",
|
||||||
Host: route.Upstream.Address,
|
Host: route.Upstream.Address,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Set the director to apply path rewriting
|
|
||||||
proxy.Director = route.Director
|
proxy.Director = route.Director
|
||||||
|
proxy.Transport = tracing.NewTransport(route.Transport)
|
||||||
|
|
||||||
// Use the connection-pooled transport
|
capturedBackend := selectedBackend
|
||||||
proxy.Transport = route.Transport
|
capturedRoute := route
|
||||||
|
if headersPrewritten {
|
||||||
// Set error handler to log upstream errors
|
// The 200 + text/event-stream status is already on the wire, so an
|
||||||
|
// upstream status can no longer be forwarded: httputil.ReverseProxy
|
||||||
|
// calls WriteHeader again and net/http keeps the first one. Frame a
|
||||||
|
// failure as an SSE event rather than letting a raw error body land
|
||||||
|
// inside the stream, which a streaming client reads as an empty
|
||||||
|
// completion and reports as success.
|
||||||
|
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||||
|
if resp.StatusCode < http.StatusBadRequest {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
|
||||||
|
resp.Body.Close()
|
||||||
|
logging.Warnf("upstream error after parked stream started", map[string]string{
|
||||||
|
"upstream": capturedRoute.Upstream.Address,
|
||||||
|
"status": strconv.Itoa(resp.StatusCode),
|
||||||
|
})
|
||||||
|
resp.Body = io.NopCloser(strings.NewReader(fmt.Sprintf(
|
||||||
|
"event: error\ndata: {\"upstream_status\":%d,\"error\":%q}\n\n",
|
||||||
|
resp.StatusCode, strings.TrimSpace(string(body)))))
|
||||||
|
resp.ContentLength = -1
|
||||||
|
resp.Header.Del("Content-Length")
|
||||||
|
resp.Header.Set("Content-Type", "text/event-stream")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
proxy.ErrorHandler = func(w http.ResponseWriter, r *http.Request, err error) {
|
||||||
|
if capturedBackend != nil {
|
||||||
|
if s := h.schedulers[capturedRoute.ModelName]; s != nil {
|
||||||
|
s.MarkFailed(capturedBackend.Address)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if headersPrewritten {
|
||||||
|
// SSE headers and a 200 are already on the wire: a 502 here would be
|
||||||
|
// an ignored WriteHeader and would tear the event stream. Report the
|
||||||
|
// failure as an event instead.
|
||||||
|
fmt.Fprint(w, "event: error\ndata: {\"error\":\"upstream error\"}\n\n")
|
||||||
|
if f, ok := w.(http.Flusher); ok {
|
||||||
|
f.Flush()
|
||||||
|
}
|
||||||
|
logging.Errorf("upstream error", err, map[string]string{
|
||||||
|
"upstream": capturedRoute.Upstream.Address,
|
||||||
|
"path": r.URL.Path,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
w.WriteHeader(http.StatusBadGateway)
|
w.WriteHeader(http.StatusBadGateway)
|
||||||
fmt.Fprintf(w, "upstream error")
|
fmt.Fprintf(w, "upstream error")
|
||||||
logging.Errorf("upstream error", err, map[string]string{
|
logging.Errorf("upstream error", err, map[string]string{
|
||||||
"upstream": route.Upstream.Address,
|
"upstream": capturedRoute.Upstream.Address,
|
||||||
"path": r.URL.Path,
|
"path": r.URL.Path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: We apply connection timeout via Transport dialer, but NOT read timeout as a context deadline.
|
if cacheKey != "" && r.URL.Path == "/v1/chat/completions" {
|
||||||
// Read timeout should apply to inactivity (socket read timeout), not total request duration.
|
proxy.ModifyResponse = func(resp *http.Response) error {
|
||||||
// A streaming response that's continuously sending should not be cut off.
|
raw := resp.Header.Get("vllm-num-cached-tokens")
|
||||||
// The Transport's socket read timeout (via Dialer) handles inactivity timeouts.
|
if raw == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
n, err := strconv.ParseInt(raw, 10, 64)
|
||||||
|
if err != nil || n <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
observability.Global().IncrPrefixCacheHitTokens(n)
|
||||||
|
logging.Event(map[string]interface{}{
|
||||||
|
"event": "llm_request",
|
||||||
|
"cache_key": cacheKey,
|
||||||
|
"cached_tokens": n,
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Serve the request through the proxy
|
return proxy, selectedBackend, true
|
||||||
proxy.ServeHTTP(w, r)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// getValidModels returns a sorted list of all configured model names.
|
// getValidModels returns a sorted list of all configured model names.
|
||||||
func (h *Handler) getValidModels() []string {
|
func (h *Handler) getValidModels() []string {
|
||||||
var models []string
|
var models []string
|
||||||
|
|||||||
@@ -1,15 +1,24 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestProxyBasic(t *testing.T) {
|
func TestProxyBasic(t *testing.T) {
|
||||||
@@ -414,3 +423,356 @@ func TestProxyPreservesBody(t *testing.T) {
|
|||||||
t.Errorf("expected body %s, got %s", testBody, receivedBody)
|
t.Errorf("expected body %s, got %s", testBody, receivedBody)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestAuthenticateV1ProxyLevel exercises the three branches of authenticateV1:
|
||||||
|
// no-token→401, bad-token→403, valid-token→proxied (non-401/403).
|
||||||
|
func TestAuthenticateV1ProxyLevel(t *testing.T) {
|
||||||
|
// Generate RSA keypair for signing test JWTs.
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build minimal JWKS document from the RSA public key.
|
||||||
|
nBytes := privateKey.PublicKey.N.Bytes()
|
||||||
|
eBig := new(big.Int).SetInt64(int64(privateKey.PublicKey.E))
|
||||||
|
eBytes := eBig.Bytes()
|
||||||
|
jwksDoc, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"keys": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"kty": "RSA",
|
||||||
|
"use": "sig",
|
||||||
|
"kid": "test-key",
|
||||||
|
"alg": "RS256",
|
||||||
|
"n": base64.RawURLEncoding.EncodeToString(nBytes),
|
||||||
|
"e": base64.RawURLEncoding.EncodeToString(eBytes),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Serve JWKS from a local httptest server.
|
||||||
|
jwksSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(jwksDoc)
|
||||||
|
}))
|
||||||
|
defer jwksSrv.Close()
|
||||||
|
|
||||||
|
// Upstream stub — records whether it was reached.
|
||||||
|
reached := make(chan struct{}, 1)
|
||||||
|
upstreamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case reached <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer upstreamSrv.Close()
|
||||||
|
upstreamAddr := strings.TrimPrefix(upstreamSrv.URL, "http://")
|
||||||
|
|
||||||
|
// signJWT returns a valid RS256 JWT accepted by the Authentik-pattern issuer check.
|
||||||
|
signJWT := func() string {
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||||
|
"iss": "https://authentik.riotpiao.com/application/o/test/",
|
||||||
|
"aud": "test-audience",
|
||||||
|
"sub": "test-user",
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
})
|
||||||
|
tok.Header["kid"] = "test-key"
|
||||||
|
signed, err := tok.SignedString(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign JWT: %v", err)
|
||||||
|
}
|
||||||
|
return signed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build proxy with auth enabled.
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
Models: map[string]*config.ModelUpstream{
|
||||||
|
"auth-model": {
|
||||||
|
Name: "auth-model",
|
||||||
|
Address: upstreamAddr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Auth: config.AuthConfig{
|
||||||
|
Enabled: true,
|
||||||
|
JWKSURL: jwksSrv.URL,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := New(cfg)
|
||||||
|
defer h.Close()
|
||||||
|
srv := httptest.NewServer(h)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
mkReq := func(authHeader string) *http.Request {
|
||||||
|
body := strings.NewReader(`{"model":"auth-model","messages":[]}`)
|
||||||
|
req, _ := http.NewRequest("POST", srv.URL+"/v1/chat/completions", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if authHeader != "" {
|
||||||
|
req.Header.Set("Authorization", authHeader)
|
||||||
|
}
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branch 1: no Authorization header → 401.
|
||||||
|
t.Run("no_token_returns_401", func(t *testing.T) {
|
||||||
|
resp, err := http.DefaultClient.Do(mkReq(""))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusUnauthorized {
|
||||||
|
t.Errorf("expected 401, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Branch 2: malformed/invalid JWT → 403.
|
||||||
|
t.Run("bad_token_returns_403", func(t *testing.T) {
|
||||||
|
resp, err := http.DefaultClient.Do(mkReq("Bearer this.is.garbage"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Branch 3: valid RS256 JWT → proxied to upstream (not 401/403).
|
||||||
|
t.Run("valid_token_proxied", func(t *testing.T) {
|
||||||
|
resp, err := http.DefaultClient.Do(mkReq("Bearer " + signJWT()))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == http.StatusUnauthorized || resp.StatusCode == http.StatusForbidden {
|
||||||
|
body, _ := io.ReadAll(resp.Body)
|
||||||
|
t.Errorf("expected proxied response, got %d body=%s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-reached:
|
||||||
|
// upstream was hit — auth passed
|
||||||
|
default:
|
||||||
|
t.Error("upstream was not reached after valid JWT")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxyQueueFull verifies that a 503 is returned and the queue_full metric
|
||||||
|
// is incremented when the backend queue is at capacity.
|
||||||
|
//
|
||||||
|
// Setup: maxSlots=1, maxQueue=1. Request 1 holds the slot. Request 2 fills the
|
||||||
|
// queue (blocks waiting for slot). Request 3 gets ErrQueueFull → 503.
|
||||||
|
func TestProxyQueueFull(t *testing.T) {
|
||||||
|
t.Setenv("VLLM_MAX_NUM_SEQS", "1") // 1 concurrent slot
|
||||||
|
t.Setenv("VLLM_MAX_QUEUE_DEPTH", "1") // queue capacity 1
|
||||||
|
t.Setenv("WARMUP_DELAY_MS", "0")
|
||||||
|
observability.Global().Reset()
|
||||||
|
|
||||||
|
// Upstream blocks until released; buffers one signal when it first starts.
|
||||||
|
slotHeld := make(chan struct{}, 1)
|
||||||
|
release := make(chan struct{})
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
select {
|
||||||
|
case slotHeld <- struct{}{}:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
<-release
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
upstreamAddr := strings.TrimPrefix(upstream.URL, "http://")
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
Models: map[string]*config.ModelUpstream{
|
||||||
|
"q-model": {
|
||||||
|
Name: "q-model",
|
||||||
|
Address: upstreamAddr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := New(cfg)
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(h)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
mkReq := func() *http.Request {
|
||||||
|
body := strings.NewReader(`{"model":"q-model","messages":[]}`)
|
||||||
|
req, _ := http.NewRequest("POST", srv.URL+"/v1/chat/completions", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
return req
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request 1: acquires the only slot, reaches upstream, blocks there.
|
||||||
|
r1ch := make(chan *http.Response, 1)
|
||||||
|
go func() {
|
||||||
|
resp, _ := http.DefaultClient.Do(mkReq())
|
||||||
|
r1ch <- resp
|
||||||
|
}()
|
||||||
|
<-slotHeld // slot is acquired; upstream is holding request 1
|
||||||
|
|
||||||
|
// Request 2: enters the queue (capacity 1), blocks waiting for slot.
|
||||||
|
r2ch := make(chan *http.Response, 1)
|
||||||
|
go func() {
|
||||||
|
resp, _ := http.DefaultClient.Do(mkReq())
|
||||||
|
r2ch <- resp
|
||||||
|
}()
|
||||||
|
// Spin until request 2 enters the backend queue channel.
|
||||||
|
for len(h.schedulers["q-model"].backends[0].queue) == 0 {
|
||||||
|
runtime.Gosched()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Request 3: queue is full → expect 503 and metric increment.
|
||||||
|
resp3, err := http.DefaultClient.Do(mkReq())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request 3 failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp3.Body.Close()
|
||||||
|
|
||||||
|
if resp3.StatusCode != http.StatusServiceUnavailable {
|
||||||
|
t.Errorf("expected 503 queue full, got %d", resp3.StatusCode)
|
||||||
|
}
|
||||||
|
if got := observability.Global().GetQueueFull(); got != 1 {
|
||||||
|
t.Errorf("expected GetQueueFull()=1, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: unblock upstream so goroutines can finish.
|
||||||
|
close(release)
|
||||||
|
if r := <-r1ch; r != nil {
|
||||||
|
r.Body.Close()
|
||||||
|
}
|
||||||
|
if r := <-r2ch; r != nil {
|
||||||
|
r.Body.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProxyCacheHitLogging verifies that when a response contains the
|
||||||
|
// vllm-num-cached-tokens header and a cache key was set, the prefix cache
|
||||||
|
// hit token count is incremented in observability metrics.
|
||||||
|
func TestProxyCacheHitLogging(t *testing.T) {
|
||||||
|
observability.Global().Reset()
|
||||||
|
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("vllm-num-cached-tokens", "128")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
upstreamAddr := strings.TrimPrefix(upstream.URL, "http://")
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
Models: map[string]*config.ModelUpstream{
|
||||||
|
"cache-model": {
|
||||||
|
Name: "cache-model",
|
||||||
|
Address: upstreamAddr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := New(cfg)
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(h)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
body := strings.NewReader(`{"model":"cache-model","messages":[]}`)
|
||||||
|
req, _ := http.NewRequest("POST", srv.URL+"/v1/chat/completions", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("X-Poimen-Cache-Key", "testkey123")
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if got := observability.Global().GetPrefixCacheHitTokens(); got != 128 {
|
||||||
|
t.Errorf("expected PrefixCacheHitTokens=128, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWarmupHeaderHandling verifies that X-Poimen-Warmup: true is stripped from
|
||||||
|
// the upstream request and replaced with X-Priority: low, while a request without
|
||||||
|
// X-Poimen-Warmup reaches upstream without either header.
|
||||||
|
//
|
||||||
|
// The warmup path runs only for model-based routes (cfg.Models + scheduler),
|
||||||
|
// so we POST to /v1/chat/completions with a JSON body that names the test model.
|
||||||
|
// WARMUP_DELAY_MS=0 eliminates the 500 ms sleep.
|
||||||
|
func TestWarmupHeaderHandling(t *testing.T) {
|
||||||
|
t.Setenv("WARMUP_DELAY_MS", "0")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
sendWarmup bool
|
||||||
|
wantWarmupGone bool
|
||||||
|
wantPriorityLow bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "warmup=true",
|
||||||
|
sendWarmup: true,
|
||||||
|
wantWarmupGone: true, // X-Poimen-Warmup must NOT reach upstream
|
||||||
|
wantPriorityLow: true, // X-Priority: low must be set
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "warmup=false",
|
||||||
|
sendWarmup: false,
|
||||||
|
wantWarmupGone: true, // no header sent, so absent at upstream
|
||||||
|
wantPriorityLow: false, // X-Priority must NOT be set
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
var gotWarmup, gotPriority string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotWarmup = r.Header.Get("X-Poimen-Warmup")
|
||||||
|
gotPriority = r.Header.Get("X-Priority")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
upstreamAddr := strings.TrimPrefix(upstream.URL, "http://")
|
||||||
|
// Warmup code only runs for model-based routes (scheduler path).
|
||||||
|
// Use cfg.Models so New() builds a Scheduler for "warmup-model".
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
Models: map[string]*config.ModelUpstream{
|
||||||
|
"warmup-model": {
|
||||||
|
Name: "warmup-model",
|
||||||
|
Address: upstreamAddr,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
h := New(cfg)
|
||||||
|
defer h.Close()
|
||||||
|
|
||||||
|
srv := httptest.NewServer(h)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
// POST to /v1/chat/completions — triggers model-based dispatch and warmup path.
|
||||||
|
body := strings.NewReader(`{"model":"warmup-model","messages":[]}`)
|
||||||
|
req, _ := http.NewRequest("POST", srv.URL+"/v1/chat/completions", body)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if tc.sendWarmup {
|
||||||
|
req.Header.Set("X-Poimen-Warmup", "true")
|
||||||
|
}
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
resp.Body.Close()
|
||||||
|
|
||||||
|
if tc.wantWarmupGone && gotWarmup != "" {
|
||||||
|
t.Errorf("X-Poimen-Warmup must be stripped before upstream, got %q", gotWarmup)
|
||||||
|
}
|
||||||
|
if tc.wantPriorityLow && gotPriority != "low" {
|
||||||
|
t.Errorf("X-Priority: low must be set when warmup=true, got %q", gotPriority)
|
||||||
|
}
|
||||||
|
if !tc.wantPriorityLow && gotPriority != "" {
|
||||||
|
t.Errorf("X-Priority must not be set for normal requests, got %q", gotPriority)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+125
-10
@@ -9,7 +9,7 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"net/url"
|
"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.
|
// RouteRequest determines which upstream should handle the request.
|
||||||
@@ -69,9 +69,10 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
|||||||
// Restore the body so it can be read again by the upstream
|
// Restore the body so it can be read again by the upstream
|
||||||
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
r.Body = io.NopCloser(bytes.NewReader(bodyBytes))
|
||||||
|
|
||||||
// Parse the JSON to find the model field
|
// Extract only the top-level "model" value with a token walk: the common
|
||||||
var payload map[string]interface{}
|
// path must not materialise the whole body (messages and all) as a map.
|
||||||
if err := json.Unmarshal(bodyBytes, &payload); err != nil {
|
modelRaw, hasModel, err := extractTopLevelModel(bodyBytes)
|
||||||
|
if err != nil {
|
||||||
return nil, &modelValidationError{
|
return nil, &modelValidationError{
|
||||||
Kind: "invalid_json",
|
Kind: "invalid_json",
|
||||||
Message: "request body is not valid JSON",
|
Message: "request body is not valid JSON",
|
||||||
@@ -79,7 +80,6 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract the model name
|
// Extract the model name
|
||||||
modelVal, hasModel := payload["model"]
|
|
||||||
if !hasModel {
|
if !hasModel {
|
||||||
return nil, &modelValidationError{
|
return nil, &modelValidationError{
|
||||||
Kind: "missing_model",
|
Kind: "missing_model",
|
||||||
@@ -88,7 +88,7 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle null model
|
// Handle null model
|
||||||
if modelVal == nil {
|
if bytes.Equal(bytes.TrimSpace(modelRaw), []byte("null")) {
|
||||||
return nil, &modelValidationError{
|
return nil, &modelValidationError{
|
||||||
Kind: "null_model",
|
Kind: "null_model",
|
||||||
Message: "'model' field is null",
|
Message: "'model' field is null",
|
||||||
@@ -96,8 +96,8 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Extract as string
|
// Extract as string
|
||||||
modelName, ok := modelVal.(string)
|
var modelName string
|
||||||
if !ok {
|
if err := json.Unmarshal(modelRaw, &modelName); err != nil {
|
||||||
return nil, &modelValidationError{
|
return nil, &modelValidationError{
|
||||||
Kind: "missing_model",
|
Kind: "missing_model",
|
||||||
Message: "'model' field must be a string",
|
Message: "'model' field must be a string",
|
||||||
@@ -122,6 +122,36 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If upstream expects a different model name, rewrite the body. This path is
|
||||||
|
// uncommon, so it may decode the body as raw fields; the common path above
|
||||||
|
// does not build a full map.
|
||||||
|
if modelUpstream.UpstreamModel != "" && modelUpstream.UpstreamModel != modelName {
|
||||||
|
var fields map[string]json.RawMessage
|
||||||
|
if err := json.Unmarshal(bodyBytes, &fields); err != nil {
|
||||||
|
return nil, &modelValidationError{
|
||||||
|
Kind: "invalid_request",
|
||||||
|
Message: fmt.Sprintf("failed to rewrite model name: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
newModel, err := json.Marshal(modelUpstream.UpstreamModel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &modelValidationError{
|
||||||
|
Kind: "invalid_request",
|
||||||
|
Message: fmt.Sprintf("failed to rewrite model name: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fields["model"] = newModel
|
||||||
|
newBody, err := json.Marshal(fields)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &modelValidationError{
|
||||||
|
Kind: "invalid_request",
|
||||||
|
Message: fmt.Sprintf("failed to rewrite model name: %v", err),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(newBody))
|
||||||
|
r.ContentLength = int64(len(newBody))
|
||||||
|
}
|
||||||
|
|
||||||
// Determine the upstream path based on the request path
|
// Determine the upstream path based on the request path
|
||||||
upstreamPath := path
|
upstreamPath := path
|
||||||
if path == "/v1/rerank" {
|
if path == "/v1/rerank" {
|
||||||
@@ -137,14 +167,15 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
|||||||
ConnectTimeout: h.defaultConnectTimeout,
|
ConnectTimeout: h.defaultConnectTimeout,
|
||||||
ReadTimeout: h.defaultReadTimeout,
|
ReadTimeout: h.defaultReadTimeout,
|
||||||
WriteTimeout: h.defaultWriteTimeout,
|
WriteTimeout: h.defaultWriteTimeout,
|
||||||
MaxBodySize: h.defaultMaxBodySize,
|
MaxBodySize: h.maxBodyBytes,
|
||||||
AuthRequired: false,
|
AuthRequired: false,
|
||||||
}
|
}
|
||||||
|
|
||||||
targetURL, _ := url.Parse("http://" + modelUpstream.Address)
|
targetURL := &url.URL{Scheme: "http", Host: modelUpstream.Address}
|
||||||
|
|
||||||
route := &Route{
|
route := &Route{
|
||||||
Name: "v1-chat-" + modelName,
|
Name: "v1-chat-" + modelName,
|
||||||
|
ModelName: modelName,
|
||||||
Upstream: upstreamCfg,
|
Upstream: upstreamCfg,
|
||||||
Transport: h.getOrCreateTransport(modelUpstream.Address, upstreamCfg),
|
Transport: h.getOrCreateTransport(modelUpstream.Address, upstreamCfg),
|
||||||
Director: func(req *http.Request) {
|
Director: func(req *http.Request) {
|
||||||
@@ -154,3 +185,87 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
|||||||
|
|
||||||
return route, nil
|
return route, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// extractTopLevelModel walks a JSON object body with a streaming decoder and
|
||||||
|
// returns the raw value of the top-level "model" field. It never builds the
|
||||||
|
// full document as a map, so a large "messages" array is skipped by token.
|
||||||
|
// hasModel is false when the key is absent or the document is a bare null.
|
||||||
|
func extractTopLevelModel(body []byte) (model json.RawMessage, hasModel bool, err error) {
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(body))
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if tok == nil {
|
||||||
|
// A bare `null` body decoded to a nil map before, i.e. no model field.
|
||||||
|
if _, err := dec.Token(); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return nil, false, fmt.Errorf("unexpected data after JSON null")
|
||||||
|
}
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
if delim, ok := tok.(json.Delim); !ok || delim != '{' {
|
||||||
|
return nil, false, fmt.Errorf("request body is not a JSON object")
|
||||||
|
}
|
||||||
|
for dec.More() {
|
||||||
|
keyTok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
key, _ := keyTok.(string)
|
||||||
|
if key == "model" {
|
||||||
|
if err := dec.Decode(&model); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
hasModel = true
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if err := skipJSONValue(dec); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Consume the closing brace and reject trailing data, preserving the
|
||||||
|
// invalid_json answer the old whole-body decode produced.
|
||||||
|
if _, err := dec.Token(); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
if _, err := dec.Token(); err != io.EOF {
|
||||||
|
if err == nil {
|
||||||
|
return nil, false, fmt.Errorf("unexpected data after JSON object")
|
||||||
|
}
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
return model, hasModel, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// skipJSONValue advances dec past the next JSON value without materialising it.
|
||||||
|
func skipJSONValue(dec *json.Decoder) error {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
delim, ok := tok.(json.Delim)
|
||||||
|
if !ok {
|
||||||
|
return nil // scalar
|
||||||
|
}
|
||||||
|
if delim != '{' && delim != '[' {
|
||||||
|
return fmt.Errorf("unexpected delimiter %v", delim)
|
||||||
|
}
|
||||||
|
depth := 1
|
||||||
|
for depth > 0 {
|
||||||
|
tok, err := dec.Token()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if d, ok := tok.(json.Delim); ok {
|
||||||
|
if d == '{' || d == '[' {
|
||||||
|
depth++
|
||||||
|
} else {
|
||||||
|
depth--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"hash/fnv"
|
||||||
|
"sort"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrQueueFull is returned when the in-memory queue is at capacity.
|
||||||
|
var ErrQueueFull = errors.New("backend queue full")
|
||||||
|
|
||||||
|
// ErrAdmissionTimeout is returned when a request parked on engine admission
|
||||||
|
// exceeds the configured maximum park duration.
|
||||||
|
var ErrAdmissionTimeout = errors.New("admission timeout")
|
||||||
|
|
||||||
|
// AdmissionLimits are the engine-wide admission thresholds applied to every
|
||||||
|
// backend of a scheduler (#64).
|
||||||
|
type AdmissionLimits struct {
|
||||||
|
MaxInFlight int
|
||||||
|
MaxKVUsage float64
|
||||||
|
MaxPark time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backend represents a single vLLM upstream with a bounded in-memory queue.
|
||||||
|
// Two channels model the queue:
|
||||||
|
//
|
||||||
|
// queue — waiting requests (bounded by VLLM_MAX_QUEUE_DEPTH)
|
||||||
|
// slots — concurrent in-service requests (bounded by VLLM_MAX_NUM_SEQS)
|
||||||
|
//
|
||||||
|
// The engine gate is applied before the queue: admission reads the engine's
|
||||||
|
// own published gauges via observer, which is global across replicas.
|
||||||
|
type Backend struct {
|
||||||
|
Address string
|
||||||
|
queue chan struct{}
|
||||||
|
slots chan struct{}
|
||||||
|
observer *EngineObserver
|
||||||
|
maxInFlight int
|
||||||
|
maxKVUsage float64
|
||||||
|
maxPark time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// newBackend creates a Backend with given queue depth and concurrency limit.
|
||||||
|
func newBackend(addr string, maxSlots, maxQueue int) *Backend {
|
||||||
|
slots := make(chan struct{}, maxSlots)
|
||||||
|
for i := 0; i < maxSlots; i++ {
|
||||||
|
slots <- struct{}{}
|
||||||
|
}
|
||||||
|
return &Backend{
|
||||||
|
Address: addr,
|
||||||
|
queue: make(chan struct{}, maxQueue),
|
||||||
|
slots: slots,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Acquire enters the in-memory queue then waits for a vLLM concurrency slot.
|
||||||
|
// Returns ErrQueueFull immediately if the queue channel is at capacity (no goroutine queued).
|
||||||
|
// Returns ctx.Err() if the context is cancelled while waiting for a slot.
|
||||||
|
// Returns ErrAdmissionTimeout if the engine gate parks the request past maxPark.
|
||||||
|
func (b *Backend) Acquire(ctx context.Context) error {
|
||||||
|
return b.acquire(ctx, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// acquire is Acquire with an optional onPark callback. onPark is invoked once,
|
||||||
|
// on the calling goroutine, the first time the request has to wait for engine
|
||||||
|
// admission; the parked streaming path uses it to start an SSE heartbeat.
|
||||||
|
func (b *Backend) acquire(ctx context.Context, onPark func()) error {
|
||||||
|
// Engine gate (#64): admit on the engine's own gauges, not on this pod's
|
||||||
|
// counters. Failing OPEN on !Healthy is deliberate — a metrics outage must
|
||||||
|
// not become an inference outage; the three-replica gate is only a fairness
|
||||||
|
// improvement on top of the engine's own queueing.
|
||||||
|
if b.observer != nil {
|
||||||
|
deadline := time.Now().Add(b.maxPark)
|
||||||
|
timer := time.NewTimer(time.Until(deadline))
|
||||||
|
defer timer.Stop()
|
||||||
|
parked := false
|
||||||
|
for {
|
||||||
|
// State and tick channel are read together under one lock: taking
|
||||||
|
// them separately can miss a poll that lands between the two calls.
|
||||||
|
st, tick := b.observer.Snapshot()
|
||||||
|
if !st.Healthy || (st.InFlight() < int64(b.maxInFlight) && st.KVUsage < b.maxKVUsage) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if !parked {
|
||||||
|
observability.Global().IncrAdmissionParked()
|
||||||
|
parked = true
|
||||||
|
if onPark != nil {
|
||||||
|
onPark()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-tick:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
case <-timer.C:
|
||||||
|
return ErrAdmissionTimeout
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Non-blocking queue entry: reject immediately when queue is full.
|
||||||
|
select {
|
||||||
|
case b.queue <- struct{}{}:
|
||||||
|
// entered queue
|
||||||
|
default:
|
||||||
|
return ErrQueueFull
|
||||||
|
}
|
||||||
|
// Wait for a concurrency slot, then leave the queue.
|
||||||
|
select {
|
||||||
|
case <-b.slots:
|
||||||
|
<-b.queue // leave queue; now in service
|
||||||
|
return nil
|
||||||
|
case <-ctx.Done():
|
||||||
|
<-b.queue // leave queue; cancelled
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release returns a concurrency slot to the pool after the upstream request completes.
|
||||||
|
func (b *Backend) Release() {
|
||||||
|
b.slots <- struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scheduler routes LLM requests to backends using rendezvous hashing (cache-aware)
|
||||||
|
// or round-robin (no cache key). Failed backends are excluded for a configurable window.
|
||||||
|
type Scheduler struct {
|
||||||
|
mu sync.RWMutex
|
||||||
|
backends []*Backend
|
||||||
|
failedUntil map[string]time.Time
|
||||||
|
failWindow time.Duration
|
||||||
|
rrIdx atomic.Uint64
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewScheduler creates a Scheduler for the given addresses.
|
||||||
|
// maxSlots = VLLM_MAX_NUM_SEQS, maxQueue = VLLM_MAX_QUEUE_DEPTH.
|
||||||
|
// observers holds one engine observer per backend address (may be nil).
|
||||||
|
func NewScheduler(addresses []string, maxSlots, maxQueue int, observers map[string]*EngineObserver, limits AdmissionLimits) *Scheduler {
|
||||||
|
backends := make([]*Backend, len(addresses))
|
||||||
|
for i, addr := range addresses {
|
||||||
|
backends[i] = newBackend(addr, maxSlots, maxQueue)
|
||||||
|
backends[i].observer = observers[addr]
|
||||||
|
backends[i].maxInFlight = limits.MaxInFlight
|
||||||
|
backends[i].maxKVUsage = limits.MaxKVUsage
|
||||||
|
backends[i].maxPark = limits.MaxPark
|
||||||
|
}
|
||||||
|
return &Scheduler{
|
||||||
|
backends: backends,
|
||||||
|
failedUntil: make(map[string]time.Time),
|
||||||
|
failWindow: 30 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Select returns a backend. Uses rendezvous hashing when cacheKey is non-empty
|
||||||
|
// (same key always routes to the same backend, skipping unhealthy ones).
|
||||||
|
// Uses round-robin when cacheKey is empty.
|
||||||
|
func (s *Scheduler) Select(cacheKey string) *Backend {
|
||||||
|
s.mu.RLock()
|
||||||
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
|
if len(s.backends) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if len(s.backends) == 1 {
|
||||||
|
return s.backends[0]
|
||||||
|
}
|
||||||
|
if cacheKey != "" {
|
||||||
|
return s.selectRendezvous(cacheKey)
|
||||||
|
}
|
||||||
|
return s.selectRoundRobin()
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectRendezvous picks the healthy backend with the highest FNV32a score
|
||||||
|
// for the key (highest random weight / rendezvous hashing).
|
||||||
|
// Falls back to next-highest score when primary is excluded by MarkFailed.
|
||||||
|
// Must be called with mu held.
|
||||||
|
func (s *Scheduler) selectRendezvous(cacheKey string) *Backend {
|
||||||
|
type scored struct {
|
||||||
|
b *Backend
|
||||||
|
score uint32
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
scores := make([]scored, len(s.backends))
|
||||||
|
h := fnv.New32a()
|
||||||
|
for i, b := range s.backends {
|
||||||
|
h.Reset()
|
||||||
|
h.Write([]byte(cacheKey + ":" + b.Address))
|
||||||
|
scores[i] = scored{b: b, score: h.Sum32()}
|
||||||
|
}
|
||||||
|
sort.Slice(scores, func(i, j int) bool {
|
||||||
|
return scores[i].score > scores[j].score
|
||||||
|
})
|
||||||
|
for _, sc := range scores {
|
||||||
|
_, failed := s.failedUntil[sc.b.Address]
|
||||||
|
if !failed {
|
||||||
|
return sc.b
|
||||||
|
}
|
||||||
|
expiry := s.failedUntil[sc.b.Address]
|
||||||
|
if now.After(expiry) {
|
||||||
|
return sc.b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// All backends unhealthy: return highest-scored (fail-open).
|
||||||
|
return scores[0].b
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectRoundRobin distributes requests evenly when no cache key is present.
|
||||||
|
// Must be called with mu held.
|
||||||
|
func (s *Scheduler) selectRoundRobin() *Backend {
|
||||||
|
idx := s.rrIdx.Add(1) - 1
|
||||||
|
return s.backends[idx%uint64(len(s.backends))]
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarkFailed excludes addr from routing for the fail window (default 30 s).
|
||||||
|
// Called by the proxy ErrorHandler on upstream connection failure.
|
||||||
|
func (s *Scheduler) MarkFailed(addr string) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.failedUntil[addr] = time.Now().Add(s.failWindow)
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBackendAcquireBlocksThenUnblocksOnRelease verifies that a second Acquire
|
||||||
|
// blocks while a slot is held, then unblocks when Release is called.
|
||||||
|
func TestBackendAcquireBlocksThenUnblocksOnRelease(t *testing.T) {
|
||||||
|
b := newBackend("host:1", 1, 1)
|
||||||
|
|
||||||
|
// G1: acquire and hold the only slot.
|
||||||
|
if err := b.Acquire(context.Background()); err != nil {
|
||||||
|
t.Fatalf("G1 Acquire: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
g2Done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
g2Done <- b.Acquire(context.Background())
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait until G2 enters the queue channel (proves Acquire is blocking on slots).
|
||||||
|
for len(b.queue) == 0 {
|
||||||
|
runtime.Gosched()
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-g2Done:
|
||||||
|
t.Fatalf("G2 returned before Release: %v", err)
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// G1 releases; G2 should unblock.
|
||||||
|
b.Release()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-g2Done:
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("G2 Acquire after Release: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("G2 did not unblock within 1s after Release")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: release G2's slot.
|
||||||
|
b.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackendAcquireReturnsErrQueueFullWhenQueueFull verifies that Acquire returns
|
||||||
|
// ErrQueueFull immediately when no queue capacity exists (maxQueue=0).
|
||||||
|
// With a zero-capacity queue channel the non-blocking select always hits default.
|
||||||
|
func TestBackendAcquireReturnsErrQueueFullWhenQueueFull(t *testing.T) {
|
||||||
|
b := newBackend("host:1", 1, 0)
|
||||||
|
|
||||||
|
err := b.Acquire(context.Background())
|
||||||
|
if !errors.Is(err, ErrQueueFull) {
|
||||||
|
t.Fatalf("expected ErrQueueFull, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchedulerSelectRendezvousHashDeterministic verifies that the same cache key
|
||||||
|
// always selects the same backend (rendezvous / highest-random-weight hashing).
|
||||||
|
func TestSchedulerSelectRendezvousHashDeterministic(t *testing.T) {
|
||||||
|
addrs := []string{"a:80", "b:80", "c:80"}
|
||||||
|
s := NewScheduler(addrs, 10, 10, nil, AdmissionLimits{})
|
||||||
|
|
||||||
|
first := s.Select("same-cache-key")
|
||||||
|
if first == nil {
|
||||||
|
t.Fatal("Select returned nil")
|
||||||
|
}
|
||||||
|
for i := 1; i < 10; i++ {
|
||||||
|
b := s.Select("same-cache-key")
|
||||||
|
if b == nil || b.Address != first.Address {
|
||||||
|
t.Fatalf("call %d: want %s, got %v", i, first.Address, b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchedulerSelectNoKeyRoundRobin verifies that round-robin distributes
|
||||||
|
// requests evenly across all backends when no cache key is given.
|
||||||
|
func TestSchedulerSelectNoKeyRoundRobin(t *testing.T) {
|
||||||
|
addrs := []string{"a:80", "b:80", "c:80"}
|
||||||
|
s := NewScheduler(addrs, 10, 10, nil, AdmissionLimits{})
|
||||||
|
|
||||||
|
counts := make(map[string]int)
|
||||||
|
for i := 0; i < 9; i++ {
|
||||||
|
b := s.Select("")
|
||||||
|
if b == nil {
|
||||||
|
t.Fatalf("call %d: Select returned nil", i)
|
||||||
|
}
|
||||||
|
counts[b.Address]++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, addr := range addrs {
|
||||||
|
if counts[addr] != 3 {
|
||||||
|
t.Errorf("addr %s: want 3 selections, got %d (all counts: %v)", addr, counts[addr], counts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestBackendAcquireCancelledContext verifies that Acquire returns ctx.Err() when the
|
||||||
|
// context is cancelled while the caller is blocked waiting for a concurrency slot,
|
||||||
|
// and that the queue depth returns to 0 after cancellation.
|
||||||
|
func TestBackendAcquireCancelledContext(t *testing.T) {
|
||||||
|
b := newBackend("host:1", 1, 1)
|
||||||
|
|
||||||
|
// G1: acquire and hold the only slot indefinitely.
|
||||||
|
if err := b.Acquire(context.Background()); err != nil {
|
||||||
|
t.Fatalf("G1 Acquire: unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
g2Done := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
g2Done <- b.Acquire(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait until G2 enters the queue channel.
|
||||||
|
for len(b.queue) == 0 {
|
||||||
|
runtime.Gosched()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify G2 is queued (queue channel has one item).
|
||||||
|
if qd := len(b.queue); qd != 1 {
|
||||||
|
t.Errorf("expected queue depth 1 while G2 blocked, got %d", qd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cancel ctx while G2 is waiting for the slot.
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
select {
|
||||||
|
case err := <-g2Done:
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("G2 Acquire: expected ctx.Err(), got nil")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, context.Canceled) {
|
||||||
|
t.Fatalf("G2 Acquire: expected context.Canceled, got %v", err)
|
||||||
|
}
|
||||||
|
case <-time.After(time.Second):
|
||||||
|
t.Fatal("G2 did not return within 1s after context cancel")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Queue depth must return to 0 after G2 is drained.
|
||||||
|
if qd := len(b.queue); qd != 0 {
|
||||||
|
t.Errorf("expected queue depth 0 after cancellation, got %d", qd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup: release G1's slot.
|
||||||
|
b.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestSchedulerMarkFailedExcludesBackend verifies that a failed backend is not
|
||||||
|
// selected within the fail window (30 s default).
|
||||||
|
func TestSchedulerMarkFailedExcludesBackend(t *testing.T) {
|
||||||
|
addrs := []string{"a:80", "b:80"}
|
||||||
|
s := NewScheduler(addrs, 10, 10, nil, AdmissionLimits{})
|
||||||
|
|
||||||
|
s.MarkFailed("a:80")
|
||||||
|
|
||||||
|
var mu sync.Mutex
|
||||||
|
seen := make(map[string]int)
|
||||||
|
for i := 0; i < 10; i++ {
|
||||||
|
b := s.Select("any-key")
|
||||||
|
if b == nil {
|
||||||
|
t.Fatalf("call %d: Select returned nil", i)
|
||||||
|
}
|
||||||
|
mu.Lock()
|
||||||
|
seen[b.Address]++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
if seen["a:80"] > 0 {
|
||||||
|
t.Errorf("failed backend a:80 was selected %d times; expected 0 within fail window", seen["a:80"])
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,11 +6,13 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"runtime"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"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.
|
// TestSSEUnbuffered verifies that SSE events stream to the client without buffering.
|
||||||
@@ -473,3 +475,337 @@ func TestNoFullBuffering(t *testing.T) {
|
|||||||
t.Errorf("expected to read %d bytes, got %d", len(largeData), totalRead)
|
t.Errorf("expected to read %d bytes, got %d", len(largeData), totalRead)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTCPBackpressure verifies that TCP backpressure is respected during streaming.
|
||||||
|
// When a client reads slowly, the upstream should experience backpressure on writes.
|
||||||
|
func TestTCPBackpressure(t *testing.T) {
|
||||||
|
// Track when upstream started writing and when each write completed
|
||||||
|
var writeTimes []time.Time
|
||||||
|
writesMu := sync.Mutex{}
|
||||||
|
|
||||||
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no") // Issue #33: disable buffering
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
rc := http.NewResponseController(w)
|
||||||
|
// Send many events to trigger backpressure
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
writesMu.Lock()
|
||||||
|
writeTimes = append(writeTimes, time.Now())
|
||||||
|
writesMu.Unlock()
|
||||||
|
|
||||||
|
fmt.Fprintf(w, "data: event%d\n\n", i)
|
||||||
|
if err := rc.Flush(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstreamServer.Close()
|
||||||
|
|
||||||
|
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: map[string]*config.Route{
|
||||||
|
"backpressure-route": {
|
||||||
|
Name: "backpressure-route",
|
||||||
|
Upstream: config.Upstream{
|
||||||
|
Address: upstreamAddr,
|
||||||
|
ConnectTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 5 * time.Second,
|
||||||
|
MaxBodySize: 1024 * 1024,
|
||||||
|
AuthRequired: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := New(cfg)
|
||||||
|
defer handler.Close()
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
resp, err := http.Get(server.URL + "/backpressure")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request failed: %v", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// Verify X-Accel-Buffering header is passed through
|
||||||
|
if resp.Header.Get("X-Accel-Buffering") != "no" {
|
||||||
|
t.Errorf("X-Accel-Buffering header not propagated, got: %s", resp.Header.Get("X-Accel-Buffering"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read events with simulated slow client (small buffer)
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
readStart := time.Now()
|
||||||
|
eventCount := 0
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Fatalf("read failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasPrefix(strings.TrimSpace(line), "data:") {
|
||||||
|
eventCount++
|
||||||
|
// Simulate slow client by adding delay
|
||||||
|
time.Sleep(5 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify we got all events
|
||||||
|
if eventCount != 20 {
|
||||||
|
t.Errorf("expected 20 events, got %d", eventCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Total read time should be roughly eventCount * readDelay
|
||||||
|
// indicating backpressure was applied (upstream couldn't send all at once)
|
||||||
|
elapsed := time.Since(readStart)
|
||||||
|
expectedMin := time.Duration(20*5) * time.Millisecond
|
||||||
|
if elapsed < expectedMin {
|
||||||
|
t.Logf("backpressure test: elapsed=%.0fms (expected ~%.0fms)", elapsed.Seconds()*1000, expectedMin.Seconds()*1000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestConcurrentSSEStreams verifies that HTTP/2 multiplexing handles multiple concurrent streams.
|
||||||
|
// Issue #32: Multiple LLM requests should not block each other.
|
||||||
|
func TestConcurrentSSEStreams(t *testing.T) {
|
||||||
|
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "text/event-stream")
|
||||||
|
w.Header().Set("X-Accel-Buffering", "no")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
|
||||||
|
rc := http.NewResponseController(w)
|
||||||
|
// Each request sends unique identifier
|
||||||
|
reqID := r.URL.Query().Get("id")
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
fmt.Fprintf(w, "data: [%s] event %d\n\n", reqID, i)
|
||||||
|
if err := rc.Flush(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer upstreamServer.Close()
|
||||||
|
|
||||||
|
upstreamAddr := strings.TrimPrefix(upstreamServer.URL, "http://")
|
||||||
|
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: map[string]*config.Route{
|
||||||
|
"concurrent-route": {
|
||||||
|
Name: "concurrent-route",
|
||||||
|
Upstream: config.Upstream{
|
||||||
|
Address: upstreamAddr,
|
||||||
|
ConnectTimeout: 5 * time.Second,
|
||||||
|
ReadTimeout: 10 * time.Second,
|
||||||
|
WriteTimeout: 5 * time.Second,
|
||||||
|
MaxBodySize: 1024 * 1024,
|
||||||
|
AuthRequired: false,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := New(cfg)
|
||||||
|
defer handler.Close()
|
||||||
|
|
||||||
|
server := httptest.NewServer(handler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
// Launch multiple concurrent requests
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
results := make(map[string][]string)
|
||||||
|
resultsMu := sync.Mutex{}
|
||||||
|
|
||||||
|
for id := 0; id < 3; id++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(streamID int) {
|
||||||
|
defer wg.Done()
|
||||||
|
|
||||||
|
url := fmt.Sprintf("%s/concurrent?id=stream%d", server.URL, streamID)
|
||||||
|
resp, err := http.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("request failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
reader := bufio.NewReader(resp.Body)
|
||||||
|
var events []string
|
||||||
|
|
||||||
|
for {
|
||||||
|
line, err := reader.ReadString('\n')
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
t.Errorf("read failed: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
line = strings.TrimSpace(line)
|
||||||
|
if strings.HasPrefix(line, "data:") {
|
||||||
|
events = append(events, line)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resultsMu.Lock()
|
||||||
|
results[fmt.Sprintf("stream%d", streamID)] = events
|
||||||
|
resultsMu.Unlock()
|
||||||
|
}(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify all streams got their events
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
key := fmt.Sprintf("stream%d", i)
|
||||||
|
events, ok := results[key]
|
||||||
|
if !ok {
|
||||||
|
t.Errorf("stream%d: no results", i)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(events) != 5 {
|
||||||
|
t.Errorf("stream%d: expected 5 events, got %d", i, len(events))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify all events belong to this stream
|
||||||
|
for _, event := range events {
|
||||||
|
if !strings.Contains(event, key) {
|
||||||
|
t.Errorf("stream%d: event from wrong stream: %s", i, event)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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"
|
"testing"
|
||||||
"time"
|
"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.
|
// TestConnectTimeout verifies that connections fail at the configured timeout.
|
||||||
|
|||||||
@@ -0,0 +1,229 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// exchangeRequest represents a token exchange request (RFC 8693 subset).
|
||||||
|
type exchangeRequest struct {
|
||||||
|
SubjectToken string `json:"subject_token"`
|
||||||
|
ClientID string `json:"client_id"`
|
||||||
|
ClientSecret string `json:"client_secret"`
|
||||||
|
Scope string `json:"scope,omitempty"`
|
||||||
|
Resource string `json:"resource,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// exchangeResponse wraps the service token with subject identity metadata.
|
||||||
|
type exchangeResponse struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
TokenType string `json:"token_type"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
IssuedTokenType string `json:"issued_token_type,omitempty"`
|
||||||
|
Subject string `json:"subject,omitempty"`
|
||||||
|
ActingParty string `json:"acting_party,omitempty"`
|
||||||
|
GrantedScope string `json:"scope,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleAuthExchange implements token exchange: a service presents a user's
|
||||||
|
// JWT and its own credentials to get a scoped service token with the user's
|
||||||
|
// identity attached.
|
||||||
|
//
|
||||||
|
// Flow:
|
||||||
|
// 1. Validate subject_token (user's JWT) — signature, expiry, issuer
|
||||||
|
// 2. Authenticate service via client_credentials against Authentik
|
||||||
|
// 3. Verify requested scope is a subset of service's roles
|
||||||
|
// 4. Return service token + subject identity metadata
|
||||||
|
func (h *Handler) handleAuthExchange(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != "POST" {
|
||||||
|
writeProblemDetail(w, http.StatusMethodNotAllowed,
|
||||||
|
"about:blank#method-not-allowed", "Method Not Allowed",
|
||||||
|
"POST only", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if h.jwtValidator == nil || h.config.Auth.TokenURL == "" {
|
||||||
|
writeProblemDetail(w, http.StatusServiceUnavailable,
|
||||||
|
"about:blank#not-configured", "Not Configured",
|
||||||
|
"token exchange not configured", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req exchangeRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeProblemDetail(w, http.StatusBadRequest,
|
||||||
|
"about:blank#bad-request", "Bad Request",
|
||||||
|
"invalid JSON body", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.SubjectToken == "" || req.ClientID == "" || req.ClientSecret == "" {
|
||||||
|
writeProblemDetail(w, http.StatusBadRequest,
|
||||||
|
"about:blank#bad-request", "Bad Request",
|
||||||
|
"subject_token, client_id, and client_secret are required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Validate subject token
|
||||||
|
subjectClaims, err := h.jwtValidator.ValidateBearerToken("Bearer " + req.SubjectToken)
|
||||||
|
if err != nil {
|
||||||
|
writeProblemDetail(w, http.StatusForbidden,
|
||||||
|
"about:blank#invalid-subject-token", "Invalid Subject Token",
|
||||||
|
fmt.Sprintf("subject token validation failed: %v", err), nil)
|
||||||
|
logging.Errorf("token exchange: invalid subject", err, map[string]string{
|
||||||
|
"path": "/auth/exchange",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
subject := claimStr(subjectClaims, "sub")
|
||||||
|
|
||||||
|
// Step 2: Authenticate service via client_credentials
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"client_credentials"},
|
||||||
|
"client_id": {req.ClientID},
|
||||||
|
"client_secret": {req.ClientSecret},
|
||||||
|
"scope": {"openid roles"},
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.authHTTP.PostForm(h.config.Auth.TokenURL, form)
|
||||||
|
if err != nil {
|
||||||
|
writeProblemDetail(w, http.StatusBadGateway,
|
||||||
|
"about:blank#bad-gateway", "Bad Gateway",
|
||||||
|
"identity provider unreachable", nil)
|
||||||
|
logging.Errorf("token exchange: upstream error", err, map[string]string{
|
||||||
|
"path": "/auth/exchange",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
writeProblemDetail(w, http.StatusForbidden,
|
||||||
|
"about:blank#invalid-actor", "Invalid Actor Credentials",
|
||||||
|
fmt.Sprintf("service authentication failed (HTTP %d)", resp.StatusCode), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var tokenResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&tokenResp); err != nil {
|
||||||
|
writeProblemDetail(w, http.StatusBadGateway,
|
||||||
|
"about:blank#bad-gateway", "Bad Gateway",
|
||||||
|
"invalid response from identity provider", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Decode service token to check roles
|
||||||
|
serviceRoles, err := extractRolesFromJWT(tokenResp.AccessToken)
|
||||||
|
if err != nil {
|
||||||
|
writeProblemDetail(w, http.StatusBadGateway,
|
||||||
|
"about:blank#bad-gateway", "Bad Gateway",
|
||||||
|
"cannot decode service token", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Scope != "" && !scopeSubset(req.Scope, serviceRoles) {
|
||||||
|
writeProblemDetail(w, http.StatusForbidden,
|
||||||
|
"about:blank#scope-escalation", "Scope Escalation Denied",
|
||||||
|
fmt.Sprintf("requested scope %q exceeds service roles %v", req.Scope, serviceRoles), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
grantedScope := req.Scope
|
||||||
|
if grantedScope == "" {
|
||||||
|
grantedScope = strings.Join(serviceRoles, " ")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Return service token with subject metadata
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(exchangeResponse{
|
||||||
|
AccessToken: tokenResp.AccessToken,
|
||||||
|
TokenType: "Bearer",
|
||||||
|
ExpiresIn: tokenResp.ExpiresIn,
|
||||||
|
IssuedTokenType: "urn:ietf:params:oauth:token-type:access_token",
|
||||||
|
Subject: subject,
|
||||||
|
ActingParty: req.ClientID,
|
||||||
|
GrantedScope: grantedScope,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// scopeSubset checks that every space-separated scope token is in allowed roles.
|
||||||
|
func scopeSubset(requested string, allowed []string) bool {
|
||||||
|
allowedSet := make(map[string]bool, len(allowed))
|
||||||
|
for _, r := range allowed {
|
||||||
|
allowedSet[r] = true
|
||||||
|
}
|
||||||
|
if allowedSet["*"] {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, s := range strings.Fields(requested) {
|
||||||
|
if !allowedSet[s] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractRolesFromJWT decodes the payload of a JWT without verification
|
||||||
|
// and returns the "roles" claim. Used after the token was already obtained
|
||||||
|
// from a trusted source (Authentik client_credentials response).
|
||||||
|
func extractRolesFromJWT(token string) ([]string, error) {
|
||||||
|
parts := strings.SplitN(token, ".", 3)
|
||||||
|
if len(parts) != 3 {
|
||||||
|
return nil, fmt.Errorf("invalid JWT format")
|
||||||
|
}
|
||||||
|
|
||||||
|
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("base64 decode failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var claims map[string]interface{}
|
||||||
|
if err := json.Unmarshal(payload, &claims); err != nil {
|
||||||
|
return nil, fmt.Errorf("JSON decode failed: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return claimStrSlice(claims, "roles"), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimStr extracts a string claim.
|
||||||
|
func claimStr(claims map[string]interface{}, key string) string {
|
||||||
|
v, ok := claims[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
s, ok := v.(string)
|
||||||
|
if !ok {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// claimStrSlice extracts a string slice from a JSON-deserialized []interface{}.
|
||||||
|
func claimStrSlice(claims map[string]interface{}, key string) []string {
|
||||||
|
v, ok := claims[key]
|
||||||
|
if !ok || v == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
raw, ok := v.([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(raw))
|
||||||
|
for _, item := range raw {
|
||||||
|
if s, ok := item.(string); ok && s != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
@@ -0,0 +1,215 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeJWT creates a JWT-shaped string (header.payload.signature) with given claims.
|
||||||
|
// Not cryptographically signed — used only with stubbed validators.
|
||||||
|
func fakeJWT(claims map[string]interface{}) string {
|
||||||
|
header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none","typ":"JWT"}`))
|
||||||
|
payload, _ := json.Marshal(claims)
|
||||||
|
payloadB64 := base64.RawURLEncoding.EncodeToString(payload)
|
||||||
|
return header + "." + payloadB64 + ".fakesig"
|
||||||
|
}
|
||||||
|
|
||||||
|
// stubJWTValidator returns claims from a pre-set map keyed by token.
|
||||||
|
type stubJWTValidator struct {
|
||||||
|
tokens map[string]map[string]interface{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubJWTValidator) ValidateBearerToken(authHeader string) (map[string]interface{}, error) {
|
||||||
|
token := strings.TrimPrefix(authHeader, "Bearer ")
|
||||||
|
if claims, ok := s.tokens[token]; ok {
|
||||||
|
return claims, nil
|
||||||
|
}
|
||||||
|
return nil, fmt.Errorf("invalid token")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *stubJWTValidator) CheckPermissions(claims map[string]interface{}, required ...string) bool {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// We can't use stubJWTValidator directly because Handler expects *auth.Validator.
|
||||||
|
// Instead, test via the endpoint with a real JWKS server or test the helpers directly.
|
||||||
|
|
||||||
|
func TestScopeSubset(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
requested string
|
||||||
|
allowed []string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{"memory:read", []string{"llm:inference", "memory:read"}, true},
|
||||||
|
{"memory:read memory:write", []string{"memory:read", "memory:write"}, true},
|
||||||
|
{"memory:write", []string{"memory:read"}, false},
|
||||||
|
{"admin:*", []string{"memory:read"}, false},
|
||||||
|
{"anything", []string{"*"}, true},
|
||||||
|
{"", []string{"memory:read"}, true},
|
||||||
|
{"memory:read", []string{}, false},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := scopeSubset(tt.requested, tt.allowed)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("scopeSubset(%q, %v) = %v, want %v", tt.requested, tt.allowed, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRolesFromJWT(t *testing.T) {
|
||||||
|
token := fakeJWT(map[string]interface{}{
|
||||||
|
"roles": []interface{}{"llm:inference", "memory:read"},
|
||||||
|
"sub": "test-user",
|
||||||
|
})
|
||||||
|
|
||||||
|
roles, err := extractRolesFromJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if len(roles) != 2 || roles[0] != "llm:inference" || roles[1] != "memory:read" {
|
||||||
|
t.Errorf("roles = %v, want [llm:inference memory:read]", roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRolesFromJWT_InvalidFormat(t *testing.T) {
|
||||||
|
_, err := extractRolesFromJWT("not-a-jwt")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("expected error for invalid JWT")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractRolesFromJWT_NoRoles(t *testing.T) {
|
||||||
|
token := fakeJWT(map[string]interface{}{"sub": "user"})
|
||||||
|
roles, err := extractRolesFromJWT(token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if roles != nil {
|
||||||
|
t.Errorf("expected nil roles, got %v", roles)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthExchange_WrongMethod(t *testing.T) {
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
||||||
|
h.jwtValidator = auth.NewValidator("", "", "")
|
||||||
|
|
||||||
|
r := httptest.NewRequest("GET", "/auth/exchange", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthExchange(w, r)
|
||||||
|
|
||||||
|
if w.Code != 405 {
|
||||||
|
t.Errorf("expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthExchange_NotConfigured(t *testing.T) {
|
||||||
|
h := &Handler{
|
||||||
|
config: &config.Config{},
|
||||||
|
routes: make(map[string]*Route),
|
||||||
|
transports: make(map[string]*http.Transport),
|
||||||
|
}
|
||||||
|
|
||||||
|
body := `{"subject_token":"x","client_id":"y","client_secret":"z"}`
|
||||||
|
r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader(body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthExchange(w, r)
|
||||||
|
|
||||||
|
if w.Code != 503 {
|
||||||
|
t.Errorf("expected 503, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthExchange_MissingFields(t *testing.T) {
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
||||||
|
h.jwtValidator = auth.NewValidator("", "", "")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
}{
|
||||||
|
{"missing subject", `{"client_id":"x","client_secret":"y"}`},
|
||||||
|
{"missing client_id", `{"subject_token":"x","client_secret":"y"}`},
|
||||||
|
{"missing client_secret", `{"subject_token":"x","client_id":"y"}`},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader(tt.body))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.handleAuthExchange(w, r)
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthExchange_InvalidJSON(t *testing.T) {
|
||||||
|
h := newTestHandler("https://auth.example.com/token/", "api-gw", "secret", &stubAuthClient{})
|
||||||
|
h.jwtValidator = auth.NewValidator("", "", "")
|
||||||
|
|
||||||
|
r := httptest.NewRequest("POST", "/auth/exchange", strings.NewReader("not json"))
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
|
||||||
|
h.handleAuthExchange(w, r)
|
||||||
|
|
||||||
|
if w.Code != 400 {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHandleAuthExchange_ScopeEscalation(t *testing.T) {
|
||||||
|
// Test scopeSubset directly since full integration needs JWKS
|
||||||
|
if scopeSubset("admin:delete", []string{"memory:read", "memory:write"}) {
|
||||||
|
t.Error("scope escalation should be denied")
|
||||||
|
}
|
||||||
|
if !scopeSubset("memory:read", []string{"memory:read", "memory:write"}) {
|
||||||
|
t.Error("valid scope should be allowed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimStr(t *testing.T) {
|
||||||
|
claims := map[string]interface{}{"sub": "user-1", "num": 42, "nil": nil}
|
||||||
|
if got := claimStr(claims, "sub"); got != "user-1" {
|
||||||
|
t.Errorf("claimStr(sub) = %q, want user-1", got)
|
||||||
|
}
|
||||||
|
if got := claimStr(claims, "num"); got != "" {
|
||||||
|
t.Errorf("claimStr(num) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := claimStr(claims, "nil"); got != "" {
|
||||||
|
t.Errorf("claimStr(nil) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
if got := claimStr(claims, "missing"); got != "" {
|
||||||
|
t.Errorf("claimStr(missing) = %q, want empty", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaimStrSlice(t *testing.T) {
|
||||||
|
claims := map[string]interface{}{
|
||||||
|
"roles": []interface{}{"a", "b", "", nil, 42},
|
||||||
|
"empty": []interface{}{},
|
||||||
|
"str": "not-a-slice",
|
||||||
|
}
|
||||||
|
if got := claimStrSlice(claims, "roles"); len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
||||||
|
t.Errorf("claimStrSlice(roles) = %v, want [a b]", got)
|
||||||
|
}
|
||||||
|
if got := claimStrSlice(claims, "empty"); len(got) != 0 {
|
||||||
|
t.Errorf("claimStrSlice(empty) = %v, want empty", got)
|
||||||
|
}
|
||||||
|
if got := claimStrSlice(claims, "str"); got != nil {
|
||||||
|
t.Errorf("claimStrSlice(str) = %v, want nil", got)
|
||||||
|
}
|
||||||
|
if got := claimStrSlice(claims, "missing"); got != nil {
|
||||||
|
t.Errorf("claimStrSlice(missing) = %v, want nil", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// OpenAI-style tool definition
|
// OpenAI-style tool definition
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// RFC 9457 Problem Details
|
// RFC 9457 Problem Details
|
||||||
|
|||||||
@@ -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"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestHealthEndpoints verifies health endpoint behavior.
|
// TestHealthEndpoints verifies health endpoint behavior.
|
||||||
|
|||||||
@@ -2,35 +2,90 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/webhook"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Router implements an HTTP handler that routes health endpoints
|
// Router implements an HTTP handler that routes health endpoints,
|
||||||
// and passes other requests to an upstream handler.
|
// ServiceAdapter X-Service requests, Temporal workflow endpoints,
|
||||||
|
// and other requests to upstream handlers.
|
||||||
type Router struct {
|
type Router struct {
|
||||||
healthChecker *HealthChecker
|
healthChecker *HealthChecker
|
||||||
|
dispatcher *serviceadapter.Dispatcher
|
||||||
|
temporalHandler http.Handler
|
||||||
upstreamHandler http.Handler
|
upstreamHandler http.Handler
|
||||||
|
forgejoWebhook *webhook.ForgejoHandler
|
||||||
|
metricsHandler http.Handler
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewRouter creates a new router with health endpoints.
|
// NewRouter creates a new router with health endpoints.
|
||||||
// Health endpoints (/healthz and /readyz) are handled locally.
|
// 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.
|
// 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{
|
return &Router{
|
||||||
healthChecker: healthChecker,
|
healthChecker: healthChecker,
|
||||||
|
dispatcher: dispatcher,
|
||||||
|
temporalHandler: temporalHandler,
|
||||||
upstreamHandler: upstreamHandler,
|
upstreamHandler: upstreamHandler,
|
||||||
|
forgejoWebhook: webhook.NewForgejoHandler(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetMetricsHandler registers the Prometheus /metrics endpoint handler.
|
||||||
|
func (r *Router) SetMetricsHandler(h http.Handler) {
|
||||||
|
r.metricsHandler = h
|
||||||
|
}
|
||||||
|
|
||||||
// ServeHTTP implements http.Handler.
|
// ServeHTTP implements http.Handler.
|
||||||
// It routes /healthz and /readyz to health handlers,
|
// Priority order:
|
||||||
// and passes all other paths to the upstream handler.
|
// 1. /healthz and /readyz to health handlers
|
||||||
|
// 2. X-Service header to ServiceAdapter dispatcher (phase 8) - PREFERRED routing method
|
||||||
|
// 3. /workflow* to temporal handler - DEPRECATED: use X-Service: workflow instead
|
||||||
|
// 4. All other paths to upstream handler (phase 0-7)
|
||||||
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||||
|
// Health endpoints first
|
||||||
switch req.URL.Path {
|
switch req.URL.Path {
|
||||||
case "/healthz":
|
case "/healthz":
|
||||||
LivenessHandler(r.healthChecker)(w, req)
|
LivenessHandler(r.healthChecker)(w, req)
|
||||||
|
return
|
||||||
case "/readyz":
|
case "/readyz":
|
||||||
ReadinessHandler(r.healthChecker)(w, req)
|
ReadinessHandler(r.healthChecker)(w, req)
|
||||||
default:
|
return
|
||||||
r.upstreamHandler.ServeHTTP(w, req)
|
case "/metrics":
|
||||||
|
if r.metricsHandler != nil {
|
||||||
|
r.metricsHandler.ServeHTTP(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// X-Service (ServiceAdapter) routing - checked before path-based routing
|
||||||
|
// PREFERRED: All service routing should use X-Service header pattern for consistency,
|
||||||
|
// auth enforcement, and resource-based access control.
|
||||||
|
if req.Header.Get("X-Service") != "" {
|
||||||
|
if r.dispatcher != nil {
|
||||||
|
r.dispatcher.Dispatch(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forgejo webhook receiver — no auth, HMAC-verified by handler
|
||||||
|
if req.URL.Path == "/v1/webhooks/forgejo" {
|
||||||
|
r.forgejoWebhook.ServeHTTP(w, req)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workflow endpoints
|
||||||
|
// DEPRECATED: Path-based /workflow routing is legacy.
|
||||||
|
// New clients should use X-Service: workflow header instead for consistent auth.
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/http2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Server wraps an HTTP server with graceful shutdown support.
|
// Server wraps an HTTP server with graceful shutdown support.
|
||||||
@@ -19,14 +21,30 @@ type Server struct {
|
|||||||
|
|
||||||
// New creates a new Server with the given configuration.
|
// New creates a new Server with the given configuration.
|
||||||
func New(listenAddr string, shutdownTimeout time.Duration, handler http.Handler) *Server {
|
func New(listenAddr string, shutdownTimeout time.Duration, handler http.Handler) *Server {
|
||||||
|
httpServer := &http.Server{
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Issue #32: Enable HTTP/2 for multiplexing concurrent streams.
|
||||||
|
// This allows multiple LLM requests over a single connection,
|
||||||
|
// improving throughput and reducing latency for concurrent clients.
|
||||||
|
if err := http2.ConfigureServer(httpServer, nil); err != nil {
|
||||||
|
// Silently fail HTTP/2 config (shouldn't happen, but gracefully degrade)
|
||||||
|
// Server will still work with HTTP/1.1
|
||||||
|
}
|
||||||
|
|
||||||
return &Server{
|
return &Server{
|
||||||
httpServer: &http.Server{
|
httpServer: httpServer,
|
||||||
Addr: listenAddr,
|
|
||||||
Handler: handler,
|
|
||||||
ReadTimeout: 15 * time.Second,
|
|
||||||
WriteTimeout: 15 * time.Second,
|
|
||||||
IdleTimeout: 60 * time.Second,
|
|
||||||
},
|
|
||||||
shutdownTimeout: shutdownTimeout,
|
shutdownTimeout: shutdownTimeout,
|
||||||
healthChecker: NewHealthChecker(false, false),
|
healthChecker: NewHealthChecker(false, false),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/Riotpiaole/homelab-frontend/internal/server"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestGracefulShutdown verifies that:
|
// TestGracefulShutdown verifies that:
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
package serviceadapter
|
||||||
|
|
||||||
|
// 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{}
|
||||||
|
case "sessions":
|
||||||
|
// code-registered; see NewSessionAdapter()
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
package serviceadapter
|
||||||
|
|
||||||
|
import "os"
|
||||||
|
|
||||||
|
// NewMemoryAdapter creates a code-registered ServiceAdapter for the memory service.
|
||||||
|
// Takes precedence over any config-file loaded memory adapter (main.go skips config
|
||||||
|
// adapters when a code-registered one with the same name exists).
|
||||||
|
// Upstream URL configured via MEMORY_UPSTREAM_URL env var.
|
||||||
|
func NewMemoryAdapter() *ServiceAdapter {
|
||||||
|
upstreamURL := os.Getenv("MEMORY_UPSTREAM_URL")
|
||||||
|
if upstreamURL == "" {
|
||||||
|
upstreamURL = "http://poimen-memory.poimen.svc.cluster.local:8080"
|
||||||
|
}
|
||||||
|
return &ServiceAdapter{
|
||||||
|
Namespace: "poimen",
|
||||||
|
ServiceName: "memory",
|
||||||
|
Spec: *GetMemorySpec(upstreamURL),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemorySpec returns the ServiceAdapter spec for the memory service.
|
||||||
|
func GetMemorySpec(upstreamURL string) *Spec {
|
||||||
|
return &Spec{
|
||||||
|
ServiceName: "memory",
|
||||||
|
Upstream: Upstream{
|
||||||
|
URL: upstreamURL,
|
||||||
|
TimeoutSeconds: 30,
|
||||||
|
},
|
||||||
|
Auth: Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "memory:read",
|
||||||
|
},
|
||||||
|
Resources: []Resource{
|
||||||
|
{
|
||||||
|
Name: "ingest",
|
||||||
|
Methods: []Method{{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/memory/ingest",
|
||||||
|
Auth: &Auth{Required: true, Capability: "memory:write"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "query",
|
||||||
|
Methods: []Method{{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/memory/query",
|
||||||
|
Auth: &Auth{Required: true, Capability: "memory:read"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "skills",
|
||||||
|
Methods: []Method{{
|
||||||
|
Verb: "GET",
|
||||||
|
UpstreamPath: "/memory/skills",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "standing-context",
|
||||||
|
Methods: []Method{{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/memory/standing-context",
|
||||||
|
Auth: &Auth{Required: true, Capability: "memory:read"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
package serviceadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/temporal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewPoimenAdapter creates a ServiceAdapter for the Poimen orchestrator service.
|
||||||
|
func NewPoimenAdapter(h *temporal.Handler) *ServiceAdapter {
|
||||||
|
return &ServiceAdapter{
|
||||||
|
Namespace: "poimen",
|
||||||
|
ServiceName: "poimen",
|
||||||
|
Handler: temporal.NewPoimenHandler(h),
|
||||||
|
Spec: *GetPoimenSpec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPoimenSpec returns the ServiceAdapter spec for the Poimen service.
|
||||||
|
func GetPoimenSpec() *Spec {
|
||||||
|
return &Spec{
|
||||||
|
ServiceName: "poimen",
|
||||||
|
Upstream: Upstream{
|
||||||
|
URL: "internal://poimen",
|
||||||
|
TimeoutSeconds: 60,
|
||||||
|
},
|
||||||
|
Auth: Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:read",
|
||||||
|
},
|
||||||
|
Resources: []Resource{
|
||||||
|
{
|
||||||
|
Name: "plan",
|
||||||
|
Methods: []Method{{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/v1/poimen/plan",
|
||||||
|
Auth: &Auth{Required: true, Capability: "workflow:execute"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "execute",
|
||||||
|
Methods: []Method{{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/v1/poimen/execute",
|
||||||
|
Auth: &Auth{Required: true, Capability: "workflow:execute"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "executions",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "GET",
|
||||||
|
UpstreamPath: "/v1/poimen/executions",
|
||||||
|
Auth: &Auth{Required: true, Capability: "workflow:read"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Note: workflow_id is passed as ?workflow_id=xxx query param,
|
||||||
|
// not as a path segment. The ServiceAdapter dispatcher does not
|
||||||
|
// substitute path parameters.
|
||||||
|
{
|
||||||
|
Name: "execution-detail",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "GET",
|
||||||
|
UpstreamPath: "/v1/poimen/executions/detail",
|
||||||
|
Auth: &Auth{Required: true, Capability: "workflow:read"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Verb: "DELETE",
|
||||||
|
UpstreamPath: "/v1/poimen/executions/detail",
|
||||||
|
Auth: &Auth{Required: true, Capability: "workflow:execute"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "execution-history",
|
||||||
|
Methods: []Method{{
|
||||||
|
Verb: "GET",
|
||||||
|
UpstreamPath: "/v1/poimen/executions/history",
|
||||||
|
Auth: &Auth{Required: true, Capability: "workflow:read"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "execution-stream",
|
||||||
|
Methods: []Method{{
|
||||||
|
Verb: "GET",
|
||||||
|
UpstreamPath: "/v1/poimen/executions/stream",
|
||||||
|
Auth: &Auth{Required: true, Capability: "workflow:read"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "execution-signal",
|
||||||
|
Methods: []Method{{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/v1/poimen/executions/signal",
|
||||||
|
Auth: &Auth{Required: true, Capability: "workflow:execute"},
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,270 @@
|
|||||||
|
package serviceadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httputil"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
"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/identity"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Dispatcher routes X-Service requests to upstreams.
|
||||||
|
type Dispatcher struct {
|
||||||
|
registry *Registry
|
||||||
|
jwtValidator *auth.Validator
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDispatcher creates a dispatcher with a shared multi-issuer JWT validator.
|
||||||
|
// Pass nil to disable auth enforcement (all requests pass through).
|
||||||
|
func NewDispatcher(registry *Registry, jwtValidator *auth.Validator) *Dispatcher {
|
||||||
|
return &Dispatcher{
|
||||||
|
registry: registry,
|
||||||
|
jwtValidator: jwtValidator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Matches returns true if the request has an 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.
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
adapter := d.registry.Get(serviceName)
|
||||||
|
if adapter == nil {
|
||||||
|
d.writeError(w, problem.NotFound(fmt.Sprintf("service '%s' not found", serviceName)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceName := r.Header.Get("X-Resource")
|
||||||
|
if resourceName == "" {
|
||||||
|
d.writeError(w, problem.BadRequest("X-Resource header required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
resource := findResource(adapter, resourceName)
|
||||||
|
if resource == nil {
|
||||||
|
d.writeError(w, problem.NotFound(
|
||||||
|
fmt.Sprintf("resource '%s' not found in service '%s'", resourceName, serviceName)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
method := findMethod(resource, r.Method)
|
||||||
|
if method == nil {
|
||||||
|
d.writeError(w, problem.NotFound(
|
||||||
|
fmt.Sprintf("method %s not defined for resource '%s'", r.Method, resourceName)))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// JWT auth enforcement for adapters that require it
|
||||||
|
if adapter.Spec.Auth.Required {
|
||||||
|
if d.jwtValidator == nil {
|
||||||
|
logging.Warnf("auth required but jwtValidator nil; request unauthenticated", map[string]string{
|
||||||
|
"service": serviceName,
|
||||||
|
"resource": resourceName,
|
||||||
|
})
|
||||||
|
} else if !d.authenticate(w, r, serviceName, method) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Internal handler: dispatch directly without reverse proxy
|
||||||
|
if adapter.Handler != nil {
|
||||||
|
// Set X-Upstream-Path so the handler knows which method was matched
|
||||||
|
r.Header.Set("X-Upstream-Path", method.UpstreamPath)
|
||||||
|
adapter.Handler.ServeHTTP(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
upstreamURL := adapter.Spec.Upstream.URL
|
||||||
|
if strings.HasPrefix(upstreamURL, "grpc://") {
|
||||||
|
d.dispatchGRPC(w, r, upstreamURL, method, adapter)
|
||||||
|
} else {
|
||||||
|
d.dispatchHTTP(w, r, upstreamURL, method, adapter)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// authenticate validates the JWT and checks service-level capability.
|
||||||
|
// Returns false (and writes error response) if auth fails.
|
||||||
|
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName string, method *Method) bool {
|
||||||
|
authHeader := r.Header.Get("Authorization")
|
||||||
|
if authHeader == "" {
|
||||||
|
d.writeError(w, problem.NewProblem(http.StatusUnauthorized,
|
||||||
|
"about:blank#unauthorized", "Unauthorized",
|
||||||
|
fmt.Sprintf("service '%s' requires Authorization header", serviceName)))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
claims, err := d.jwtValidator.ValidateBearerToken(authHeader)
|
||||||
|
if err != nil {
|
||||||
|
d.writeError(w, problem.NewProblem(http.StatusForbidden,
|
||||||
|
"about:blank#forbidden", "Forbidden",
|
||||||
|
fmt.Sprintf("JWT validation failed: %v", err)))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check capability: method-level Auth.Capability if set; else derive from verb.
|
||||||
|
required := capabilityForVerb(serviceName, method.Verb)
|
||||||
|
if method.Auth != nil && method.Auth.Capability != "" {
|
||||||
|
required = method.Auth.Capability
|
||||||
|
}
|
||||||
|
if !d.jwtValidator.CheckPermissions(claims, required, "*") {
|
||||||
|
d.writeError(w, problem.NewProblem(http.StatusForbidden,
|
||||||
|
"about:blank#insufficient-permissions", "Insufficient Permissions",
|
||||||
|
fmt.Sprintf("required capability: %s", required)))
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject identity headers for downstream
|
||||||
|
identity.Inject(r, claims)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// capabilityForVerb maps HTTP verbs to <service>:read or <service>:write.
|
||||||
|
func capabilityForVerb(serviceName, verb string) string {
|
||||||
|
switch verb {
|
||||||
|
case "GET", "HEAD", "OPTIONS":
|
||||||
|
return serviceName + ":read"
|
||||||
|
default:
|
||||||
|
return serviceName + ":write"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func findResource(adapter *ServiceAdapter, name string) *Resource {
|
||||||
|
for i := range adapter.Spec.Resources {
|
||||||
|
if adapter.Spec.Resources[i].Name == name {
|
||||||
|
return &adapter.Spec.Resources[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMethod(resource *Resource, verb string) *Method {
|
||||||
|
for i := range resource.Methods {
|
||||||
|
if resource.Methods[i].Verb == verb {
|
||||||
|
return &resource.Methods[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// Preserve Authorization header for S3 SigV4 and other auth schemes
|
||||||
|
// Note: httputil.ReverseProxy preserves most headers automatically,
|
||||||
|
// but we need to ensure Authorization isn't lost when overriding Director
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) dispatchGRPC(w http.ResponseWriter, r *http.Request, upstreamURL string, method *Method, adapter *ServiceAdapter) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := adapter.Spec.Upstream.TimeoutSeconds
|
||||||
|
if timeout <= 0 {
|
||||||
|
timeout = 30
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeout)*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
conn, err := grpc.DialContext(ctx, host,
|
||||||
|
grpc.WithTransportCredentials(insecure.NewCredentials()),
|
||||||
|
grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(100*1024*1024)),
|
||||||
|
)
|
||||||
|
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()
|
||||||
|
|
||||||
|
// Create HTTP/2 reverse proxy for gRPC
|
||||||
|
// gRPC uses HTTP/2 protocol, so we need an HTTP/2-capable transport
|
||||||
|
upstreamURLObj := &url.URL{
|
||||||
|
Scheme: "http",
|
||||||
|
Host: host,
|
||||||
|
}
|
||||||
|
|
||||||
|
proxy := httputil.NewSingleHostReverseProxy(upstreamURLObj)
|
||||||
|
proxy.Director = func(req *http.Request) {
|
||||||
|
req.URL.Scheme = "http"
|
||||||
|
req.URL.Host = host
|
||||||
|
req.URL.Path = method.UpstreamPath
|
||||||
|
req.RequestURI = ""
|
||||||
|
req.Host = host
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create HTTP/2 client transport for gRPC calls
|
||||||
|
// gRPC requires HTTP/2 for proper message framing
|
||||||
|
h2transport := &http2.Transport{
|
||||||
|
AllowHTTP: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set the transport on the proxy
|
||||||
|
proxy.Transport = h2transport
|
||||||
|
|
||||||
|
// Serve the request through the proxy
|
||||||
|
proxy.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *Dispatcher) writeError(w http.ResponseWriter, p *problem.Problem) {
|
||||||
|
_ = p.Write(w)
|
||||||
|
}
|
||||||
@@ -0,0 +1,467 @@
|
|||||||
|
package serviceadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"encoding/base64"
|
||||||
|
"encoding/json"
|
||||||
|
"math/big"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/golang-jwt/jwt/v5"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stubValidator implements the minimum interface for testing auth.
|
||||||
|
// Real auth.Validator needs JWKS — we test the dispatcher logic, not JWT crypto.
|
||||||
|
|
||||||
|
func newTestRegistry(adapters ...ServiceAdapter) *Registry {
|
||||||
|
r := NewRegistry(nil)
|
||||||
|
for i := range adapters {
|
||||||
|
_ = r.Add(&adapters[i])
|
||||||
|
}
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
func sqsAdapter(authRequired bool) ServiceAdapter {
|
||||||
|
return ServiceAdapter{
|
||||||
|
Name: "sqs",
|
||||||
|
ServiceName: "sqs",
|
||||||
|
Spec: Spec{
|
||||||
|
ServiceName: "sqs",
|
||||||
|
Upstream: Upstream{URL: "http://localhost:9999", TimeoutSeconds: 5},
|
||||||
|
Auth: Auth{Required: authRequired},
|
||||||
|
Resources: []Resource{
|
||||||
|
{
|
||||||
|
Name: "list-queues",
|
||||||
|
Methods: []Method{
|
||||||
|
{Verb: "GET", UpstreamPath: "/sqs/queues"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "send-message",
|
||||||
|
Methods: []Method{
|
||||||
|
{Verb: "POST", UpstreamPath: "/sqs/send"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func memoryAdapter() ServiceAdapter {
|
||||||
|
return ServiceAdapter{
|
||||||
|
Name: "memory",
|
||||||
|
ServiceName: "memory",
|
||||||
|
Spec: Spec{
|
||||||
|
ServiceName: "memory",
|
||||||
|
Upstream: Upstream{URL: "http://localhost:8888", TimeoutSeconds: 5},
|
||||||
|
Auth: Auth{Required: false},
|
||||||
|
Resources: []Resource{
|
||||||
|
{
|
||||||
|
Name: "skills",
|
||||||
|
Methods: []Method{
|
||||||
|
{Verb: "GET", UpstreamPath: "/memory/skills"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_MissingXService(t *testing.T) {
|
||||||
|
d := NewDispatcher(newTestRegistry(), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_UnknownService(t *testing.T) {
|
||||||
|
d := NewDispatcher(newTestRegistry(), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "nonexistent")
|
||||||
|
r.Header.Set("X-Resource", "foo")
|
||||||
|
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_MissingXResource(t *testing.T) {
|
||||||
|
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "memory")
|
||||||
|
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_UnknownResource(t *testing.T) {
|
||||||
|
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "memory")
|
||||||
|
r.Header.Set("X-Resource", "nonexistent")
|
||||||
|
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_WrongHTTPVerb(t *testing.T) {
|
||||||
|
d := NewDispatcher(newTestRegistry(memoryAdapter()), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("DELETE", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "memory")
|
||||||
|
r.Header.Set("X-Resource", "skills")
|
||||||
|
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusNotFound {
|
||||||
|
t.Errorf("expected 404, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_AuthRequired_NoToken(t *testing.T) {
|
||||||
|
// Use nil validator — auth required but no validator means 401
|
||||||
|
// Actually with nil validator, auth is skipped. Use a real scenario.
|
||||||
|
// We need a mock validator. For now test that auth.Required=false passes through.
|
||||||
|
// The real auth test needs the full JWKS setup which is an integration test.
|
||||||
|
|
||||||
|
// Test: auth required, no validator configured = passes through (defense in depth via NetworkPolicy)
|
||||||
|
d := NewDispatcher(newTestRegistry(sqsAdapter(true)), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "sqs")
|
||||||
|
r.Header.Set("X-Resource", "list-queues")
|
||||||
|
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
|
||||||
|
// With nil validator, auth check is skipped — request reaches upstream (which will fail since localhost:9999 is down)
|
||||||
|
// The key assertion: it did NOT return 401/403, it tried to proxy
|
||||||
|
if w.Code == http.StatusUnauthorized || w.Code == http.StatusForbidden {
|
||||||
|
t.Errorf("expected proxy attempt (not auth rejection), got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_AuthNotRequired_NoToken(t *testing.T) {
|
||||||
|
// Start a test upstream
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]string{"path": r.URL.Path})
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
adapter := memoryAdapter()
|
||||||
|
adapter.Spec.Upstream.URL = upstream.URL
|
||||||
|
|
||||||
|
d := NewDispatcher(newTestRegistry(adapter), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "memory")
|
||||||
|
r.Header.Set("X-Resource", "skills")
|
||||||
|
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var body map[string]string
|
||||||
|
json.NewDecoder(w.Body).Decode(&body)
|
||||||
|
if body["path"] != "/memory/skills" {
|
||||||
|
t.Errorf("expected upstream path /memory/skills, got %s", body["path"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_PassThroughHeaders(t *testing.T) {
|
||||||
|
var receivedAuth string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
receivedAuth = r.Header.Get("Authorization")
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
adapter := memoryAdapter()
|
||||||
|
adapter.Spec.Upstream.URL = upstream.URL
|
||||||
|
|
||||||
|
d := NewDispatcher(newTestRegistry(adapter), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "memory")
|
||||||
|
r.Header.Set("X-Resource", "skills")
|
||||||
|
r.Header.Set("Authorization", "Bearer some-jwt")
|
||||||
|
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
|
||||||
|
if receivedAuth != "Bearer some-jwt" {
|
||||||
|
t.Errorf("Authorization header not passed through, got %q", receivedAuth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCapabilityForVerb(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
service string
|
||||||
|
verb string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"sqs", "GET", "sqs:read"},
|
||||||
|
{"sqs", "HEAD", "sqs:read"},
|
||||||
|
{"sqs", "OPTIONS", "sqs:read"},
|
||||||
|
{"sqs", "POST", "sqs:write"},
|
||||||
|
{"sqs", "PUT", "sqs:write"},
|
||||||
|
{"sqs", "DELETE", "sqs:write"},
|
||||||
|
{"sqs", "PATCH", "sqs:write"},
|
||||||
|
{"memory", "GET", "memory:read"},
|
||||||
|
{"memory", "POST", "memory:write"},
|
||||||
|
{"s3", "GET", "s3:read"},
|
||||||
|
{"s3", "PUT", "s3:write"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
got := capabilityForVerb(tt.service, tt.verb)
|
||||||
|
if got != tt.want {
|
||||||
|
t.Errorf("capabilityForVerb(%s, %s) = %s, want %s", tt.service, tt.verb, got, tt.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDispatch_IdentityHeadersNotSet_WhenNoAuth(t *testing.T) {
|
||||||
|
var gotUser, gotVerified string
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
gotUser = r.Header.Get(identity.HeaderUser)
|
||||||
|
gotVerified = r.Header.Get(identity.HeaderAuthVerified)
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
adapter := memoryAdapter()
|
||||||
|
adapter.Spec.Upstream.URL = upstream.URL
|
||||||
|
|
||||||
|
d := NewDispatcher(newTestRegistry(adapter), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("GET", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "memory")
|
||||||
|
r.Header.Set("X-Resource", "skills")
|
||||||
|
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
|
||||||
|
if gotUser != "" {
|
||||||
|
t.Errorf("X-Forwarded-User should not be set without auth, got %q", gotUser)
|
||||||
|
}
|
||||||
|
if gotVerified != "" {
|
||||||
|
t.Errorf("X-Auth-Verified should not be set without auth, got %q", gotVerified)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestDispatch_MethodCapabilityOverridesPOSTDefault verifies that a method-level
|
||||||
|
// Auth.Capability (e.g. "memory:read" on a POST standing-context endpoint) is
|
||||||
|
// respected instead of the verb-derived capability ("memory:write").
|
||||||
|
// Because auth.Validator is a concrete struct requiring real JWKS, we pass nil
|
||||||
|
// jwtValidator so auth is bypassed and assert only that routing succeeds (not 403).
|
||||||
|
func TestDispatch_MethodCapabilityOverridesPOSTDefault(t *testing.T) {
|
||||||
|
// Upstream captures the request so we can verify it was routed.
|
||||||
|
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer upstream.Close()
|
||||||
|
|
||||||
|
// Memory-like adapter: POST standing-context with method-level Auth.Capability=memory:read.
|
||||||
|
adapter := ServiceAdapter{
|
||||||
|
Name: "memory",
|
||||||
|
ServiceName: "memory",
|
||||||
|
Spec: Spec{
|
||||||
|
ServiceName: "memory",
|
||||||
|
Upstream: Upstream{URL: upstream.URL, TimeoutSeconds: 5},
|
||||||
|
Auth: Auth{Required: false},
|
||||||
|
Resources: []Resource{
|
||||||
|
{
|
||||||
|
Name: "standing-context",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/memory/standing-context",
|
||||||
|
// Override: read capability even though verb is POST.
|
||||||
|
Auth: &Auth{Capability: "memory:read"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// nil jwtValidator: auth.Required=false + nil validator → auth skipped entirely.
|
||||||
|
d := NewDispatcher(newTestRegistry(adapter), nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "memory")
|
||||||
|
r.Header.Set("X-Resource", "standing-context")
|
||||||
|
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
|
||||||
|
// Must NOT be 403: the method-level memory:read override is not checked
|
||||||
|
// against verb-derived memory:write (auth is bypassed with nil validator).
|
||||||
|
// Must be 200 routed to real upstream.
|
||||||
|
if w.Code == http.StatusForbidden {
|
||||||
|
t.Errorf("got 403 Forbidden; memory:read override should not produce forbidden (auth bypassed)")
|
||||||
|
}
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 from upstream, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAuthenticate_MethodCapabilityOverrideDirect calls d.Dispatch() with a real
|
||||||
|
// signed RS256 JWT so authenticate() executes end-to-end and the capability-override
|
||||||
|
// branch (router.go lines 124-130) is reached and covered.
|
||||||
|
//
|
||||||
|
// Sub-test 1: token has "memory:read", method.Auth.Capability="memory:read" → 200.
|
||||||
|
// Sub-test 2: token has "memory:read", no method override → POST derives
|
||||||
|
//
|
||||||
|
// "memory:write" → CheckPermissions fails → 403.
|
||||||
|
func TestAuthenticate_MethodCapabilityOverrideDirect(t *testing.T) {
|
||||||
|
// Generate an RSA key pair for signing test JWTs.
|
||||||
|
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("rsa.GenerateKey: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build minimal JWKS document from the RSA public key.
|
||||||
|
nBytes := privateKey.PublicKey.N.Bytes()
|
||||||
|
eBig := new(big.Int).SetInt64(int64(privateKey.PublicKey.E))
|
||||||
|
eBytes := eBig.Bytes()
|
||||||
|
jwksDoc, _ := json.Marshal(map[string]interface{}{
|
||||||
|
"keys": []map[string]interface{}{
|
||||||
|
{
|
||||||
|
"kty": "RSA",
|
||||||
|
"use": "sig",
|
||||||
|
"kid": "test-key",
|
||||||
|
"alg": "RS256",
|
||||||
|
"n": base64.RawURLEncoding.EncodeToString(nBytes),
|
||||||
|
"e": base64.RawURLEncoding.EncodeToString(eBytes),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Serve JWKS from a local test server.
|
||||||
|
jwksSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write(jwksDoc)
|
||||||
|
}))
|
||||||
|
defer jwksSrv.Close()
|
||||||
|
|
||||||
|
// Upstream that returns 200 when authenticate() passes.
|
||||||
|
upstreamSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}))
|
||||||
|
defer upstreamSrv.Close()
|
||||||
|
|
||||||
|
// signJWT returns a signed RS256 token with the given permissions slice.
|
||||||
|
signJWT := func(permissions []string) string {
|
||||||
|
tok := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
|
||||||
|
"iss": "https://authentik.riotpiao.com/application/o/test/",
|
||||||
|
"aud": "test-audience",
|
||||||
|
"sub": "test-user",
|
||||||
|
"exp": time.Now().Add(time.Hour).Unix(),
|
||||||
|
"iat": time.Now().Unix(),
|
||||||
|
"permissions": permissions,
|
||||||
|
})
|
||||||
|
tok.Header["kid"] = "test-key"
|
||||||
|
signed, err := tok.SignedString(privateKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("sign JWT: %v", err)
|
||||||
|
}
|
||||||
|
return signed
|
||||||
|
}
|
||||||
|
|
||||||
|
validator := auth.NewValidator("", "", jwksSrv.URL)
|
||||||
|
|
||||||
|
// Sub-test 1: method.Auth.Capability="memory:read"; token has "memory:read".
|
||||||
|
// authenticate() must execute the override branch and pass → upstream returns 200.
|
||||||
|
t.Run("capability_override_match_reaches_upstream", func(t *testing.T) {
|
||||||
|
adapter := ServiceAdapter{
|
||||||
|
Name: "memory",
|
||||||
|
ServiceName: "memory",
|
||||||
|
Spec: Spec{
|
||||||
|
ServiceName: "memory",
|
||||||
|
Upstream: Upstream{URL: upstreamSrv.URL, TimeoutSeconds: 5},
|
||||||
|
Auth: Auth{Required: true},
|
||||||
|
Resources: []Resource{
|
||||||
|
{
|
||||||
|
Name: "standing-context",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/memory/standing-context",
|
||||||
|
Auth: &Auth{Capability: "memory:read"}, // override
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
d := NewDispatcher(newTestRegistry(adapter), validator)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "memory")
|
||||||
|
r.Header.Set("X-Resource", "standing-context")
|
||||||
|
r.Header.Set("Authorization", "Bearer "+signJWT([]string{"memory:read"}))
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Errorf("expected 200 (override memory:read match), got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sub-test 2: no method-level Auth override; POST → capabilityForVerb → "memory:write".
|
||||||
|
// Token has only "memory:read" → CheckPermissions fails → 403.
|
||||||
|
t.Run("verb_derived_capability_mismatch_returns_403", func(t *testing.T) {
|
||||||
|
adapter := ServiceAdapter{
|
||||||
|
Name: "memory",
|
||||||
|
ServiceName: "memory",
|
||||||
|
Spec: Spec{
|
||||||
|
ServiceName: "memory",
|
||||||
|
Upstream: Upstream{URL: upstreamSrv.URL, TimeoutSeconds: 5},
|
||||||
|
Auth: Auth{Required: true},
|
||||||
|
Resources: []Resource{
|
||||||
|
{
|
||||||
|
Name: "data",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/memory/data",
|
||||||
|
// No method-level Auth: POST derives "memory:write".
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
d := NewDispatcher(newTestRegistry(adapter), validator)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
r := httptest.NewRequest("POST", "/", nil)
|
||||||
|
r.Header.Set("X-Service", "memory")
|
||||||
|
r.Header.Set("X-Resource", "data")
|
||||||
|
r.Header.Set("Authorization", "Bearer "+signJWT([]string{"memory:read"}))
|
||||||
|
d.Dispatch(w, r)
|
||||||
|
if w.Code != http.StatusForbidden {
|
||||||
|
t.Errorf("expected 403 (write required, read only in token), got %d body=%s", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
package serviceadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/sessions"
|
||||||
|
)
|
||||||
|
|
||||||
|
// NewSessionAdapter creates a ServiceAdapter for the sessions service.
|
||||||
|
//
|
||||||
|
// Route mapping (all via X-Service: sessions):
|
||||||
|
//
|
||||||
|
// X-Resource Verb upstream path handler
|
||||||
|
// ──────────────────────────────────────────────────────────────
|
||||||
|
// sessions POST /v1/sessions/create create session
|
||||||
|
// sessions GET /v1/sessions/list list sessions (?status=,?cwd=)
|
||||||
|
// session GET /v1/sessions/get get session (?session_id=)
|
||||||
|
// session PATCH /v1/sessions/patch update session(?session_id=)
|
||||||
|
// milestones POST /v1/sessions/milestones/create create milestone(?session_id=)
|
||||||
|
// episodes GET /v1/sessions/episodes/list list episodes (?session_id=)
|
||||||
|
// episode GET /v1/sessions/episodes/get get episode (?session_id=,?episode_id=)
|
||||||
|
//
|
||||||
|
// Path params (:id, :eid) are passed as query params because the ServiceAdapter
|
||||||
|
// dispatcher does not substitute URL path parameters.
|
||||||
|
func NewSessionAdapter() *ServiceAdapter {
|
||||||
|
return &ServiceAdapter{
|
||||||
|
Namespace: "poimen",
|
||||||
|
ServiceName: "sessions",
|
||||||
|
Handler: sessions.NewHandler(),
|
||||||
|
Spec: *GetSessionSpec(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessionSpec returns the ServiceAdapter spec for the sessions service.
|
||||||
|
func GetSessionSpec() *Spec {
|
||||||
|
return &Spec{
|
||||||
|
ServiceName: "sessions",
|
||||||
|
Upstream: Upstream{
|
||||||
|
URL: "internal://sessions",
|
||||||
|
TimeoutSeconds: 30,
|
||||||
|
},
|
||||||
|
Auth: Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "sessions:read",
|
||||||
|
},
|
||||||
|
Resources: []Resource{
|
||||||
|
{
|
||||||
|
Name: "sessions",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/v1/sessions/create",
|
||||||
|
Auth: &Auth{Required: true, Capability: "sessions:write"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Verb: "GET",
|
||||||
|
UpstreamPath: "/v1/sessions/list",
|
||||||
|
Auth: &Auth{Required: true, Capability: "sessions:read"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "session",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "GET",
|
||||||
|
UpstreamPath: "/v1/sessions/get",
|
||||||
|
Auth: &Auth{Required: true, Capability: "sessions:read"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Verb: "PATCH",
|
||||||
|
UpstreamPath: "/v1/sessions/patch",
|
||||||
|
Auth: &Auth{Required: true, Capability: "sessions:write"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "milestones",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/v1/sessions/milestones/create",
|
||||||
|
Auth: &Auth{Required: true, Capability: "sessions:write"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "episodes",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "GET",
|
||||||
|
UpstreamPath: "/v1/sessions/episodes/list",
|
||||||
|
Auth: &Auth{Required: true, Capability: "sessions:read"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "episode",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "GET",
|
||||||
|
UpstreamPath: "/v1/sessions/episodes/get",
|
||||||
|
Auth: &Auth{Required: true, Capability: "sessions:read"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// sandbox — per-session k8s Pod+PVC+Service lifecycle (§4.15.4)
|
||||||
|
{
|
||||||
|
Name: "sandbox",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/v1/sessions/sandbox",
|
||||||
|
Auth: &Auth{Required: true, Capability: "sessions:write"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Verb: "DELETE",
|
||||||
|
UpstreamPath: "/v1/sessions/sandbox",
|
||||||
|
Auth: &Auth{Required: true, Capability: "sessions:write"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
package serviceadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"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.
|
||||||
|
// When Handler is set, the dispatcher routes directly to the internal handler
|
||||||
|
// instead of reverse-proxying to Spec.Upstream.URL.
|
||||||
|
type ServiceAdapter struct {
|
||||||
|
Name string // namespace/name
|
||||||
|
Namespace string
|
||||||
|
ServiceName string
|
||||||
|
Spec Spec
|
||||||
|
Status Status
|
||||||
|
CreatedAt time.Time
|
||||||
|
Handler http.Handler `json:"-" yaml:"-"` // internal handler (skip serialization)
|
||||||
|
}
|
||||||
@@ -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,377 @@
|
|||||||
|
package serviceadapter
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/temporal"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkflowAdapter handles X-Service: workflow requests.
|
||||||
|
// It forwards workflow operations to the Temporal gRPC service.
|
||||||
|
// Users can specify namespace via the request payload.
|
||||||
|
type WorkflowAdapter struct {
|
||||||
|
temporalHandler *temporal.Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWorkflowAdapter creates a new WorkflowAdapter.
|
||||||
|
func NewWorkflowAdapter(handler *temporal.Handler) *WorkflowAdapter {
|
||||||
|
return &WorkflowAdapter{
|
||||||
|
temporalHandler: handler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleStart handles workflow start requests.
|
||||||
|
// Expects payload: { "namespace": "default", "workflow_id": "...", "workflow_type": "...", "task_queue": "...", "input": {...} }
|
||||||
|
func (wa *WorkflowAdapter) HandleStart(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wa.forwardToTemporal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleDescribe handles workflow describe requests.
|
||||||
|
// Expects payload: { "namespace": "default", "workflow_id": "..." }
|
||||||
|
func (wa *WorkflowAdapter) HandleDescribe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wa.forwardToTemporal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleList handles workflow list requests.
|
||||||
|
// Expects payload: { "namespace": "default", "query": "..." (optional) }
|
||||||
|
func (wa *WorkflowAdapter) HandleList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wa.forwardToTemporal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleHistory handles workflow history requests.
|
||||||
|
// Expects payload: { "namespace": "default", "workflow_id": "..." }
|
||||||
|
func (wa *WorkflowAdapter) HandleHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wa.forwardToTemporal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleTerminate handles workflow termination.
|
||||||
|
// Expects payload: { "namespace": "default", "workflow_id": "...", "reason": "..." }
|
||||||
|
func (wa *WorkflowAdapter) HandleTerminate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wa.forwardToTemporal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleCancel handles workflow cancellation.
|
||||||
|
// Expects payload: { "namespace": "default", "workflow_id": "..." }
|
||||||
|
func (wa *WorkflowAdapter) HandleCancel(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wa.forwardToTemporal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSignal handles workflow signal.
|
||||||
|
// Expects payload: { "namespace": "default", "workflow_id": "...", "signal_name": "...", "signal_data": {...} }
|
||||||
|
func (wa *WorkflowAdapter) HandleSignal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wa.forwardToTemporal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleQuery handles workflow query.
|
||||||
|
// Expects payload: { "namespace": "default", "workflow_id": "...", "query_type": "...", "query_data": {...} }
|
||||||
|
func (wa *WorkflowAdapter) HandleQuery(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wa.forwardToTemporal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleReset handles workflow reset.
|
||||||
|
// Expects payload: { "namespace": "default", "workflow_id": "...", "reset_type": "..." }
|
||||||
|
func (wa *WorkflowAdapter) HandleReset(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wa.forwardToTemporal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleUpdate handles workflow update.
|
||||||
|
// Expects payload: { "namespace": "default", "workflow_id": "...", "update_data": {...} }
|
||||||
|
func (wa *WorkflowAdapter) HandleUpdate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
wa.forwardToTemporal(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resourceToAction maps X-Resource names to Temporal action names.
|
||||||
|
var resourceToAction = map[string]string{
|
||||||
|
"execute": "START_WORKFLOW",
|
||||||
|
"describe": "DESCRIBE_WORKFLOW",
|
||||||
|
"list": "LIST_WORKFLOWS",
|
||||||
|
"history": "GET_WORKFLOW_HISTORY",
|
||||||
|
"terminate": "TERMINATE_WORKFLOW",
|
||||||
|
"cancel": "CANCEL_WORKFLOW",
|
||||||
|
"signal": "SIGNAL_WORKFLOW",
|
||||||
|
"query": "QUERY_WORKFLOW",
|
||||||
|
"reset": "RESET_WORKFLOW",
|
||||||
|
"update": "UPDATE_WORKFLOW",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP implements http.Handler for X-Service: workflow routing.
|
||||||
|
// Maps X-Resource header to Temporal action, injects action into body,
|
||||||
|
// and forwards to the temporal handler.
|
||||||
|
func (wa *WorkflowAdapter) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
resource := r.Header.Get("X-Resource")
|
||||||
|
action, ok := resourceToAction[resource]
|
||||||
|
if !ok {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprintf(w, `{"error":"unknown workflow resource: %s"}`, resource)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read body, inject action, forward
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
fmt.Fprintf(w, `{"error":"failed to read body: %s"}`, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if len(body) > 0 {
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
fmt.Fprintf(w, `{"error":"invalid JSON: %s"}`, err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
payload = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inject action into body for temporal handler
|
||||||
|
payload["action"] = action
|
||||||
|
|
||||||
|
// namespace is required for all workflow operations
|
||||||
|
if ns, ok := payload["namespace"].(string); !ok || ns == "" {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
fmt.Fprintf(w, `{"error":"namespace is required"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
newBody, _ := json.Marshal(payload)
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(newBody))
|
||||||
|
r.ContentLength = int64(len(newBody))
|
||||||
|
r.URL.Path = "/workflow"
|
||||||
|
|
||||||
|
wa.temporalHandler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// forwardToTemporal reads the request body, ensures namespace is specified,
|
||||||
|
// and forwards to the temporal handler.
|
||||||
|
func (wa *WorkflowAdapter) forwardToTemporal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Read request body
|
||||||
|
body, err := io.ReadAll(r.Body)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("failed to read request body: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer r.Body.Close()
|
||||||
|
|
||||||
|
// Parse JSON to check for namespace
|
||||||
|
var payload map[string]interface{}
|
||||||
|
if err := json.Unmarshal(body, &payload); err != nil {
|
||||||
|
http.Error(w, fmt.Sprintf("invalid JSON payload: %v", err), http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure namespace is specified (required for Temporal routing)
|
||||||
|
namespace, ok := payload["namespace"].(string)
|
||||||
|
if !ok || namespace == "" {
|
||||||
|
http.Error(w, `"namespace" field required in payload`, http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward to temporal handler by calling it with the request
|
||||||
|
// Restore body for temporal handler
|
||||||
|
r.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
r.ContentLength = int64(len(body))
|
||||||
|
|
||||||
|
// Call temporal handler
|
||||||
|
wa.temporalHandler.ServeHTTP(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpec returns the ServiceAdapter spec for workflow service.
|
||||||
|
// This defines the available resources and methods.
|
||||||
|
func GetWorkflowSpec() *Spec {
|
||||||
|
return &Spec{
|
||||||
|
ServiceName: "workflow",
|
||||||
|
Upstream: Upstream{
|
||||||
|
URL: "grpc://temporal:7233", // gRPC endpoint
|
||||||
|
TimeoutSeconds: 30,
|
||||||
|
},
|
||||||
|
Auth: Auth{
|
||||||
|
Required: false,
|
||||||
|
Capability: "workflow:execute",
|
||||||
|
},
|
||||||
|
Retryable: true,
|
||||||
|
Resources: []Resource{
|
||||||
|
{
|
||||||
|
Name: "execute",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/temporal.workflowservice.v1.WorkflowService/StartWorkflowExecution",
|
||||||
|
RequestSchema: "workflow_start_request",
|
||||||
|
ResponseSchema: "workflow_start_response",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:execute",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "describe",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "GET",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/temporal.workflowservice.v1.WorkflowService/DescribeWorkflowExecution",
|
||||||
|
RequestSchema: "workflow_describe_request",
|
||||||
|
ResponseSchema: "workflow_describe_response",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "list",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "GET",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/temporal.workflowservice.v1.WorkflowService/ListWorkflowExecutions",
|
||||||
|
RequestSchema: "workflow_list_request",
|
||||||
|
ResponseSchema: "workflow_list_response",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "history",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "GET",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/temporal.workflowservice.v1.WorkflowService/GetWorkflowExecutionHistory",
|
||||||
|
RequestSchema: "workflow_history_request",
|
||||||
|
ResponseSchema: "workflow_history_response",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "terminate",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/temporal.workflowservice.v1.WorkflowService/TerminateWorkflowExecution",
|
||||||
|
RequestSchema: "workflow_terminate_request",
|
||||||
|
ResponseSchema: "workflow_terminate_response",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:execute",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "cancel",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/temporal.workflowservice.v1.WorkflowService/RequestCancelWorkflowExecution",
|
||||||
|
RequestSchema: "workflow_cancel_request",
|
||||||
|
ResponseSchema: "workflow_cancel_response",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:execute",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "signal",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/temporal.workflowservice.v1.WorkflowService/SignalWorkflowExecution",
|
||||||
|
RequestSchema: "workflow_signal_request",
|
||||||
|
ResponseSchema: "workflow_signal_response",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:signal",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "query",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/temporal.workflowservice.v1.WorkflowService/QueryWorkflow",
|
||||||
|
RequestSchema: "workflow_query_request",
|
||||||
|
ResponseSchema: "workflow_query_response",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:query",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "reset",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/temporal.workflowservice.v1.WorkflowService/ResetWorkflowExecution",
|
||||||
|
RequestSchema: "workflow_reset_request",
|
||||||
|
ResponseSchema: "workflow_reset_response",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:execute",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "POST",
|
||||||
|
UpstreamPath: "/temporal.workflowservice.v1.WorkflowService/UpdateWorkflowExecution",
|
||||||
|
RequestSchema: "workflow_update_request",
|
||||||
|
ResponseSchema: "workflow_update_response",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:execute",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "stream",
|
||||||
|
Methods: []Method{
|
||||||
|
{
|
||||||
|
Verb: "GET",
|
||||||
|
UpstreamPath: "/workflow/stream",
|
||||||
|
Auth: &Auth{
|
||||||
|
Required: true,
|
||||||
|
Capability: "workflow:read",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
package serviceadapter_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/temporal"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkflowListRequiresNamespace(t *testing.T) {
|
||||||
|
th := temporal.NewHandler("localhost:7233")
|
||||||
|
wfAdapter := serviceadapter.NewWorkflowAdapter(th)
|
||||||
|
wfSpec := serviceadapter.GetWorkflowSpec()
|
||||||
|
adapter := &serviceadapter.ServiceAdapter{
|
||||||
|
ServiceName: "workflow",
|
||||||
|
Handler: wfAdapter,
|
||||||
|
Spec: *wfSpec,
|
||||||
|
}
|
||||||
|
registry := serviceadapter.NewRegistry(nil)
|
||||||
|
registry.Add(adapter)
|
||||||
|
dispatcher := serviceadapter.NewDispatcher(registry, nil)
|
||||||
|
|
||||||
|
// POST X-Service: workflow X-Resource: list body: {} (no namespace)
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/", strings.NewReader(`{}`))
|
||||||
|
req.Header.Set("X-Service", "workflow")
|
||||||
|
req.Header.Set("X-Resource", "list")
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
dispatcher.Dispatch(w, req)
|
||||||
|
|
||||||
|
t.Logf("Status: %d Body: %s", w.Code, w.Body.String())
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
// Package sessions — FleetClient calls the SandboxFleet controller REST API
|
||||||
|
// to assign sessions to fleet pods. This is the primary sandbox path;
|
||||||
|
// the raw-k8s SandboxManager is the fallback when the fleet controller
|
||||||
|
// is unavailable (e.g. during fleet rollout).
|
||||||
|
package sessions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultFleetControllerURL = "http://tool-definition-controller.poimen.svc.cluster.local:8788"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FleetClient calls the SandboxFleet controller REST API.
|
||||||
|
type FleetClient struct {
|
||||||
|
baseURL string
|
||||||
|
client *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// FleetAssignResult is returned by the controller assign endpoint.
|
||||||
|
type FleetAssignResult struct {
|
||||||
|
PodSvcDns string `json:"podSvcDns"`
|
||||||
|
PodName string `json:"podName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewFleetClient creates a FleetClient from env or default URL.
|
||||||
|
// FLEET_CONTROLLER_URL — default: defaultFleetControllerURL
|
||||||
|
func NewFleetClient() *FleetClient {
|
||||||
|
url := os.Getenv("FLEET_CONTROLLER_URL")
|
||||||
|
if url == "" {
|
||||||
|
url = defaultFleetControllerURL
|
||||||
|
}
|
||||||
|
return &FleetClient{
|
||||||
|
baseURL: url,
|
||||||
|
client: &http.Client{Timeout: 35 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assign asks the fleet controller to assign sessionID to a fleet pod.
|
||||||
|
// Returns the pod's ClusterIP service DNS and pod name.
|
||||||
|
func (f *FleetClient) Assign(ctx context.Context, sessionID string) (*FleetAssignResult, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost,
|
||||||
|
f.baseURL+"/sessions/"+sessionID+"/assign", nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
resp, err := f.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("fleet controller unreachable: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("fleet controller returned %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result FleetAssignResult
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
if result.PodSvcDns == "" {
|
||||||
|
return nil, fmt.Errorf("fleet controller returned empty podSvcDns")
|
||||||
|
}
|
||||||
|
return &result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Release notifies the fleet controller that a session has ended.
|
||||||
|
// Cleans up the per-session ServiceAccount (audit trail SA).
|
||||||
|
// Non-fatal: fleet controller handles this async.
|
||||||
|
func (f *FleetClient) Release(ctx context.Context, sessionID string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete,
|
||||||
|
f.baseURL+"/sessions/"+sessionID, bytes.NewReader(nil))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
resp, err := f.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("fleet controller unreachable: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return fmt.Errorf("fleet controller returned %d: %s", resp.StatusCode, body)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,634 @@
|
|||||||
|
// Package sessions provides the internal handler for the sessions service adapter.
|
||||||
|
// It bridges the api-gw REST surface to poimen-graphql for all reads and writes:
|
||||||
|
//
|
||||||
|
// - Reads: hybridSearch / entity queries
|
||||||
|
// - Writes: upsertEntity mutation (direct, no LLM extraction)
|
||||||
|
//
|
||||||
|
// All IDs passed by the caller (session_id, episode_id) go as query params because
|
||||||
|
// the ServiceAdapter dispatcher does not substitute URL path parameters.
|
||||||
|
// episode_id is sha256(session_id + milestone_id + attempt) — callers compute it the
|
||||||
|
// same way; the handler accepts it on write and returns it on read.
|
||||||
|
package sessions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultGraphQLURL = "http://poimen-graphql.poimen.svc.cluster.local:8090/graphql"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Handler handles session REST endpoints via internal service calls.
|
||||||
|
// All traffic (reads and writes) goes to graphqlURL (poimen-graphql).
|
||||||
|
// Sandbox creates/deletes pi-session pods via the SandboxFleet controller
|
||||||
|
// (primary) with SandboxManager as direct-k8s fallback.
|
||||||
|
type Handler struct {
|
||||||
|
graphqlURL string
|
||||||
|
client *http.Client
|
||||||
|
sandbox *SandboxManager
|
||||||
|
fleet *FleetClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewHandler creates a Handler reading upstream URL from env var.
|
||||||
|
// GRAPHQL_UPSTREAM_URL — default: defaultGraphQLURL
|
||||||
|
func NewHandler() *Handler {
|
||||||
|
gql := os.Getenv("GRAPHQL_UPSTREAM_URL")
|
||||||
|
if gql == "" {
|
||||||
|
gql = defaultGraphQLURL
|
||||||
|
}
|
||||||
|
h := &Handler{
|
||||||
|
graphqlURL: gql,
|
||||||
|
client: &http.Client{Timeout: 30 * time.Second, Transport: tracing.NewTransport(nil)},
|
||||||
|
sandbox: NewSandboxManager(),
|
||||||
|
fleet: NewFleetClient(),
|
||||||
|
}
|
||||||
|
h.startIdleReaper()
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// startIdleReaper launches a background goroutine that deletes exited pi-session
|
||||||
|
// pods (bridge.js idle-timeout triggered) while preserving their PVCs.
|
||||||
|
func (h *Handler) startIdleReaper() {
|
||||||
|
intervalStr := os.Getenv("SANDBOX_REAPER_INTERVAL_MINUTES")
|
||||||
|
interval := defaultReaperInterval
|
||||||
|
if intervalStr != "" {
|
||||||
|
var min int
|
||||||
|
if n, _ := fmt.Sscanf(intervalStr, "%d", &min); n == 1 && min > 0 {
|
||||||
|
interval = time.Duration(min) * time.Minute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
go func() {
|
||||||
|
ticker := time.NewTicker(interval)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for range ticker.C {
|
||||||
|
h.sandbox.ReapIdlePods(context.Background())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP routes by X-Upstream-Path set by the dispatcher.
|
||||||
|
func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.Header.Get("X-Upstream-Path")
|
||||||
|
switch path {
|
||||||
|
case "/v1/sessions/create":
|
||||||
|
h.handleCreate(w, r)
|
||||||
|
case "/v1/sessions/list":
|
||||||
|
h.handleList(w, r)
|
||||||
|
case "/v1/sessions/get":
|
||||||
|
h.handleGet(w, r)
|
||||||
|
case "/v1/sessions/patch":
|
||||||
|
h.handlePatch(w, r)
|
||||||
|
case "/v1/sessions/milestones/create":
|
||||||
|
h.handleCreateMilestone(w, r)
|
||||||
|
case "/v1/sessions/episodes/list":
|
||||||
|
h.handleListEpisodes(w, r)
|
||||||
|
case "/v1/sessions/episodes/get":
|
||||||
|
h.handleGetEpisode(w, r)
|
||||||
|
case "/v1/sessions/sandbox":
|
||||||
|
h.handleSandbox(w, r)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "sessions endpoint not found"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── request / response types ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type createSessionReq struct {
|
||||||
|
Name string `json:"name"`
|
||||||
|
Goal string `json:"goal,omitempty"`
|
||||||
|
Cwd string `json:"cwd,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type sessionResp struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Goal string `json:"goal,omitempty"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
MemoryProject string `json:"memory_project"`
|
||||||
|
Cwd string `json:"cwd,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type patchSessionReq struct {
|
||||||
|
Name *string `json:"name,omitempty"`
|
||||||
|
Goal *string `json:"goal,omitempty"`
|
||||||
|
Status *string `json:"status,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type createMilestoneReq struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
OrderIdx int `json:"order_idx"`
|
||||||
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type milestoneResp struct {
|
||||||
|
MilestoneID string `json:"milestone_id"`
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
OrderIdx int `json:"order_idx"`
|
||||||
|
ParentID *string `json:"parent_id,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── handlers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// POST /v1/sessions (X-Resource: sessions)
|
||||||
|
func (h *Handler) handleCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req createSessionReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Name == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionID := uuid.New().String()
|
||||||
|
project := "session-" + sessionID
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
|
||||||
|
summaryBytes, err := json.Marshal(map[string]any{
|
||||||
|
"id": sessionID,
|
||||||
|
"name": req.Name,
|
||||||
|
"goal": req.Goal,
|
||||||
|
"status": "active",
|
||||||
|
"cwd": req.Cwd,
|
||||||
|
"memory_project": project,
|
||||||
|
"created_at": now,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.upsertEntity(r, project, sessionID, "SESSION", string(summaryBytes)); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, sessionResp{
|
||||||
|
SessionID: sessionID,
|
||||||
|
Name: req.Name,
|
||||||
|
Goal: req.Goal,
|
||||||
|
Status: "active",
|
||||||
|
MemoryProject: project,
|
||||||
|
Cwd: req.Cwd,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/sessions (X-Resource: sessions) ?status=active&cwd=/path
|
||||||
|
func (h *Handler) handleList(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
status := q.Get("status")
|
||||||
|
cwd := q.Get("cwd")
|
||||||
|
limitStr := q.Get("limit")
|
||||||
|
limit := 50
|
||||||
|
if limitStr != "" {
|
||||||
|
fmt.Sscanf(limitStr, "%d", &limit) //nolint:errcheck
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
searchQuery := "session"
|
||||||
|
if status != "" {
|
||||||
|
searchQuery = fmt.Sprintf("session status:%s", status)
|
||||||
|
}
|
||||||
|
if cwd != "" {
|
||||||
|
searchQuery = fmt.Sprintf("%s cwd:%s", searchQuery, cwd)
|
||||||
|
}
|
||||||
|
|
||||||
|
// entities query (not hybridSearch) — project arg is optional, so it queries
|
||||||
|
// across ALL projects. Required because each session lives in its own
|
||||||
|
// session-<uuid> project; there is no single project to hybridSearch against.
|
||||||
|
gqlQuery := `query($q:String!,$limit:Int!){
|
||||||
|
entities(query:$q,limit:$limit,entityType:SESSION){
|
||||||
|
entities{id name summary projectId tCreated}
|
||||||
|
searchMode
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
vars := map[string]any{"q": searchQuery, "limit": limit}
|
||||||
|
result, err := h.gql(r, gqlQuery, vars)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "graphql query failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/sessions/:id (X-Resource: session) ?session_id=<uuid>
|
||||||
|
func (h *Handler) handleGet(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := r.URL.Query().Get("session_id")
|
||||||
|
if sessionID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project := "session-" + sessionID
|
||||||
|
gqlQuery := `query($id:String!,$project:String!){
|
||||||
|
hybridSearch(query:$id,project:$project,limit:1,entityType:SESSION){
|
||||||
|
entities{id name summary tCreated}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
vars := map[string]any{"id": sessionID, "project": project}
|
||||||
|
result, err := h.gql(r, gqlQuery, vars)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "graphql query failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PATCH /v1/sessions/:id (X-Resource: session) ?session_id=<uuid>
|
||||||
|
func (h *Handler) handlePatch(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := r.URL.Query().Get("session_id")
|
||||||
|
if sessionID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req patchSessionReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
project := "session-" + sessionID
|
||||||
|
|
||||||
|
// Read current summary so the upsert carries all existing fields.
|
||||||
|
current, err := h.fetchEntitySummary(r, project, sessionID, "SESSION")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "fetch failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
current["updated_at"] = now
|
||||||
|
if req.Name != nil {
|
||||||
|
current["name"] = *req.Name
|
||||||
|
}
|
||||||
|
if req.Goal != nil {
|
||||||
|
current["goal"] = *req.Goal
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
current["status"] = *req.Status
|
||||||
|
if *req.Status == "archived" {
|
||||||
|
current["archived_at"] = now
|
||||||
|
}
|
||||||
|
}
|
||||||
|
summaryBytes, err := json.Marshal(current)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.upsertEntity(r, project, sessionID, "SESSION", string(summaryBytes)); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"session_id": sessionID, "status": "updated"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /v1/sessions/:id/milestones (X-Resource: milestones) ?session_id=<uuid>
|
||||||
|
func (h *Handler) handleCreateMilestone(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := r.URL.Query().Get("session_id")
|
||||||
|
if sessionID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req createMilestoneReq
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Title == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
milestoneID := uuid.New().String()
|
||||||
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
|
project := "session-" + sessionID
|
||||||
|
|
||||||
|
msContent := map[string]any{
|
||||||
|
"type": "MILESTONE",
|
||||||
|
"id": milestoneID,
|
||||||
|
"session_id": sessionID,
|
||||||
|
"title": req.Title,
|
||||||
|
"status": "pending",
|
||||||
|
"order_idx": req.OrderIdx,
|
||||||
|
"created_at": now,
|
||||||
|
}
|
||||||
|
if req.ParentID != nil {
|
||||||
|
msContent["parent_id"] = *req.ParentID
|
||||||
|
}
|
||||||
|
summaryBytes, err := json.Marshal(msContent)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "marshal failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err := h.upsertEntity(r, project, milestoneID, "MILESTONE", string(summaryBytes)); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "upsert failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusCreated, milestoneResp{
|
||||||
|
MilestoneID: milestoneID,
|
||||||
|
SessionID: sessionID,
|
||||||
|
Title: req.Title,
|
||||||
|
Status: "pending",
|
||||||
|
OrderIdx: req.OrderIdx,
|
||||||
|
ParentID: req.ParentID,
|
||||||
|
CreatedAt: now,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/sessions/:id/episodes (X-Resource: episodes) ?session_id=<uuid>
|
||||||
|
func (h *Handler) handleListEpisodes(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := r.URL.Query().Get("session_id")
|
||||||
|
if sessionID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
limitStr := r.URL.Query().Get("limit")
|
||||||
|
limit := 50
|
||||||
|
if limitStr != "" {
|
||||||
|
fmt.Sscanf(limitStr, "%d", &limit) //nolint:errcheck
|
||||||
|
}
|
||||||
|
if limit > 200 {
|
||||||
|
limit = 200
|
||||||
|
}
|
||||||
|
|
||||||
|
project := "session-" + sessionID
|
||||||
|
gqlQuery := `query($project:String!,$limit:Int!){
|
||||||
|
hybridSearch(query:"episode",project:$project,limit:$limit,entityType:EPISODE){
|
||||||
|
entities{id name summary tCreated}
|
||||||
|
searchMode
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
vars := map[string]any{"project": project, "limit": limit}
|
||||||
|
result, err := h.gql(r, gqlQuery, vars)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "graphql query failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /v1/sessions/:id/episodes/:eid (X-Resource: episode)
|
||||||
|
// ?session_id=<uuid>&episode_id=<sha256>
|
||||||
|
func (h *Handler) handleGetEpisode(w http.ResponseWriter, r *http.Request) {
|
||||||
|
q := r.URL.Query()
|
||||||
|
sessionID := q.Get("session_id")
|
||||||
|
episodeID := q.Get("episode_id")
|
||||||
|
if sessionID == "" || episodeID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{
|
||||||
|
"error": "session_id and episode_id query params required",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
gqlQuery := `query($id:ID!){
|
||||||
|
entity(id:$id){
|
||||||
|
id name entityType summary tCreated
|
||||||
|
edges{
|
||||||
|
relationType fact confidence
|
||||||
|
target{name entityType}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
vars := map[string]any{"id": episodeID}
|
||||||
|
result, err := h.gql(r, gqlQuery, vars)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "graphql query failed: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// upsertEntity runs a GraphQL upsertEntity mutation on poimen-graphql.
|
||||||
|
// Writes are direct (no LLM extraction). Returns the entity id assigned by the server.
|
||||||
|
// The name field is used as the stable upsert key within (project, entityType).
|
||||||
|
func (h *Handler) upsertEntity(r *http.Request, project, name, entityType, summary string) (string, error) {
|
||||||
|
mutation := `mutation($input:UpsertEntityInput!){
|
||||||
|
upsertEntity(input:$input){ id created }
|
||||||
|
}`
|
||||||
|
vars := map[string]any{
|
||||||
|
"input": map[string]any{
|
||||||
|
"project": project,
|
||||||
|
"name": name,
|
||||||
|
"entityType": entityType,
|
||||||
|
"summary": summary,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := h.gql(r, mutation, vars)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
data, _ := result.(map[string]any)
|
||||||
|
d, _ := data["data"].(map[string]any)
|
||||||
|
entity, _ := d["upsertEntity"].(map[string]any)
|
||||||
|
id, _ := entity["id"].(string)
|
||||||
|
return id, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// fetchEntitySummary fetches the current summary blob of an entity by (project, name, entityType).
|
||||||
|
// Returns a parsed map so callers can merge patch fields before re-upserting.
|
||||||
|
// Returns empty map (not error) if no entity found.
|
||||||
|
func (h *Handler) fetchEntitySummary(r *http.Request, project, name, entityType string) (map[string]any, error) {
|
||||||
|
gqlQuery := `query($q:String!,$project:String!,$entityType:GqlEntityType!,$limit:Int!){
|
||||||
|
hybridSearch(query:$q,project:$project,limit:$limit,entityType:$entityType){
|
||||||
|
entities{id name summary tCreated}
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
vars := map[string]any{
|
||||||
|
"q": name,
|
||||||
|
"project": project,
|
||||||
|
"entityType": entityType,
|
||||||
|
"limit": 1,
|
||||||
|
}
|
||||||
|
result, err := h.gql(r, gqlQuery, vars)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
data, _ := result.(map[string]any)
|
||||||
|
d, _ := data["data"].(map[string]any)
|
||||||
|
search, _ := d["hybridSearch"].(map[string]any)
|
||||||
|
entities, _ := search["entities"].([]any)
|
||||||
|
if len(entities) == 0 {
|
||||||
|
return map[string]any{}, nil
|
||||||
|
}
|
||||||
|
entity, _ := entities[0].(map[string]any)
|
||||||
|
summaryStr, _ := entity["summary"].(string)
|
||||||
|
var summary map[string]any
|
||||||
|
if summaryStr != "" {
|
||||||
|
if err := json.Unmarshal([]byte(summaryStr), &summary); err != nil {
|
||||||
|
return map[string]any{}, nil // corrupt summary — start fresh
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if summary == nil {
|
||||||
|
summary = map[string]any{}
|
||||||
|
}
|
||||||
|
return summary, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// gql executes a GraphQL query against poimen-graphql, forwarding the caller's JWT.
|
||||||
|
func (h *Handler) gql(r *http.Request, query string, variables map[string]any) (any, error) {
|
||||||
|
payload, err := json.Marshal(map[string]any{
|
||||||
|
"query": query,
|
||||||
|
"variables": variables,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("marshal: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(r.Context(), http.MethodPost,
|
||||||
|
h.graphqlURL, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("build request: %w", err)
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||||
|
req.Header.Set("Authorization", auth)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("http: %w", err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode >= 300 {
|
||||||
|
return nil, fmt.Errorf("graphql returned %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
if errs, ok := result["errors"]; ok && errs != nil {
|
||||||
|
b, _ := json.Marshal(errs)
|
||||||
|
return nil, fmt.Errorf("graphql errors: %s", b)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSandbox dispatches POST (create/resume) and DELETE (teardown).
|
||||||
|
// Both take ?session_id= as a query param.
|
||||||
|
func (h *Handler) handleSandbox(w http.ResponseWriter, r *http.Request) {
|
||||||
|
sessionID := r.URL.Query().Get("session_id")
|
||||||
|
if sessionID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "session_id query param required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch r.Method {
|
||||||
|
case http.MethodPost:
|
||||||
|
h.handleSandboxCreate(w, r, sessionID)
|
||||||
|
case http.MethodDelete:
|
||||||
|
h.handleSandboxDelete(w, r, sessionID)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusMethodNotAllowed, map[string]string{"error": "method not allowed"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST ?session_id= — assign session to SandboxFleet pod (primary) or
|
||||||
|
// create a dedicated pod via SandboxManager (fallback).
|
||||||
|
func (h *Handler) handleSandboxCreate(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||||
|
// Primary: fleet controller assigns session to a pre-warmed pod.
|
||||||
|
if assign, err := h.fleet.Assign(r.Context(), sessionID); err == nil {
|
||||||
|
svcDns := assign.PodSvcDns + fmt.Sprintf(":%d", sandboxPort)
|
||||||
|
log.Printf("sandbox: fleet assigned session=%s pod=%s dns=%s", sessionID, assign.PodName, svcDns)
|
||||||
|
go h.updateSandboxSummary(r, sessionID, assign.PodName, "", svcDns)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"pod_name": assign.PodName,
|
||||||
|
"svc_dns": svcDns,
|
||||||
|
"resumed": false,
|
||||||
|
"fleet": true,
|
||||||
|
})
|
||||||
|
return
|
||||||
|
} else {
|
||||||
|
log.Printf("sandbox: fleet unavailable for session=%s (%v), falling back to direct k8s", sessionID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: create a dedicated pod via SandboxManager.
|
||||||
|
result, err := h.sandbox.CreateOrResume(r.Context(), sessionID)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sandbox create/resume session=%s: %v", sessionID, err)
|
||||||
|
writeJSON(w, http.StatusBadGateway, map[string]string{"error": "sandbox error: " + err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
go h.updateSandboxSummary(r, sessionID, result.PodName, result.PVCName, result.SvcDNS)
|
||||||
|
log.Printf("sandbox: direct pod session=%s pod=%s resumed=%v dns=%s", sessionID, result.PodName, result.Resumed, result.SvcDNS)
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// updateSandboxSummary merges pod/svc metadata into the session memory_entity (fire-and-forget).
|
||||||
|
// Uses context.Background() because r.Context() is cancelled once the HTTP handler returns.
|
||||||
|
func (h *Handler) updateSandboxSummary(r *http.Request, sessionID, podName, pvcName, svcDns string) {
|
||||||
|
// Build a detached request carrying just the auth header.
|
||||||
|
detached, _ := http.NewRequestWithContext(context.Background(), r.Method, r.URL.String(), nil)
|
||||||
|
if auth := r.Header.Get("Authorization"); auth != "" {
|
||||||
|
detached.Header.Set("Authorization", auth)
|
||||||
|
}
|
||||||
|
current, _ := h.fetchEntitySummary(detached, "session-"+sessionID, sessionID, "SESSION")
|
||||||
|
if current == nil {
|
||||||
|
current = map[string]any{}
|
||||||
|
}
|
||||||
|
current["pod_name"] = podName
|
||||||
|
current["svc_dns"] = svcDns
|
||||||
|
current["last_active_at"] = time.Now().UTC().Format(time.RFC3339)
|
||||||
|
if pvcName != "" {
|
||||||
|
current["pvc_name"] = pvcName
|
||||||
|
}
|
||||||
|
if b, err := json.Marshal(current); err == nil {
|
||||||
|
h.upsertEntity(r, "session-"+sessionID, sessionID, "SESSION", string(b)) //nolint:errcheck
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE ?session_id= — release from fleet + delete dedicated pod+svc+pvc if any.
|
||||||
|
func (h *Handler) handleSandboxDelete(w http.ResponseWriter, r *http.Request, sessionID string) {
|
||||||
|
// Release from fleet (cleans up per-session SA; non-fatal if session was fleet-based).
|
||||||
|
if err := h.fleet.Release(r.Context(), sessionID); err != nil {
|
||||||
|
log.Printf("sandbox: fleet release session=%s: %v (continuing)", sessionID, err)
|
||||||
|
}
|
||||||
|
// Also attempt direct k8s cleanup (no-ops if resources don't exist).
|
||||||
|
if err := h.sandbox.Delete(r.Context(), sessionID); err != nil {
|
||||||
|
log.Printf("sandbox delete session=%s: %v", sessionID, err)
|
||||||
|
}
|
||||||
|
log.Printf("sandbox: released session=%s", sessionID)
|
||||||
|
writeJSON(w, http.StatusOK, map[string]string{"session_id": sessionID, "status": "deleted"})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EpisodeID computes the deterministic episode ID per §4.14 Q6:
|
||||||
|
// sha256(session_id + milestone_id + attempt).
|
||||||
|
// Exported so callers can verify the value the server would produce.
|
||||||
|
func EpisodeID(sessionID, milestoneID string, attempt int) string {
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(fmt.Sprintf("%s%s%d", sessionID, milestoneID, attempt)))
|
||||||
|
return fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data) //nolint:errcheck
|
||||||
|
}
|
||||||
@@ -0,0 +1,594 @@
|
|||||||
|
package sessions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── stub servers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// newGQLServer returns a test server that routes by inspecting the request body's
|
||||||
|
// "query" field and records captured request bodies.
|
||||||
|
//
|
||||||
|
// - "upsertEntity" → mutation response
|
||||||
|
// - "hybridSearch" → search response (get, patch, episodes handlers)
|
||||||
|
// - "entities" → list response (sessions list handler)
|
||||||
|
// - "entity" → single entity response
|
||||||
|
func newGQLServer(t *testing.T, captured *[]byte) *httptest.Server {
|
||||||
|
t.Helper()
|
||||||
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
body, _ := io.ReadAll(r.Body)
|
||||||
|
if captured != nil {
|
||||||
|
*captured = body
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
bs := string(body)
|
||||||
|
switch {
|
||||||
|
case strings.Contains(bs, "upsertEntity"):
|
||||||
|
fmt.Fprint(w, `{"data":{"upsertEntity":{"id":"test-entity-id","created":true}}}`)
|
||||||
|
case strings.Contains(bs, "hybridSearch"):
|
||||||
|
// get, patch, episodes handlers still use hybridSearch
|
||||||
|
fmt.Fprint(w, `{"data":{"hybridSearch":{"entities":[]}}}`)
|
||||||
|
case strings.Contains(bs, "entities"):
|
||||||
|
// sessions list handler uses entities query
|
||||||
|
fmt.Fprint(w, `{"data":{"entities":{"entities":[]}}}`)
|
||||||
|
case strings.Contains(bs, "entity"):
|
||||||
|
fmt.Fprint(w, `{"data":{"entity":null}}`)
|
||||||
|
default:
|
||||||
|
fmt.Fprint(w, `{"data":{}}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildHandler wires a Handler with the supplied graphql URL.
|
||||||
|
func buildHandler(gqlURL string) *Handler {
|
||||||
|
return &Handler{
|
||||||
|
graphqlURL: gqlURL,
|
||||||
|
client: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// do fires a request through the handler ServeHTTP. path sets X-Upstream-Path.
|
||||||
|
func do(h *Handler, method, path, upstreamPath, body, query string) *httptest.ResponseRecorder {
|
||||||
|
var bodyReader io.Reader
|
||||||
|
if body != "" {
|
||||||
|
bodyReader = strings.NewReader(body)
|
||||||
|
} else {
|
||||||
|
bodyReader = http.NoBody
|
||||||
|
}
|
||||||
|
url := "/"
|
||||||
|
if query != "" {
|
||||||
|
url = "/?" + query
|
||||||
|
}
|
||||||
|
req := httptest.NewRequest(method, url, bodyReader)
|
||||||
|
req.Header.Set("X-Upstream-Path", upstreamPath)
|
||||||
|
if body != "" {
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
}
|
||||||
|
_ = path // kept for readability in callers
|
||||||
|
rr := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rr, req)
|
||||||
|
return rr
|
||||||
|
}
|
||||||
|
|
||||||
|
// jsonBody decodes response body into map.
|
||||||
|
func jsonBody(t *testing.T, rr *httptest.ResponseRecorder) map[string]any {
|
||||||
|
t.Helper()
|
||||||
|
var out map[string]any
|
||||||
|
if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil {
|
||||||
|
t.Fatalf("decode body: %v — raw: %s", err, rr.Body.String())
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 1. handleCreate ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCreate(t *testing.T) {
|
||||||
|
t.Run("missing name → 400", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
||||||
|
`{"goal":"some goal"}`, "")
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bad JSON → 400", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
||||||
|
`not json`, "")
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("upsert graphql error → 502", func(t *testing.T) {
|
||||||
|
gqlErr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(200)
|
||||||
|
fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`)
|
||||||
|
}))
|
||||||
|
defer gqlErr.Close()
|
||||||
|
h := buildHandler(gqlErr.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
||||||
|
`{"name":"fail-session"}`, "")
|
||||||
|
if rr.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("want 502, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rr.Body.String(), "upsert failed:") {
|
||||||
|
t.Fatalf("want 'upsert failed:' in body, got: %s", rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("upsert 200 → 201 with session_id, status=active, memory_project", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
||||||
|
`{"name":"my-session","goal":"finish task"}`, "")
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("want 201, got %d — body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
body := jsonBody(t, rr)
|
||||||
|
if _, ok := body["session_id"]; !ok {
|
||||||
|
t.Fatal("missing session_id")
|
||||||
|
}
|
||||||
|
if body["status"] != "active" {
|
||||||
|
t.Fatalf("want status=active, got %v", body["status"])
|
||||||
|
}
|
||||||
|
proj, _ := body["memory_project"].(string)
|
||||||
|
if !strings.HasPrefix(proj, "session-") {
|
||||||
|
t.Fatalf("memory_project must start with 'session-', got %s", proj)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 2. handleList ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleList(t *testing.T) {
|
||||||
|
t.Run("no params → 200, gql called with query containing 'session'", func(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
gql := newGQLServer(t, &captured)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(captured), "session") {
|
||||||
|
t.Fatalf("gql payload missing 'session': %s", captured)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("status=paused → gql query contains 'status:paused'", func(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
gql := newGQLServer(t, &captured)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "status=paused")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(captured), "status:paused") {
|
||||||
|
t.Fatalf("gql payload missing 'status:paused': %s", captured)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("graphql errors body → 502", func(t *testing.T) {
|
||||||
|
gql2 := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(500)
|
||||||
|
fmt.Fprint(w, `{"errors":[{"message":"resolver error"}]}`)
|
||||||
|
}))
|
||||||
|
defer gql2.Close()
|
||||||
|
h2 := buildHandler(gql2.URL)
|
||||||
|
|
||||||
|
rr := do(h2, http.MethodGet, "/v1/sessions/list", "/v1/sessions/list", "", "")
|
||||||
|
if rr.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("want 502, got %d — body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 3. handleGet ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleGet(t *testing.T) {
|
||||||
|
t.Run("missing session_id → 400", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "")
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success → 200, gql called with session-scoped project", func(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
gql := newGQLServer(t, &captured)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
sid := "abc-123"
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/get", "/v1/sessions/get", "", "session_id="+sid)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(captured), "session-"+sid) {
|
||||||
|
t.Fatalf("gql payload missing scoped project 'session-%s': %s", sid, captured)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 4. handlePatch ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandlePatch(t *testing.T) {
|
||||||
|
t.Run("missing session_id → 400", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
||||||
|
`{"status":"paused"}`, "")
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("bad JSON → 400", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
||||||
|
`not json`, "session_id=abc-123")
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("upsert success → 200 with session_id, status=updated", func(t *testing.T) {
|
||||||
|
var secondCall []byte
|
||||||
|
callCount := 0
|
||||||
|
gqlServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
body, _ := io.ReadAll(req.Body)
|
||||||
|
bodyStr := string(body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
callCount++
|
||||||
|
if strings.Contains(bodyStr, "hybridSearch") {
|
||||||
|
summary := `{"id":"sess-1","name":"abc-123","status":"active","goal":"old-goal"}`
|
||||||
|
fmt.Fprintf(w, `{"data":{"hybridSearch":{"entities":[{"id":"eid","name":"abc-123","summary":%q,"tCreated":"2026-01-01T00:00:00Z"}]}}}`, summary)
|
||||||
|
} else {
|
||||||
|
secondCall = body
|
||||||
|
fmt.Fprint(w, `{"data":{"upsertEntity":{"id":"eid","created":false}}}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer gqlServer.Close()
|
||||||
|
h := buildHandler(gqlServer.URL)
|
||||||
|
|
||||||
|
sid := "abc-123"
|
||||||
|
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
||||||
|
`{"status":"paused"}`, "session_id="+sid)
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
body := jsonBody(t, rr)
|
||||||
|
if body["session_id"] != sid {
|
||||||
|
t.Fatalf("want session_id=%s, got %v", sid, body["session_id"])
|
||||||
|
}
|
||||||
|
if body["status"] != "updated" {
|
||||||
|
t.Fatalf("want status=updated, got %v", body["status"])
|
||||||
|
}
|
||||||
|
if callCount != 2 {
|
||||||
|
t.Fatalf("want 2 gql calls (hybridSearch+upsert), got %d", callCount)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(secondCall), "paused") {
|
||||||
|
t.Fatalf("upsert payload missing merged status: %s", secondCall)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("upsert error → 502", func(t *testing.T) {
|
||||||
|
gqlServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
|
||||||
|
body, _ := io.ReadAll(req.Body)
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if strings.Contains(string(body), "hybridSearch") {
|
||||||
|
fmt.Fprint(w, `{"data":{"hybridSearch":{"entities":[]}}}`)
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, `{"errors":[{"message":"write error"}]}`)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
defer gqlServer.Close()
|
||||||
|
h := buildHandler(gqlServer.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
||||||
|
`{"status":"paused"}`, "session_id=abc-123")
|
||||||
|
if rr.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("want 502, got %d — body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 5. handleCreateMilestone ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCreateMilestone(t *testing.T) {
|
||||||
|
t.Run("missing session_id → 400", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
|
||||||
|
`{"title":"step 1"}`, "")
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing title → 400", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
|
||||||
|
`{"order_idx":1}`, "session_id=abc-123")
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("upsert error → 502", func(t *testing.T) {
|
||||||
|
gqlErr := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
fmt.Fprint(w, `{"errors":[{"message":"write error"}]}`)
|
||||||
|
}))
|
||||||
|
defer gqlErr.Close()
|
||||||
|
h := buildHandler(gqlErr.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
|
||||||
|
`{"title":"step 1","order_idx":0}`, "session_id=abc-123")
|
||||||
|
if rr.Code != http.StatusBadGateway {
|
||||||
|
t.Fatalf("want 502, got %d — body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success → 201 with milestone_id, session_id, status=pending", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
sid := "abc-123"
|
||||||
|
rr := do(h, http.MethodPost, "/v1/sessions/milestones/create", "/v1/sessions/milestones/create",
|
||||||
|
`{"title":"phase 1","order_idx":0}`, "session_id="+sid)
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("want 201, got %d — body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
body := jsonBody(t, rr)
|
||||||
|
if _, ok := body["milestone_id"]; !ok {
|
||||||
|
t.Fatal("missing milestone_id")
|
||||||
|
}
|
||||||
|
if body["session_id"] != sid {
|
||||||
|
t.Fatalf("want session_id=%s, got %v", sid, body["session_id"])
|
||||||
|
}
|
||||||
|
if body["status"] != "pending" {
|
||||||
|
t.Fatalf("want status=pending, got %v", body["status"])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 6. handleListEpisodes ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleListEpisodes(t *testing.T) {
|
||||||
|
t.Run("missing session_id → 400", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list",
|
||||||
|
"", "")
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("limit > 200 clamped → gql called with limit=200", func(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
gql := newGQLServer(t, &captured)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list",
|
||||||
|
"", "session_id=abc-123&limit=999")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
// Verify GQL was called (body non-empty).
|
||||||
|
if len(captured) == 0 {
|
||||||
|
t.Fatal("gql not called")
|
||||||
|
}
|
||||||
|
// GQL was called; handler clamps limit to 200.
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success → 200", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/list", "/v1/sessions/episodes/list",
|
||||||
|
"", "session_id=abc-123")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 7. handleGetEpisode ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleGetEpisode(t *testing.T) {
|
||||||
|
t.Run("missing session_id → 400", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get",
|
||||||
|
"", "episode_id=deadbeef")
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("missing episode_id → 400", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get",
|
||||||
|
"", "session_id=abc-123")
|
||||||
|
if rr.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("want 400, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("success → 200", func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/episodes/get", "/v1/sessions/episodes/get",
|
||||||
|
"", "session_id=abc-123&episode_id=deadbeef")
|
||||||
|
if rr.Code != http.StatusOK {
|
||||||
|
t.Fatalf("want 200, got %d — body: %s", rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 8. EpisodeID helper ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestEpisodeID(t *testing.T) {
|
||||||
|
sid := "session-abc"
|
||||||
|
mid := "milestone-xyz"
|
||||||
|
attempt := 1
|
||||||
|
h := sha256.New()
|
||||||
|
h.Write([]byte(fmt.Sprintf("%s%s%d", sid, mid, attempt)))
|
||||||
|
expected := fmt.Sprintf("%x", h.Sum(nil))
|
||||||
|
|
||||||
|
got := EpisodeID(sid, mid, attempt)
|
||||||
|
if got != expected {
|
||||||
|
t.Fatalf("EpisodeID mismatch: want %s, got %s", expected, got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sanity: different inputs differ.
|
||||||
|
if EpisodeID(sid, mid, 2) == got {
|
||||||
|
t.Fatal("attempt 2 must differ from attempt 1")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 9. unknown path → 404 ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestUnknownPath(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodGet, "/v1/sessions/unknown", "/v1/sessions/unknown", "", "")
|
||||||
|
if rr.Code != http.StatusNotFound {
|
||||||
|
t.Fatalf("want 404, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
body := jsonBody(t, rr)
|
||||||
|
if body["error"] == nil {
|
||||||
|
t.Fatal("missing error field in 404 response")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── table-driven summary for handleCreate (extra variants) ───────────────────
|
||||||
|
|
||||||
|
func TestHandleCreate_Table(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
body string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"valid", `{"name":"s1"}`, http.StatusCreated},
|
||||||
|
{"empty name", `{"name":""}`, http.StatusBadRequest},
|
||||||
|
{"empty_name_string", `{"name":""}`, http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create", tc.body, "")
|
||||||
|
if rr.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("want %d, got %d — body: %s", tc.wantStatus, rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── table-driven summary for handlePatch ─────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandlePatch_Table(t *testing.T) {
|
||||||
|
validSID := "session_id=abc-123"
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
query string
|
||||||
|
body string
|
||||||
|
wantStatus int
|
||||||
|
}{
|
||||||
|
{"ok", validSID, `{"status":"paused"}`, http.StatusOK},
|
||||||
|
{"no session_id", "", `{"status":"paused"}`, http.StatusBadRequest},
|
||||||
|
{"bad json", validSID, `{bad}`, http.StatusBadRequest},
|
||||||
|
}
|
||||||
|
for _, tc := range tests {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
gql := newGQLServer(t, nil)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPatch, "/v1/sessions/patch", "/v1/sessions/patch",
|
||||||
|
tc.body, tc.query)
|
||||||
|
if rr.Code != tc.wantStatus {
|
||||||
|
t.Fatalf("want %d, got %d — body: %s", tc.wantStatus, rr.Code, rr.Body.String())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── verify upsertEntity mutation payload ─────────────────────────────────────
|
||||||
|
|
||||||
|
func TestHandleCreate_IngestPayload(t *testing.T) {
|
||||||
|
var captured []byte
|
||||||
|
gql := newGQLServer(t, &captured)
|
||||||
|
defer gql.Close()
|
||||||
|
h := buildHandler(gql.URL)
|
||||||
|
|
||||||
|
rr := do(h, http.MethodPost, "/v1/sessions/create", "/v1/sessions/create",
|
||||||
|
`{"name":"test-session","goal":"do stuff"}`, "")
|
||||||
|
if rr.Code != http.StatusCreated {
|
||||||
|
t.Fatalf("want 201, got %d", rr.Code)
|
||||||
|
}
|
||||||
|
bs := string(captured)
|
||||||
|
if !strings.Contains(bs, "upsertEntity") {
|
||||||
|
t.Fatalf("gql body missing 'upsertEntity': %s", bs)
|
||||||
|
}
|
||||||
|
if !strings.Contains(bs, "SESSION") {
|
||||||
|
t.Fatalf("gql body missing 'SESSION': %s", bs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,492 @@
|
|||||||
|
// Package sessions — SandboxManager provisions per-session k8s resources:
|
||||||
|
//
|
||||||
|
// PVC workspace-session-<uuid> Longhorn 1Gi
|
||||||
|
// Pod pi-session-<uuid> single container, /workspace <- PVC
|
||||||
|
// Svc pi-session-<uuid> ClusterIP :8787
|
||||||
|
// NetPol pi-session-sandbox shared for all pi-session pods (created once)
|
||||||
|
//
|
||||||
|
// All k8s API calls use raw HTTP + in-cluster SA token (no client-go).
|
||||||
|
// Same pattern as internal/webhook/promoter.go.
|
||||||
|
package sessions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
sandboxNamespace = "poimen"
|
||||||
|
sandboxImage = "forgejo.riotpiao.com/riotpiao-poimen/poimen-pi-session-runtime:latest"
|
||||||
|
sandboxPort = 8787
|
||||||
|
defaultPVCSize = "1Gi"
|
||||||
|
defaultStorageClass = "longhorn"
|
||||||
|
defaultReadyTimeout = 30 * time.Second
|
||||||
|
defaultReaperInterval = 5 * time.Minute
|
||||||
|
sandboxSATokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
||||||
|
sandboxCACertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SandboxManager manages the k8s lifecycle of pi-session Pods, PVCs, Services.
|
||||||
|
type SandboxManager struct {
|
||||||
|
k8sHost string
|
||||||
|
k8sClient *http.Client
|
||||||
|
pvcSize string
|
||||||
|
readyTimeout time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// SandboxResult is returned by CreateOrResume.
|
||||||
|
type SandboxResult struct {
|
||||||
|
SessionID string `json:"session_id"`
|
||||||
|
PodName string `json:"pod_name"`
|
||||||
|
PVCName string `json:"pvc_name"`
|
||||||
|
SvcDNS string `json:"svc_dns"`
|
||||||
|
Resumed bool `json:"resumed"` // true = pod already existed
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewSandboxManager creates a SandboxManager from env + in-cluster SA files.
|
||||||
|
func NewSandboxManager() *SandboxManager {
|
||||||
|
host := os.Getenv("K8S_API_HOST")
|
||||||
|
if host == "" {
|
||||||
|
host = "https://kubernetes.default.svc"
|
||||||
|
}
|
||||||
|
pvcSize := os.Getenv("SANDBOX_PVC_SIZE")
|
||||||
|
if pvcSize == "" {
|
||||||
|
pvcSize = defaultPVCSize
|
||||||
|
}
|
||||||
|
caCert, _ := os.ReadFile(sandboxCACertPath)
|
||||||
|
return &SandboxManager{
|
||||||
|
k8sHost: host,
|
||||||
|
k8sClient: buildSandboxK8sClient(caCert),
|
||||||
|
pvcSize: pvcSize,
|
||||||
|
readyTimeout: defaultReadyTimeout,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSandboxK8sClient(caCert []byte) *http.Client {
|
||||||
|
transport := &http.Transport{}
|
||||||
|
if len(caCert) > 0 {
|
||||||
|
pool := x509.NewCertPool()
|
||||||
|
pool.AppendCertsFromPEM(caCert)
|
||||||
|
transport.TLSClientConfig = &tls.Config{RootCAs: pool}
|
||||||
|
} else {
|
||||||
|
log.Printf("sandbox: WARNING no SA CA cert at %s — using InsecureSkipVerify", sandboxCACertPath)
|
||||||
|
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
|
||||||
|
}
|
||||||
|
return &http.Client{Timeout: 30 * time.Second, Transport: transport}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sandboxSAToken() string {
|
||||||
|
b, err := os.ReadFile(sandboxSATokenPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sandbox: WARNING failed to read SA token %s: %v", sandboxSATokenPath, err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
func pvcName(sessionID string) string { return "workspace-session-" + sessionID }
|
||||||
|
func podName(sessionID string) string { return "pi-session-" + sessionID }
|
||||||
|
func svcName(sessionID string) string { return "pi-session-" + sessionID }
|
||||||
|
func svcDNS(sessionID string) string {
|
||||||
|
return fmt.Sprintf("%s.%s.svc.cluster.local:%d", svcName(sessionID), sandboxNamespace, sandboxPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── public API ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// CreateOrResume provisions or resumes a sandbox for sessionID.
|
||||||
|
// Idempotent: if the pod is already Running, returns its DNS immediately.
|
||||||
|
func (s *SandboxManager) CreateOrResume(ctx context.Context, sessionID string) (*SandboxResult, error) {
|
||||||
|
res := &SandboxResult{
|
||||||
|
SessionID: sessionID,
|
||||||
|
PodName: podName(sessionID),
|
||||||
|
PVCName: pvcName(sessionID),
|
||||||
|
SvcDNS: svcDNS(sessionID),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Ensure PVC exists
|
||||||
|
if err := s.ensurePVC(ctx, sessionID); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure PVC: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Check if pod already running (resume case)
|
||||||
|
phase, err := s.getPodPhase(ctx, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("check pod: %w", err)
|
||||||
|
}
|
||||||
|
if phase == "Running" {
|
||||||
|
res.Resumed = true
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Ensure NetworkPolicy for all pi-session pods exists
|
||||||
|
if err := s.ensureNetworkPolicy(ctx); err != nil {
|
||||||
|
log.Printf("sandbox: WARNING failed to ensure network policy: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Create Pod + Service
|
||||||
|
if err := s.createPod(ctx, sessionID); err != nil {
|
||||||
|
return nil, fmt.Errorf("create pod: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.ensureService(ctx, sessionID); err != nil {
|
||||||
|
return nil, fmt.Errorf("ensure service: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Wait for Ready
|
||||||
|
if err := s.waitPodReady(ctx, sessionID); err != nil {
|
||||||
|
return nil, fmt.Errorf("wait ready: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete removes the Pod, Service, and PVC for sessionID.
|
||||||
|
func (s *SandboxManager) Delete(ctx context.Context, sessionID string) error {
|
||||||
|
var errs []string
|
||||||
|
for _, fn := range []func(context.Context, string) error{
|
||||||
|
s.deletePod, s.deleteService, s.deletePVC,
|
||||||
|
} {
|
||||||
|
if err := fn(ctx, sessionID); err != nil && !isNotFound(err) {
|
||||||
|
errs = append(errs, err.Error())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(errs) > 0 {
|
||||||
|
return fmt.Errorf("delete sandbox: %s", strings.Join(errs, "; "))
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReapIdlePods lists all pi-session-* pods and deletes those that have exited
|
||||||
|
// (Succeeded/Failed) — bridge.js self-exits after idle timeout.
|
||||||
|
// The PVC is preserved so the next CreateOrResume reattaches the same workspace.
|
||||||
|
func (s *SandboxManager) ReapIdlePods(ctx context.Context) {
|
||||||
|
pods, err := s.listSandboxPods(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("sandbox reaper: list pods: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
for _, podAny := range pods {
|
||||||
|
pod, _ := podAny.(map[string]any)
|
||||||
|
phase, _ := pod["status"].(map[string]any)["phase"].(string)
|
||||||
|
name, _ := pod["metadata"].(map[string]any)["name"].(string)
|
||||||
|
if phase == "Succeeded" || phase == "Failed" {
|
||||||
|
log.Printf("sandbox reaper: deleting exited pod %s (phase=%s)", name, phase)
|
||||||
|
if err := s.k8sDelete(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods/"+name); err != nil && !isNotFound(err) {
|
||||||
|
log.Printf("sandbox reaper: delete pod %s: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── k8s resource helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *SandboxManager) ensurePVC(ctx context.Context, sessionID string) error {
|
||||||
|
name := pvcName(sessionID)
|
||||||
|
_, err := s.k8sGet(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/persistentvolumeclaims/"+name)
|
||||||
|
if err == nil {
|
||||||
|
return nil // already exists
|
||||||
|
}
|
||||||
|
if !isNotFound(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Create
|
||||||
|
body := map[string]any{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "PersistentVolumeClaim",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": name, "namespace": sandboxNamespace,
|
||||||
|
"labels": map[string]string{"app": "pi-session", "session-id": sessionID},
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"accessModes": []string{"ReadWriteOnce"},
|
||||||
|
"storageClassName": defaultStorageClass,
|
||||||
|
"resources": map[string]any{
|
||||||
|
"requests": map[string]string{"storage": s.pvcSize},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return s.k8sCreate(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/persistentvolumeclaims", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SandboxManager) getPodPhase(ctx context.Context, sessionID string) (string, error) {
|
||||||
|
pod, err := s.k8sGet(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods/"+podName(sessionID))
|
||||||
|
if isNotFound(err) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
phase, _ := pod["status"].(map[string]any)["phase"].(string)
|
||||||
|
return phase, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SandboxManager) createPod(ctx context.Context, sessionID string) error {
|
||||||
|
body := map[string]any{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Pod",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": podName(sessionID), "namespace": sandboxNamespace,
|
||||||
|
"labels": map[string]string{"app": "pi-session", "session-id": sessionID},
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"restartPolicy": "Never",
|
||||||
|
"automountServiceAccountToken": false,
|
||||||
|
"securityContext": map[string]any{
|
||||||
|
"runAsNonRoot": true,
|
||||||
|
"runAsUser": int64(1000),
|
||||||
|
"seccompProfile": map[string]string{"type": "RuntimeDefault"},
|
||||||
|
},
|
||||||
|
"containers": []map[string]any{{
|
||||||
|
"name": "pi-session",
|
||||||
|
"image": sandboxImage,
|
||||||
|
"ports": []map[string]any{{"containerPort": sandboxPort}},
|
||||||
|
"securityContext": map[string]any{
|
||||||
|
"readOnlyRootFilesystem": true,
|
||||||
|
"allowPrivilegeEscalation": false,
|
||||||
|
"capabilities": map[string]any{"drop": []string{"ALL"}},
|
||||||
|
},
|
||||||
|
"resources": map[string]any{
|
||||||
|
"requests": map[string]string{"cpu": "500m", "memory": "512Mi"},
|
||||||
|
"limits": map[string]string{"cpu": "1", "memory": "1Gi"},
|
||||||
|
},
|
||||||
|
"readinessProbe": map[string]any{
|
||||||
|
"tcpSocket": map[string]any{"port": sandboxPort},
|
||||||
|
"initialDelaySeconds": 3,
|
||||||
|
"periodSeconds": 2,
|
||||||
|
"failureThreshold": 15,
|
||||||
|
},
|
||||||
|
"volumeMounts": []map[string]any{
|
||||||
|
{"name": "workspace", "mountPath": "/workspace"},
|
||||||
|
{"name": "tmp", "mountPath": "/tmp"},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
"volumes": []map[string]any{
|
||||||
|
{"name": "workspace", "persistentVolumeClaim": map[string]string{"claimName": pvcName(sessionID)}},
|
||||||
|
{"name": "tmp", "emptyDir": map[string]any{}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return s.k8sCreate(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SandboxManager) ensureService(ctx context.Context, sessionID string) error {
|
||||||
|
name := svcName(sessionID)
|
||||||
|
_, err := s.k8sGet(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/services/"+name)
|
||||||
|
if err == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if !isNotFound(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"apiVersion": "v1",
|
||||||
|
"kind": "Service",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": name, "namespace": sandboxNamespace,
|
||||||
|
"labels": map[string]string{"app": "pi-session", "session-id": sessionID},
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"type": "ClusterIP",
|
||||||
|
"selector": map[string]string{"app": "pi-session", "session-id": sessionID},
|
||||||
|
"ports": []map[string]any{{
|
||||||
|
"port": sandboxPort, "targetPort": sandboxPort, "protocol": "TCP",
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return s.k8sCreate(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/services", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SandboxManager) ensureNetworkPolicy(ctx context.Context) error {
|
||||||
|
const npName = "pi-session-sandbox"
|
||||||
|
_, err := s.k8sGet(ctx, "/apis/networking.k8s.io/v1/namespaces/"+sandboxNamespace+"/networkpolicies/"+npName)
|
||||||
|
if err == nil {
|
||||||
|
return nil // already exists
|
||||||
|
}
|
||||||
|
if !isNotFound(err) {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
body := map[string]any{
|
||||||
|
"apiVersion": "networking.k8s.io/v1",
|
||||||
|
"kind": "NetworkPolicy",
|
||||||
|
"metadata": map[string]any{
|
||||||
|
"name": npName, "namespace": sandboxNamespace,
|
||||||
|
"labels": map[string]string{"app": "pi-session"},
|
||||||
|
},
|
||||||
|
"spec": map[string]any{
|
||||||
|
"podSelector": map[string]any{"matchLabels": map[string]string{"app": "pi-session"}},
|
||||||
|
"policyTypes": []string{"Ingress", "Egress"},
|
||||||
|
"ingress": []map[string]any{{
|
||||||
|
"from": []map[string]any{{
|
||||||
|
"podSelector": map[string]any{"matchLabels": map[string]string{"app": "poimen-frontend"}},
|
||||||
|
}},
|
||||||
|
"ports": []map[string]any{{"protocol": "TCP", "port": sandboxPort}},
|
||||||
|
}},
|
||||||
|
"egress": []map[string]any{
|
||||||
|
// DNS
|
||||||
|
{
|
||||||
|
"to": []map[string]any{{"namespaceSelector": map[string]any{"matchLabels": map[string]string{"kubernetes.io/metadata.name": "kube-system"}}}},
|
||||||
|
"ports": []map[string]any{{"protocol": "UDP", "port": 53}, {"protocol": "TCP", "port": 53}},
|
||||||
|
},
|
||||||
|
// api-gateway (LLM inference)
|
||||||
|
{
|
||||||
|
"to": []map[string]any{{"namespaceSelector": map[string]any{"matchLabels": map[string]string{"kubernetes.io/metadata.name": "api"}}}},
|
||||||
|
"ports": []map[string]any{{"protocol": "TCP", "port": 8080}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return s.k8sCreate(ctx, "/apis/networking.k8s.io/v1/namespaces/"+sandboxNamespace+"/networkpolicies", body)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SandboxManager) waitPodReady(ctx context.Context, sessionID string) error {
|
||||||
|
deadline := time.Now().Add(s.readyTimeout)
|
||||||
|
for time.Now().Before(deadline) {
|
||||||
|
pod, err := s.k8sGet(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods/"+podName(sessionID))
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("poll pod: %w", err)
|
||||||
|
}
|
||||||
|
status, _ := pod["status"].(map[string]any)
|
||||||
|
phase, _ := status["phase"].(string)
|
||||||
|
if phase == "Failed" || phase == "Succeeded" {
|
||||||
|
return fmt.Errorf("pod entered terminal phase %s before ready", phase)
|
||||||
|
}
|
||||||
|
if phase == "Running" {
|
||||||
|
for _, cond := range toSlice(status["conditions"]) {
|
||||||
|
if c, ok := cond.(map[string]any); ok {
|
||||||
|
if c["type"] == "Ready" && c["status"] == "True" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
select {
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Errorf("pod not ready after %v", s.readyTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SandboxManager) deletePod(ctx context.Context, sessionID string) error {
|
||||||
|
return s.k8sDelete(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods/"+podName(sessionID))
|
||||||
|
}
|
||||||
|
func (s *SandboxManager) deleteService(ctx context.Context, sessionID string) error {
|
||||||
|
return s.k8sDelete(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/services/"+svcName(sessionID))
|
||||||
|
}
|
||||||
|
func (s *SandboxManager) deletePVC(ctx context.Context, sessionID string) error {
|
||||||
|
return s.k8sDelete(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/persistentvolumeclaims/"+pvcName(sessionID))
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SandboxManager) listSandboxPods(ctx context.Context) ([]any, error) {
|
||||||
|
body, err := s.k8sGet(ctx, "/api/v1/namespaces/"+sandboxNamespace+"/pods?labelSelector=app%3Dpi-session")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
items, _ := body["items"].([]any)
|
||||||
|
return items, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── raw k8s HTTP ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (s *SandboxManager) k8sGet(ctx context.Context, path string) (map[string]any, error) {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.k8sHost+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+sandboxSAToken())
|
||||||
|
resp, err := s.k8sClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("k8s GET %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return nil, &k8sNotFound{path: path}
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return nil, fmt.Errorf("k8s GET %s returned %d: %s", path, resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
var result map[string]any
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
|
||||||
|
return nil, fmt.Errorf("decode: %w", err)
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SandboxManager) k8sCreate(ctx context.Context, path string, body any) error {
|
||||||
|
payload, err := json.Marshal(body)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("marshal: %w", err)
|
||||||
|
}
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.k8sHost+path, bytes.NewReader(payload))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+sandboxSAToken())
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := s.k8sClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("k8s POST %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == 409 { // AlreadyExists — idempotent
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return fmt.Errorf("k8s POST %s returned %d: %s", path, resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SandboxManager) k8sDelete(ctx context.Context, path string) error {
|
||||||
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, s.k8sHost+path, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
req.Header.Set("Authorization", "Bearer "+sandboxSAToken())
|
||||||
|
resp, err := s.k8sClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("k8s DELETE %s: %w", path, err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
if resp.StatusCode == 404 {
|
||||||
|
return &k8sNotFound{path: path}
|
||||||
|
}
|
||||||
|
if resp.StatusCode >= 400 {
|
||||||
|
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
||||||
|
return fmt.Errorf("k8s DELETE %s returned %d: %s", path, resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── error helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
type k8sNotFound struct{ path string }
|
||||||
|
|
||||||
|
func (e *k8sNotFound) Error() string { return "not found: " + e.path }
|
||||||
|
|
||||||
|
func isNotFound(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
_, ok := err.(*k8sNotFound)
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
func toSlice(v any) []any {
|
||||||
|
if s, ok := v.([]any); ok {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,271 @@
|
|||||||
|
package sessions
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ── helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// runningReadyPod returns minimal k8s pod JSON: phase Running + Ready condition.
|
||||||
|
func runningReadyPod(name string) map[string]any {
|
||||||
|
return map[string]any{
|
||||||
|
"metadata": map[string]any{"name": name},
|
||||||
|
"status": map[string]any{
|
||||||
|
"phase": "Running",
|
||||||
|
"conditions": []any{
|
||||||
|
map[string]any{"type": "Ready", "status": "True"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondK8sJSON writes a JSON body with the given status code.
|
||||||
|
func respondK8sJSON(w http.ResponseWriter, status int, body any) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(body) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestSM builds a SandboxManager pointed at the given test server URL.
|
||||||
|
func newTestSM(url string) *SandboxManager {
|
||||||
|
return &SandboxManager{
|
||||||
|
k8sHost: url,
|
||||||
|
k8sClient: &http.Client{Timeout: 5 * time.Second},
|
||||||
|
pvcSize: "1Gi",
|
||||||
|
readyTimeout: 3 * time.Second,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TestSandboxCreateOrResume_CreateNew ───────────────────────────────────────
|
||||||
|
|
||||||
|
// No existing resources → creates PVC, NetworkPolicy, Pod, Service; Resumed=false.
|
||||||
|
func TestSandboxCreateOrResume_CreateNew(t *testing.T) {
|
||||||
|
id := "newid"
|
||||||
|
var podGetCount int32
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// PVC: GET → 404, POST → 201
|
||||||
|
mux.HandleFunc("GET /api/v1/namespaces/poimen/persistentvolumeclaims/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /api/v1/namespaces/poimen/persistentvolumeclaims", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
// NetworkPolicy (fixed name "pi-session-sandbox"): GET → 404, POST → 201
|
||||||
|
mux.HandleFunc("GET /apis/networking.k8s.io/v1/namespaces/poimen/networkpolicies/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /apis/networking.k8s.io/v1/namespaces/poimen/networkpolicies", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pod: first GET (getPodPhase) → 404; second GET (waitPodReady) → Running+Ready; POST → 201
|
||||||
|
mux.HandleFunc("GET /api/v1/namespaces/poimen/pods/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
if atomic.AddInt32(&podGetCount, 1) == 1 {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
respondK8sJSON(w, http.StatusOK, runningReadyPod(podName(id)))
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /api/v1/namespaces/poimen/pods", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Service: GET → 404, POST → 201
|
||||||
|
mux.HandleFunc("GET /api/v1/namespaces/poimen/services/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("POST /api/v1/namespaces/poimen/services", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusCreated)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
result, err := newTestSM(srv.URL).CreateOrResume(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateOrResume: %v", err)
|
||||||
|
}
|
||||||
|
if want := svcDNS(id); result.SvcDNS != want {
|
||||||
|
t.Errorf("SvcDNS: want %q, got %q", want, result.SvcDNS)
|
||||||
|
}
|
||||||
|
if result.Resumed {
|
||||||
|
t.Error("want Resumed=false, got true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TestSandboxCreateOrResume_Resume ─────────────────────────────────────────
|
||||||
|
|
||||||
|
// PVC exists + pod Running → Resumed=true, no POST calls.
|
||||||
|
func TestSandboxCreateOrResume_Resume(t *testing.T) {
|
||||||
|
id := "resumeid"
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// PVC exists
|
||||||
|
mux.HandleFunc("GET /api/v1/namespaces/poimen/persistentvolumeclaims/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
respondK8sJSON(w, http.StatusOK, map[string]any{"metadata": map[string]any{"name": pvcName(id)}})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Pod exists and Running (getPodPhase uses GET /{name})
|
||||||
|
mux.HandleFunc("GET /api/v1/namespaces/poimen/pods/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
respondK8sJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"metadata": map[string]any{"name": podName(id)},
|
||||||
|
"status": map[string]any{"phase": "Running"},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Any POST → 500 to assert no creates are issued
|
||||||
|
mux.HandleFunc("POST /", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
t.Errorf("unexpected POST to %s", r.URL.Path)
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
result, err := newTestSM(srv.URL).CreateOrResume(context.Background(), id)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("CreateOrResume: %v", err)
|
||||||
|
}
|
||||||
|
if !result.Resumed {
|
||||||
|
t.Error("want Resumed=true, got false")
|
||||||
|
}
|
||||||
|
if want := svcDNS(id); result.SvcDNS != want {
|
||||||
|
t.Errorf("SvcDNS: want %q, got %q", want, result.SvcDNS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TestSandboxDelete ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// All DELETEs succeed → no error returned.
|
||||||
|
func TestSandboxDelete(t *testing.T) {
|
||||||
|
id := "deleteid"
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/pods/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/services/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/persistentvolumeclaims/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if err := newTestSM(srv.URL).Delete(context.Background(), id); err != nil {
|
||||||
|
t.Errorf("Delete: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TestSandboxDelete_NotFound ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// All DELETEs return 404 → isNotFound errors swallowed → no error returned.
|
||||||
|
func TestSandboxDelete_NotFound(t *testing.T) {
|
||||||
|
id := "ghostid"
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/pods/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/services/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
})
|
||||||
|
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/persistentvolumeclaims/{name}", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
if err := newTestSM(srv.URL).Delete(context.Background(), id); err != nil {
|
||||||
|
t.Errorf("Delete_NotFound: want nil, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TestSandboxReapIdlePods ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
// pod-a=Succeeded → deleted; pod-b=Running → skipped.
|
||||||
|
func TestSandboxReapIdlePods(t *testing.T) {
|
||||||
|
var deletedA int32
|
||||||
|
|
||||||
|
mux := http.NewServeMux()
|
||||||
|
|
||||||
|
// List all pi-session pods (query string not part of mux path matching)
|
||||||
|
mux.HandleFunc("GET /api/v1/namespaces/poimen/pods", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
respondK8sJSON(w, http.StatusOK, map[string]any{
|
||||||
|
"items": []any{
|
||||||
|
map[string]any{
|
||||||
|
"metadata": map[string]any{"name": "pod-a"},
|
||||||
|
"status": map[string]any{"phase": "Succeeded"},
|
||||||
|
},
|
||||||
|
map[string]any{
|
||||||
|
"metadata": map[string]any{"name": "pod-b"},
|
||||||
|
"status": map[string]any{"phase": "Running"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
mux.HandleFunc("DELETE /api/v1/namespaces/poimen/pods/{name}", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
name := r.PathValue("name")
|
||||||
|
if name == "pod-b" {
|
||||||
|
t.Errorf("pod-b (Running) must NOT be deleted")
|
||||||
|
}
|
||||||
|
if name == "pod-a" {
|
||||||
|
atomic.AddInt32(&deletedA, 1)
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
})
|
||||||
|
|
||||||
|
srv := httptest.NewServer(mux)
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
newTestSM(srv.URL).ReapIdlePods(context.Background())
|
||||||
|
|
||||||
|
if got := atomic.LoadInt32(&deletedA); got != 1 {
|
||||||
|
t.Errorf("pod-a delete count: want 1, got %d", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TestSandboxNameHelpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSandboxNameHelpers(t *testing.T) {
|
||||||
|
cases := []struct{ fn, want, got string }{
|
||||||
|
{"pvcName", "workspace-session-abc", pvcName("abc")},
|
||||||
|
{"podName", "pi-session-abc", podName("abc")},
|
||||||
|
{"svcName", "pi-session-abc", svcName("abc")},
|
||||||
|
{"svcDNS", "pi-session-abc.poimen.svc.cluster.local:8787", svcDNS("abc")},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if c.got != c.want {
|
||||||
|
t.Errorf("%s: want %q, got %q", c.fn, c.want, c.got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── TestSandboxIsNotFound ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func TestSandboxIsNotFound(t *testing.T) {
|
||||||
|
if !isNotFound(&k8sNotFound{}) {
|
||||||
|
t.Error("isNotFound(&k8sNotFound{}) must be true")
|
||||||
|
}
|
||||||
|
if isNotFound(fmt.Errorf("other")) {
|
||||||
|
t.Error("isNotFound(fmt.Errorf) must be false")
|
||||||
|
}
|
||||||
|
if isNotFound(nil) {
|
||||||
|
t.Error("isNotFound(nil) must be 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,702 @@
|
|||||||
|
// 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"
|
||||||
|
update "go.temporal.io/api/update/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
|
||||||
|
grpcClient *GRPCClient
|
||||||
|
ops *WorkflowGRPCImpl
|
||||||
|
searchOps *SearchAttributesGRPCImpl
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
|
||||||
|
h := &Handler{
|
||||||
|
hostPort: hostPort,
|
||||||
|
grpcClient: grpcClient,
|
||||||
|
}
|
||||||
|
if grpcClient != nil {
|
||||||
|
h.ops = NewWorkflowGRPCImpl(grpcClient)
|
||||||
|
h.searchOps = NewSearchAttributesGRPCImpl(grpcClient)
|
||||||
|
}
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
// unavailable returns TEMPORAL_UNAVAILABLE when grpcClient is nil
|
||||||
|
func (h *Handler) unavailable() (interface{}, string, string) {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("Temporal not reachable at %s", h.hostPort)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
case "/workflow/stream":
|
||||||
|
h.StreamWorkflowEvents(w, r)
|
||||||
|
default:
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
h.writeError(w, "", "NOT_FOUND", "Endpoint not found")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
var req RequestPayload
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
w.WriteHeader(http.StatusBadRequest)
|
||||||
|
h.writeError(w, "", "INVALID_REQUEST", "Failed to parse request body")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
|
||||||
|
var result interface{}
|
||||||
|
var errCode, errMsg string
|
||||||
|
|
||||||
|
switch req.Action {
|
||||||
|
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)
|
||||||
|
case "HEARTBEAT_ACTIVITY":
|
||||||
|
result, errCode, errMsg = h.heartbeatActivity(ctx, req.Payload)
|
||||||
|
case "COMPLETE_ACTIVITY":
|
||||||
|
result, errCode, errMsg = h.completeActivity(ctx, req.Payload)
|
||||||
|
case "FAIL_ACTIVITY":
|
||||||
|
result, errCode, errMsg = h.failActivity(ctx, req.Payload)
|
||||||
|
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)
|
||||||
|
case "LIST_SEARCH_ATTRIBUTES":
|
||||||
|
result, errCode, errMsg = h.listSearchAttributes(ctx, req.Namespace)
|
||||||
|
case "LIST_TASK_QUEUES":
|
||||||
|
result, errCode, errMsg = h.listTaskQueues(ctx, req.Namespace, req.Payload)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
statusCode := http.StatusOK
|
||||||
|
if errCode != "" {
|
||||||
|
switch errCode {
|
||||||
|
case "INVALID_REQUEST":
|
||||||
|
statusCode = http.StatusBadRequest
|
||||||
|
case "NOT_FOUND", "WORKFLOW_NOT_FOUND":
|
||||||
|
statusCode = http.StatusNotFound
|
||||||
|
case "ALREADY_EXISTS":
|
||||||
|
statusCode = http.StatusConflict
|
||||||
|
case "TEMPORAL_UNAVAILABLE":
|
||||||
|
statusCode = http.StatusServiceUnavailable
|
||||||
|
default:
|
||||||
|
statusCode = http.StatusInternalServerError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
w.WriteHeader(statusCode)
|
||||||
|
if errCode != "" {
|
||||||
|
h.writeErrorWithCode(w, req.Action, req.Namespace, errCode, errMsg)
|
||||||
|
} else {
|
||||||
|
h.writeSuccess(w, req.Action, req.Namespace, result)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||||
|
connected := h.grpcClient != nil
|
||||||
|
status := "healthy"
|
||||||
|
if !connected {
|
||||||
|
status = "degraded"
|
||||||
|
}
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
json.NewEncoder(w).Encode(map[string]interface{}{
|
||||||
|
"status": status,
|
||||||
|
"temporal_connected": connected,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handleMetrics(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("# Temporal gateway metrics\n"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Workflow operations ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) startWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.ops == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
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")
|
||||||
|
result, err := h.ops.StartWorkflowExecution(ctx, namespace, workflowID, workflowType, taskQueue, input)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to start workflow: %v", err)
|
||||||
|
}
|
||||||
|
return result, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) describeWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.ops == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
workflowID := getString(payload, "workflow_id")
|
||||||
|
if workflowID == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||||
|
}
|
||||||
|
runID := getString(payload, "run_id")
|
||||||
|
result, err := h.ops.DescribeWorkflowExecution(ctx, namespace, workflowID, runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "WORKFLOW_NOT_FOUND", fmt.Sprintf("failed to describe workflow: %v", err)
|
||||||
|
}
|
||||||
|
return result, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) listWorkflows(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
query := getString(payload, "query")
|
||||||
|
var pageSize int32 = 100
|
||||||
|
|
||||||
|
resp, err := h.grpcClient.GetWorkflowServiceStub().ListWorkflowExecutions(ctx,
|
||||||
|
&workflowservice.ListWorkflowExecutionsRequest{
|
||||||
|
Namespace: namespace,
|
||||||
|
PageSize: pageSize,
|
||||||
|
Query: query,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to list workflows: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
executions := make([]interface{}, 0, len(resp.Executions))
|
||||||
|
for _, e := range resp.Executions {
|
||||||
|
entry := map[string]interface{}{
|
||||||
|
"workflow_id": e.Execution.GetWorkflowId(),
|
||||||
|
"run_id": e.Execution.GetRunId(),
|
||||||
|
"status": e.Status.String(),
|
||||||
|
}
|
||||||
|
if e.StartTime != nil {
|
||||||
|
entry["start_time"] = e.StartTime.AsTime()
|
||||||
|
}
|
||||||
|
if e.CloseTime != nil {
|
||||||
|
entry["close_time"] = e.CloseTime.AsTime()
|
||||||
|
}
|
||||||
|
if e.Type != nil {
|
||||||
|
entry["workflow_type"] = e.Type.Name
|
||||||
|
}
|
||||||
|
executions = append(executions, entry)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"executions": executions,
|
||||||
|
"next_page_token": string(resp.NextPageToken),
|
||||||
|
}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getWorkflowHistory(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.ops == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
workflowID := getString(payload, "workflow_id")
|
||||||
|
if workflowID == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||||
|
}
|
||||||
|
runID := getString(payload, "run_id")
|
||||||
|
result, err := h.ops.GetWorkflowExecutionHistory(ctx, namespace, workflowID, runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to get history: %v", err)
|
||||||
|
}
|
||||||
|
return result, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) terminateWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.ops == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
workflowID := getString(payload, "workflow_id")
|
||||||
|
if workflowID == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||||
|
}
|
||||||
|
runID := getString(payload, "run_id")
|
||||||
|
reason := getString(payload, "reason")
|
||||||
|
result, err := h.ops.TerminateWorkflowExecution(ctx, namespace, workflowID, runID, reason)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to terminate workflow: %v", err)
|
||||||
|
}
|
||||||
|
return result, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) cancelWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.ops == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
workflowID := getString(payload, "workflow_id")
|
||||||
|
if workflowID == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||||
|
}
|
||||||
|
runID := getString(payload, "run_id")
|
||||||
|
result, err := h.ops.CancelWorkflowExecution(ctx, namespace, workflowID, runID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to cancel workflow: %v", err)
|
||||||
|
}
|
||||||
|
return result, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) signalWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.ops == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
runID := getString(payload, "run_id")
|
||||||
|
signalData := getMap(payload, "signal_data")
|
||||||
|
result, err := h.ops.SignalWorkflowExecution(ctx, namespace, workflowID, runID, signalName, signalData)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to signal workflow: %v", err)
|
||||||
|
}
|
||||||
|
return result, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) queryWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.ops == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}
|
||||||
|
runID := getString(payload, "run_id")
|
||||||
|
result, err := h.ops.QueryWorkflowExecution(ctx, namespace, workflowID, runID, queryType)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to query workflow: %v", err)
|
||||||
|
}
|
||||||
|
return result, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) resetWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
workflowID := getString(payload, "workflow_id")
|
||||||
|
if workflowID == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||||
|
}
|
||||||
|
runID := getString(payload, "run_id")
|
||||||
|
eventID, _ := payload["event_id"].(float64)
|
||||||
|
|
||||||
|
resp, err := h.grpcClient.GetWorkflowServiceStub().ResetWorkflowExecution(ctx,
|
||||||
|
&workflowservice.ResetWorkflowExecutionRequest{
|
||||||
|
Namespace: namespace,
|
||||||
|
WorkflowExecution: &common.WorkflowExecution{WorkflowId: workflowID, RunId: runID},
|
||||||
|
WorkflowTaskFinishEventId: int64(eventID),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to reset workflow: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"workflow_id": workflowID,
|
||||||
|
"new_run_id": resp.RunId,
|
||||||
|
}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateWorkflow(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
workflowID := getString(payload, "workflow_id")
|
||||||
|
if workflowID == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "workflow_id is required"
|
||||||
|
}
|
||||||
|
updateName := getString(payload, "update_name")
|
||||||
|
if updateName == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "update_name is required"
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := h.grpcClient.GetWorkflowServiceStub().UpdateWorkflowExecution(ctx,
|
||||||
|
&workflowservice.UpdateWorkflowExecutionRequest{
|
||||||
|
Namespace: namespace,
|
||||||
|
WorkflowExecution: &common.WorkflowExecution{WorkflowId: workflowID},
|
||||||
|
Request: &update.Request{},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to update workflow: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"workflow_id": workflowID,
|
||||||
|
"update_name": updateName,
|
||||||
|
}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Activity operations ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) heartbeatActivity(ctx context.Context, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
taskToken := getString(payload, "task_token")
|
||||||
|
if taskToken == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "task_token is required"
|
||||||
|
}
|
||||||
|
_, err := h.grpcClient.GetWorkflowServiceStub().RecordActivityTaskHeartbeat(ctx,
|
||||||
|
&workflowservice.RecordActivityTaskHeartbeatRequest{
|
||||||
|
TaskToken: []byte(taskToken),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to heartbeat: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"status": "heartbeat_recorded"}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) completeActivity(ctx context.Context, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
taskToken := getString(payload, "task_token")
|
||||||
|
if taskToken == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "task_token is required"
|
||||||
|
}
|
||||||
|
_, err := h.grpcClient.GetWorkflowServiceStub().RespondActivityTaskCompleted(ctx,
|
||||||
|
&workflowservice.RespondActivityTaskCompletedRequest{
|
||||||
|
TaskToken: []byte(taskToken),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to complete activity: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"status": "activity_completed"}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) failActivity(ctx context.Context, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
taskToken := getString(payload, "task_token")
|
||||||
|
if taskToken == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "task_token is required"
|
||||||
|
}
|
||||||
|
_, err := h.grpcClient.GetWorkflowServiceStub().RespondActivityTaskFailed(ctx,
|
||||||
|
&workflowservice.RespondActivityTaskFailedRequest{
|
||||||
|
TaskToken: []byte(taskToken),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to fail activity: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"status": "activity_failed"}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Namespace operations ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) listNamespaces(ctx context.Context) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
resp, err := h.grpcClient.GetWorkflowServiceStub().ListNamespaces(ctx,
|
||||||
|
&workflowservice.ListNamespacesRequest{PageSize: 100})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to list namespaces: %v", err)
|
||||||
|
}
|
||||||
|
names := make([]string, 0, len(resp.Namespaces))
|
||||||
|
for _, ns := range resp.Namespaces {
|
||||||
|
if ns.NamespaceInfo != nil {
|
||||||
|
names = append(names, ns.NamespaceInfo.Name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"namespaces": names}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) describeNamespace(ctx context.Context, namespace string) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
resp, err := h.grpcClient.GetWorkflowServiceStub().DescribeNamespace(ctx,
|
||||||
|
&workflowservice.DescribeNamespaceRequest{Namespace: namespace})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "NOT_FOUND", fmt.Sprintf("failed to describe namespace: %v", err)
|
||||||
|
}
|
||||||
|
state := ""
|
||||||
|
if resp.NamespaceInfo != nil {
|
||||||
|
state = resp.NamespaceInfo.State.String()
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"name": namespace,
|
||||||
|
"state": state,
|
||||||
|
}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) createNamespace(ctx context.Context, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
name := getString(payload, "namespace_name")
|
||||||
|
if name == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "namespace_name is required"
|
||||||
|
}
|
||||||
|
_, err := h.grpcClient.GetWorkflowServiceStub().RegisterNamespace(ctx,
|
||||||
|
&workflowservice.RegisterNamespaceRequest{Namespace: name})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to create namespace: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"namespace": name, "status": "created"}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) updateNamespace(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
_, err := h.grpcClient.GetWorkflowServiceStub().UpdateNamespace(ctx,
|
||||||
|
&workflowservice.UpdateNamespaceRequest{Namespace: namespace})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to update namespace: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"namespace": namespace, "status": "updated"}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) deleteNamespace(ctx context.Context, namespace string) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
_, err := h.grpcClient.GetWorkflowServiceStub().UpdateNamespace(ctx,
|
||||||
|
&workflowservice.UpdateNamespaceRequest{Namespace: namespace})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to delete namespace: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"namespace": namespace, "status": "deleted"}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Search attributes ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) listSearchAttributes(ctx context.Context, namespace string) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
resp, err := h.grpcClient.GetWorkflowServiceStub().GetSearchAttributes(ctx,
|
||||||
|
&workflowservice.GetSearchAttributesRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to list search attributes: %v", err)
|
||||||
|
}
|
||||||
|
attrs := make(map[string]string)
|
||||||
|
for k, v := range resp.GetKeys() {
|
||||||
|
attrs[k] = v.String()
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"attributes": attrs}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Task queue operations ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) listTaskQueues(ctx context.Context, namespace string, payload map[string]interface{}) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
taskQueue := getString(payload, "task_queue")
|
||||||
|
if taskQueue == "" {
|
||||||
|
return nil, "INVALID_REQUEST", "task_queue is required"
|
||||||
|
}
|
||||||
|
resp, err := h.grpcClient.GetWorkflowServiceStub().ListTaskQueuePartitions(ctx,
|
||||||
|
&workflowservice.ListTaskQueuePartitionsRequest{
|
||||||
|
Namespace: namespace,
|
||||||
|
TaskQueue: &taskqueue.TaskQueue{Name: taskQueue},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to list task queues: %v", err)
|
||||||
|
}
|
||||||
|
queues := make([]string, 0)
|
||||||
|
for _, p := range resp.GetActivityTaskQueuePartitions() {
|
||||||
|
queues = append(queues, p.GetKey())
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"queues": queues}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Cluster operations ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) getClusterInfo(ctx context.Context) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
resp, err := h.grpcClient.GetWorkflowServiceStub().GetClusterInfo(ctx,
|
||||||
|
&workflowservice.GetClusterInfoRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to get cluster info: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"cluster_name": resp.GetClusterName(),
|
||||||
|
"version_info": resp.GetVersionInfo(),
|
||||||
|
}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) listClusterMembers(ctx context.Context) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
// ListClusterMembers is on the operator service
|
||||||
|
resp, err := h.grpcClient.GetOperatorServiceStub().ListClusters(ctx, nil)
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to list cluster members: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{"clusters": resp.GetClusters()}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) getSystemInfo(ctx context.Context) (interface{}, string, string) {
|
||||||
|
if h.grpcClient == nil {
|
||||||
|
return h.unavailable()
|
||||||
|
}
|
||||||
|
resp, err := h.grpcClient.GetWorkflowServiceStub().GetSystemInfo(ctx,
|
||||||
|
&workflowservice.GetSystemInfoRequest{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, "TEMPORAL_UNAVAILABLE", fmt.Sprintf("failed to get system info: %v", err)
|
||||||
|
}
|
||||||
|
return map[string]interface{}{
|
||||||
|
"server_version": resp.GetServerVersion(),
|
||||||
|
}, "", ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func (h *Handler) writeSuccess(w http.ResponseWriter, action, namespace string, data interface{}) {
|
||||||
|
json.NewEncoder(w).Encode(ResponsePayload{
|
||||||
|
Success: true, Action: action, Namespace: namespace,
|
||||||
|
Data: data, Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeError(w http.ResponseWriter, action, errorCode, message string) {
|
||||||
|
json.NewEncoder(w).Encode(ResponsePayload{
|
||||||
|
Success: false, Action: action,
|
||||||
|
Error: errorCode, Message: message, Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) writeErrorWithCode(w http.ResponseWriter, action, namespace, errorCode, message string) {
|
||||||
|
json.NewEncoder(w).Encode(ResponsePayload{
|
||||||
|
Success: false, Action: action, Namespace: namespace,
|
||||||
|
Error: errorCode, Message: message, Timestamp: time.Now(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func getString(payload map[string]interface{}, key string) string {
|
||||||
|
if val, ok := payload[key]; ok {
|
||||||
|
if str, ok := val.(string); ok {
|
||||||
|
return str
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -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,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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,329 @@
|
|||||||
|
package temporal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// PoimenHandler provides typed REST endpoints for Poimen workflows.
|
||||||
|
// Accessed via X-Service: poimen header through the ServiceAdapter dispatcher.
|
||||||
|
type PoimenHandler struct {
|
||||||
|
handler *Handler
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPoimenHandler creates a new Poimen handler wrapping the Temporal handler.
|
||||||
|
func NewPoimenHandler(h *Handler) *PoimenHandler {
|
||||||
|
return &PoimenHandler{handler: h}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServeHTTP routes based on X-Upstream-Path set by the dispatcher.
|
||||||
|
func (p *PoimenHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||||
|
path := r.Header.Get("X-Upstream-Path")
|
||||||
|
if path == "" {
|
||||||
|
path = r.URL.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case path == "/v1/poimen/plan" && r.Method == http.MethodPost:
|
||||||
|
p.handlePlan(w, r)
|
||||||
|
case path == "/v1/poimen/execute" && r.Method == http.MethodPost:
|
||||||
|
p.handleExecute(w, r)
|
||||||
|
case path == "/v1/poimen/executions" && r.Method == http.MethodGet:
|
||||||
|
p.handleListExecutions(w, r)
|
||||||
|
case path == "/v1/poimen/executions/stream" && r.Method == http.MethodGet:
|
||||||
|
p.handleStream(w, r)
|
||||||
|
case path == "/v1/poimen/executions/signal" && r.Method == http.MethodPost:
|
||||||
|
p.handleSignal(w, r)
|
||||||
|
case path == "/v1/poimen/executions/history" && r.Method == http.MethodGet:
|
||||||
|
p.handleHistory(w, r)
|
||||||
|
case path == "/v1/poimen/executions/detail" && r.Method == http.MethodDelete:
|
||||||
|
p.handleTerminate(w, r)
|
||||||
|
case path == "/v1/poimen/executions/detail" && r.Method == http.MethodGet:
|
||||||
|
p.handleDescribe(w, r)
|
||||||
|
default:
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": "endpoint not found"})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWorkflowID reads the workflow ID from query param (preferred) or path.
|
||||||
|
// The ServiceAdapter sets X-Upstream-Path to a template like
|
||||||
|
// /v1/poimen/executions/{id}/stream — the {id} is literal, not substituted.
|
||||||
|
// So workflow_id must come from a query parameter.
|
||||||
|
func getWorkflowID(r *http.Request) string {
|
||||||
|
// Query param is the primary source
|
||||||
|
if id := r.URL.Query().Get("workflow_id"); id != "" {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// PoimenWorkflowTypes maps Poimen workflow names to Temporal workflow types and task queues.
|
||||||
|
var PoimenWorkflowTypes = map[string]struct {
|
||||||
|
WorkflowType string
|
||||||
|
TaskQueue string
|
||||||
|
}{
|
||||||
|
"dag": {WorkflowType: "DAGWorkflow", TaskQueue: "poimen-worker"},
|
||||||
|
"orchestrator": {WorkflowType: "OrchestratorWorkflow", TaskQueue: "poimen-worker"},
|
||||||
|
"routing": {WorkflowType: "RoutingWorkflow", TaskQueue: "poimen-worker"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Plan ---
|
||||||
|
|
||||||
|
type PlanRequest struct {
|
||||||
|
Message string `json:"message"`
|
||||||
|
Context map[string]interface{} `json:"context,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PoimenHandler) handlePlan(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req PlanRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Message == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "message is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Call PlannerActivity directly (not via Temporal) for low latency.
|
||||||
|
// For now, return a placeholder indicating the plan endpoint is wired.
|
||||||
|
writeJSON(w, http.StatusNotImplemented, map[string]interface{}{
|
||||||
|
"error": "PlannerActivity not yet available",
|
||||||
|
"message": req.Message,
|
||||||
|
"context": req.Context,
|
||||||
|
"hint": "Depends on poimen-workflows#23 (PlannerActivity)",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Execute ---
|
||||||
|
|
||||||
|
type ExecuteRequest struct {
|
||||||
|
WorkflowType string `json:"workflow_type"` // "dag", "orchestrator", "routing"
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
Input map[string]interface{} `json:"input"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PoimenHandler) handleExecute(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req ExecuteRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Namespace == "" {
|
||||||
|
req.Namespace = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
wfType, ok := PoimenWorkflowTypes[req.WorkflowType]
|
||||||
|
if !ok {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]interface{}{
|
||||||
|
"error": fmt.Sprintf("unknown workflow_type: %s", req.WorkflowType),
|
||||||
|
"valid_types": []string{"dag", "orchestrator", "routing"},
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if p.handler.ops == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Temporal not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowID := fmt.Sprintf("poimen-%s-%d", req.WorkflowType, time.Now().UnixMilli())
|
||||||
|
|
||||||
|
result, err := p.handler.ops.StartWorkflowExecution(
|
||||||
|
r.Context(),
|
||||||
|
req.Namespace,
|
||||||
|
workflowID,
|
||||||
|
wfType.WorkflowType,
|
||||||
|
wfType.TaskQueue,
|
||||||
|
req.Input,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("failed to start workflow: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- List Executions ---
|
||||||
|
|
||||||
|
func (p *PoimenHandler) handleListExecutions(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if p.handler.ops == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Temporal not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace := r.URL.Query().Get("namespace")
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := p.handler.ops.ListWorkflowExecutions(r.Context(), namespace, 100)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("failed to list: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Describe ---
|
||||||
|
|
||||||
|
func (p *PoimenHandler) handleDescribe(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if p.handler.ops == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Temporal not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowID := getWorkflowID(r)
|
||||||
|
if workflowID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace := r.URL.Query().Get("namespace")
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = "default"
|
||||||
|
}
|
||||||
|
runID := r.URL.Query().Get("run_id")
|
||||||
|
|
||||||
|
result, err := p.handler.ops.DescribeWorkflowExecution(r.Context(), namespace, workflowID, runID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusNotFound, map[string]string{"error": fmt.Sprintf("workflow not found: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- History ---
|
||||||
|
|
||||||
|
func (p *PoimenHandler) handleHistory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if p.handler.ops == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Temporal not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowID := getWorkflowID(r)
|
||||||
|
if workflowID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace := r.URL.Query().Get("namespace")
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = "default"
|
||||||
|
}
|
||||||
|
runID := r.URL.Query().Get("run_id")
|
||||||
|
|
||||||
|
result, err := p.handler.ops.GetWorkflowExecutionHistory(r.Context(), namespace, workflowID, runID)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("failed to get history: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Signal ---
|
||||||
|
|
||||||
|
type SignalRequest struct {
|
||||||
|
SignalName string `json:"signal_name"`
|
||||||
|
Data map[string]interface{} `json:"data,omitempty"`
|
||||||
|
Namespace string `json:"namespace,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *PoimenHandler) handleSignal(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if p.handler.ops == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Temporal not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowID := getWorkflowID(r)
|
||||||
|
if workflowID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var req SignalRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.SignalName == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "signal_name is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Namespace == "" {
|
||||||
|
req.Namespace = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := p.handler.ops.SignalWorkflowExecution(r.Context(), req.Namespace, workflowID, "", req.SignalName, req.Data)
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("failed to signal: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Terminate ---
|
||||||
|
|
||||||
|
func (p *PoimenHandler) handleTerminate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if p.handler.ops == nil {
|
||||||
|
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "Temporal not available"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowID := getWorkflowID(r)
|
||||||
|
if workflowID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
namespace := r.URL.Query().Get("namespace")
|
||||||
|
if namespace == "" {
|
||||||
|
namespace = "default"
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := p.handler.ops.TerminateWorkflowExecution(r.Context(), namespace, workflowID, "", "terminated via API")
|
||||||
|
if err != nil {
|
||||||
|
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("failed to terminate: %v", err)})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
writeJSON(w, http.StatusOK, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Stream (delegate to existing SSE handler) ---
|
||||||
|
|
||||||
|
func (p *PoimenHandler) handleStream(w http.ResponseWriter, r *http.Request) {
|
||||||
|
workflowID := getWorkflowID(r)
|
||||||
|
if workflowID == "" {
|
||||||
|
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "workflow_id query param required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set query params for the SSE handler
|
||||||
|
q := r.URL.Query()
|
||||||
|
q.Set("workflow_id", workflowID)
|
||||||
|
if q.Get("namespace") == "" {
|
||||||
|
q.Set("namespace", "default")
|
||||||
|
}
|
||||||
|
r.URL.RawQuery = q.Encode()
|
||||||
|
|
||||||
|
p.handler.StreamWorkflowEvents(w, r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Helpers ---
|
||||||
|
|
||||||
|
func writeJSON(w http.ResponseWriter, status int, data interface{}) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.WriteHeader(status)
|
||||||
|
json.NewEncoder(w).Encode(data) //nolint:errcheck
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user