Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1dd71de97f | ||
|
|
fa9938df8e | ||
|
|
a700ac065b | ||
|
|
e0622449cc | ||
|
|
52c36e587b |
+64
-127
@@ -17,10 +17,10 @@ jobs:
|
|||||||
name: CI
|
name: CI
|
||||||
runs-on: golang
|
runs-on: golang
|
||||||
steps:
|
steps:
|
||||||
- name: Install dependencies
|
- name: Install Node.js and Docker
|
||||||
run: |
|
run: |
|
||||||
apt-get update
|
apt-get update
|
||||||
apt-get install -y docker.io curl nodejs
|
apt-get install -y nodejs docker.io
|
||||||
|
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
@@ -29,14 +29,13 @@ jobs:
|
|||||||
run: go vet ./...
|
run: go vet ./...
|
||||||
|
|
||||||
- name: Go test
|
- name: Go test
|
||||||
run: go test -short ./...
|
run: go test ./...
|
||||||
|
|
||||||
- name: Get short SHA
|
- name: Get short SHA
|
||||||
id: sha
|
id: sha
|
||||||
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
run: echo "short_sha=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
|
||||||
|
|
||||||
- name: Registry login
|
- name: Registry login
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
run: |
|
run: |
|
||||||
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \
|
echo "${REGISTRY_TOKEN}" | docker login "${REGISTRY}" \
|
||||||
--username "${REGISTRY_USER}" --password-stdin
|
--username "${REGISTRY_USER}" --password-stdin
|
||||||
@@ -45,145 +44,83 @@ jobs:
|
|||||||
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
|
REGISTRY_TOKEN: ${{ secrets.FORGEJO_REGISTRY_TOKEN }}
|
||||||
|
|
||||||
- name: Build Docker image
|
- name: Build Docker image
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
|
||||||
run: |
|
run: |
|
||||||
docker build --no-cache \
|
docker build --no-cache \
|
||||||
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
-t "${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
||||||
-f Dockerfile .
|
-f Dockerfile .
|
||||||
|
echo "Built image: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
|
|
||||||
- name: Push image (SHA tag)
|
- name: Push test image (SHA tag only, not latest yet)
|
||||||
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: |
|
run: |
|
||||||
mkdir -p ~/.kube
|
docker push "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
echo "${KUBECONFIG_B64}" | base64 -d > ~/.kube/config
|
echo "✓ Pushed test image: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
kubectl get namespaces --no-headers | head -1
|
|
||||||
echo '✓ kubeconfig works'
|
|
||||||
env:
|
|
||||||
KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
|
|
||||||
|
|
||||||
- name: Deploy canary pod
|
- name: Detect in-cluster Kubernetes authentication
|
||||||
if: false # SKIP: SA lacks pod create RBAC. Re-enable when fixed.
|
|
||||||
id: canary
|
|
||||||
run: |
|
run: |
|
||||||
SHA="${{ steps.sha.outputs.short_sha }}"
|
# When running inside K8s cluster, kubectl auto-detects service account
|
||||||
POD_NAME="api-gateway-canary-${SHA}"
|
# Mounted at: /var/run/secrets/kubernetes.io/serviceaccount/
|
||||||
|
if [ -f /var/run/secrets/kubernetes.io/serviceaccount/token ]; then
|
||||||
# Remove any stale canary
|
echo "✓ In-cluster authentication detected"
|
||||||
kubectl delete pod "${POD_NAME}" -n api --ignore-not-found
|
export KUBECONFIG=/dev/null # kubectl will auto-use in-cluster auth
|
||||||
|
|
||||||
# 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
|
else
|
||||||
echo "⚠ No canary pod to clean up (name was empty)"
|
echo "⚠ Not running in-cluster, kubectl may fail"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
- name: Gate on test result
|
- name: Run integration tests via Kubernetes Job
|
||||||
if: false # SKIP: depends on canary pod
|
|
||||||
run: |
|
run: |
|
||||||
echo "✗ Integration tests FAILED — production deployment unchanged"
|
echo "Running integration tests via Kubernetes Job..."
|
||||||
|
echo "Test image: ${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
|
|
||||||
|
# Apply job template from repo (uses in-cluster auth automatically)
|
||||||
|
kubectl apply -f k8s/integration-test-job.yaml
|
||||||
|
|
||||||
|
# Update job to use new image
|
||||||
|
kubectl set image job/api-gateway-integration-test \
|
||||||
|
integration-tester="${IMAGE}:${{ steps.sha.outputs.short_sha }}" \
|
||||||
|
-n api --record
|
||||||
|
|
||||||
|
# Wait for job to complete (max 10 minutes)
|
||||||
|
echo "Waiting for job to complete (this may take a few minutes)..."
|
||||||
|
kubectl wait --for=condition=complete job/api-gateway-integration-test \
|
||||||
|
-n api --timeout=10m 2>/dev/null || true
|
||||||
|
|
||||||
|
# Stream logs
|
||||||
|
echo ""
|
||||||
|
echo "=== Job Logs ==="
|
||||||
|
kubectl logs -n api job/api-gateway-integration-test --all-containers=true --timestamps=true || echo "No logs available"
|
||||||
|
echo "================"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Check if job succeeded
|
||||||
|
SUCCEEDED=$(kubectl get job api-gateway-integration-test -n api -o jsonpath='{.status.succeeded}' 2>/dev/null || echo "0")
|
||||||
|
FAILED=$(kubectl get job api-gateway-integration-test -n api -o jsonpath='{.status.failed}' 2>/dev/null || echo "0")
|
||||||
|
|
||||||
|
echo "Job Status: Succeeded=$SUCCEEDED, Failed=$FAILED"
|
||||||
|
|
||||||
|
if [ "$SUCCEEDED" = "1" ]; then
|
||||||
|
echo "✓ Integration tests PASSED"
|
||||||
|
exit 0
|
||||||
|
else
|
||||||
|
echo "✗ Integration tests FAILED"
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
|
continue-on-error: false
|
||||||
|
|
||||||
# ── Zero-downtime promotion ──────────────────────────────
|
- name: Promote image to latest (only if tests passed)
|
||||||
# kubectl set image triggers a rolling update: new pods come up
|
if: success()
|
||||||
# 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: |
|
run: |
|
||||||
|
docker pull "${IMAGE}:${{ steps.sha.outputs.short_sha }}"
|
||||||
docker tag "${IMAGE}:${{ steps.sha.outputs.short_sha }}" "${IMAGE}:latest"
|
docker tag "${IMAGE}:${{ steps.sha.outputs.short_sha }}" "${IMAGE}:latest"
|
||||||
docker push "${IMAGE}:latest"
|
docker push "${IMAGE}:latest"
|
||||||
echo "✓ Tagged :latest"
|
echo "✓ Promoted ${IMAGE}:${{ steps.sha.outputs.short_sha }} to latest"
|
||||||
|
|
||||||
- name: Cleanup docker images
|
- name: Cleanup integration test job
|
||||||
if: always()
|
if: always()
|
||||||
run: docker image prune -af 2>&1 | tail -3 || true
|
run: |
|
||||||
|
echo "Cleaning up test job..."
|
||||||
|
kubectl delete job api-gateway-integration-test -n api --ignore-not-found=true
|
||||||
|
continue-on-error: true
|
||||||
|
|
||||||
|
- name: Cleanup docker
|
||||||
|
if: always()
|
||||||
|
run: docker image prune -a --force 2>&1 | tail -3 || true
|
||||||
|
|||||||
@@ -571,128 +571,42 @@ curl -X GET https://api.riotpiao.com/ \
|
|||||||
|
|
||||||
## Authentication
|
## Authentication
|
||||||
|
|
||||||
All operations except `/healthz` and `/readyz` require JWT authentication.
|
|
||||||
|
|
||||||
### Bearer Token (JWT)
|
### Bearer Token (JWT)
|
||||||
|
|
||||||
Provide JWT in Authorization header:
|
All operations except `/healthz` and `/readyz` require authentication.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
curl -H 'Authorization: Bearer <jwt-token>' \
|
curl -H 'Authorization: Bearer <jwt-token>' \
|
||||||
https://api.riotpiao.com/v1/models
|
https://api.riotpiao.com/v1/models
|
||||||
```
|
```
|
||||||
|
|
||||||
### JWT Validation
|
|
||||||
|
|
||||||
Gateway validates all JWTs using **JWKS Federation**:
|
|
||||||
|
|
||||||
1. **Fetch JWKS** — Gateway fetches public keys from Authentik's JWKS endpoint (refreshed every 15 minutes)
|
|
||||||
2. **Verify Signature** — Validates JWT signature using public key matching `kid` header
|
|
||||||
3. **Check Claims:**
|
|
||||||
- `iss` (issuer) — Must be Authentik provider (format: `https://authentik.riotpiao.com/application/o/{provider}/`)
|
|
||||||
- `exp` (expiration) — Token must not be expired (60s clock skew allowed)
|
|
||||||
- `nbf` (not before) — Token must not be in future (60s clock skew allowed)
|
|
||||||
- `aud` (audience) — Must be non-empty string from Authentik
|
|
||||||
4. **Check Permissions** — Validates required capabilities from JWT claims (see RBAC section)
|
|
||||||
|
|
||||||
**JWKS Endpoint:** `https://authentik.riotpiao.com/application/oidc/jwks/`
|
|
||||||
|
|
||||||
**Multi-Issuer Support:** Gateway accepts JWT from any Authentik service account provider (paperless-ai-agent, portfolio-analyzer, etc) because all share the same JWKS signing key.
|
|
||||||
|
|
||||||
### Obtaining Tokens
|
### Obtaining Tokens
|
||||||
|
|
||||||
#### User Login (OIDC Device Code Flow)
|
**Via Authentik OIDC (human login):**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
core auth login --username [email protected]
|
core auth login --username [email protected]
|
||||||
export USER_TOKEN=$(cat ~/.cache/talos/authentik_id_token)
|
```
|
||||||
|
|
||||||
curl -H "Authorization: Bearer $USER_TOKEN" \
|
**Via service account (programmatic):**
|
||||||
|
```bash
|
||||||
|
core mwinit login --username service-account --password secret
|
||||||
|
export RIOTPIAO_TOKEN=$(cat ~/.talos/.riotpiao-auth)
|
||||||
|
|
||||||
|
curl -H "Authorization: Bearer $RIOTPIAO_TOKEN" \
|
||||||
https://api.riotpiao.com/v1/models
|
https://api.riotpiao.com/v1/models
|
||||||
```
|
```
|
||||||
|
|
||||||
User tokens contain:
|
|
||||||
- `sub` — user ID
|
|
||||||
- `permissions` — array of granted capabilities
|
|
||||||
- `email` — user email
|
|
||||||
- `name` — user name
|
|
||||||
|
|
||||||
#### Service Account (Client Credentials Flow)
|
|
||||||
|
|
||||||
Service account gets JWT signed by Authentik:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. Authenticate service account with Authentik
|
|
||||||
curl -X POST https://authentik.riotpiao.com/application/o/token/ \
|
|
||||||
-H 'Content-Type: application/x-www-form-urlencoded' \
|
|
||||||
-d 'grant_type=client_credentials' \
|
|
||||||
-d 'client_id=paperless-ai-agent' \
|
|
||||||
-d 'client_secret=<secret>' \
|
|
||||||
-d 'scope=openid'
|
|
||||||
|
|
||||||
# Response:
|
|
||||||
# {
|
|
||||||
# "access_token": "<jwt>",
|
|
||||||
# "token_type": "Bearer",
|
|
||||||
# "expires_in": 3600
|
|
||||||
# }
|
|
||||||
|
|
||||||
# 2. Use token for gateway calls
|
|
||||||
export SERVICE_TOKEN=$(curl ... | jq -r .access_token)
|
|
||||||
curl -H "Authorization: Bearer $SERVICE_TOKEN" \
|
|
||||||
https://api.riotpiao.com/v1/chat/completions
|
|
||||||
```
|
|
||||||
|
|
||||||
Service account tokens contain:
|
|
||||||
- `sub` — service account ID
|
|
||||||
- `roles` — array of granted capabilities
|
|
||||||
- `service_account` — service name
|
|
||||||
- `aud` — audience (Authentik app ID)
|
|
||||||
|
|
||||||
#### Token Exchange (Service Impersonates User)
|
|
||||||
|
|
||||||
Service presents user's JWT + its own credentials to get a delegated token (see `/auth/exchange` endpoint):
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Service exchanges user JWT for scoped service token
|
|
||||||
curl -X POST https://api.riotpiao.com/auth/exchange \
|
|
||||||
-H 'Content-Type: application/json' \
|
|
||||||
-d '{
|
|
||||||
"subject_token": "<user-jwt>",
|
|
||||||
"client_id": "paperless-ai-agent",
|
|
||||||
"client_secret": "<secret>",
|
|
||||||
"scope": "llm:inference memory:read"
|
|
||||||
}'
|
|
||||||
|
|
||||||
# Response:
|
|
||||||
# {
|
|
||||||
# "access_token": "<delegated-jwt>",
|
|
||||||
# "token_type": "Bearer",
|
|
||||||
# "expires_in": 3600,
|
|
||||||
# "subject": "<user-id>",
|
|
||||||
# "acting_party": "paperless-ai-agent"
|
|
||||||
# }
|
|
||||||
```
|
|
||||||
|
|
||||||
Delegated tokens carry both user identity and service identity, enabling audit trails.
|
|
||||||
|
|
||||||
### Capabilities (RBAC)
|
### Capabilities (RBAC)
|
||||||
|
|
||||||
JWT claims contain permission arrays. Required capabilities:
|
Tokens embed capabilities in claims. Required capabilities:
|
||||||
|
|
||||||
| Capability | Used For |
|
- `llm:inference` — `/v1/*` chat/embeddings/rerank
|
||||||
|------------|----------|
|
- `workflow:execute` — `/workflow` operations
|
||||||
| `llm:inference` | `/v1/chat/completions`, `/v1/embeddings`, `/v1/rerank` |
|
- `memory:read` — Memory queries
|
||||||
| `workflow:execute` | `/workflow` (Temporal operations) |
|
- `memory:write` — Memory ingest
|
||||||
| `memory:read` | `/memory` query operations |
|
- `sqs:access` — Queue operations
|
||||||
| `memory:write` | `/memory` ingest operations |
|
- `s3:access` — S3 operations
|
||||||
| `sqs:access` | `/sqs` queue operations |
|
- `iam:admin` — IAM management
|
||||||
| `s3:access` | `/s3` object storage operations |
|
|
||||||
| `iam:admin` | `/iam` user/group management |
|
|
||||||
|
|
||||||
**Wildcard:** Token with `*` capability grants all permissions.
|
|
||||||
|
|
||||||
**Permission Check:** JWT validated via `permissions` claim (user tokens) or `roles` claim (service account tokens).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -38,22 +38,22 @@ Production API gateway for the homelab cluster. Single entry point (`api.riotpia
|
|||||||
│ (routing, auth, limits) │
|
│ (routing, auth, limits) │
|
||||||
└──────┬───────────────────────┘
|
└──────┬───────────────────────┘
|
||||||
│
|
│
|
||||||
┌──────┴───────────────────────────────────┐
|
┌──────┴──────────────────────────────────┐
|
||||||
│ │
|
│ │
|
||||||
/v1/* X-Service header routing /
|
/v1/* /workflow /sqs /
|
||||||
(LLM) (workflow, sqs, s3, iam, memory) /
|
(LLM) (Temporal gRPC) (Queues) (X-Service)
|
||||||
│ │ │
|
│ │ │ │
|
||||||
▼ ▼ ▼
|
▼ ▼ ▼ ▼
|
||||||
llm-serving temporal:7233 kmsvc/Kafka, MinIO,
|
llm-serving temporal:7233 kmsvc/Kafka IAM, S3
|
||||||
(vLLM, Ollama) (gRPC) Authentik, poimen-memory
|
(vLLM, Ollama) (WorkflowService) Memory
|
||||||
(TEI)
|
(TEI) (gRPC bridge) (poimen)
|
||||||
```
|
```
|
||||||
|
|
||||||
**Design principles:**
|
**Design principles:**
|
||||||
- ✅ Single hostname, unified X-Service + X-Resource header routing
|
- ✅ Single hostname, multiple path prefixes
|
||||||
- ✅ HTTP REST gateway → gRPC Temporal bridge (via X-Service: workflow)
|
- ✅ HTTP REST gateway → gRPC Temporal bridge
|
||||||
- ✅ Bearer token auth via Authentik (JWT + RBAC)
|
- ✅ Bearer token auth via Authentik (JWT + RBAC)
|
||||||
- ✅ Streaming unbuffered (SSE, WebSocket, HTTP/2 multiplexing)
|
- ✅ Streaming unbuffered (SSE, WebSocket)
|
||||||
- ✅ Per-route timeouts & rate limits
|
- ✅ Per-route timeouts & rate limits
|
||||||
- ✅ No cluster credentials held by gateway
|
- ✅ No cluster credentials held by gateway
|
||||||
|
|
||||||
@@ -61,16 +61,16 @@ llm-serving temporal:7233 kmsvc/Kafka, MinIO,
|
|||||||
|
|
||||||
## Services & Capabilities
|
## Services & Capabilities
|
||||||
|
|
||||||
| Service | Method | Upstream | Status |
|
| Service | Prefix | Upstream | Status |
|
||||||
|---------|--------|----------|--------|
|
|---------|--------|----------|--------|
|
||||||
| **LLM Chat** | `POST /v1/chat/completions` | llm-serving (vLLM) | ✅ Live |
|
| **LLM Chat** | `/v1/chat/completions` | llm-serving (vLLM) | ✅ Live |
|
||||||
| **Embeddings** | `POST /v1/embeddings` | llm-serving (TEI) | ✅ Live |
|
| **Embeddings** | `/v1/embeddings` | llm-serving (TEI) | ✅ Live |
|
||||||
| **Reranking** | `POST /v1/rerank` | llm-serving (TEI) | ✅ Live |
|
| **Reranking** | `/v1/rerank` | llm-serving (TEI) | ✅ Live |
|
||||||
| **Workflows** | `X-Service: workflow` + `X-Resource: {action}` | Temporal gRPC (7233) | ✅ Live (START, DESCRIBE, SIGNAL, QUERY, etc) |
|
| **Workflows** | `/workflow` | Temporal gRPC (7233) | ✅ Live (START, DESCRIBE, SIGNAL, QUERY, etc) |
|
||||||
| **Queues** | `X-Service: sqs` + `X-Resource: {action}` | kmsvc/Kafka | ✅ Live |
|
| **Queues** | `/` + `X-Service: sqs` | kmsvc/Kafka | ⏳ Ready (ServiceAdapter) |
|
||||||
| **Memory** | `X-Service: memory` + `X-Resource: {action}` | poimen-memory | ✅ Live |
|
| **Memory** | `/` + `X-Service: memory` | poimen-memory | ✅ Live |
|
||||||
| **IAM** | `X-Service: iam` + `X-Resource: {action}` | Authentik API | ✅ Live |
|
| **IAM** | `/` + `X-Service: iam` | Authentik API | ✅ Live |
|
||||||
| **S3** | `X-Service: s3` + `X-Resource: {action}` | MinIO | ✅ Live |
|
| **S3** | `/` + `X-Service: s3` | MinIO | ✅ Live |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -102,17 +102,18 @@ curl -X POST https://api.riotpiao.com/v1/chat/completions \
|
|||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
**Workflow (via X-Service header):**
|
**Workflow:**
|
||||||
```bash
|
```bash
|
||||||
curl -X POST https://api.riotpiao.com/ \
|
curl -X POST https://api.riotpiao.com/workflow \
|
||||||
-H "Authorization: Bearer $TOKEN" \
|
-H "Authorization: Bearer $TOKEN" \
|
||||||
-H "X-Service: workflow" \
|
|
||||||
-H "X-Resource: start" \
|
|
||||||
-d '{
|
-d '{
|
||||||
|
"action": "START_WORKFLOW",
|
||||||
"namespace": "default",
|
"namespace": "default",
|
||||||
|
"payload": {
|
||||||
"workflow_id": "my-workflow",
|
"workflow_id": "my-workflow",
|
||||||
"workflow_type": "MyWorkflow",
|
"workflow_type": "MyWorkflow",
|
||||||
"task_queue": "default"
|
"task_queue": "default"
|
||||||
|
}
|
||||||
}'
|
}'
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -11,8 +11,6 @@ import (
|
|||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
"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/proxy"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/server"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
||||||
@@ -74,59 +72,7 @@ func main() {
|
|||||||
|
|
||||||
// Create ServiceAdapter registry and dispatcher (phase 8)
|
// Create ServiceAdapter registry and dispatcher (phase 8)
|
||||||
registry := serviceadapter.NewRegistry(nil)
|
registry := serviceadapter.NewRegistry(nil)
|
||||||
|
|
||||||
// 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 {
|
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)
|
_ = registry.Add(a)
|
||||||
}
|
}
|
||||||
log.Printf("%d service adapters loaded", registry.Count())
|
log.Printf("%d service adapters loaded", registry.Count())
|
||||||
@@ -138,13 +84,9 @@ func main() {
|
|||||||
}
|
}
|
||||||
dispatcher := serviceadapter.NewDispatcher(registry, jwtValidator)
|
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,
|
// Create router that handles health endpoints, X-Service (ServiceAdapter) routing,
|
||||||
// temporal endpoints, and passes others to upstream handler
|
// temporal endpoints, and passes others to upstream handler
|
||||||
router := server.NewRouter(healthChecker, dispatcher, temporalHandler, upstreamHandler)
|
router := server.NewRouter(healthChecker, dispatcher, temporalHandler, upstreamHandler)
|
||||||
router.SetMetricsHandler(observability.NewMetricsHandler(observability.Global()))
|
|
||||||
|
|
||||||
// Wrap router with tracing middleware
|
// Wrap router with tracing middleware
|
||||||
tracedRouter := tracing.Middleware(router)
|
tracedRouter := tracing.Middleware(router)
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
#!/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 .
|
|
||||||
@@ -1,20 +0,0 @@
|
|||||||
#!/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 ""
|
|
||||||
@@ -33,17 +33,9 @@ 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 indicates whether this model requires JWT authentication.
|
||||||
AuthRequired bool
|
AuthRequired bool
|
||||||
}
|
}
|
||||||
@@ -120,18 +112,6 @@ 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"
|
||||||
|
|||||||
@@ -39,9 +39,7 @@ type rawRoute struct {
|
|||||||
type rawModel struct {
|
type rawModel struct {
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Address string `yaml:"address"`
|
Address string `yaml:"address"`
|
||||||
Addresses []string `yaml:"addresses"`
|
|
||||||
Path string `yaml:"path"`
|
Path string `yaml:"path"`
|
||||||
UpstreamModel string `yaml:"upstreamModel"`
|
|
||||||
AuthRequired *bool `yaml:"authRequired"`
|
AuthRequired *bool `yaml:"authRequired"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,18 +120,11 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
|
|||||||
if _, exists := models[rawModel.Name]; exists {
|
if _, exists := models[rawModel.Name]; exists {
|
||||||
return nil, nil, nil, AuthConfig{}, 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.
|
if rawModel.Address == "" {
|
||||||
addresses := rawModel.Addresses
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": field 'address' is required", rawModel.Name)
|
||||||
if len(addresses) == 0 && rawModel.Address != "" {
|
|
||||||
addresses = []string{rawModel.Address}
|
|
||||||
}
|
|
||||||
if len(addresses) == 0 {
|
|
||||||
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": 'address' or 'addresses' is required", rawModel.Name)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
}
|
||||||
|
if _, _, err := net.SplitHostPort(rawModel.Address); err != nil {
|
||||||
|
return nil, nil, nil, AuthConfig{}, fmt.Errorf("model \"%s\": invalid address \"%s\": %w", rawModel.Name, rawModel.Address, err)
|
||||||
}
|
}
|
||||||
// Default authRequired to global auth.enabled if not specified per-model
|
// Default authRequired to global auth.enabled if not specified per-model
|
||||||
authRequired := false
|
authRequired := false
|
||||||
@@ -142,10 +133,8 @@ func LoadRoutesAndModelsFromFile(path string) (map[string]*Route, map[string]*Mo
|
|||||||
}
|
}
|
||||||
models[rawModel.Name] = &ModelUpstream{
|
models[rawModel.Name] = &ModelUpstream{
|
||||||
Name: rawModel.Name,
|
Name: rawModel.Name,
|
||||||
Address: addresses[0],
|
Address: rawModel.Address,
|
||||||
Addresses: addresses,
|
|
||||||
Path: rawModel.Path,
|
Path: rawModel.Path,
|
||||||
UpstreamModel: rawModel.UpstreamModel,
|
|
||||||
AuthRequired: authRequired,
|
AuthRequired: authRequired,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -161,7 +161,7 @@ models:
|
|||||||
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\": 'address' or 'addresses' is required" {
|
if err.Error() != "model \"reasoning\": field 'address' is required" {
|
||||||
t.Errorf("expected missing address error, got: %v", err)
|
t.Errorf("expected missing address error, got: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,139 +283,3 @@ 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
|
|
||||||
}())
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -198,11 +198,3 @@ 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'))
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,260 +0,0 @@
|
|||||||
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))
|
|
||||||
}
|
|
||||||
@@ -1,291 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,513 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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()))
|
|
||||||
}
|
|
||||||
@@ -28,19 +28,6 @@ type Metrics struct {
|
|||||||
// Streaming metrics
|
// Streaming metrics
|
||||||
streamingResponsesTotal map[string]int64
|
streamingResponsesTotal map[string]int64
|
||||||
streamingByteCount 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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewMetrics creates a new Metrics instance.
|
// NewMetrics creates a new Metrics instance.
|
||||||
@@ -54,9 +41,6 @@ func NewMetrics() *Metrics {
|
|||||||
upstreamHealth: make(map[string]int),
|
upstreamHealth: make(map[string]int),
|
||||||
streamingResponsesTotal: make(map[string]int64),
|
streamingResponsesTotal: make(map[string]int64),
|
||||||
streamingByteCount: make(map[string]int64),
|
streamingByteCount: make(map[string]int64),
|
||||||
ttftMs: make(map[string][]int64),
|
|
||||||
itlMs: make(map[string][]int64),
|
|
||||||
tokenCount: make(map[string]int64),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,113 +146,9 @@ func (m *Metrics) GetMetrics() map[string]interface{} {
|
|||||||
"upstream_health": m.upstreamHealth,
|
"upstream_health": m.upstreamHealth,
|
||||||
"streaming_responses_total": m.streamingResponsesTotal,
|
"streaming_responses_total": m.streamingResponsesTotal,
|
||||||
"streaming_byte_count": m.streamingByteCount,
|
"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).
|
// Reset clears all metrics (for testing).
|
||||||
func (m *Metrics) Reset() {
|
func (m *Metrics) Reset() {
|
||||||
m.mu.Lock()
|
m.mu.Lock()
|
||||||
@@ -282,64 +162,4 @@ func (m *Metrics) Reset() {
|
|||||||
m.upstreamHealth = make(map[string]int)
|
m.upstreamHealth = make(map[string]int)
|
||||||
m.streamingResponsesTotal = make(map[string]int64)
|
m.streamingResponsesTotal = make(map[string]int64)
|
||||||
m.streamingByteCount = 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,210 +0,0 @@
|
|||||||
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()))
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -381,66 +381,3 @@ 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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,153 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"net"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
|
||||||
)
|
|
||||||
|
|
||||||
// LLMMetricsCapture wraps a response writer to capture TTFT and ITL metrics
|
|
||||||
type LLMMetricsCapture struct {
|
|
||||||
writer io.WriteCloser
|
|
||||||
model string
|
|
||||||
metrics *observability.Metrics
|
|
||||||
firstTokenTime time.Time
|
|
||||||
lastTokenTime time.Time
|
|
||||||
requestStartTime time.Time
|
|
||||||
ttftRecorded bool
|
|
||||||
tokenCount int64
|
|
||||||
responseStartTime time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewLLMMetricsCapture creates a new metrics capture wrapper
|
|
||||||
func NewLLMMetricsCapture(writer io.WriteCloser, model string, metrics *observability.Metrics, startTime time.Time) *LLMMetricsCapture {
|
|
||||||
return &LLMMetricsCapture{
|
|
||||||
writer: writer,
|
|
||||||
model: model,
|
|
||||||
metrics: metrics,
|
|
||||||
requestStartTime: startTime,
|
|
||||||
responseStartTime: time.Now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write intercepts writes to detect tokens and record metrics
|
|
||||||
func (c *LLMMetricsCapture) Write(p []byte) (int, error) {
|
|
||||||
// Record first token time
|
|
||||||
if !c.ttftRecorded && len(p) > 0 {
|
|
||||||
now := time.Now()
|
|
||||||
ttft := now.Sub(c.requestStartTime).Milliseconds()
|
|
||||||
c.metrics.RecordTTFT(c.model, ttft)
|
|
||||||
c.ttftRecorded = true
|
|
||||||
c.firstTokenTime = now
|
|
||||||
c.lastTokenTime = now
|
|
||||||
}
|
|
||||||
|
|
||||||
// Count tokens in SSE stream (simple: count "data: " lines)
|
|
||||||
if c.ttftRecorded {
|
|
||||||
tokenCount := strings.Count(string(p), "data: ")
|
|
||||||
if tokenCount > 0 {
|
|
||||||
now := time.Now()
|
|
||||||
if !c.firstTokenTime.IsZero() && c.lastTokenTime != now {
|
|
||||||
itl := now.Sub(c.lastTokenTime).Milliseconds()
|
|
||||||
c.metrics.RecordITL(c.model, itl)
|
|
||||||
}
|
|
||||||
c.lastTokenTime = now
|
|
||||||
c.tokenCount += int64(tokenCount)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return c.writer.Write(p)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Close records final metrics and closes writer
|
|
||||||
func (c *LLMMetricsCapture) Close() error {
|
|
||||||
if c.tokenCount > 0 {
|
|
||||||
c.metrics.RecordTokenCount(c.model, c.tokenCount)
|
|
||||||
}
|
|
||||||
return c.writer.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
// ResponseWriterWrapper wraps http.ResponseWriter to capture metrics
|
|
||||||
type ResponseWriterWrapper struct {
|
|
||||||
writer http.ResponseWriter
|
|
||||||
statusCode int
|
|
||||||
metrics *observability.Metrics
|
|
||||||
model string
|
|
||||||
startTime time.Time
|
|
||||||
firstByteTime time.Time
|
|
||||||
lastWriteTime time.Time
|
|
||||||
ttftRecorded bool
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewResponseWriterWrapper creates a wrapper for response writer
|
|
||||||
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()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write implements http.ResponseWriter
|
|
||||||
func (w *ResponseWriterWrapper) Write(b []byte) (int, error) {
|
|
||||||
// Record TTFT on first write
|
|
||||||
if !w.ttftRecorded && len(b) > 0 {
|
|
||||||
now := time.Now()
|
|
||||||
ttft := now.Sub(w.startTime).Milliseconds()
|
|
||||||
w.metrics.RecordTTFT(w.model, ttft)
|
|
||||||
w.ttftRecorded = true
|
|
||||||
w.firstByteTime = now
|
|
||||||
w.lastWriteTime = now
|
|
||||||
}
|
|
||||||
|
|
||||||
// Record ITL for subsequent writes (for streaming)
|
|
||||||
if w.ttftRecorded && len(b) > 0 {
|
|
||||||
now := time.Now()
|
|
||||||
if !w.firstByteTime.IsZero() && w.lastWriteTime != now {
|
|
||||||
itl := now.Sub(w.lastWriteTime).Milliseconds()
|
|
||||||
// Only record if ITL > 0 (avoid recording same millisecond twice)
|
|
||||||
if itl > 0 {
|
|
||||||
w.metrics.RecordITL(w.model, itl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
w.lastWriteTime = now
|
|
||||||
}
|
|
||||||
|
|
||||||
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")
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
+89
-302
@@ -2,28 +2,21 @@
|
|||||||
package proxy
|
package proxy
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"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"
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/observability"
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/tracing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,18 +36,11 @@ type Handler struct {
|
|||||||
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
|
|
||||||
// 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
|
||||||
ModelName string // non-empty for model-based routes; used for scheduler lookup
|
|
||||||
Upstream *config.Upstream
|
Upstream *config.Upstream
|
||||||
Director func(*http.Request)
|
Director func(*http.Request)
|
||||||
Transport *http.Transport
|
Transport *http.Transport
|
||||||
@@ -106,47 +92,6 @@ func New(cfg *config.Config) *Handler {
|
|||||||
h.authHTTP = newAuthClient()
|
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 := 20
|
|
||||||
if v := os.Getenv("VLLM_MAX_QUEUE_DEPTH"); v != "" {
|
|
||||||
if n, err := strconv.Atoi(v); err == nil && n > 0 {
|
|
||||||
maxQueue = n
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.schedulers = make(map[string]*Scheduler)
|
|
||||||
for modelName, model := range cfg.Models {
|
|
||||||
addrs := model.AllAddresses()
|
|
||||||
if len(addrs) > 0 {
|
|
||||||
h.schedulers[modelName] = NewScheduler(addrs, maxSlots, maxQueue)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// 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)
|
||||||
@@ -170,16 +115,7 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,15 +127,6 @@ 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{
|
||||||
@@ -207,15 +134,8 @@ 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
|
||||||
@@ -228,7 +148,7 @@ func (h *Handler) getOrCreateTransport(addr string, up *config.Upstream) *http.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
h.transports[addr] = transport
|
h.transports[addr] = transport
|
||||||
return transport // written under transportsMu write lock
|
return transport
|
||||||
}
|
}
|
||||||
|
|
||||||
// directorFunc modifies the request to be sent to the upstream.
|
// directorFunc modifies the request to be sent to the upstream.
|
||||||
@@ -314,7 +234,17 @@ 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) {
|
||||||
if h.handleAuthEndpoint(w, r) {
|
// Auth endpoints — no JWT required (they issue tokens)
|
||||||
|
if r.URL.Path == "/auth/token" {
|
||||||
|
h.handleAuthToken(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/auth/refresh" {
|
||||||
|
h.handleAuthRefresh(w, r)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r.URL.Path == "/auth/exchange" {
|
||||||
|
h.handleAuthExchange(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,166 +254,22 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolve route; handles validation errors and 404.
|
// Handle /workflows endpoint (workflow orchestration)
|
||||||
route, ok := h.routeOrError(w, r)
|
if r.URL.Path == "/workflows" {
|
||||||
if !ok {
|
h.handleWorkflow(w, r)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip spoofed identity headers from all inbound requests.
|
// Try to find a matching route (including body-based dispatch for /v1/chat/completions)
|
||||||
// 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")
|
|
||||||
|
|
||||||
proxy.ServeHTTP(w, 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, and ok=true.
|
|
||||||
// If the slot cannot be acquired it writes a 503 and returns (nil, nil, false).
|
|
||||||
func (h *Handler) acquireScheduledBackend(ctx context.Context, w http.ResponseWriter, r *http.Request, sched *Scheduler, route *Route, cacheKey string) (*Backend, *Route, bool) {
|
|
||||||
backend := sched.Select(cacheKey)
|
|
||||||
if !h.applyWarmup(ctx, r) {
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
if backend == nil {
|
|
||||||
return nil, route, true
|
|
||||||
}
|
|
||||||
if err := backend.Acquire(ctx); err != nil {
|
|
||||||
if errors.Is(err, ErrQueueFull) {
|
|
||||||
observability.Global().IncrQueueFull()
|
|
||||||
logging.Warnf("backend queue full", map[string]string{
|
|
||||||
"backend": backend.Address,
|
|
||||||
"path": r.URL.Path,
|
|
||||||
"model": route.ModelName,
|
|
||||||
})
|
|
||||||
w.Header().Set("Retry-After", "5")
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
fmt.Fprintf(w, "queue full")
|
|
||||||
} else {
|
|
||||||
logging.Warnf("backend acquire failed", map[string]string{
|
|
||||||
"backend": backend.Address,
|
|
||||||
"path": r.URL.Path,
|
|
||||||
"model": route.ModelName,
|
|
||||||
"err": err.Error(),
|
|
||||||
})
|
|
||||||
w.WriteHeader(http.StatusServiceUnavailable)
|
|
||||||
fmt.Fprintf(w, "upstream busy")
|
|
||||||
}
|
|
||||||
return nil, nil, false
|
|
||||||
}
|
|
||||||
if backend.Address != route.Upstream.Address {
|
|
||||||
patchedCfg := *route.Upstream
|
|
||||||
patchedCfg.Address = backend.Address
|
|
||||||
patchedURL, _ := url.Parse("http://" + 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, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
||||||
if validationErr, ok := err.(*modelValidationError); ok {
|
|
||||||
h.writeModelValidationError(w, validationErr, h.getValidModels())
|
|
||||||
logging.Errorf("client error", validationErr, map[string]string{
|
|
||||||
"path": r.URL.Path,
|
|
||||||
"method": r.Method,
|
|
||||||
"reason": validationErr.Kind,
|
|
||||||
})
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if err != nil || route == nil {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
fmt.Fprintf(w, "not found")
|
|
||||||
if err != nil {
|
|
||||||
logging.Errorf("routing failed", err, map[string]string{
|
|
||||||
"path": r.URL.Path,
|
|
||||||
"method": r.Method,
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
logging.Errorf("no route matches", fmt.Errorf("path=%s method=%s", r.URL.Path, r.Method), nil)
|
|
||||||
}
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return route, true
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeModelValidationError writes a problem-detail response for model validation errors.
|
// Check if this is a model validation error (from body-based dispatch)
|
||||||
func (h *Handler) writeModelValidationError(w http.ResponseWriter, validationErr *modelValidationError, validModels []string) {
|
if validationErr, ok := err.(*modelValidationError); ok {
|
||||||
|
// This is a client error, not a routing error
|
||||||
var status int
|
var status int
|
||||||
var problemType, title, detail string
|
var problemType string
|
||||||
var models []string
|
var title string
|
||||||
|
var detail string
|
||||||
|
|
||||||
switch validationErr.Kind {
|
switch validationErr.Kind {
|
||||||
case "invalid_json":
|
case "invalid_json":
|
||||||
@@ -496,13 +282,11 @@ func (h *Handler) writeModelValidationError(w http.ResponseWriter, validationErr
|
|||||||
problemType = "https://api.example.com/problems/missing-model"
|
problemType = "https://api.example.com/problems/missing-model"
|
||||||
title = "Missing Model"
|
title = "Missing Model"
|
||||||
detail = "The 'model' field is required and must be a non-empty string"
|
detail = "The 'model' field is required and must be a non-empty string"
|
||||||
models = validModels
|
|
||||||
case "unknown_model":
|
case "unknown_model":
|
||||||
status = http.StatusBadRequest
|
status = http.StatusBadRequest
|
||||||
problemType = "https://api.example.com/problems/unknown-model"
|
problemType = "https://api.example.com/problems/unknown-model"
|
||||||
title = "Unknown Model"
|
title = "Unknown Model"
|
||||||
detail = fmt.Sprintf("Model %q is not available. See valid_models for available options.", validationErr.Model)
|
detail = fmt.Sprintf("Model %q is not available. See valid_models for available options.", validationErr.Model)
|
||||||
models = validModels
|
|
||||||
default:
|
default:
|
||||||
status = http.StatusBadRequest
|
status = http.StatusBadRequest
|
||||||
problemType = "https://api.example.com/problems/invalid-request"
|
problemType = "https://api.example.com/problems/invalid-request"
|
||||||
@@ -510,19 +294,42 @@ func (h *Handler) writeModelValidationError(w http.ResponseWriter, validationErr
|
|||||||
detail = validationErr.Message
|
detail = validationErr.Message
|
||||||
}
|
}
|
||||||
|
|
||||||
writeProblemDetail(w, status, problemType, title, detail, models)
|
// 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()
|
||||||
|
}
|
||||||
|
|
||||||
// authenticateV1 validates JWT for /v1/* paths.
|
writeProblemDetail(w, status, problemType, title, detail, validModels)
|
||||||
// Returns false when auth fails (response already written).
|
logging.Errorf("client error", validationErr, map[string]string{
|
||||||
func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
"path": r.URL.Path,
|
||||||
if h.jwtValidator == nil {
|
"method": r.Method,
|
||||||
logging.Warnf("JWT auth disabled; /v1/* request unauthenticated", map[string]string{"path": r.URL.Path})
|
"reason": validationErr.Kind,
|
||||||
return true
|
})
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if !strings.HasPrefix(r.URL.Path, "/v1/") {
|
|
||||||
return true
|
if err != nil || route == nil {
|
||||||
|
// Route not found or error determining route
|
||||||
|
w.WriteHeader(http.StatusNotFound)
|
||||||
|
fmt.Fprintf(w, "not found")
|
||||||
|
if err != nil {
|
||||||
|
logging.Errorf("routing failed", err, map[string]string{
|
||||||
|
"path": r.URL.Path,
|
||||||
|
"method": r.Method,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
logging.Errorf("no route matches", fmt.Errorf("path=%s method=%s", r.URL.Path, r.Method), nil)
|
||||||
}
|
}
|
||||||
|
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.jwtValidator != nil && strings.HasPrefix(r.URL.Path, "/v1/") {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
writeProblemDetail(w, http.StatusUnauthorized,
|
writeProblemDetail(w, http.StatusUnauthorized,
|
||||||
@@ -533,8 +340,9 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
logging.Errorf("auth failed", fmt.Errorf("missing auth header"), map[string]string{
|
logging.Errorf("auth failed", fmt.Errorf("missing auth header"), map[string]string{
|
||||||
"path": r.URL.Path,
|
"path": r.URL.Path,
|
||||||
})
|
})
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
claims, err := h.jwtValidator.ValidateBearerToken(authHeader)
|
claims, err := h.jwtValidator.ValidateBearerToken(authHeader)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeProblemDetail(w, http.StatusForbidden,
|
writeProblemDetail(w, http.StatusForbidden,
|
||||||
@@ -545,9 +353,13 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
logging.Errorf("auth failed", err, map[string]string{
|
logging.Errorf("auth failed", err, map[string]string{
|
||||||
"path": r.URL.Path,
|
"path": r.URL.Path,
|
||||||
})
|
})
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Inject identity headers for downstream services
|
||||||
identity.Inject(r, claims)
|
identity.Inject(r, claims)
|
||||||
|
|
||||||
|
// Check required capability if configured
|
||||||
if h.config.Auth.RequiredCapability != "" {
|
if h.config.Auth.RequiredCapability != "" {
|
||||||
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
|
if !h.jwtValidator.CheckPermissions(claims, h.config.Auth.RequiredCapability, "*") {
|
||||||
writeProblemDetail(w, http.StatusForbidden,
|
writeProblemDetail(w, http.StatusForbidden,
|
||||||
@@ -559,18 +371,17 @@ func (h *Handler) authenticateV1(w http.ResponseWriter, r *http.Request) bool {
|
|||||||
"path": r.URL.Path,
|
"path": r.URL.Path,
|
||||||
"required": h.config.Auth.RequiredCapability,
|
"required": h.config.Auth.RequiredCapability,
|
||||||
})
|
})
|
||||||
return false
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|
||||||
// enforceBodyLimit caps request body size for non-chat routes.
|
// Note: Body size checking already happened in RouteRequest (body was read for model dispatch).
|
||||||
// Returns false when the request is rejected (response already written).
|
// For other paths, we still need to enforce the cap.
|
||||||
func (h *Handler) enforceBodyLimit(w http.ResponseWriter, r *http.Request, route *Route) bool {
|
// For /v1/chat/completions, the body was already read and validated.
|
||||||
if r.URL.Path == "/v1/chat/completions" {
|
|
||||||
return true
|
// Enforce request body size cap for non-chat routes
|
||||||
}
|
if r.URL.Path != "/v1/chat/completions" {
|
||||||
if route.Upstream.MaxBodySize > 0 && r.ContentLength > route.Upstream.MaxBodySize {
|
if route.Upstream.MaxBodySize > 0 && r.ContentLength > route.Upstream.MaxBodySize {
|
||||||
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
w.WriteHeader(http.StatusRequestEntityTooLarge)
|
||||||
fmt.Fprintf(w, "request body too large")
|
fmt.Fprintf(w, "request body too large")
|
||||||
@@ -581,73 +392,49 @@ func (h *Handler) enforceBodyLimit(w http.ResponseWriter, r *http.Request, route
|
|||||||
"content_length": fmt.Sprintf("%d", r.ContentLength),
|
"content_length": fmt.Sprintf("%d", r.ContentLength),
|
||||||
"max_body_size": fmt.Sprintf("%d", route.Upstream.MaxBodySize),
|
"max_body_size": fmt.Sprintf("%d", route.Upstream.MaxBodySize),
|
||||||
})
|
})
|
||||||
return false
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrap request body with size limiter
|
||||||
|
// This enforces the cap at read time, not after buffering
|
||||||
if route.Upstream.MaxBodySize > 0 && r.Body != nil {
|
if route.Upstream.MaxBodySize > 0 && r.Body != nil {
|
||||||
r.Body = io.NopCloser(io.LimitReader(r.Body, route.Upstream.MaxBodySize))
|
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
|
|
||||||
if sched != nil {
|
|
||||||
var ok bool
|
|
||||||
selectedBackend, route, 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
|
||||||
|
|
||||||
|
// Use the connection-pooled transport wrapped with tracing
|
||||||
proxy.Transport = tracing.NewTransport(route.Transport)
|
proxy.Transport = tracing.NewTransport(route.Transport)
|
||||||
|
|
||||||
capturedBackend := selectedBackend
|
// Set error handler to log upstream errors
|
||||||
capturedRoute := route
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
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": capturedRoute.Upstream.Address,
|
"upstream": route.Upstream.Address,
|
||||||
"path": r.URL.Path,
|
"path": r.URL.Path,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if cacheKey != "" && r.URL.Path == "/v1/chat/completions" {
|
// Note: We apply connection timeout via Transport dialer, but NOT read timeout as a context deadline.
|
||||||
proxy.ModifyResponse = func(resp *http.Response) error {
|
// Read timeout should apply to inactivity (socket read timeout), not total request duration.
|
||||||
raw := resp.Header.Get("vllm-num-cached-tokens")
|
// A streaming response that's continuously sending should not be cut off.
|
||||||
if raw == "" {
|
// The Transport's socket read timeout (via Dialer) handles inactivity timeouts.
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return proxy, selectedBackend, true
|
// Serve the request through the proxy
|
||||||
|
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,24 +1,15 @@
|
|||||||
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/golang-jwt/jwt/v5"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
"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) {
|
||||||
@@ -423,356 +414,3 @@ 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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -122,20 +122,6 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// If upstream expects a different model name, rewrite the body
|
|
||||||
if modelUpstream.UpstreamModel != "" && modelUpstream.UpstreamModel != modelName {
|
|
||||||
payload["model"] = modelUpstream.UpstreamModel
|
|
||||||
newBody, err := json.Marshal(payload)
|
|
||||||
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" {
|
||||||
@@ -159,7 +145,6 @@ func (h *Handler) routeByModel(r *http.Request, path string) (*Route, error) {
|
|||||||
|
|
||||||
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) {
|
||||||
|
|||||||
@@ -1,157 +0,0 @@
|
|||||||
package proxy
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"errors"
|
|
||||||
"hash/fnv"
|
|
||||||
"sort"
|
|
||||||
"sync"
|
|
||||||
"sync/atomic"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ErrQueueFull is returned when the in-memory queue is at capacity.
|
|
||||||
var ErrQueueFull = errors.New("backend queue full")
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
type Backend struct {
|
|
||||||
Address string
|
|
||||||
queue chan struct{}
|
|
||||||
slots chan struct{}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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.
|
|
||||||
func (b *Backend) Acquire(ctx context.Context) error {
|
|
||||||
// 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.
|
|
||||||
func NewScheduler(addresses []string, maxSlots, maxQueue int) *Scheduler {
|
|
||||||
backends := make([]*Backend, len(addresses))
|
|
||||||
for i, addr := range addresses {
|
|
||||||
backends[i] = newBackend(addr, maxSlots, maxQueue)
|
|
||||||
}
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,182 +0,0 @@
|
|||||||
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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
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"])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -476,214 +476,6 @@ func TestNoFullBuffering(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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,
|
// TestClientDisconnectCancelsUpstream verifies that when a client closes mid-stream,
|
||||||
// the upstream request context is cancelled immediately and no goroutines are leaked.
|
// the upstream request context is cancelled immediately and no goroutines are leaked.
|
||||||
func TestClientDisconnectCancelsUpstream(t *testing.T) {
|
func TestClientDisconnectCancelsUpstream(t *testing.T) {
|
||||||
|
|||||||
@@ -0,0 +1,484 @@
|
|||||||
|
// Package proxy provides request routing and forwarding.
|
||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WorkflowRequest represents a workflow execution request
|
||||||
|
type WorkflowRequest struct {
|
||||||
|
// Workflow ID or name
|
||||||
|
Workflow string `json:"workflow"`
|
||||||
|
|
||||||
|
// Input parameters for the workflow
|
||||||
|
Input map[string]interface{} `json:"input"`
|
||||||
|
|
||||||
|
// Optional: timeout in seconds
|
||||||
|
Timeout int `json:"timeout,omitempty"`
|
||||||
|
|
||||||
|
// Optional: wait for result (default: true)
|
||||||
|
Wait *bool `json:"wait,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// WorkflowResponse represents the response from workflow execution
|
||||||
|
type WorkflowResponse struct {
|
||||||
|
// Workflow execution ID
|
||||||
|
ID string `json:"id"`
|
||||||
|
|
||||||
|
// Workflow name
|
||||||
|
Workflow string `json:"workflow"`
|
||||||
|
|
||||||
|
// Execution status: pending, running, completed, failed
|
||||||
|
Status string `json:"status"`
|
||||||
|
|
||||||
|
// Output of the workflow
|
||||||
|
Output interface{} `json:"output,omitempty"`
|
||||||
|
|
||||||
|
// Error message if workflow failed
|
||||||
|
Error string `json:"error,omitempty"`
|
||||||
|
|
||||||
|
// Timestamp when workflow was created
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
|
||||||
|
// Timestamp when workflow completed
|
||||||
|
CompletedAt *time.Time `json:"completed_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// PredefinedWorkflow defines a workflow template that combines multiple API calls
|
||||||
|
type PredefinedWorkflow struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Handler func(*http.Request, *Handler, map[string]interface{}) (interface{}, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleWorkflow handles the /workflows endpoint
|
||||||
|
// It accepts workflow definitions and orchestrates API calls
|
||||||
|
func (h *Handler) handleWorkflow(w http.ResponseWriter, r *http.Request) {
|
||||||
|
// Only POST is supported
|
||||||
|
if r.Method != "POST" {
|
||||||
|
w.Header().Set("Content-Type", "application/problem+json")
|
||||||
|
w.WriteHeader(http.StatusMethodNotAllowed)
|
||||||
|
fmt.Fprintf(w, `{"type":"https://api.example.com/problems/method-not-allowed","title":"Method Not Allowed","status":405,"detail":"Only POST is supported for /workflows"}`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse request body
|
||||||
|
var workflowReq WorkflowRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&workflowReq); err != nil {
|
||||||
|
writeProblemDetail(w, http.StatusBadRequest, "https://api.example.com/problems/invalid-workflow-request", "Invalid Workflow Request", "Failed to parse workflow request: "+err.Error(), nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate workflow name
|
||||||
|
if workflowReq.Workflow == "" {
|
||||||
|
writeProblemDetail(w, http.StatusBadRequest, "https://api.example.com/problems/missing-workflow", "Missing Workflow", "The 'workflow' field is required", nil)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get predefined workflow
|
||||||
|
workflow, ok := h.getWorkflow(workflowReq.Workflow)
|
||||||
|
if !ok {
|
||||||
|
availableWorkflows := h.getAvailableWorkflows()
|
||||||
|
writeProblemDetail(w, http.StatusBadRequest, "https://api.example.com/problems/unknown-workflow", "Unknown Workflow", fmt.Sprintf("Workflow %q is not available", workflowReq.Workflow), availableWorkflows)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default wait to true
|
||||||
|
wait := true
|
||||||
|
if workflowReq.Wait != nil {
|
||||||
|
wait = *workflowReq.Wait
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set default timeout if not provided
|
||||||
|
timeout := time.Duration(30) * time.Second
|
||||||
|
if workflowReq.Timeout > 0 {
|
||||||
|
timeout = time.Duration(workflowReq.Timeout) * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a context with timeout for workflow execution
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), timeout)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Execute workflow
|
||||||
|
output, err := workflow.Handler(r.WithContext(ctx), h, workflowReq.Input)
|
||||||
|
|
||||||
|
// Build response
|
||||||
|
workflowResp := WorkflowResponse{
|
||||||
|
ID: generateWorkflowID(),
|
||||||
|
Workflow: workflowReq.Workflow,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
workflowResp.Status = "failed"
|
||||||
|
workflowResp.Error = err.Error()
|
||||||
|
} else {
|
||||||
|
if wait {
|
||||||
|
workflowResp.Status = "completed"
|
||||||
|
workflowResp.Output = output
|
||||||
|
now := time.Now()
|
||||||
|
workflowResp.CompletedAt = &now
|
||||||
|
} else {
|
||||||
|
workflowResp.Status = "pending"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write response
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
if err != nil {
|
||||||
|
w.WriteHeader(http.StatusInternalServerError)
|
||||||
|
} else {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
}
|
||||||
|
json.NewEncoder(w).Encode(workflowResp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// getWorkflow returns a predefined workflow by name
|
||||||
|
func (h *Handler) getWorkflow(name string) (*PredefinedWorkflow, bool) {
|
||||||
|
workflows := h.getPredefinedWorkflows()
|
||||||
|
for _, wf := range workflows {
|
||||||
|
if wf.Name == name {
|
||||||
|
return &wf, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPredefinedWorkflows returns all available workflows
|
||||||
|
func (h *Handler) getPredefinedWorkflows() []PredefinedWorkflow {
|
||||||
|
return []PredefinedWorkflow{
|
||||||
|
{
|
||||||
|
Name: "chat-and-embed",
|
||||||
|
Description: "Chat with a model and then embed the response",
|
||||||
|
Handler: h.chatAndEmbedWorkflow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "multi-model-chat",
|
||||||
|
Description: "Chat with multiple models sequentially",
|
||||||
|
Handler: h.multiModelChatWorkflow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "rag-pipeline",
|
||||||
|
Description: "RAG pipeline: embed query, rerank, then chat with context",
|
||||||
|
Handler: h.ragPipelineWorkflow,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "batch-embeddings",
|
||||||
|
Description: "Generate embeddings for multiple texts",
|
||||||
|
Handler: h.batchEmbeddingsWorkflow,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// getAvailableWorkflows returns a list of available workflow names
|
||||||
|
func (h *Handler) getAvailableWorkflows() []string {
|
||||||
|
workflows := h.getPredefinedWorkflows()
|
||||||
|
names := make([]string, len(workflows))
|
||||||
|
for i, wf := range workflows {
|
||||||
|
names[i] = wf.Name
|
||||||
|
}
|
||||||
|
return names
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workflow implementations
|
||||||
|
|
||||||
|
// chatAndEmbedWorkflow: Chat with a model, then embed the response
|
||||||
|
func (h *Handler) chatAndEmbedWorkflow(r *http.Request, handler *Handler, input map[string]interface{}) (interface{}, error) {
|
||||||
|
model, ok := input["model"].(string)
|
||||||
|
if !ok || model == "" {
|
||||||
|
return nil, fmt.Errorf("missing required parameter: model")
|
||||||
|
}
|
||||||
|
|
||||||
|
embedModel, ok := input["embed_model"].(string)
|
||||||
|
if !ok {
|
||||||
|
embedModel = "nomic-ai/nomic-embed-text-v2-moe"
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, ok := input["messages"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing required parameter: messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Chat
|
||||||
|
chatReq := map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
chatBody, _ := json.Marshal(chatReq)
|
||||||
|
chatHTTPReq, _ := http.NewRequest("POST", "/v1/chat/completions", io.NopCloser(bytes.NewReader(chatBody)))
|
||||||
|
chatHTTPReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
// Create a response writer to capture the chat response
|
||||||
|
chatResp := &responseCapture{}
|
||||||
|
handler.ServeHTTP(chatResp, chatHTTPReq)
|
||||||
|
|
||||||
|
var chatResult map[string]interface{}
|
||||||
|
if err := json.Unmarshal(chatResp.body.Bytes(), &chatResult); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse chat response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract message content
|
||||||
|
var messageContent string
|
||||||
|
if choices, ok := chatResult["choices"].([]interface{}); ok && len(choices) > 0 {
|
||||||
|
if choice, ok := choices[0].(map[string]interface{}); ok {
|
||||||
|
if message, ok := choice["message"].(map[string]interface{}); ok {
|
||||||
|
if content, ok := message["content"].(string); ok {
|
||||||
|
messageContent = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Embed the response
|
||||||
|
embedReq := map[string]interface{}{
|
||||||
|
"model": embedModel,
|
||||||
|
"input": messageContent,
|
||||||
|
}
|
||||||
|
|
||||||
|
embedBody, _ := json.Marshal(embedReq)
|
||||||
|
embedHTTPReq, _ := http.NewRequest("POST", "/v1/embeddings", io.NopCloser(bytes.NewReader(embedBody)))
|
||||||
|
embedHTTPReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
embedResp := &responseCapture{}
|
||||||
|
handler.ServeHTTP(embedResp, embedHTTPReq)
|
||||||
|
|
||||||
|
var embedResult map[string]interface{}
|
||||||
|
if err := json.Unmarshal(embedResp.body.Bytes(), &embedResult); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse embedding response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"chat_response": chatResult,
|
||||||
|
"embedding_response": embedResult,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// multiModelChatWorkflow: Chat with multiple models sequentially
|
||||||
|
func (h *Handler) multiModelChatWorkflow(r *http.Request, handler *Handler, input map[string]interface{}) (interface{}, error) {
|
||||||
|
models, ok := input["models"].([]interface{})
|
||||||
|
if !ok || len(models) == 0 {
|
||||||
|
return nil, fmt.Errorf("missing required parameter: models (array)")
|
||||||
|
}
|
||||||
|
|
||||||
|
messages, ok := input["messages"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing required parameter: messages")
|
||||||
|
}
|
||||||
|
|
||||||
|
results := make([]map[string]interface{}, 0)
|
||||||
|
|
||||||
|
for _, modelInterface := range models {
|
||||||
|
model, ok := modelInterface.(string)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
chatReq := map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"messages": messages,
|
||||||
|
}
|
||||||
|
|
||||||
|
chatBody, _ := json.Marshal(chatReq)
|
||||||
|
chatHTTPReq, _ := http.NewRequest("POST", "/v1/chat/completions", io.NopCloser(bytes.NewReader(chatBody)))
|
||||||
|
chatHTTPReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
chatResp := &responseCapture{}
|
||||||
|
handler.ServeHTTP(chatResp, chatHTTPReq)
|
||||||
|
|
||||||
|
var chatResult map[string]interface{}
|
||||||
|
if err := json.Unmarshal(chatResp.body.Bytes(), &chatResult); err != nil {
|
||||||
|
results = append(results, map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"error": err.Error(),
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
results = append(results, map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"result": chatResult,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return results, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ragPipelineWorkflow: RAG pipeline - embed query, rerank, chat with context
|
||||||
|
func (h *Handler) ragPipelineWorkflow(r *http.Request, handler *Handler, input map[string]interface{}) (interface{}, error) {
|
||||||
|
query, ok := input["query"].(string)
|
||||||
|
if !ok || query == "" {
|
||||||
|
return nil, fmt.Errorf("missing required parameter: query")
|
||||||
|
}
|
||||||
|
|
||||||
|
documents, ok := input["documents"].([]interface{})
|
||||||
|
if !ok {
|
||||||
|
return nil, fmt.Errorf("missing required parameter: documents")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, ok := input["model"].(string)
|
||||||
|
if !ok {
|
||||||
|
model = "reasoning"
|
||||||
|
}
|
||||||
|
|
||||||
|
rerankModel, ok := input["rerank_model"].(string)
|
||||||
|
if !ok {
|
||||||
|
rerankModel = "BAAI/bge-reranker-base"
|
||||||
|
}
|
||||||
|
|
||||||
|
topK := 3
|
||||||
|
if tk, ok := input["top_k"].(float64); ok {
|
||||||
|
topK = int(tk)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 1: Rerank documents based on query
|
||||||
|
rerankReq := map[string]interface{}{
|
||||||
|
"model": rerankModel,
|
||||||
|
"query": query,
|
||||||
|
"texts": documents,
|
||||||
|
"top_k": topK,
|
||||||
|
}
|
||||||
|
|
||||||
|
rerankBody, _ := json.Marshal(rerankReq)
|
||||||
|
rerankHTTPReq, _ := http.NewRequest("POST", "/v1/rerank", io.NopCloser(bytes.NewReader(rerankBody)))
|
||||||
|
rerankHTTPReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
rerankResp := &responseCapture{}
|
||||||
|
handler.ServeHTTP(rerankResp, rerankHTTPReq)
|
||||||
|
|
||||||
|
var rerankResult map[string]interface{}
|
||||||
|
if err := json.Unmarshal(rerankResp.body.Bytes(), &rerankResult); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse rerank response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract top documents
|
||||||
|
var topDocs []string
|
||||||
|
if results, ok := rerankResult["results"].([]interface{}); ok {
|
||||||
|
for i, resultInterface := range results {
|
||||||
|
if i >= topK {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if result, ok := resultInterface.(map[string]interface{}); ok {
|
||||||
|
if text, ok := result["text"].(string); ok {
|
||||||
|
topDocs = append(topDocs, text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: Chat with context
|
||||||
|
context := fmt.Sprintf("Context from documents:\n%v\n\nQuery: %s", topDocs, query)
|
||||||
|
|
||||||
|
chatReq := map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"messages": []interface{}{
|
||||||
|
map[string]interface{}{
|
||||||
|
"role": "user",
|
||||||
|
"content": context,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
chatBody, _ := json.Marshal(chatReq)
|
||||||
|
chatHTTPReq, _ := http.NewRequest("POST", "/v1/chat/completions", io.NopCloser(bytes.NewReader(chatBody)))
|
||||||
|
chatHTTPReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
chatResp := &responseCapture{}
|
||||||
|
handler.ServeHTTP(chatResp, chatHTTPReq)
|
||||||
|
|
||||||
|
var chatResult map[string]interface{}
|
||||||
|
if err := json.Unmarshal(chatResp.body.Bytes(), &chatResult); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse chat response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"reranked_documents": topDocs,
|
||||||
|
"chat_response": chatResult,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// batchEmbeddingsWorkflow: Generate embeddings for multiple texts
|
||||||
|
func (h *Handler) batchEmbeddingsWorkflow(r *http.Request, handler *Handler, input map[string]interface{}) (interface{}, error) {
|
||||||
|
texts, ok := input["texts"].([]interface{})
|
||||||
|
if !ok || len(texts) == 0 {
|
||||||
|
return nil, fmt.Errorf("missing required parameter: texts (array)")
|
||||||
|
}
|
||||||
|
|
||||||
|
model, ok := input["model"].(string)
|
||||||
|
if !ok {
|
||||||
|
model = "nomic-ai/nomic-embed-text-v2-moe"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert interface{} to []string
|
||||||
|
textStrings := make([]string, 0)
|
||||||
|
for _, t := range texts {
|
||||||
|
if str, ok := t.(string); ok {
|
||||||
|
textStrings = append(textStrings, str)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(textStrings) == 0 {
|
||||||
|
return nil, fmt.Errorf("no valid text strings in texts array")
|
||||||
|
}
|
||||||
|
|
||||||
|
embedReq := map[string]interface{}{
|
||||||
|
"model": model,
|
||||||
|
"input": textStrings,
|
||||||
|
}
|
||||||
|
|
||||||
|
embedBody, _ := json.Marshal(embedReq)
|
||||||
|
embedHTTPReq, _ := http.NewRequest("POST", "/v1/embeddings", io.NopCloser(bytes.NewReader(embedBody)))
|
||||||
|
embedHTTPReq.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
embedResp := &responseCapture{}
|
||||||
|
handler.ServeHTTP(embedResp, embedHTTPReq)
|
||||||
|
|
||||||
|
var embedResult map[string]interface{}
|
||||||
|
if err := json.Unmarshal(embedResp.body.Bytes(), &embedResult); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse embedding response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return embedResult, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
|
||||||
|
// responseCapture captures HTTP response for reuse within workflows
|
||||||
|
type responseCapture struct {
|
||||||
|
status int
|
||||||
|
header http.Header
|
||||||
|
body bytes.Buffer
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *responseCapture) Header() http.Header {
|
||||||
|
if w.header == nil {
|
||||||
|
w.header = make(http.Header)
|
||||||
|
}
|
||||||
|
return w.header
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *responseCapture) Write(b []byte) (int, error) {
|
||||||
|
if w.status == 0 {
|
||||||
|
w.status = http.StatusOK
|
||||||
|
}
|
||||||
|
return w.body.Write(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *responseCapture) WriteHeader(statusCode int) {
|
||||||
|
if w.status == 0 {
|
||||||
|
w.status = statusCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// generateWorkflowID generates a unique workflow execution ID
|
||||||
|
func generateWorkflowID() string {
|
||||||
|
return fmt.Sprintf("wf_%d", time.Now().UnixNano())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
package proxy
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWorkflowEndpointNotFound(t *testing.T) {
|
||||||
|
// Create a minimal config
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
Models: map[string]*config.ModelUpstream{
|
||||||
|
"reasoning": {
|
||||||
|
Address: "localhost:8001",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := New(cfg)
|
||||||
|
|
||||||
|
// Test POST /workflows with unknown workflow
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"workflow": "unknown-workflow",
|
||||||
|
"input": map[string]interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := json.Marshal(body)
|
||||||
|
req := httptest.NewRequest("POST", "/workflows", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
|
||||||
|
if response["type"] != "https://api.example.com/problems/unknown-workflow" {
|
||||||
|
t.Errorf("Expected unknown-workflow error, got %v", response["type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowEndpointMissingWorkflow(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
Models: make(map[string]*config.ModelUpstream),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := New(cfg)
|
||||||
|
|
||||||
|
// Test POST /workflows with missing workflow field
|
||||||
|
body := map[string]interface{}{
|
||||||
|
"input": map[string]interface{}{},
|
||||||
|
}
|
||||||
|
|
||||||
|
bodyBytes, _ := json.Marshal(body)
|
||||||
|
req := httptest.NewRequest("POST", "/workflows", bytes.NewReader(bodyBytes))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
json.Unmarshal(w.Body.Bytes(), &response)
|
||||||
|
|
||||||
|
if response["type"] != "https://api.example.com/problems/missing-workflow" {
|
||||||
|
t.Errorf("Expected missing-workflow error, got %v", response["type"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowEndpointInvalidMethod(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
Models: make(map[string]*config.ModelUpstream),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := New(cfg)
|
||||||
|
|
||||||
|
// Test GET /workflows (should be 405)
|
||||||
|
req := httptest.NewRequest("GET", "/workflows", nil)
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusMethodNotAllowed {
|
||||||
|
t.Errorf("Expected 405, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowEndpointInvalidJSON(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
Models: make(map[string]*config.ModelUpstream),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := New(cfg)
|
||||||
|
|
||||||
|
// Test POST /workflows with invalid JSON
|
||||||
|
req := httptest.NewRequest("POST", "/workflows", bytes.NewReader([]byte("not json")))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
handler.ServeHTTP(w, req)
|
||||||
|
|
||||||
|
if w.Code != http.StatusBadRequest {
|
||||||
|
t.Errorf("Expected 400, got %d", w.Code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAvailableWorkflows(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
Models: make(map[string]*config.ModelUpstream),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := New(cfg)
|
||||||
|
|
||||||
|
workflows := handler.getAvailableWorkflows()
|
||||||
|
|
||||||
|
expectedWorkflows := []string{
|
||||||
|
"chat-and-embed",
|
||||||
|
"multi-model-chat",
|
||||||
|
"rag-pipeline",
|
||||||
|
"batch-embeddings",
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(workflows) != len(expectedWorkflows) {
|
||||||
|
t.Errorf("Expected %d workflows, got %d", len(expectedWorkflows), len(workflows))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check that all expected workflows are present
|
||||||
|
for _, expected := range expectedWorkflows {
|
||||||
|
found := false
|
||||||
|
for _, actual := range workflows {
|
||||||
|
if actual == expected {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Errorf("Expected workflow %q not found", expected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetWorkflow(t *testing.T) {
|
||||||
|
cfg := &config.Config{
|
||||||
|
Routes: make(map[string]*config.Route),
|
||||||
|
Models: make(map[string]*config.ModelUpstream),
|
||||||
|
}
|
||||||
|
|
||||||
|
handler := New(cfg)
|
||||||
|
|
||||||
|
// Test getting a valid workflow
|
||||||
|
workflow, ok := handler.getWorkflow("chat-and-embed")
|
||||||
|
if !ok {
|
||||||
|
t.Error("Expected to find chat-and-embed workflow")
|
||||||
|
}
|
||||||
|
if workflow.Name != "chat-and-embed" {
|
||||||
|
t.Errorf("Expected workflow name chat-and-embed, got %s", workflow.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test getting an invalid workflow
|
||||||
|
workflow, ok = handler.getWorkflow("invalid-workflow")
|
||||||
|
if ok {
|
||||||
|
t.Error("Expected not to find invalid-workflow")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGenerateWorkflowID(t *testing.T) {
|
||||||
|
id1 := generateWorkflowID()
|
||||||
|
id2 := generateWorkflowID()
|
||||||
|
|
||||||
|
if id1 == id2 {
|
||||||
|
t.Error("Generated workflow IDs should be unique")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !bytes.HasPrefix([]byte(id1), []byte("wf_")) {
|
||||||
|
t.Errorf("Workflow ID should start with 'wf_', got %s", id1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestResponseCapture(t *testing.T) {
|
||||||
|
rc := &responseCapture{}
|
||||||
|
|
||||||
|
// Test Header
|
||||||
|
rc.Header().Set("X-Test", "value")
|
||||||
|
if rc.Header().Get("X-Test") != "value" {
|
||||||
|
t.Error("Header not set correctly")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test Write
|
||||||
|
n, err := rc.Write([]byte("test content"))
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
if n != 12 {
|
||||||
|
t.Errorf("Expected 12 bytes written, got %d", n)
|
||||||
|
}
|
||||||
|
if rc.body.String() != "test content" {
|
||||||
|
t.Errorf("Expected 'test content', got %s", rc.body.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test WriteHeader
|
||||||
|
rc.WriteHeader(http.StatusOK)
|
||||||
|
if rc.status != http.StatusOK {
|
||||||
|
t.Errorf("Expected status 200, got %d", rc.status)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test WriteHeader doesn't override
|
||||||
|
rc.WriteHeader(http.StatusInternalServerError)
|
||||||
|
if rc.status != http.StatusOK {
|
||||||
|
t.Error("WriteHeader should not override existing status")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWorkflowResponseSerialization(t *testing.T) {
|
||||||
|
resp := WorkflowResponse{
|
||||||
|
ID: "wf_123",
|
||||||
|
Workflow: "test-workflow",
|
||||||
|
Status: "completed",
|
||||||
|
Output: map[string]interface{}{
|
||||||
|
"key": "value",
|
||||||
|
},
|
||||||
|
Error: "",
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := json.Marshal(resp)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("Failed to marshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var unmarshaled WorkflowResponse
|
||||||
|
if err := json.Unmarshal(data, &unmarshaled); err != nil {
|
||||||
|
t.Errorf("Failed to unmarshal response: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if unmarshaled.ID != resp.ID {
|
||||||
|
t.Errorf("Expected ID %s, got %s", resp.ID, unmarshaled.ID)
|
||||||
|
}
|
||||||
|
if unmarshaled.Workflow != resp.Workflow {
|
||||||
|
t.Errorf("Expected Workflow %s, got %s", resp.Workflow, unmarshaled.Workflow)
|
||||||
|
}
|
||||||
|
if unmarshaled.Status != resp.Status {
|
||||||
|
t.Errorf("Expected Status %s, got %s", resp.Status, unmarshaled.Status)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ import (
|
|||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/serviceadapter"
|
"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,
|
||||||
@@ -15,8 +14,6 @@ type Router struct {
|
|||||||
dispatcher *serviceadapter.Dispatcher
|
dispatcher *serviceadapter.Dispatcher
|
||||||
temporalHandler http.Handler
|
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.
|
||||||
@@ -30,15 +27,9 @@ func NewRouter(healthChecker *HealthChecker, dispatcher *serviceadapter.Dispatch
|
|||||||
dispatcher: dispatcher,
|
dispatcher: dispatcher,
|
||||||
temporalHandler: temporalHandler,
|
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.
|
||||||
// Priority order:
|
// Priority order:
|
||||||
// 1. /healthz and /readyz to health handlers
|
// 1. /healthz and /readyz to health handlers
|
||||||
@@ -54,11 +45,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
case "/readyz":
|
case "/readyz":
|
||||||
ReadinessHandler(r.healthChecker)(w, req)
|
ReadinessHandler(r.healthChecker)(w, req)
|
||||||
return
|
return
|
||||||
case "/metrics":
|
|
||||||
if r.metricsHandler != nil {
|
|
||||||
r.metricsHandler.ServeHTTP(w, req)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// X-Service (ServiceAdapter) routing - checked before path-based routing
|
// X-Service (ServiceAdapter) routing - checked before path-based routing
|
||||||
@@ -71,12 +57,6 @@ func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Forgejo webhook receiver — no auth, HMAC-verified by handler
|
|
||||||
if req.URL.Path == "/v1/webhooks/forgejo" {
|
|
||||||
r.forgejoWebhook.ServeHTTP(w, req)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Workflow endpoints
|
// Workflow endpoints
|
||||||
// DEPRECATED: Path-based /workflow routing is legacy.
|
// DEPRECATED: Path-based /workflow routing is legacy.
|
||||||
// New clients should use X-Service: workflow header instead for consistent auth.
|
// New clients should use X-Service: workflow header instead for consistent auth.
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ 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.
|
||||||
@@ -21,7 +19,8 @@ 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{
|
return &Server{
|
||||||
|
httpServer: &http.Server{
|
||||||
Addr: listenAddr,
|
Addr: listenAddr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
// ReadHeaderTimeout (not ReadTimeout) and a long WriteTimeout: both
|
// ReadHeaderTimeout (not ReadTimeout) and a long WriteTimeout: both
|
||||||
@@ -33,18 +32,7 @@ func New(listenAddr string, shutdownTimeout time.Duration, handler http.Handler)
|
|||||||
ReadHeaderTimeout: 15 * time.Second,
|
ReadHeaderTimeout: 15 * time.Second,
|
||||||
WriteTimeout: 1 * time.Hour,
|
WriteTimeout: 1 * time.Hour,
|
||||||
IdleTimeout: 60 * time.Second,
|
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{
|
|
||||||
httpServer: httpServer,
|
|
||||||
shutdownTimeout: shutdownTimeout,
|
shutdownTimeout: shutdownTimeout,
|
||||||
healthChecker: NewHealthChecker(false, false),
|
healthChecker: NewHealthChecker(false, false),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
package serviceadapter
|
package serviceadapter
|
||||||
|
|
||||||
|
// WorkflowAdapter handles X-Service: workflow requests.
|
||||||
|
type WorkflowAdapter struct{}
|
||||||
|
|
||||||
// SQSAdapter handles X-Service: sqs requests.
|
// SQSAdapter handles X-Service: sqs requests.
|
||||||
type SQSAdapter struct{}
|
type SQSAdapter struct{}
|
||||||
|
|
||||||
@@ -28,9 +31,6 @@ func AdapterFactory(serviceName string) interface{} {
|
|||||||
return &IAMAdapter{}
|
return &IAMAdapter{}
|
||||||
case "memory":
|
case "memory":
|
||||||
return &MemoryAdapter{}
|
return &MemoryAdapter{}
|
||||||
case "sessions":
|
|
||||||
// code-registered; see NewSessionAdapter()
|
|
||||||
return nil
|
|
||||||
default:
|
default:
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,67 +0,0 @@
|
|||||||
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"},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,100 +0,0 @@
|
|||||||
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"},
|
|
||||||
}},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -16,7 +16,6 @@ import (
|
|||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/logging"
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/problem"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,25 +74,12 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// JWT auth enforcement for adapters that require it
|
// JWT auth enforcement for adapters that require it
|
||||||
if adapter.Spec.Auth.Required {
|
if adapter.Spec.Auth.Required && d.jwtValidator != nil {
|
||||||
if d.jwtValidator == nil {
|
if !d.authenticate(w, r, serviceName, method.Verb) {
|
||||||
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
|
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
|
upstreamURL := adapter.Spec.Upstream.URL
|
||||||
if strings.HasPrefix(upstreamURL, "grpc://") {
|
if strings.HasPrefix(upstreamURL, "grpc://") {
|
||||||
d.dispatchGRPC(w, r, upstreamURL, method, adapter)
|
d.dispatchGRPC(w, r, upstreamURL, method, adapter)
|
||||||
@@ -104,7 +90,7 @@ func (d *Dispatcher) Dispatch(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
// authenticate validates the JWT and checks service-level capability.
|
// authenticate validates the JWT and checks service-level capability.
|
||||||
// Returns false (and writes error response) if auth fails.
|
// Returns false (and writes error response) if auth fails.
|
||||||
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName string, method *Method) bool {
|
func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, serviceName, verb string) bool {
|
||||||
authHeader := r.Header.Get("Authorization")
|
authHeader := r.Header.Get("Authorization")
|
||||||
if authHeader == "" {
|
if authHeader == "" {
|
||||||
d.writeError(w, problem.NewProblem(http.StatusUnauthorized,
|
d.writeError(w, problem.NewProblem(http.StatusUnauthorized,
|
||||||
@@ -121,11 +107,8 @@ func (d *Dispatcher) authenticate(w http.ResponseWriter, r *http.Request, servic
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check capability: method-level Auth.Capability if set; else derive from verb.
|
// Check capability: <service>:read for GET/HEAD, <service>:write for mutating verbs
|
||||||
required := capabilityForVerb(serviceName, method.Verb)
|
required := capabilityForVerb(serviceName, verb)
|
||||||
if method.Auth != nil && method.Auth.Capability != "" {
|
|
||||||
required = method.Auth.Capability
|
|
||||||
}
|
|
||||||
if !d.jwtValidator.CheckPermissions(claims, required, "*") {
|
if !d.jwtValidator.CheckPermissions(claims, required, "*") {
|
||||||
d.writeError(w, problem.NewProblem(http.StatusForbidden,
|
d.writeError(w, problem.NewProblem(http.StatusForbidden,
|
||||||
"about:blank#insufficient-permissions", "Insufficient Permissions",
|
"about:blank#insufficient-permissions", "Insufficient Permissions",
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
package serviceadapter
|
package serviceadapter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"crypto/rand"
|
|
||||||
"crypto/rsa"
|
|
||||||
"encoding/base64"
|
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"math/big"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/golang-jwt/jwt/v5"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/auth"
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
"forgejo.riotpiao.com/rock/homelab-frontend/internal/identity"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -271,197 +263,3 @@ func TestDispatch_IdentityHeadersNotSet_WhenNoAuth(t *testing.T) {
|
|||||||
t.Errorf("X-Auth-Verified should not be set without auth, got %q", 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())
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,107 +0,0 @@
|
|||||||
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"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
package serviceadapter
|
package serviceadapter
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"net/http"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -50,8 +49,6 @@ type Status struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ServiceAdapter is a gateway service adapter.
|
// 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 {
|
type ServiceAdapter struct {
|
||||||
Name string // namespace/name
|
Name string // namespace/name
|
||||||
Namespace string
|
Namespace string
|
||||||
@@ -59,5 +56,4 @@ type ServiceAdapter struct {
|
|||||||
Spec Spec
|
Spec Spec
|
||||||
Status Status
|
Status Status
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
Handler http.Handler `json:"-" yaml:"-"` // internal handler (skip serialization)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,377 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,511 +0,0 @@
|
|||||||
// 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"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"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).
|
|
||||||
type Handler struct {
|
|
||||||
graphqlURL string
|
|
||||||
client *http.Client
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
return &Handler{
|
|
||||||
graphqlURL: gql,
|
|
||||||
client: &http.Client{Timeout: 30 * time.Second, Transport: tracing.NewTransport(nil)},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
|
|
||||||
gqlQuery := `query($q:String!,$limit:Int!){
|
|
||||||
hybridSearch(query:$q,limit:$limit,entityType:SESSION){
|
|
||||||
entities{id name summary 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
@@ -1,589 +0,0 @@
|
|||||||
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
|
|
||||||
// - "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"):
|
|
||||||
fmt.Fprint(w, `{"data":{"hybridSearch":{"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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+404
-498
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,95 @@
|
|||||||
|
// Package temporal provides operation wrappers for Temporal operations
|
||||||
|
package temporal
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// OperationHandler handles Temporal operations
|
||||||
|
type OperationHandler struct {
|
||||||
|
grpc *GRPCClient
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewOperationHandler creates a new operation handler
|
||||||
|
func NewOperationHandler(grpcClient *GRPCClient) *OperationHandler {
|
||||||
|
return &OperationHandler{
|
||||||
|
grpc: grpcClient,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartWorkflowExecution starts a new workflow execution
|
||||||
|
func (oh *OperationHandler) StartWorkflowExecution(ctx context.Context, namespace, workflowID, workflowType, taskQueue string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
// TODO: Implement gRPC call to Temporal
|
||||||
|
// This is a placeholder for actual implementation
|
||||||
|
if oh.grpc == nil {
|
||||||
|
return nil, fmt.Errorf("gRPC client not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
return map[string]interface{}{
|
||||||
|
"workflow_id": workflowID,
|
||||||
|
"run_id": fmt.Sprintf("run_%d", time.Now().UnixNano()),
|
||||||
|
"start_time": time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DescribeWorkflowExecution gets workflow details
|
||||||
|
func (oh *OperationHandler) DescribeWorkflowExecution(ctx context.Context, namespace, workflowID, runID string) (map[string]interface{}, error) {
|
||||||
|
// TODO: Implement gRPC call to Temporal
|
||||||
|
return map[string]interface{}{
|
||||||
|
"workflow_id": workflowID,
|
||||||
|
"run_id": runID,
|
||||||
|
"status": "RUNNING",
|
||||||
|
"start_time": time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// TerminateWorkflowExecution terminates a workflow
|
||||||
|
func (oh *OperationHandler) TerminateWorkflowExecution(ctx context.Context, namespace, workflowID, runID, reason string) (map[string]interface{}, error) {
|
||||||
|
// TODO: Implement gRPC call to Temporal
|
||||||
|
return map[string]interface{}{
|
||||||
|
"workflow_id": workflowID,
|
||||||
|
"run_id": runID,
|
||||||
|
"terminated_at": time.Now(),
|
||||||
|
"reason": reason,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// CancelWorkflowExecution cancels a workflow
|
||||||
|
func (oh *OperationHandler) CancelWorkflowExecution(ctx context.Context, namespace, workflowID, runID string) (map[string]interface{}, error) {
|
||||||
|
// TODO: Implement gRPC call to Temporal
|
||||||
|
return map[string]interface{}{
|
||||||
|
"workflow_id": workflowID,
|
||||||
|
"status": "canceling",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SignalWorkflowExecution sends a signal to a workflow
|
||||||
|
func (oh *OperationHandler) SignalWorkflowExecution(ctx context.Context, namespace, workflowID, runID, signalName string, input map[string]interface{}) (map[string]interface{}, error) {
|
||||||
|
// TODO: Implement gRPC call to Temporal
|
||||||
|
return map[string]interface{}{
|
||||||
|
"workflow_id": workflowID,
|
||||||
|
"signal_name": signalName,
|
||||||
|
"signaled_at": time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// QueryWorkflowExecution queries a workflow
|
||||||
|
func (oh *OperationHandler) QueryWorkflowExecution(ctx context.Context, namespace, workflowID, runID, queryType string) (map[string]interface{}, error) {
|
||||||
|
// TODO: Implement gRPC call to Temporal
|
||||||
|
return map[string]interface{}{
|
||||||
|
"workflow_id": workflowID,
|
||||||
|
"query_type": queryType,
|
||||||
|
"query_result": map[string]interface{}{},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListWorkflowExecutions lists workflows
|
||||||
|
func (oh *OperationHandler) ListWorkflowExecutions(ctx context.Context, namespace string, pageSize int32) (map[string]interface{}, error) {
|
||||||
|
// TODO: Implement gRPC call to Temporal
|
||||||
|
return map[string]interface{}{
|
||||||
|
"executions": []interface{}{},
|
||||||
|
"next_page_token": "",
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
@@ -1,329 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
package temporal
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"net/http"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// SSEEvent represents a Server-Sent Event
|
|
||||||
type SSEEvent struct {
|
|
||||||
Event string `json:"event"`
|
|
||||||
Data interface{} `json:"data"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// StreamWorkflowEvents streams workflow execution history as SSE events.
|
|
||||||
// Polls Temporal GetWorkflowExecutionHistory every 2s and sends new events.
|
|
||||||
// Sends heartbeat every 15s. Closes on workflow completion or client disconnect.
|
|
||||||
func (h *Handler) StreamWorkflowEvents(w http.ResponseWriter, r *http.Request) {
|
|
||||||
// Only GET allowed
|
|
||||||
if r.Method != http.MethodGet {
|
|
||||||
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract workflow_id from query params
|
|
||||||
workflowID := r.URL.Query().Get("workflow_id")
|
|
||||||
if workflowID == "" {
|
|
||||||
http.Error(w, `{"error":"workflow_id query parameter required"}`, http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runID := r.URL.Query().Get("run_id")
|
|
||||||
namespace := r.URL.Query().Get("namespace")
|
|
||||||
if namespace == "" {
|
|
||||||
namespace = "default"
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check gRPC client
|
|
||||||
if h.grpcClient == nil {
|
|
||||||
http.Error(w, `{"error":"Temporal server not available"}`, http.StatusServiceUnavailable)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set SSE headers
|
|
||||||
flusher, ok := w.(http.Flusher)
|
|
||||||
if !ok {
|
|
||||||
http.Error(w, "Streaming not supported", http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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") // disable nginx buffering
|
|
||||||
|
|
||||||
// Use request context for cancellation (client disconnect)
|
|
||||||
ctx := r.Context()
|
|
||||||
|
|
||||||
var lastEventID int64
|
|
||||||
pollTicker := time.NewTicker(2 * time.Second)
|
|
||||||
heartbeatTicker := time.NewTicker(15 * time.Second)
|
|
||||||
defer pollTicker.Stop()
|
|
||||||
defer heartbeatTicker.Stop()
|
|
||||||
|
|
||||||
// Send initial connected event
|
|
||||||
writeSSE(w, flusher, "connected", map[string]interface{}{
|
|
||||||
"workflow_id": workflowID,
|
|
||||||
"run_id": runID,
|
|
||||||
"namespace": namespace,
|
|
||||||
"timestamp": time.Now().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
// Client disconnected
|
|
||||||
return
|
|
||||||
|
|
||||||
case <-heartbeatTicker.C:
|
|
||||||
writeSSE(w, flusher, "heartbeat", map[string]string{
|
|
||||||
"timestamp": time.Now().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
|
|
||||||
case <-pollTicker.C:
|
|
||||||
events, workflowDone, err := h.pollNewEvents(ctx, namespace, workflowID, runID, lastEventID)
|
|
||||||
if err != nil {
|
|
||||||
writeSSE(w, flusher, "error", map[string]string{
|
|
||||||
"error": err.Error(),
|
|
||||||
"timestamp": time.Now().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, evt := range events {
|
|
||||||
writeSSE(w, flusher, evt.Event, evt.Data)
|
|
||||||
if evtData, ok := evt.Data.(map[string]interface{}); ok {
|
|
||||||
if id, ok := evtData["event_id"]; ok {
|
|
||||||
if idInt, ok := id.(int64); ok {
|
|
||||||
if idInt > lastEventID {
|
|
||||||
lastEventID = idInt
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if workflowDone {
|
|
||||||
writeSSE(w, flusher, "workflow_completed", map[string]string{
|
|
||||||
"workflow_id": workflowID,
|
|
||||||
"timestamp": time.Now().Format(time.RFC3339),
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// pollNewEvents fetches workflow history from Temporal and returns new events since lastEventID.
|
|
||||||
// Returns (events, workflowDone, error).
|
|
||||||
func (h *Handler) pollNewEvents(ctx context.Context, namespace, workflowID, runID string, lastEventID int64) ([]SSEEvent, bool, error) {
|
|
||||||
pollCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
grpcImpl := NewWorkflowGRPCImpl(h.grpcClient)
|
|
||||||
result, err := grpcImpl.GetWorkflowExecutionHistory(pollCtx, namespace, workflowID, runID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, false, fmt.Errorf("failed to get history: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
eventsRaw, ok := result["events"]
|
|
||||||
if !ok {
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
events, ok := eventsRaw.([]map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return nil, false, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
var sseEvents []SSEEvent
|
|
||||||
workflowDone := false
|
|
||||||
|
|
||||||
for _, evt := range events {
|
|
||||||
eventID, _ := evt["event_id"].(int64)
|
|
||||||
if eventID <= lastEventID {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// operations_grpc.go stores event type under "type" key
|
|
||||||
eventType, _ := evt["type"].(string)
|
|
||||||
sseEvent := mapTemporalEvent(eventType, evt)
|
|
||||||
if sseEvent != nil {
|
|
||||||
sseEvents = append(sseEvents, *sseEvent)
|
|
||||||
}
|
|
||||||
|
|
||||||
if isTerminalEvent(eventType) {
|
|
||||||
workflowDone = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return sseEvents, workflowDone, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// mapTemporalEvent converts a Temporal history event to an SSE event.
|
|
||||||
func mapTemporalEvent(eventType string, evt map[string]interface{}) *SSEEvent {
|
|
||||||
data := map[string]interface{}{
|
|
||||||
"event_id": evt["event_id"],
|
|
||||||
"event_type": eventType,
|
|
||||||
"timestamp": evt["timestamp"],
|
|
||||||
}
|
|
||||||
|
|
||||||
switch {
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_ACTIVITY_TASK_SCHEDULED"):
|
|
||||||
return &SSEEvent{Event: "activity_scheduled", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_ACTIVITY_TASK_STARTED"):
|
|
||||||
return &SSEEvent{Event: "activity_started", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_ACTIVITY_TASK_COMPLETED"):
|
|
||||||
return &SSEEvent{Event: "activity_completed", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_ACTIVITY_TASK_FAILED"):
|
|
||||||
return &SSEEvent{Event: "activity_failed", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_ACTIVITY_TASK_TIMED_OUT"):
|
|
||||||
return &SSEEvent{Event: "activity_timed_out", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_WORKFLOW_EXECUTION_STARTED"):
|
|
||||||
return &SSEEvent{Event: "workflow_started", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_WORKFLOW_EXECUTION_COMPLETED"):
|
|
||||||
return &SSEEvent{Event: "workflow_completed", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_WORKFLOW_EXECUTION_FAILED"):
|
|
||||||
return &SSEEvent{Event: "workflow_failed", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_WORKFLOW_EXECUTION_TIMED_OUT"):
|
|
||||||
return &SSEEvent{Event: "workflow_timed_out", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_WORKFLOW_EXECUTION_CANCELED"):
|
|
||||||
return &SSEEvent{Event: "workflow_canceled", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_WORKFLOW_EXECUTION_TERMINATED"):
|
|
||||||
return &SSEEvent{Event: "workflow_terminated", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_CHILD_WORKFLOW"):
|
|
||||||
return &SSEEvent{Event: "child_workflow", Data: data}
|
|
||||||
case strings.Contains(eventType, "EVENT_TYPE_WORKFLOW_EXECUTION_SIGNALED"):
|
|
||||||
return &SSEEvent{Event: "workflow_signaled", Data: data}
|
|
||||||
default:
|
|
||||||
// Skip internal/uninteresting events (timer, decision task, etc.)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// isTerminalEvent returns true if the event type indicates workflow completion.
|
|
||||||
func isTerminalEvent(eventType string) bool {
|
|
||||||
return strings.Contains(eventType, "COMPLETED") ||
|
|
||||||
strings.Contains(eventType, "FAILED") ||
|
|
||||||
strings.Contains(eventType, "TIMED_OUT") ||
|
|
||||||
strings.Contains(eventType, "CANCELED") ||
|
|
||||||
strings.Contains(eventType, "TERMINATED")
|
|
||||||
}
|
|
||||||
|
|
||||||
// writeSSE writes a single SSE event to the response writer.
|
|
||||||
func writeSSE(w http.ResponseWriter, flusher http.Flusher, event string, data interface{}) {
|
|
||||||
jsonData, err := json.Marshal(data)
|
|
||||||
if err != nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
fmt.Fprintf(w, "event: %s\ndata: %s\n\n", event, string(jsonData))
|
|
||||||
flusher.Flush()
|
|
||||||
}
|
|
||||||
@@ -1,302 +0,0 @@
|
|||||||
package webhook
|
|
||||||
|
|
||||||
import (
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha256"
|
|
||||||
"encoding/hex"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"forgejo.riotpiao.com/rock/homelab-frontend/internal/notification"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ForgejoHandler receives Forgejo webhook payloads and forwards them to Gotify.
|
|
||||||
// Forgejo sends a Gitea-compatible JSON payload with X-Gitea-Signature-256 header.
|
|
||||||
type ForgejoHandler struct {
|
|
||||||
secret string
|
|
||||||
gotify *notification.GotifyClient
|
|
||||||
promoter *ImagePromoter
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewForgejoHandler creates a handler from environment variables.
|
|
||||||
// Required: GOTIFY_URL, GOTIFY_APP_TOKEN
|
|
||||||
// Optional: FORGEJO_WEBHOOK_SECRET (if empty, HMAC verification is skipped)
|
|
||||||
func NewForgejoHandler() *ForgejoHandler {
|
|
||||||
gotifyURL := os.Getenv("GOTIFY_URL")
|
|
||||||
appToken := os.Getenv("GOTIFY_APP_TOKEN")
|
|
||||||
|
|
||||||
var client *notification.GotifyClient
|
|
||||||
if gotifyURL != "" && appToken != "" {
|
|
||||||
client = notification.NewGotifyClient(gotifyURL, appToken, "")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ForgejoHandler{
|
|
||||||
secret: os.Getenv("FORGEJO_WEBHOOK_SECRET"),
|
|
||||||
gotify: client,
|
|
||||||
promoter: NewImagePromoter(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ServeHTTP handles POST /v1/webhooks/forgejo
|
|
||||||
func (h *ForgejoHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
||||||
if r.Method != http.MethodPost {
|
|
||||||
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
body, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) // 1MB limit
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "failed to read body", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify HMAC if secret is set
|
|
||||||
if h.secret != "" {
|
|
||||||
sig := r.Header.Get("X-Gitea-Signature-256")
|
|
||||||
if sig == "" {
|
|
||||||
sig = r.Header.Get("X-Hub-Signature-256")
|
|
||||||
}
|
|
||||||
if !h.verifySignature(body, sig) {
|
|
||||||
http.Error(w, "invalid signature", http.StatusForbidden)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
event := r.Header.Get("X-Gitea-Event")
|
|
||||||
if event == "" {
|
|
||||||
event = r.Header.Get("X-GitHub-Event")
|
|
||||||
}
|
|
||||||
|
|
||||||
// package events trigger image promotion; handled independently of Gotify
|
|
||||||
if event == "package" {
|
|
||||||
h.handlePackagePromotion(w, r, body)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.gotify == nil {
|
|
||||||
log.Printf("forgejo webhook received but Gotify not configured (GOTIFY_URL/GOTIFY_APP_TOKEN missing)")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
title, message, priority := h.formatMessage(event, body)
|
|
||||||
if title == "" {
|
|
||||||
// Unhandled event type — ack and ignore
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
msg := notification.GotifyMessage{
|
|
||||||
Title: title,
|
|
||||||
Message: message,
|
|
||||||
Priority: priority,
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := h.gotify.SendMessage(msg); err != nil {
|
|
||||||
log.Printf("forgejo webhook: failed to send gotify message: %v", err)
|
|
||||||
http.Error(w, "failed to send notification", http.StatusBadGateway)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("forgejo webhook: sent gotify notification event=%s title=%q", event, title)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// handlePackagePromotion processes a Forgejo package webhook event.
|
|
||||||
// It promotes container images by patching ArgoCD Application CRs.
|
|
||||||
// Sends a Gotify notification with the result (success or failure).
|
|
||||||
func (h *ForgejoHandler) handlePackagePromotion(w http.ResponseWriter, r *http.Request, body []byte) {
|
|
||||||
// Generate a short request ID for correlating log lines from concurrent promotions.
|
|
||||||
reqID := fmt.Sprintf("%08x", time.Now().UnixNano()&0xFFFFFFFF)
|
|
||||||
|
|
||||||
var payload map[string]interface{}
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
log.Printf("forgejo webhook [%s]: package event: invalid JSON: %v", reqID, err)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
action := fmt.Sprintf("%v", payload["action"])
|
|
||||||
if action != "created" {
|
|
||||||
// Ignore deletions and other actions
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
pkg, _ := payload["package"].(map[string]interface{})
|
|
||||||
pkgType := fmt.Sprintf("%v", pkg["type"])
|
|
||||||
if pkgType != "container" {
|
|
||||||
// Only promote container images
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
owner := jsonStr(pkg, "owner", "login")
|
|
||||||
name, _ := pkg["name"].(string)
|
|
||||||
version, _ := pkg["version"].(string)
|
|
||||||
|
|
||||||
if owner == "" || name == "" || version == "" {
|
|
||||||
log.Printf("forgejo webhook [%s]: package event: missing owner/name/version in payload", reqID)
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
image := fmt.Sprintf("forgejo.riotpiao.com/%s/%s", owner, name)
|
|
||||||
log.Printf("forgejo webhook [%s]: package event action=%s image=%s tag=%s", reqID, action, image, version)
|
|
||||||
|
|
||||||
var title, message string
|
|
||||||
var priority int
|
|
||||||
|
|
||||||
if h.promoter == nil {
|
|
||||||
title = fmt.Sprintf("📦 Image pushed: %s:%s", image, version)
|
|
||||||
message = "Image promotion disabled — REGISTRY_PAT not configured"
|
|
||||||
priority = 3
|
|
||||||
log.Printf("forgejo webhook [%s]: promoter disabled, image=%s tag=%s", reqID, image, version)
|
|
||||||
} else if err := h.promoter.Promote(r.Context(), image, version); err != nil {
|
|
||||||
title = fmt.Sprintf("❌ Promotion failed: %s:%s", image, version)
|
|
||||||
message = err.Error()
|
|
||||||
priority = 8
|
|
||||||
log.Printf("forgejo webhook [%s]: image promotion failed: %v", reqID, err)
|
|
||||||
} else {
|
|
||||||
title = fmt.Sprintf("🚀 Promoted: %s:%s", image, version)
|
|
||||||
message = "ArgoCD Application patched — redeployment triggered"
|
|
||||||
priority = 5
|
|
||||||
log.Printf("forgejo webhook [%s]: promoted image=%s tag=%s", reqID, image, version)
|
|
||||||
}
|
|
||||||
|
|
||||||
if h.gotify != nil && title != "" {
|
|
||||||
if _, err := h.gotify.SendMessage(notification.GotifyMessage{
|
|
||||||
Title: title,
|
|
||||||
Message: message,
|
|
||||||
Priority: priority,
|
|
||||||
}); err != nil {
|
|
||||||
log.Printf("forgejo webhook: failed to send gotify notification: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
|
|
||||||
// verifySignature checks X-Gitea-Signature-256: sha256=<hex>
|
|
||||||
func (h *ForgejoHandler) verifySignature(body []byte, sig string) bool {
|
|
||||||
sig = strings.TrimPrefix(sig, "sha256=")
|
|
||||||
if sig == "" {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
mac := hmac.New(sha256.New, []byte(h.secret))
|
|
||||||
mac.Write(body)
|
|
||||||
expected := hex.EncodeToString(mac.Sum(nil))
|
|
||||||
return hmac.Equal([]byte(sig), []byte(expected))
|
|
||||||
}
|
|
||||||
|
|
||||||
// formatMessage converts a Forgejo event payload into a Gotify title + message.
|
|
||||||
// Returns empty title if the event should be ignored.
|
|
||||||
func (h *ForgejoHandler) formatMessage(event string, body []byte) (title, message string, priority int) {
|
|
||||||
var payload map[string]interface{}
|
|
||||||
if err := json.Unmarshal(body, &payload); err != nil {
|
|
||||||
return "", "", 0
|
|
||||||
}
|
|
||||||
|
|
||||||
repo := jsonStr(payload, "repository", "full_name")
|
|
||||||
sender := jsonStr(payload, "sender", "login")
|
|
||||||
|
|
||||||
switch event {
|
|
||||||
case "push":
|
|
||||||
ref := strings.TrimPrefix(fmt.Sprintf("%v", payload["ref"]), "refs/heads/")
|
|
||||||
commits, _ := payload["commits"].([]interface{})
|
|
||||||
count := len(commits)
|
|
||||||
commitMsg := ""
|
|
||||||
if count > 0 {
|
|
||||||
if c, ok := commits[0].(map[string]interface{}); ok {
|
|
||||||
commitMsg = fmt.Sprintf("%v", c["message"])
|
|
||||||
// truncate long commit messages
|
|
||||||
if len(commitMsg) > 80 {
|
|
||||||
commitMsg = commitMsg[:80] + "…"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("📦 %s", repo),
|
|
||||||
fmt.Sprintf("%s pushed %d commit(s) to %s\n%s", sender, count, ref, commitMsg),
|
|
||||||
5
|
|
||||||
|
|
||||||
case "pull_request":
|
|
||||||
action := fmt.Sprintf("%v", payload["action"])
|
|
||||||
if action != "opened" && action != "closed" && action != "reopened" && action != "merged" {
|
|
||||||
return "", "", 0 // ignore noise (labeled, assigned, etc.)
|
|
||||||
}
|
|
||||||
pr, _ := payload["pull_request"].(map[string]interface{})
|
|
||||||
number := fmt.Sprintf("%v", pr["number"])
|
|
||||||
prTitle := fmt.Sprintf("%v", pr["title"])
|
|
||||||
merged, _ := pr["merged"].(bool)
|
|
||||||
if action == "closed" && merged {
|
|
||||||
action = "merged"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("🔀 PR #%s %s — %s", number, action, repo),
|
|
||||||
fmt.Sprintf("%s: %s\nby %s", action, prTitle, sender),
|
|
||||||
5
|
|
||||||
|
|
||||||
case "issues":
|
|
||||||
action := fmt.Sprintf("%v", payload["action"])
|
|
||||||
if action != "opened" && action != "closed" && action != "reopened" {
|
|
||||||
return "", "", 0
|
|
||||||
}
|
|
||||||
issue, _ := payload["issue"].(map[string]interface{})
|
|
||||||
number := fmt.Sprintf("%v", issue["number"])
|
|
||||||
issueTitle := fmt.Sprintf("%v", issue["title"])
|
|
||||||
return fmt.Sprintf("🐛 Issue #%s %s — %s", number, action, repo),
|
|
||||||
fmt.Sprintf("%s: %s\nby %s", action, issueTitle, sender),
|
|
||||||
4
|
|
||||||
|
|
||||||
case "issue_comment", "pull_request_review_comment":
|
|
||||||
issue, _ := payload["issue"].(map[string]interface{})
|
|
||||||
comment, _ := payload["comment"].(map[string]interface{})
|
|
||||||
number := fmt.Sprintf("%v", issue["number"])
|
|
||||||
body := fmt.Sprintf("%v", comment["body"])
|
|
||||||
if len(body) > 100 {
|
|
||||||
body = body[:100] + "…"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf("💬 Comment on #%s — %s", number, repo),
|
|
||||||
fmt.Sprintf("%s: %s", sender, body),
|
|
||||||
3
|
|
||||||
|
|
||||||
case "release":
|
|
||||||
action := fmt.Sprintf("%v", payload["action"])
|
|
||||||
if action != "published" {
|
|
||||||
return "", "", 0
|
|
||||||
}
|
|
||||||
release, _ := payload["release"].(map[string]interface{})
|
|
||||||
tag := fmt.Sprintf("%v", release["tag_name"])
|
|
||||||
name := fmt.Sprintf("%v", release["name"])
|
|
||||||
return fmt.Sprintf("🚀 Release %s — %s", tag, repo),
|
|
||||||
fmt.Sprintf("%s published by %s", name, sender),
|
|
||||||
7
|
|
||||||
|
|
||||||
default:
|
|
||||||
return "", "", 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// jsonStr safely traverses nested map keys.
|
|
||||||
func jsonStr(m map[string]interface{}, keys ...string) string {
|
|
||||||
cur := m
|
|
||||||
for i, k := range keys {
|
|
||||||
v, ok := cur[k]
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if i == len(keys)-1 {
|
|
||||||
return fmt.Sprintf("%v", v)
|
|
||||||
}
|
|
||||||
cur, ok = v.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
@@ -1,424 +0,0 @@
|
|||||||
// Package webhook — ImagePromoter patches ArgoCD Application CRs when a
|
|
||||||
// Forgejo container package webhook fires. It replaces ArgoCD Image Updater
|
|
||||||
// with a webhook-driven flow:
|
|
||||||
//
|
|
||||||
// 1. Forgejo fires X-Gitea-Event: package (action: created, type: container)
|
|
||||||
// 2. ForgejoHandler calls ImagePromoter.Promote(image, tag)
|
|
||||||
// 3. Promoter fetches the digest from Forgejo registry v2 API
|
|
||||||
// 4. Promoter patches the ArgoCD Application CR via the k8s API server
|
|
||||||
// using the in-cluster ServiceAccount token.
|
|
||||||
//
|
|
||||||
// No external dependencies — raw HTTP only.
|
|
||||||
package webhook
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/tls"
|
|
||||||
"crypto/x509"
|
|
||||||
"encoding/json"
|
|
||||||
"fmt"
|
|
||||||
"io"
|
|
||||||
"log"
|
|
||||||
"net/http"
|
|
||||||
"os"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
k8sAPIHost = "https://kubernetes.default.svc"
|
|
||||||
argoNamespace = "argocd"
|
|
||||||
saTokenPath = "/var/run/secrets/kubernetes.io/serviceaccount/token"
|
|
||||||
saCACertPath = "/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"
|
|
||||||
defaultRegBase = "http://forgejo-gitea-http.cicd.svc.cluster.local:3000"
|
|
||||||
defaultRegUser = "rock"
|
|
||||||
promoterTimeout = 20 * time.Second
|
|
||||||
)
|
|
||||||
|
|
||||||
// promotionEntry maps a fully-qualified container image to an ArgoCD Application.
|
|
||||||
type promotionEntry struct {
|
|
||||||
Image string // e.g. "forgejo.riotpiao.com/rock/api-gateway"
|
|
||||||
Application string // ArgoCD Application name in namespace argocd
|
|
||||||
SourceIndex int // index into spec.sources[]; -1 = spec.source (singular, helm)
|
|
||||||
Type string // "kustomize" or "helm"
|
|
||||||
}
|
|
||||||
|
|
||||||
// promotionTable is the static image-to-application mapping.
|
|
||||||
// Order matters for poimen-toolkit: two images share the same source index.
|
|
||||||
var promotionTable = []promotionEntry{
|
|
||||||
{"forgejo.riotpiao.com/rock/api-gateway", "api-gw", 0, "kustomize"},
|
|
||||||
{"forgejo.riotpiao.com/rock/portfolio", "portfolio", 0, "kustomize"},
|
|
||||||
{"forgejo.riotpiao.com/riotpiao-poimen/poimen-workflows", "poimen", 0, "kustomize"},
|
|
||||||
{"forgejo.riotpiao.com/riotpiao-poimen/poimen-memory", "poimen", 1, "kustomize"},
|
|
||||||
// sourceIndex 2 in poimen is a non-container source (chart/config repo) — not promoted.
|
|
||||||
{"forgejo.riotpiao.com/riotpiao-poimen/poimen-frontend", "poimen", 3, "kustomize"},
|
|
||||||
{"forgejo.riotpiao.com/riotpiao-poimen/poimen-toolkit-controller", "poimen-toolkit", 0, "kustomize"},
|
|
||||||
{"forgejo.riotpiao.com/riotpiao-poimen/pi-worker", "poimen-toolkit", 0, "kustomize"},
|
|
||||||
{"forgejo.riotpiao.com/rock/forgejo-runner-golang", "forgejo-runner-golang", -1, "helm"},
|
|
||||||
{"forgejo.riotpiao.com/rock/forgejo-runner-node", "forgejo-runner-node", -1, "helm"},
|
|
||||||
{"forgejo.riotpiao.com/rock/forgejo-runner-rust", "forgejo-runner-rust", -1, "helm"},
|
|
||||||
}
|
|
||||||
|
|
||||||
// ImagePromoter fetches digests and patches ArgoCD Applications.
|
|
||||||
type ImagePromoter struct {
|
|
||||||
registryBase string
|
|
||||||
registryUser string
|
|
||||||
registryPAT string
|
|
||||||
k8sHost string // k8s API server base URL; defaults to k8sAPIHost
|
|
||||||
k8sClient *http.Client // mTLS with SA CA cert
|
|
||||||
regClient *http.Client // plain HTTP to internal Forgejo
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewImagePromoter builds an ImagePromoter from environment and in-cluster SA files.
|
|
||||||
// Returns nil (not an error) when REGISTRY_PAT is empty — promoter is disabled.
|
|
||||||
func NewImagePromoter() *ImagePromoter {
|
|
||||||
pat := os.Getenv("REGISTRY_PAT")
|
|
||||||
if pat == "" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
regBase := os.Getenv("REGISTRY_BASE")
|
|
||||||
if regBase == "" {
|
|
||||||
regBase = defaultRegBase
|
|
||||||
}
|
|
||||||
regUser := os.Getenv("REGISTRY_USER")
|
|
||||||
if regUser == "" {
|
|
||||||
regUser = defaultRegUser
|
|
||||||
}
|
|
||||||
|
|
||||||
caCert, _ := os.ReadFile(saCACertPath)
|
|
||||||
return &ImagePromoter{
|
|
||||||
registryBase: regBase,
|
|
||||||
registryUser: regUser,
|
|
||||||
registryPAT: pat,
|
|
||||||
k8sClient: buildK8sClient(caCert),
|
|
||||||
regClient: &http.Client{Timeout: promoterTimeout},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildK8sClient creates an HTTP client that trusts the in-cluster CA cert.
|
|
||||||
func buildK8sClient(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("image-promoter: WARNING SA CA cert not found at %s — using InsecureSkipVerify (dev mode only)", saCACertPath)
|
|
||||||
transport.TLSClientConfig = &tls.Config{InsecureSkipVerify: true} //nolint:gosec
|
|
||||||
}
|
|
||||||
return &http.Client{Timeout: promoterTimeout, Transport: transport}
|
|
||||||
}
|
|
||||||
|
|
||||||
// saToken reads the current in-cluster ServiceAccount token.
|
|
||||||
// Re-reads on every call to handle token rotation.
|
|
||||||
func saToken() string {
|
|
||||||
b, err := os.ReadFile(saTokenPath)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("image-promoter: WARNING failed to read SA token %s: %v", saTokenPath, err)
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(string(b))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Promote fetches the digest for image:tag and patches the corresponding
|
|
||||||
// ArgoCD Application CR. Returns an error if the image is unknown.
|
|
||||||
// Retries up to 3 times on k8s 409 Conflict (optimistic concurrency for apps
|
|
||||||
// like poimen-toolkit that share a source index across multiple images).
|
|
||||||
func (p *ImagePromoter) Promote(ctx context.Context, image, tag string) error {
|
|
||||||
entry := lookupEntry(image)
|
|
||||||
if entry == nil {
|
|
||||||
return fmt.Errorf("image %q not in promotion table — skipping", image)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Parse owner/name from "forgejo.riotpiao.com/owner/name"
|
|
||||||
withoutHost := strings.TrimPrefix(image, "forgejo.riotpiao.com/")
|
|
||||||
parts := strings.SplitN(withoutHost, "/", 2)
|
|
||||||
if len(parts) != 2 {
|
|
||||||
return fmt.Errorf("cannot parse owner/name from image %q", image)
|
|
||||||
}
|
|
||||||
owner, name := parts[0], parts[1]
|
|
||||||
|
|
||||||
digest, err := p.fetchDigest(ctx, owner, name, tag)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("fetch digest for %s:%s: %w", image, tag, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("image-promoter: digest %s → %s application=%s sourceIndex=%d",
|
|
||||||
image, digest, entry.Application, entry.SourceIndex)
|
|
||||||
|
|
||||||
// Retry on 409 Conflict: concurrent promotions for the same (app, sourceIndex)
|
|
||||||
// (e.g. poimen-toolkit-controller and pi-worker) can race on the same source slot.
|
|
||||||
for attempt := 1; attempt <= 3; attempt++ {
|
|
||||||
err = p.patchApplication(ctx, *entry, image, digest)
|
|
||||||
if err == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "409") || attempt == 3 {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
log.Printf("image-promoter: 409 conflict on PATCH %s (attempt %d/3), retrying",
|
|
||||||
entry.Application, attempt)
|
|
||||||
select {
|
|
||||||
case <-time.After(time.Duration(attempt) * 150 * time.Millisecond):
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ctx.Err()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// lookupEntry returns the promotion entry for the given image, or nil if not found.
|
|
||||||
func lookupEntry(image string) *promotionEntry {
|
|
||||||
for i := range promotionTable {
|
|
||||||
if promotionTable[i].Image == image {
|
|
||||||
return &promotionTable[i]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// fetchDigest queries the Forgejo OCI registry v2 API for the manifest digest.
|
|
||||||
// Uses HTTP Basic auth with the configured PAT.
|
|
||||||
func (p *ImagePromoter) fetchDigest(ctx context.Context, owner, name, tag string) (string, error) {
|
|
||||||
url := fmt.Sprintf("%s/v2/%s/%s/manifests/%s", p.registryBase, owner, name, tag)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodHead, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("build request: %w", err)
|
|
||||||
}
|
|
||||||
req.SetBasicAuth(p.registryUser, p.registryPAT)
|
|
||||||
req.Header.Add("Accept", "application/vnd.docker.distribution.manifest.v2+json")
|
|
||||||
req.Header.Add("Accept", "application/vnd.oci.image.manifest.v1+json")
|
|
||||||
|
|
||||||
resp, err := p.regClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("http: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
return "", fmt.Errorf("registry returned %d for %s/%s:%s", resp.StatusCode, owner, name, tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
digest := resp.Header.Get("Docker-Content-Digest")
|
|
||||||
if digest == "" {
|
|
||||||
return "", fmt.Errorf("Docker-Content-Digest header missing from registry response")
|
|
||||||
}
|
|
||||||
return digest, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// patchApplication performs a read-modify-write on the ArgoCD Application CR.
|
|
||||||
// It reads the current spec to preserve existing image entries, updates the
|
|
||||||
// relevant entry, then sends a JSON Patch to the k8s API server.
|
|
||||||
func (p *ImagePromoter) patchApplication(ctx context.Context, entry promotionEntry, image, digest string) error {
|
|
||||||
app, err := p.getApplication(ctx, entry.Application)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("get application %s: %w", entry.Application, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var patch []map[string]any
|
|
||||||
|
|
||||||
switch entry.Type {
|
|
||||||
case "kustomize":
|
|
||||||
images := getKustomizeImages(app, entry.SourceIndex)
|
|
||||||
newImages := updateImageEntry(images, image, digest)
|
|
||||||
// Patch the full kustomize block (not just /kustomize/images) so the
|
|
||||||
// op succeeds even when the kustomize key is absent from the source.
|
|
||||||
// These apps only use kustomize overrides for image pinning, so
|
|
||||||
// replacing the whole block is safe.
|
|
||||||
patch = []map[string]any{{
|
|
||||||
"op": "add",
|
|
||||||
"path": fmt.Sprintf("/spec/sources/%d/kustomize", entry.SourceIndex),
|
|
||||||
"value": map[string]any{"images": newImages},
|
|
||||||
}}
|
|
||||||
|
|
||||||
case "helm":
|
|
||||||
// Helm runners use spec.source (singular). Patch helm parameters
|
|
||||||
// to set runner.image.repository and runner.image.tag with digest.
|
|
||||||
existing := getHelmParameters(app)
|
|
||||||
updates := []map[string]string{
|
|
||||||
{"name": "runner.image.repository", "value": image},
|
|
||||||
{"name": "runner.image.tag", "value": "latest@" + digest},
|
|
||||||
}
|
|
||||||
merged := mergeHelmParams(existing, updates)
|
|
||||||
patch = []map[string]any{
|
|
||||||
// "add" works whether parameters key exists or not (replace fails on absent key)
|
|
||||||
{"op": "add", "path": "/spec/source/helm/parameters", "value": merged},
|
|
||||||
}
|
|
||||||
|
|
||||||
default:
|
|
||||||
return fmt.Errorf("unknown promotion type %q for %s", entry.Type, entry.Application)
|
|
||||||
}
|
|
||||||
|
|
||||||
return p.applyJSONPatch(ctx, entry.Application, patch)
|
|
||||||
}
|
|
||||||
|
|
||||||
// k8sBase returns the configured k8s host or the default in-cluster address.
|
|
||||||
func (p *ImagePromoter) k8sBase() string {
|
|
||||||
if p.k8sHost != "" {
|
|
||||||
return p.k8sHost
|
|
||||||
}
|
|
||||||
return k8sAPIHost
|
|
||||||
}
|
|
||||||
|
|
||||||
// getApplication fetches the current ArgoCD Application CR from the k8s API.
|
|
||||||
func (p *ImagePromoter) getApplication(ctx context.Context, name string) (map[string]any, error) {
|
|
||||||
url := fmt.Sprintf("%s/apis/argoproj.io/v1alpha1/namespaces/%s/applications/%s",
|
|
||||||
p.k8sBase(), argoNamespace, name)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("build request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Authorization", "Bearer "+saToken())
|
|
||||||
|
|
||||||
resp, err := p.k8sClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("http: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode != http.StatusOK {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
||||||
return nil, fmt.Errorf("k8s GET returned %d: %s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
|
|
||||||
var app map[string]any
|
|
||||||
if err := json.NewDecoder(resp.Body).Decode(&app); err != nil {
|
|
||||||
return nil, fmt.Errorf("decode: %w", err)
|
|
||||||
}
|
|
||||||
return app, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// applyJSONPatch sends RFC 6902 JSON Patch to the ArgoCD Application.
|
|
||||||
func (p *ImagePromoter) applyJSONPatch(ctx context.Context, appName string, patch []map[string]any) error {
|
|
||||||
patchBytes, err := json.Marshal(patch)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("marshal patch: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
url := fmt.Sprintf("%s/apis/argoproj.io/v1alpha1/namespaces/%s/applications/%s",
|
|
||||||
p.k8sBase(), argoNamespace, appName)
|
|
||||||
|
|
||||||
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, url, bytes.NewReader(patchBytes))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("build request: %w", err)
|
|
||||||
}
|
|
||||||
req.Header.Set("Content-Type", "application/json-patch+json")
|
|
||||||
req.Header.Set("Authorization", "Bearer "+saToken())
|
|
||||||
|
|
||||||
resp, err := p.k8sClient.Do(req)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("http: %w", err)
|
|
||||||
}
|
|
||||||
defer resp.Body.Close()
|
|
||||||
|
|
||||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
|
||||||
body, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
|
|
||||||
return fmt.Errorf("k8s PATCH returned %d: %s", resp.StatusCode, body)
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── spec helpers ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
// getKustomizeImages extracts spec.sources[N].kustomize.images.
|
|
||||||
// Returns nil if the path does not exist.
|
|
||||||
func getKustomizeImages(app map[string]any, sourceIndex int) []string {
|
|
||||||
spec, _ := app["spec"].(map[string]any)
|
|
||||||
sources, _ := spec["sources"].([]any)
|
|
||||||
if sourceIndex < 0 || sourceIndex >= len(sources) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
src, _ := sources[sourceIndex].(map[string]any)
|
|
||||||
kust, _ := src["kustomize"].(map[string]any)
|
|
||||||
return toStringSlice(kust["images"])
|
|
||||||
}
|
|
||||||
|
|
||||||
// getHelmParameters extracts spec.source.helm.parameters.
|
|
||||||
func getHelmParameters(app map[string]any) []map[string]string {
|
|
||||||
spec, _ := app["spec"].(map[string]any)
|
|
||||||
source, _ := spec["source"].(map[string]any)
|
|
||||||
helm, _ := source["helm"].(map[string]any)
|
|
||||||
raw, _ := helm["parameters"].([]any)
|
|
||||||
|
|
||||||
params := make([]map[string]string, 0, len(raw))
|
|
||||||
for _, item := range raw {
|
|
||||||
m, ok := item.(map[string]any)
|
|
||||||
if !ok {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
p := map[string]string{}
|
|
||||||
if n, ok := m["name"].(string); ok {
|
|
||||||
p["name"] = n
|
|
||||||
}
|
|
||||||
if v, ok := m["value"].(string); ok {
|
|
||||||
p["value"] = v
|
|
||||||
}
|
|
||||||
params = append(params, p)
|
|
||||||
}
|
|
||||||
return params
|
|
||||||
}
|
|
||||||
|
|
||||||
// updateImageEntry replaces the entry for image in the images slice, or appends
|
|
||||||
// if not present. The new entry format is "{image}@{digest}".
|
|
||||||
// Matching is by image name (prefix before ':' or '@').
|
|
||||||
func updateImageEntry(images []string, image, digest string) []string {
|
|
||||||
newEntry := image + "@" + digest
|
|
||||||
for i, img := range images {
|
|
||||||
// Strip tag/digest suffix to get the bare name
|
|
||||||
bare := img
|
|
||||||
if idx := strings.IndexAny(img, ":@"); idx >= 0 {
|
|
||||||
bare = img[:idx]
|
|
||||||
}
|
|
||||||
if bare == image {
|
|
||||||
result := make([]string, len(images))
|
|
||||||
copy(result, images)
|
|
||||||
result[i] = newEntry
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return append(images, newEntry)
|
|
||||||
}
|
|
||||||
|
|
||||||
// mergeHelmParams merges update params into existing, preserving unrelated params.
|
|
||||||
func mergeHelmParams(existing, updates []map[string]string) []map[string]string {
|
|
||||||
idx := make(map[string]string, len(updates))
|
|
||||||
for _, u := range updates {
|
|
||||||
idx[u["name"]] = u["value"]
|
|
||||||
}
|
|
||||||
|
|
||||||
result := make([]map[string]string, 0, len(existing)+len(updates))
|
|
||||||
replaced := make(map[string]bool)
|
|
||||||
for _, p := range existing {
|
|
||||||
if val, ok := idx[p["name"]]; ok {
|
|
||||||
result = append(result, map[string]string{"name": p["name"], "value": val})
|
|
||||||
replaced[p["name"]] = true
|
|
||||||
} else {
|
|
||||||
result = append(result, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, u := range updates {
|
|
||||||
if !replaced[u["name"]] {
|
|
||||||
result = append(result, u)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// toStringSlice converts []any to []string, skipping non-strings.
|
|
||||||
func toStringSlice(v any) []string {
|
|
||||||
arr, ok := v.([]any)
|
|
||||||
if !ok {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
result := make([]string, 0, len(arr))
|
|
||||||
for _, item := range arr {
|
|
||||||
if s, ok := item.(string); ok {
|
|
||||||
result = append(result, s)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
@@ -1,405 +0,0 @@
|
|||||||
package webhook
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"io"
|
|
||||||
"net/http"
|
|
||||||
"net/http/httptest"
|
|
||||||
"strings"
|
|
||||||
"sync/atomic"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ── unit: updateImageEntry ────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestUpdateImageEntry(t *testing.T) {
|
|
||||||
t.Run("replaces existing by bare name strips tag", func(t *testing.T) {
|
|
||||||
images := []string{"forgejo.riotpiao.com/rock/api-gateway:old"}
|
|
||||||
got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new")
|
|
||||||
if len(got) != 1 || got[0] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" {
|
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("replaces existing by bare name strips digest", func(t *testing.T) {
|
|
||||||
images := []string{"forgejo.riotpiao.com/rock/api-gateway@sha256:old"}
|
|
||||||
got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new")
|
|
||||||
if len(got) != 1 || got[0] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" {
|
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("appends new entry when image not in list", func(t *testing.T) {
|
|
||||||
images := []string{"forgejo.riotpiao.com/rock/other:latest"}
|
|
||||||
got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new")
|
|
||||||
if len(got) != 2 || got[1] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" {
|
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("preserves other entries when updating one", func(t *testing.T) {
|
|
||||||
images := []string{
|
|
||||||
"forgejo.riotpiao.com/rock/other:1.0",
|
|
||||||
"forgejo.riotpiao.com/rock/api-gateway:old",
|
|
||||||
"forgejo.riotpiao.com/rock/third:2.0",
|
|
||||||
}
|
|
||||||
got := updateImageEntry(images, "forgejo.riotpiao.com/rock/api-gateway", "sha256:new")
|
|
||||||
if len(got) != 3 {
|
|
||||||
t.Fatalf("expected 3 entries got %d: %v", len(got), got)
|
|
||||||
}
|
|
||||||
if got[0] != "forgejo.riotpiao.com/rock/other:1.0" {
|
|
||||||
t.Errorf("first entry changed: %s", got[0])
|
|
||||||
}
|
|
||||||
if got[1] != "forgejo.riotpiao.com/rock/api-gateway@sha256:new" {
|
|
||||||
t.Errorf("second entry wrong: %s", got[1])
|
|
||||||
}
|
|
||||||
if got[2] != "forgejo.riotpiao.com/rock/third:2.0" {
|
|
||||||
t.Errorf("third entry changed: %s", got[2])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── unit: mergeHelmParams ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestMergeHelmParams(t *testing.T) {
|
|
||||||
t.Run("update existing param preserve unrelated", func(t *testing.T) {
|
|
||||||
existing := []map[string]string{
|
|
||||||
{"name": "runner.image.tag", "value": "old"},
|
|
||||||
{"name": "replicas", "value": "2"},
|
|
||||||
}
|
|
||||||
updates := []map[string]string{
|
|
||||||
{"name": "runner.image.tag", "value": "new"},
|
|
||||||
}
|
|
||||||
got := mergeHelmParams(existing, updates)
|
|
||||||
find := func(name string) string {
|
|
||||||
for _, p := range got {
|
|
||||||
if p["name"] == name {
|
|
||||||
return p["value"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
if find("runner.image.tag") != "new" {
|
|
||||||
t.Errorf("tag not updated: %v", got)
|
|
||||||
}
|
|
||||||
if find("replicas") != "2" {
|
|
||||||
t.Errorf("replicas lost: %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("append new param not in existing list", func(t *testing.T) {
|
|
||||||
existing := []map[string]string{
|
|
||||||
{"name": "replicas", "value": "2"},
|
|
||||||
}
|
|
||||||
updates := []map[string]string{
|
|
||||||
{"name": "runner.image.tag", "value": "new"},
|
|
||||||
}
|
|
||||||
got := mergeHelmParams(existing, updates)
|
|
||||||
if len(got) != 2 {
|
|
||||||
t.Fatalf("expected 2 params got %d: %v", len(got), got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("empty existing list just returns updates", func(t *testing.T) {
|
|
||||||
updates := []map[string]string{
|
|
||||||
{"name": "runner.image.tag", "value": "new"},
|
|
||||||
}
|
|
||||||
got := mergeHelmParams(nil, updates)
|
|
||||||
if len(got) != 1 || got[0]["name"] != "runner.image.tag" {
|
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── unit: lookupEntry ─────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestLookupEntry(t *testing.T) {
|
|
||||||
t.Run("known image returns entry", func(t *testing.T) {
|
|
||||||
e := lookupEntry("forgejo.riotpiao.com/rock/api-gateway")
|
|
||||||
if e == nil {
|
|
||||||
t.Fatal("expected entry, got nil")
|
|
||||||
}
|
|
||||||
if e.Application != "api-gw" {
|
|
||||||
t.Errorf("wrong app: %s", e.Application)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("unknown image returns nil", func(t *testing.T) {
|
|
||||||
e := lookupEntry("forgejo.riotpiao.com/rock/unknown")
|
|
||||||
if e != nil {
|
|
||||||
t.Fatalf("expected nil, got %+v", e)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── unit: toStringSlice ───────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestToStringSlice(t *testing.T) {
|
|
||||||
t.Run("skips non-string elements", func(t *testing.T) {
|
|
||||||
got := toStringSlice([]any{"a", "b", 42})
|
|
||||||
if len(got) != 2 || got[0] != "a" || got[1] != "b" {
|
|
||||||
t.Fatalf("got %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("nil input returns nil", func(t *testing.T) {
|
|
||||||
got := toStringSlice(nil)
|
|
||||||
if got != nil {
|
|
||||||
t.Fatalf("expected nil, got %v", got)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── helpers ───────────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func newPromoter(regURL, k8sURL string) *ImagePromoter {
|
|
||||||
return &ImagePromoter{
|
|
||||||
registryBase: regURL,
|
|
||||||
registryUser: "testuser",
|
|
||||||
registryPAT: "testpat",
|
|
||||||
k8sHost: k8sURL,
|
|
||||||
k8sClient: &http.Client{Timeout: 5 * time.Second},
|
|
||||||
regClient: &http.Client{Timeout: 5 * time.Second},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func mustJSON(v any) []byte {
|
|
||||||
b, err := json.Marshal(v)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return b
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── integration: fetchDigest ──────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestFetchDigest_Success(t *testing.T) {
|
|
||||||
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Docker-Content-Digest", "sha256:abc123")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer reg.Close()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
p := newPromoter(reg.URL, "")
|
|
||||||
digest, err := p.fetchDigest(ctx, "rock", "api-gateway", "latest")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("unexpected error: %v", err)
|
|
||||||
}
|
|
||||||
if digest != "sha256:abc123" {
|
|
||||||
t.Errorf("got digest %q", digest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchDigest_NotFound(t *testing.T) {
|
|
||||||
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusNotFound)
|
|
||||||
}))
|
|
||||||
defer reg.Close()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
p := newPromoter(reg.URL, "")
|
|
||||||
_, err := p.fetchDigest(ctx, "rock", "api-gateway", "latest")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "404") {
|
|
||||||
t.Errorf("error should contain 404: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestFetchDigest_MissingDigestHeader(t *testing.T) {
|
|
||||||
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
// No Docker-Content-Digest header
|
|
||||||
}))
|
|
||||||
defer reg.Close()
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
p := newPromoter(reg.URL, "")
|
|
||||||
_, err := p.fetchDigest(ctx, "rock", "api-gateway", "latest")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "Docker-Content-Digest") {
|
|
||||||
t.Errorf("error should mention Docker-Content-Digest: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── integration: Promote kustomize ───────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestPromote_KustomizeSuccess(t *testing.T) {
|
|
||||||
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Docker-Content-Digest", "sha256:newdigest")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer reg.Close()
|
|
||||||
|
|
||||||
kustomizeApp := map[string]any{
|
|
||||||
"spec": map[string]any{
|
|
||||||
"sources": []any{
|
|
||||||
map[string]any{
|
|
||||||
"kustomize": map[string]any{
|
|
||||||
"images": []any{"forgejo.riotpiao.com/rock/api-gateway:latest"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var getCalled, patchCalled int32
|
|
||||||
k8s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
atomic.AddInt32(&getCalled, 1)
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write(mustJSON(kustomizeApp))
|
|
||||||
case http.MethodPatch:
|
|
||||||
atomic.AddInt32(&patchCalled, 1)
|
|
||||||
raw, _ := readAll(r.Body)
|
|
||||||
if !strings.Contains(string(raw), "/spec/sources/0/kustomize/images") {
|
|
||||||
t.Errorf("patch path missing: %s", raw)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(raw), "@sha256:newdigest") {
|
|
||||||
t.Errorf("digest missing from patch body: %s", raw)
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer k8s.Close()
|
|
||||||
|
|
||||||
p := newPromoter(reg.URL, k8s.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := p.Promote(ctx, "forgejo.riotpiao.com/rock/api-gateway", "latest"); err != nil {
|
|
||||||
t.Fatalf("Promote error: %v", err)
|
|
||||||
}
|
|
||||||
if atomic.LoadInt32(&getCalled) == 0 {
|
|
||||||
t.Error("k8s GET not called")
|
|
||||||
}
|
|
||||||
if atomic.LoadInt32(&patchCalled) == 0 {
|
|
||||||
t.Error("k8s PATCH not called")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── integration: Promote helm ─────────────────────────────────────────────────
|
|
||||||
|
|
||||||
func TestPromote_HelmSuccess(t *testing.T) {
|
|
||||||
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Docker-Content-Digest", "sha256:helmdigest")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer reg.Close()
|
|
||||||
|
|
||||||
helmApp := map[string]any{
|
|
||||||
"spec": map[string]any{
|
|
||||||
"source": map[string]any{
|
|
||||||
"helm": map[string]any{
|
|
||||||
"parameters": []any{
|
|
||||||
map[string]any{"name": "runner.image.tag", "value": "old"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var patchCalled int32
|
|
||||||
k8s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write(mustJSON(helmApp))
|
|
||||||
case http.MethodPatch:
|
|
||||||
atomic.AddInt32(&patchCalled, 1)
|
|
||||||
raw, _ := readAll(r.Body)
|
|
||||||
if !strings.Contains(string(raw), "/spec/source/helm/parameters") {
|
|
||||||
t.Errorf("patch path missing: %s", raw)
|
|
||||||
}
|
|
||||||
if !strings.Contains(string(raw), "helmdigest") {
|
|
||||||
t.Errorf("digest missing from patch body: %s", raw)
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer k8s.Close()
|
|
||||||
|
|
||||||
p := newPromoter(reg.URL, k8s.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := p.Promote(ctx, "forgejo.riotpiao.com/rock/forgejo-runner-golang", "latest"); err != nil {
|
|
||||||
t.Fatalf("Promote error: %v", err)
|
|
||||||
}
|
|
||||||
if atomic.LoadInt32(&patchCalled) == 0 {
|
|
||||||
t.Error("k8s PATCH not called")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── integration: Promote unknown image ───────────────────────────────────────
|
|
||||||
|
|
||||||
func TestPromote_UnknownImage(t *testing.T) {
|
|
||||||
p := newPromoter("http://unused", "http://unused")
|
|
||||||
ctx := context.TODO()
|
|
||||||
err := p.Promote(ctx, "forgejo.riotpiao.com/rock/unknown-image", "latest")
|
|
||||||
if err == nil {
|
|
||||||
t.Fatal("expected error, got nil")
|
|
||||||
}
|
|
||||||
if !strings.Contains(err.Error(), "not in promotion table") {
|
|
||||||
t.Errorf("error should mention 'not in promotion table': %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── integration: Promote conflict retry ──────────────────────────────────────
|
|
||||||
|
|
||||||
func TestPromote_Conflict_Retry(t *testing.T) {
|
|
||||||
reg := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Docker-Content-Digest", "sha256:retrydigest")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}))
|
|
||||||
defer reg.Close()
|
|
||||||
|
|
||||||
kustomizeApp := map[string]any{
|
|
||||||
"spec": map[string]any{
|
|
||||||
"sources": []any{
|
|
||||||
map[string]any{
|
|
||||||
"kustomize": map[string]any{
|
|
||||||
"images": []any{"forgejo.riotpiao.com/rock/api-gateway:latest"},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
var patchCount int32
|
|
||||||
k8s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
switch r.Method {
|
|
||||||
case http.MethodGet:
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
w.Write(mustJSON(kustomizeApp))
|
|
||||||
case http.MethodPatch:
|
|
||||||
n := atomic.AddInt32(&patchCount, 1)
|
|
||||||
if n < 3 {
|
|
||||||
w.WriteHeader(http.StatusConflict) // 409
|
|
||||||
return
|
|
||||||
}
|
|
||||||
w.WriteHeader(http.StatusOK)
|
|
||||||
}
|
|
||||||
}))
|
|
||||||
defer k8s.Close()
|
|
||||||
|
|
||||||
p := newPromoter(reg.URL, k8s.URL)
|
|
||||||
ctx := context.Background()
|
|
||||||
if err := p.Promote(ctx, "forgejo.riotpiao.com/rock/api-gateway", "latest"); err != nil {
|
|
||||||
t.Fatalf("Promote error after retries: %v", err)
|
|
||||||
}
|
|
||||||
if got := atomic.LoadInt32(&patchCount); got != 3 {
|
|
||||||
t.Errorf("expected 3 PATCH calls, got %d", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// readAll is a test helper to read request body.
|
|
||||||
func readAll(r io.Reader) ([]byte, error) {
|
|
||||||
return io.ReadAll(r)
|
|
||||||
}
|
|
||||||
@@ -76,33 +76,6 @@ spec:
|
|||||||
value: "1.0.0"
|
value: "1.0.0"
|
||||||
- name: OTEL_ENVIRONMENT
|
- name: OTEL_ENVIRONMENT
|
||||||
value: "production"
|
value: "production"
|
||||||
# Gotify integration — Forgejo webhook → push notifications
|
|
||||||
- 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: REGISTRY_PAT
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: forgejo-registry-pat
|
|
||||||
key: pat
|
|
||||||
optional: true
|
|
||||||
- name: REGISTRY_BASE
|
|
||||||
value: "http://forgejo-gitea-http.cicd.svc.cluster.local:3000"
|
|
||||||
volumeMounts:
|
volumeMounts:
|
||||||
- name: config
|
- name: config
|
||||||
mountPath: /etc/gateway
|
mountPath: /etc/gateway
|
||||||
|
|||||||
@@ -1,321 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: ConfigMap
|
|
||||||
metadata:
|
|
||||||
name: grafana-dashboard-llm-metrics
|
|
||||||
namespace: monitoring
|
|
||||||
labels:
|
|
||||||
grafana_dashboard: "1"
|
|
||||||
data:
|
|
||||||
llm-metrics.json: |
|
|
||||||
{
|
|
||||||
"annotations": {
|
|
||||||
"list": [
|
|
||||||
{
|
|
||||||
"builtIn": 1,
|
|
||||||
"datasource": "-- Grafana --",
|
|
||||||
"enable": true,
|
|
||||||
"hide": true,
|
|
||||||
"iconColor": "rgba(0, 211, 255, 1)",
|
|
||||||
"name": "Annotations & Alerts",
|
|
||||||
"type": "dashboard"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"editable": true,
|
|
||||||
"gnetId": null,
|
|
||||||
"graphTooltip": 0,
|
|
||||||
"id": null,
|
|
||||||
"links": [],
|
|
||||||
"panels": [
|
|
||||||
{
|
|
||||||
"datasource": "Prometheus",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {
|
|
||||||
"mode": "palette-classic"
|
|
||||||
},
|
|
||||||
"custom": {
|
|
||||||
"axisLabel": "Milliseconds",
|
|
||||||
"axisPlacement": "auto",
|
|
||||||
"barAlignment": 0,
|
|
||||||
"drawStyle": "line",
|
|
||||||
"fillOpacity": 10,
|
|
||||||
"gradientMode": "none",
|
|
||||||
"hideFrom": {
|
|
||||||
"tooltip": false,
|
|
||||||
"viz": false,
|
|
||||||
"legend": false
|
|
||||||
},
|
|
||||||
"lineInterpolation": "linear",
|
|
||||||
"lineWidth": 1,
|
|
||||||
"pointSize": 5,
|
|
||||||
"scaleDistribution": {
|
|
||||||
"type": "linear"
|
|
||||||
},
|
|
||||||
"showPoints": "auto",
|
|
||||||
"spanNulls": false,
|
|
||||||
"stacking": {
|
|
||||||
"group": "A",
|
|
||||||
"mode": "none"
|
|
||||||
},
|
|
||||||
"thresholdsStyle": {
|
|
||||||
"mode": "off"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mappings": [],
|
|
||||||
"thresholds": {
|
|
||||||
"mode": "absolute",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"color": "green",
|
|
||||||
"value": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"color": "red",
|
|
||||||
"value": 80
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": {
|
|
||||||
"h": 8,
|
|
||||||
"w": 12,
|
|
||||||
"x": 0,
|
|
||||||
"y": 0
|
|
||||||
},
|
|
||||||
"id": 2,
|
|
||||||
"options": {
|
|
||||||
"legend": {
|
|
||||||
"calcs": [
|
|
||||||
"mean",
|
|
||||||
"max",
|
|
||||||
"min"
|
|
||||||
],
|
|
||||||
"displayMode": "table",
|
|
||||||
"placement": "bottom"
|
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"mode": "multi"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pluginVersion": "8.0.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "llm_ttft_seconds * 1000",
|
|
||||||
"legendFormat": "{{model}}",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Time to First Token (TTFT) by Model",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": "Prometheus",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {
|
|
||||||
"mode": "palette-classic"
|
|
||||||
},
|
|
||||||
"custom": {
|
|
||||||
"axisLabel": "Milliseconds",
|
|
||||||
"axisPlacement": "auto",
|
|
||||||
"barAlignment": 0,
|
|
||||||
"drawStyle": "line",
|
|
||||||
"fillOpacity": 10,
|
|
||||||
"gradientMode": "none",
|
|
||||||
"hideFrom": {
|
|
||||||
"tooltip": false,
|
|
||||||
"viz": false,
|
|
||||||
"legend": false
|
|
||||||
},
|
|
||||||
"lineInterpolation": "linear",
|
|
||||||
"lineWidth": 1,
|
|
||||||
"pointSize": 5,
|
|
||||||
"scaleDistribution": {
|
|
||||||
"type": "linear"
|
|
||||||
},
|
|
||||||
"showPoints": "auto",
|
|
||||||
"spanNulls": false,
|
|
||||||
"stacking": {
|
|
||||||
"group": "A",
|
|
||||||
"mode": "none"
|
|
||||||
},
|
|
||||||
"thresholdsStyle": {
|
|
||||||
"mode": "off"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mappings": [],
|
|
||||||
"thresholds": {
|
|
||||||
"mode": "absolute",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"color": "green",
|
|
||||||
"value": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"color": "red",
|
|
||||||
"value": 80
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": {
|
|
||||||
"h": 8,
|
|
||||||
"w": 12,
|
|
||||||
"x": 12,
|
|
||||||
"y": 0
|
|
||||||
},
|
|
||||||
"id": 3,
|
|
||||||
"options": {
|
|
||||||
"legend": {
|
|
||||||
"calcs": [
|
|
||||||
"mean",
|
|
||||||
"max",
|
|
||||||
"min"
|
|
||||||
],
|
|
||||||
"displayMode": "table",
|
|
||||||
"placement": "bottom"
|
|
||||||
},
|
|
||||||
"tooltip": {
|
|
||||||
"mode": "multi"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"pluginVersion": "8.0.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "llm_itl_seconds * 1000",
|
|
||||||
"legendFormat": "{{model}}",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Inter-Token Latency (ITL) by Model",
|
|
||||||
"type": "timeseries"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": "Prometheus",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {
|
|
||||||
"mode": "palette-classic"
|
|
||||||
},
|
|
||||||
"custom": {
|
|
||||||
"hideFrom": {
|
|
||||||
"tooltip": false,
|
|
||||||
"viz": false,
|
|
||||||
"legend": false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"mappings": []
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": {
|
|
||||||
"h": 8,
|
|
||||||
"w": 12,
|
|
||||||
"x": 0,
|
|
||||||
"y": 8
|
|
||||||
},
|
|
||||||
"id": 4,
|
|
||||||
"options": {
|
|
||||||
"legend": {
|
|
||||||
"displayMode": "list",
|
|
||||||
"placement": "bottom"
|
|
||||||
},
|
|
||||||
"pieType": "pie"
|
|
||||||
},
|
|
||||||
"pluginVersion": "8.0.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "llm_tokens_total",
|
|
||||||
"legendFormat": "{{model}}",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Total Tokens Generated by Model",
|
|
||||||
"type": "piechart"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"datasource": "Prometheus",
|
|
||||||
"fieldConfig": {
|
|
||||||
"defaults": {
|
|
||||||
"color": {
|
|
||||||
"mode": "thresholds"
|
|
||||||
},
|
|
||||||
"mappings": [],
|
|
||||||
"thresholds": {
|
|
||||||
"mode": "absolute",
|
|
||||||
"steps": [
|
|
||||||
{
|
|
||||||
"color": "green",
|
|
||||||
"value": null
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"color": "yellow",
|
|
||||||
"value": 50
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"color": "red",
|
|
||||||
"value": 100
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"unit": "ms"
|
|
||||||
},
|
|
||||||
"overrides": []
|
|
||||||
},
|
|
||||||
"gridPos": {
|
|
||||||
"h": 8,
|
|
||||||
"w": 12,
|
|
||||||
"x": 12,
|
|
||||||
"y": 8
|
|
||||||
},
|
|
||||||
"id": 5,
|
|
||||||
"options": {
|
|
||||||
"orientation": "auto",
|
|
||||||
"reduceOptions": {
|
|
||||||
"values": false,
|
|
||||||
"fields": "",
|
|
||||||
"calcs": [
|
|
||||||
"lastNotNull"
|
|
||||||
]
|
|
||||||
},
|
|
||||||
"showThresholdLabels": false,
|
|
||||||
"showThresholdMarkers": true
|
|
||||||
},
|
|
||||||
"pluginVersion": "8.0.0",
|
|
||||||
"targets": [
|
|
||||||
{
|
|
||||||
"expr": "avg(llm_ttft_seconds) * 1000",
|
|
||||||
"legendFormat": "Average TTFT",
|
|
||||||
"refId": "A"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"title": "Average TTFT (All Models)",
|
|
||||||
"type": "gauge"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"refresh": "10s",
|
|
||||||
"schemaVersion": 27,
|
|
||||||
"style": "dark",
|
|
||||||
"tags": [
|
|
||||||
"llm",
|
|
||||||
"inference",
|
|
||||||
"metrics"
|
|
||||||
],
|
|
||||||
"templating": {
|
|
||||||
"list": []
|
|
||||||
},
|
|
||||||
"time": {
|
|
||||||
"from": "now-1h",
|
|
||||||
"to": "now"
|
|
||||||
},
|
|
||||||
"timepicker": {},
|
|
||||||
"timezone": "",
|
|
||||||
"title": "LLM Inference Metrics (TTFT & ITL)",
|
|
||||||
"uid": "llm-metrics",
|
|
||||||
"version": 0
|
|
||||||
}
|
|
||||||
@@ -5,32 +5,35 @@ metadata:
|
|||||||
namespace: api
|
namespace: api
|
||||||
spec:
|
spec:
|
||||||
template:
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: api-gateway
|
||||||
|
managed-by: test
|
||||||
|
role: integration-test
|
||||||
spec:
|
spec:
|
||||||
serviceAccountName: api-gateway
|
serviceAccountName: api-gateway
|
||||||
restartPolicy: Never
|
restartPolicy: Never
|
||||||
containers:
|
containers:
|
||||||
- name: integration-tester
|
- name: integration-tester
|
||||||
image: golang:1.26-bookworm
|
image: forgejo.riotpiao.com/rock/api-gateway:latest
|
||||||
imagePullPolicy: IfNotPresent
|
imagePullPolicy: Always
|
||||||
workingDir: /workspace
|
workingDir: /app
|
||||||
command:
|
command:
|
||||||
- /bin/bash
|
- /bin/sh
|
||||||
- -c
|
- -c
|
||||||
- |
|
- |
|
||||||
set -e
|
set -e
|
||||||
echo "Starting integration tests..."
|
echo "Starting integration tests..."
|
||||||
|
echo "Gateway URL: http://api-gateway:8080"
|
||||||
|
|
||||||
# Clone the repo
|
# Wait for gateway service to be ready
|
||||||
git clone https://forgejo.riotpiao.com/riotpiao-poimen/homelab-frontend.git .
|
|
||||||
|
|
||||||
# Wait for gateway to be ready
|
|
||||||
echo "Waiting for gateway service to be ready..."
|
echo "Waiting for gateway service to be ready..."
|
||||||
for i in {1..30}; do
|
for i in $(seq 1 30); do
|
||||||
if curl -s http://api-gateway:8080/healthz | grep -q "alive"; then
|
if curl -s http://api-gateway:8080/healthz > /dev/null 2>&1; then
|
||||||
echo "✓ Gateway is ready"
|
echo "✓ Gateway is ready"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
echo "Attempting to reach gateway ($i/30)..."
|
echo "Waiting for gateway... ($i/30)"
|
||||||
sleep 2
|
sleep 2
|
||||||
done
|
done
|
||||||
|
|
||||||
@@ -42,6 +45,8 @@ spec:
|
|||||||
env:
|
env:
|
||||||
- name: GATEWAY_URL
|
- name: GATEWAY_URL
|
||||||
value: "http://api-gateway:8080"
|
value: "http://api-gateway:8080"
|
||||||
|
- name: CI
|
||||||
|
value: "true"
|
||||||
resources:
|
resources:
|
||||||
requests:
|
requests:
|
||||||
cpu: 250m
|
cpu: 250m
|
||||||
@@ -67,4 +72,6 @@ spec:
|
|||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
- name: home
|
- name: home
|
||||||
emptyDir: {}
|
emptyDir: {}
|
||||||
|
imagePullSecrets:
|
||||||
|
- name: regcred
|
||||||
backoffLimit: 1
|
backoffLimit: 1
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ resources:
|
|||||||
- deployment.yaml
|
- deployment.yaml
|
||||||
- network-policy.yaml
|
- network-policy.yaml
|
||||||
- gateway-config-secret.yaml
|
- gateway-config-secret.yaml
|
||||||
- rbac.yaml
|
|
||||||
|
|
||||||
# The deployed image tag lives here and nowhere else. CI publishes
|
# The deployed image tag lives here and nowhere else. CI publishes
|
||||||
# forgejo.riotpiao.com/rock/api-gateway:<commit-sha> and tags it as :latest on main.
|
# forgejo.riotpiao.com/rock/api-gateway:<commit-sha> and tags it as :latest on main.
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
|
|
||||||
namespace: api
|
|
||||||
|
|
||||||
resources:
|
|
||||||
- serviceaccount.yaml
|
|
||||||
- service.yaml
|
|
||||||
- deployment.yaml
|
|
||||||
- network-policy.yaml
|
|
||||||
- gateway-config-secret.enc.yaml
|
|
||||||
|
|
||||||
# The deployed image tag lives here and nowhere else. CI publishes
|
|
||||||
# forgejo.riotpiao.com/rock/api-gateway:<commit-sha> and tags it as :latest on main.
|
|
||||||
# ArgoCD auto-syncs when the latest image is available.
|
|
||||||
images:
|
|
||||||
- name: forgejo.riotpiao.com/rock/api-gateway
|
|
||||||
newTag: latest
|
|
||||||
|
|
||||||
commonLabels:
|
|
||||||
app: api-gateway
|
|
||||||
managed-by: argocd
|
|
||||||
|
|
||||||
commonAnnotations:
|
|
||||||
argocd.argoproj.io/sync-wave: "2"
|
|
||||||
# Wave 2 ensures the gateway is ready before anything that depends on it
|
|
||||||
# Kong remains on wave 7 unchanged
|
|
||||||
@@ -46,14 +46,6 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 8080
|
port: 8080
|
||||||
# Allow from paperless namespace (paperless-ai document auto-tagging)
|
|
||||||
- from:
|
|
||||||
- namespaceSelector:
|
|
||||||
matchLabels:
|
|
||||||
kubernetes.io/metadata.name: paperless
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 8080
|
|
||||||
egress:
|
egress:
|
||||||
# Allow DNS
|
# Allow DNS
|
||||||
- to:
|
- to:
|
||||||
@@ -130,14 +122,6 @@ spec:
|
|||||||
ports:
|
ports:
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 8080
|
port: 8080
|
||||||
# Allow to Gotify (push notifications)
|
|
||||||
- to:
|
|
||||||
- namespaceSelector:
|
|
||||||
matchLabels:
|
|
||||||
kubernetes.io/metadata.name: notifications
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 80
|
|
||||||
# Allow to MinIO (S3-compatible storage)
|
# Allow to MinIO (S3-compatible storage)
|
||||||
# Service `minio` listens on port 80 (targetPort 9000).
|
# Service `minio` listens on port 80 (targetPort 9000).
|
||||||
# Headless `minio-cluster-hl` is 9000. Allow both.
|
# Headless `minio-cluster-hl` is 9000. Allow both.
|
||||||
@@ -150,24 +134,3 @@ spec:
|
|||||||
port: 80
|
port: 80
|
||||||
- protocol: TCP
|
- protocol: TCP
|
||||||
port: 9000
|
port: 9000
|
||||||
# Allow to Forgejo registry v2 API (image digest lookup for promotion)
|
|
||||||
- to:
|
|
||||||
- namespaceSelector:
|
|
||||||
matchLabels:
|
|
||||||
kubernetes.io/metadata.name: cicd
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 3000
|
|
||||||
# Allow to kubernetes API server (ArgoCD Application patching via in-cluster SA).
|
|
||||||
# kube-apiserver runs on host network (192.168.1.162/166:6443) — must use ipBlock.
|
|
||||||
# ClusterIP 10.96.0.1:443 also routes there, covered by the 10.96.0.0/12 CIDR.
|
|
||||||
- to:
|
|
||||||
- ipBlock:
|
|
||||||
cidr: 10.96.0.0/12
|
|
||||||
- ipBlock:
|
|
||||||
cidr: 192.168.1.0/24
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 443
|
|
||||||
- protocol: TCP
|
|
||||||
port: 6443
|
|
||||||
|
|||||||
@@ -1,28 +0,0 @@
|
|||||||
# RBAC for api-gateway image promotion.
|
|
||||||
# Grants the api-gateway ServiceAccount get+patch on ArgoCD Applications
|
|
||||||
# so it can update kustomize.images / helm.parameters after a container push.
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRole
|
|
||||||
metadata:
|
|
||||||
name: image-promoter
|
|
||||||
labels:
|
|
||||||
app: api-gateway
|
|
||||||
rules:
|
|
||||||
- apiGroups: ["argoproj.io"]
|
|
||||||
resources: ["applications"]
|
|
||||||
verbs: ["get", "patch"]
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: ClusterRoleBinding
|
|
||||||
metadata:
|
|
||||||
name: image-promoter-api-gateway
|
|
||||||
labels:
|
|
||||||
app: api-gateway
|
|
||||||
subjects:
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: api-gateway
|
|
||||||
namespace: api
|
|
||||||
roleRef:
|
|
||||||
kind: ClusterRole
|
|
||||||
name: image-promoter
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
@@ -1,29 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: smtp-credentials
|
|
||||||
namespace: api
|
|
||||||
labels:
|
|
||||||
app: api-gateway
|
|
||||||
component: notification
|
|
||||||
type: Opaque
|
|
||||||
data:
|
|
||||||
host: <base64-encoded SMTP hostname>
|
|
||||||
port: <base64-encoded SMTP port, e.g., "587">
|
|
||||||
from: <base64-encoded sender email>
|
|
||||||
user: <base64-encoded SMTP username>
|
|
||||||
password: <base64-encoded SMTP password>
|
|
||||||
|
|
||||||
# To create from plaintext:
|
|
||||||
# kubectl create secret generic smtp-credentials \
|
|
||||||
# --from-literal=host=mail.example.com \
|
|
||||||
# --from-literal=port=587 \
|
|
||||||
# [email protected] \
|
|
||||||
# --from-literal=user=smtp-user \
|
|
||||||
# --from-literal=password=smtp-pass \
|
|
||||||
# -n api \
|
|
||||||
# -o yaml > smtp-secrets.yaml
|
|
||||||
#
|
|
||||||
# Then encrypt with SOPS:
|
|
||||||
# sops -e smtp-secrets.yaml > smtp-secrets.enc.yaml
|
|
||||||
# rm smtp-secrets.yaml
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
# ServiceAccount and RBAC for CI runner to create/watch Tekton PipelineRuns.
|
|
||||||
# Applied to the `api` namespace where PipelineRuns execute.
|
|
||||||
apiVersion: v1
|
|
||||||
kind: ServiceAccount
|
|
||||||
metadata:
|
|
||||||
name: ci-tekton-trigger
|
|
||||||
namespace: api
|
|
||||||
labels:
|
|
||||||
app: api-gateway
|
|
||||||
component: ci
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: Role
|
|
||||||
metadata:
|
|
||||||
name: ci-tekton-trigger
|
|
||||||
namespace: api
|
|
||||||
rules:
|
|
||||||
- apiGroups: ["tekton.dev"]
|
|
||||||
resources: ["taskruns"]
|
|
||||||
verbs: ["create", "get", "list", "watch", "delete"]
|
|
||||||
- apiGroups: [""]
|
|
||||||
resources: ["pods", "pods/log"]
|
|
||||||
verbs: ["get", "list"]
|
|
||||||
---
|
|
||||||
apiVersion: rbac.authorization.k8s.io/v1
|
|
||||||
kind: RoleBinding
|
|
||||||
metadata:
|
|
||||||
name: ci-tekton-trigger
|
|
||||||
namespace: api
|
|
||||||
subjects:
|
|
||||||
- kind: ServiceAccount
|
|
||||||
name: ci-tekton-trigger
|
|
||||||
namespace: api
|
|
||||||
roleRef:
|
|
||||||
kind: Role
|
|
||||||
name: ci-tekton-trigger
|
|
||||||
apiGroup: rbac.authorization.k8s.io
|
|
||||||
---
|
|
||||||
# Secret to generate a long-lived token for the CI runner.
|
|
||||||
# The runner mounts this as KUBECONFIG_B64 or uses it directly.
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Secret
|
|
||||||
metadata:
|
|
||||||
name: ci-tekton-trigger-token
|
|
||||||
namespace: api
|
|
||||||
annotations:
|
|
||||||
kubernetes.io/service-account.name: ci-tekton-trigger
|
|
||||||
type: kubernetes.io/service-account-token
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
|
||||||
kind: Kustomization
|
|
||||||
|
|
||||||
namespace: api
|
|
||||||
|
|
||||||
resources:
|
|
||||||
- ci-rbac.yaml
|
|
||||||
- task-integration-test.yaml
|
|
||||||
- task-load-test.yaml
|
|
||||||
- task-workflow-visibility.yaml
|
|
||||||
- pipeline-sse-optimization.yaml
|
|
||||||
|
|
||||||
generatorOptions:
|
|
||||||
disableNameSuffixHash: true
|
|
||||||
|
|
||||||
configMapGenerator:
|
|
||||||
- name: integration-test-script
|
|
||||||
files:
|
|
||||||
- scripts/integration-test.sh
|
|
||||||
- name: load-test-script
|
|
||||||
files:
|
|
||||||
- scripts/load-test.sh
|
|
||||||
- name: workflow-visibility-test-script
|
|
||||||
files:
|
|
||||||
- scripts/workflow-visibility-test.sh
|
|
||||||
@@ -1,124 +0,0 @@
|
|||||||
apiVersion: tekton.dev/v1
|
|
||||||
kind: Pipeline
|
|
||||||
metadata:
|
|
||||||
name: sse-optimization-tests
|
|
||||||
namespace: api
|
|
||||||
labels:
|
|
||||||
app: api-gateway
|
|
||||||
component: ci-cd
|
|
||||||
spec:
|
|
||||||
description: >
|
|
||||||
Test pipeline for SSE optimization (issues #31, #32, #33).
|
|
||||||
Runs both functional integration tests and performance load tests.
|
|
||||||
|
|
||||||
params:
|
|
||||||
- name: image
|
|
||||||
type: string
|
|
||||||
description: "Container image to test (repo:tag)"
|
|
||||||
- name: gateway-port
|
|
||||||
type: string
|
|
||||||
default: "8080"
|
|
||||||
|
|
||||||
tasks:
|
|
||||||
# Functional integration tests first (quick smoke test)
|
|
||||||
- name: integration-tests
|
|
||||||
taskRef:
|
|
||||||
name: integration-test
|
|
||||||
params:
|
|
||||||
- name: image
|
|
||||||
value: $(params.image)
|
|
||||||
- name: gateway-port
|
|
||||||
value: $(params.gateway-port)
|
|
||||||
|
|
||||||
# Workflow visibility tests (runs after integration tests pass)
|
|
||||||
- name: workflow-visibility-tests
|
|
||||||
runAfter:
|
|
||||||
- integration-tests
|
|
||||||
taskRef:
|
|
||||||
name: workflow-visibility-test
|
|
||||||
params:
|
|
||||||
- name: image
|
|
||||||
value: $(params.image)
|
|
||||||
- name: gateway-port
|
|
||||||
value: $(params.gateway-port)
|
|
||||||
|
|
||||||
# Performance load tests (runs after integration tests pass)
|
|
||||||
- name: load-tests
|
|
||||||
runAfter:
|
|
||||||
- workflow-visibility-tests
|
|
||||||
taskRef:
|
|
||||||
name: load-test-sse-streaming
|
|
||||||
params:
|
|
||||||
- name: image
|
|
||||||
value: $(params.image)
|
|
||||||
- name: gateway-port
|
|
||||||
value: $(params.gateway-port)
|
|
||||||
- name: concurrent-streams
|
|
||||||
value: "10"
|
|
||||||
- name: events-per-stream
|
|
||||||
value: "100"
|
|
||||||
- name: event-interval-ms
|
|
||||||
value: "50"
|
|
||||||
|
|
||||||
# Summary reporter
|
|
||||||
- name: report-results
|
|
||||||
runAfter:
|
|
||||||
- load-tests
|
|
||||||
- workflow-visibility-tests
|
|
||||||
taskSpec:
|
|
||||||
description: "Report combined test results"
|
|
||||||
params:
|
|
||||||
- name: integration-result
|
|
||||||
type: string
|
|
||||||
- name: integration-summary
|
|
||||||
type: string
|
|
||||||
- name: workflow-result
|
|
||||||
type: string
|
|
||||||
- name: workflow-summary
|
|
||||||
type: string
|
|
||||||
- name: load-result
|
|
||||||
type: string
|
|
||||||
- name: load-summary
|
|
||||||
type: string
|
|
||||||
- name: load-metrics
|
|
||||||
type: string
|
|
||||||
steps:
|
|
||||||
- name: print-summary
|
|
||||||
image: busybox
|
|
||||||
script: |
|
|
||||||
#!/bin/sh
|
|
||||||
echo "╔═══════════════════════════════════════════════════════════╗"
|
|
||||||
echo "║ SSE Optimization + Workflow Tests (PR #26) ║"
|
|
||||||
echo "╠═══════════════════════════════════════════════════════════╣"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ Integration Tests: ║"
|
|
||||||
echo "║ Status: $(params.integration-result)"
|
|
||||||
echo "║ Summary: $(params.integration-summary)"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ Workflow Visibility (namespace pass-down): ║"
|
|
||||||
echo "║ Status: $(params.workflow-result)"
|
|
||||||
echo "║ Summary: $(params.workflow-summary)"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ Load Tests (Issues #31, #32, #33): ║"
|
|
||||||
echo "║ Status: $(params.load-result)"
|
|
||||||
echo "║ Summary: $(params.load-summary)"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "║ Performance Metrics: ║"
|
|
||||||
echo "║ $(params.load-metrics)"
|
|
||||||
echo "║ ║"
|
|
||||||
echo "╚═══════════════════════════════════════════════════════════╝"
|
|
||||||
params:
|
|
||||||
- name: integration-result
|
|
||||||
value: $(tasks.integration-tests.results.result)
|
|
||||||
- name: integration-summary
|
|
||||||
value: $(tasks.integration-tests.results.summary)
|
|
||||||
- name: workflow-result
|
|
||||||
value: $(tasks.workflow-visibility-tests.results.result)
|
|
||||||
- name: workflow-summary
|
|
||||||
value: $(tasks.workflow-visibility-tests.results.summary)
|
|
||||||
- name: load-result
|
|
||||||
value: $(tasks.load-tests.results.result)
|
|
||||||
- name: load-summary
|
|
||||||
value: $(tasks.load-tests.results.summary)
|
|
||||||
- name: load-metrics
|
|
||||||
value: $(tasks.load-tests.results.metrics)
|
|
||||||
@@ -1,127 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Integration test runner for API gateway.
|
|
||||||
# Runs against a real canary pod in-cluster (has real secrets + upstreams).
|
|
||||||
# GW env var must be set: e.g. http://10.244.1.5:8080
|
|
||||||
#
|
|
||||||
# Required env:
|
|
||||||
# GW — gateway base URL
|
|
||||||
# RESULTS_DIR — directory to write result/summary files
|
|
||||||
|
|
||||||
PASS=0; FAIL=0; TOTAL=0
|
|
||||||
|
|
||||||
assert() {
|
|
||||||
NAME="$1"; EXPECT="$2"
|
|
||||||
shift 2
|
|
||||||
TOTAL=$((TOTAL + 1))
|
|
||||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' "$@" 2>/dev/null || echo "000")
|
|
||||||
|
|
||||||
if [ "$CODE" = "$EXPECT" ]; then
|
|
||||||
echo " ✓ ${NAME} (${CODE})"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
else
|
|
||||||
echo " ✗ ${NAME} — expected ${EXPECT}, got ${CODE}"
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
# ── Wait for gateway ──────────────────────────────────────────────────────────
|
|
||||||
echo "⏳ Waiting for gateway at ${GW}..."
|
|
||||||
READY=false
|
|
||||||
for i in $(seq 1 30); do
|
|
||||||
CODE=$(curl -s -o /dev/null -w '%{http_code}' "${GW}/healthz" 2>/dev/null || echo "000")
|
|
||||||
if [ "$CODE" = "200" ]; then
|
|
||||||
READY=true
|
|
||||||
echo "✓ Gateway ready"
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$READY" = "false" ]; then
|
|
||||||
echo "✗ Gateway never became ready"
|
|
||||||
echo "fail" > "${RESULTS_DIR}/result"
|
|
||||||
echo "0/0 timeout" > "${RESULTS_DIR}/summary"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "═══ Integration Tests ═══"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── Health ────────────────────────────────────────────────────────────────────
|
|
||||||
echo "▸ Health"
|
|
||||||
assert "GET /healthz" 200 -X GET "${GW}/healthz"
|
|
||||||
assert "GET /readyz" 200 -X GET "${GW}/readyz"
|
|
||||||
|
|
||||||
# ── Header validation ─────────────────────────────────────────────────────────
|
|
||||||
echo "▸ Header validation"
|
|
||||||
assert "X-Service without X-Resource → 400" 400 \
|
|
||||||
-X GET -H "X-Service: memory" "${GW}/"
|
|
||||||
assert "unknown service → 404" 404 \
|
|
||||||
-X GET -H "X-Service: nonexistent" -H "X-Resource: foo" "${GW}/"
|
|
||||||
|
|
||||||
# ── S3 (no auth, MinIO rejects → 403) ────────────────────────────────────────
|
|
||||||
echo "▸ S3 service"
|
|
||||||
assert "s3/list-objects → 403" 403 \
|
|
||||||
-X GET -H "X-Service: s3" -H "X-Resource: list-objects" "${GW}/"
|
|
||||||
|
|
||||||
# ── SQS (auth required → 401) ─────────────────────────────────────────────────
|
|
||||||
echo "▸ SQS service"
|
|
||||||
assert "sqs/list-queues → 401" 401 \
|
|
||||||
-X GET -H "X-Service: sqs" -H "X-Resource: list-queues" "${GW}/"
|
|
||||||
|
|
||||||
# ── Workflow ──────────────────────────────────────────────────────────────────
|
|
||||||
echo "▸ Workflow service"
|
|
||||||
|
|
||||||
# List with namespace — real Temporal call.
|
|
||||||
# 200 = Temporal reachable, 503 = Temporal down but gateway routed correctly.
|
|
||||||
echo " Testing workflow list (poimen-harness namespace)..."
|
|
||||||
WF_LIST=$(curl -s \
|
|
||||||
-H "X-Service: workflow" \
|
|
||||||
-H "X-Resource: list" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"namespace": "poimen-harness"}' \
|
|
||||||
"${GW}/" 2>/dev/null || echo '{}')
|
|
||||||
|
|
||||||
TOTAL=$((TOTAL + 1))
|
|
||||||
if echo "$WF_LIST" | grep -qE '"executions"|"TEMPORAL_UNAVAILABLE"'; then
|
|
||||||
echo " ✓ workflow/list responded correctly"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
else
|
|
||||||
echo " ✗ workflow/list unexpected: $WF_LIST"
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Namespace is required — canary pod has real WorkflowAdapter → must return 400
|
|
||||||
assert "workflow/list without namespace → 400" 400 \
|
|
||||||
-X POST \
|
|
||||||
-H "X-Service: workflow" \
|
|
||||||
-H "X-Resource: list" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{}' \
|
|
||||||
"${GW}/"
|
|
||||||
|
|
||||||
# ── Forgejo webhook ───────────────────────────────────────────────────────────
|
|
||||||
echo "▸ Forgejo webhook"
|
|
||||||
# Canary pod has real handler wired. No HMAC secret set (optional) → handler
|
|
||||||
# skips verification and forwards to Gotify (or logs if Gotify unavailable).
|
|
||||||
assert "POST /v1/webhooks/forgejo → 200" 200 \
|
|
||||||
-X POST \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-H "X-Gitea-Event: push" \
|
|
||||||
-d '{"ref":"refs/heads/main","commits":[{"message":"test"}],"repository":{"full_name":"test/repo"},"sender":{"login":"ci"}}' \
|
|
||||||
"${GW}/v1/webhooks/forgejo"
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "═══ Results: ${PASS}/${TOTAL} passed, ${FAIL} failed ═══"
|
|
||||||
|
|
||||||
if [ "$FAIL" -eq 0 ]; then
|
|
||||||
echo "pass" > "${RESULTS_DIR}/result"
|
|
||||||
else
|
|
||||||
echo "fail" > "${RESULTS_DIR}/result"
|
|
||||||
fi
|
|
||||||
echo "${PASS}/${TOTAL} passed, ${FAIL} failed" > "${RESULTS_DIR}/summary"
|
|
||||||
|
|
||||||
[ "$FAIL" -eq 0 ]
|
|
||||||
@@ -1,224 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Load test for SSE streaming with concurrent streams.
|
|
||||||
# Measures TTFT, throughput, latency distribution, and backpressure.
|
|
||||||
# Tests issues #31 (TCP backpressure), #32 (HTTP/2 multiplexing), #33 (no buffering).
|
|
||||||
#
|
|
||||||
# Required env:
|
|
||||||
# GW — gateway base URL (e.g. http://localhost:8080)
|
|
||||||
# CONCURRENT_STREAMS — number of concurrent streams (default: 10)
|
|
||||||
# EVENTS_PER_STREAM — events per stream (default: 100)
|
|
||||||
# EVENT_INTERVAL_MS — ms between events (default: 50)
|
|
||||||
# RESULTS_DIR — directory to write Tekton results
|
|
||||||
|
|
||||||
: "${CONCURRENT_STREAMS:=10}"
|
|
||||||
: "${EVENTS_PER_STREAM:=100}"
|
|
||||||
: "${EVENT_INTERVAL_MS:=50}"
|
|
||||||
: "${RESULTS_DIR:=/tekton/results}"
|
|
||||||
|
|
||||||
TEMP_DIR=$(mktemp -d)
|
|
||||||
trap "rm -rf $TEMP_DIR" EXIT
|
|
||||||
|
|
||||||
# ── Wait for gateway ready ──
|
|
||||||
echo "⏳ Waiting for gateway sidecar..."
|
|
||||||
READY=false
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
if curl -s -f "${GW}/healthz" > /dev/null 2>&1; then
|
|
||||||
echo "✓ Gateway ready"
|
|
||||||
READY=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$READY" = "false" ]; then
|
|
||||||
echo "✗ Gateway never became ready"
|
|
||||||
echo "fail" > "${RESULTS_DIR}/result"
|
|
||||||
echo "gateway timeout" > "${RESULTS_DIR}/summary"
|
|
||||||
echo '{"error":"gateway_timeout"}' > "${RESULTS_DIR}/metrics"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Give gateway a moment to stabilize
|
|
||||||
sleep 2
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "═══ SSE Streaming Load Test ═══"
|
|
||||||
echo "Concurrent streams: $CONCURRENT_STREAMS"
|
|
||||||
echo "Events per stream: $EVENTS_PER_STREAM"
|
|
||||||
echo "Event interval: ${EVENT_INTERVAL_MS}ms"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# Create upstream mock that simulates LLM streaming
|
|
||||||
# This is a simple curl request that streams SSE events
|
|
||||||
UPSTREAM_URL="${GW}/healthz"
|
|
||||||
|
|
||||||
# Counter for metrics
|
|
||||||
TOTAL_EVENTS=0
|
|
||||||
TOTAL_TIME_MS=0
|
|
||||||
MIN_TTFT_MS=999999
|
|
||||||
MAX_TTFT_MS=0
|
|
||||||
FAILED_STREAMS=0
|
|
||||||
|
|
||||||
# Launch concurrent streams
|
|
||||||
for stream_id in $(seq 1 "$CONCURRENT_STREAMS"); do
|
|
||||||
(
|
|
||||||
# Each stream makes concurrent requests and measures latency
|
|
||||||
METRICS_FILE="${TEMP_DIR}/stream_${stream_id}_metrics.txt"
|
|
||||||
STREAM_START=$(date +%s%3N)
|
|
||||||
FIRST_BYTE_TIME=""
|
|
||||||
EVENT_COUNT=0
|
|
||||||
|
|
||||||
# Simulate SSE stream with curl (timeout+head to get first byte timing)
|
|
||||||
# In real scenario, this would be /v1/chat/completions with SSE response
|
|
||||||
CURL_START=$(date +%s%N)
|
|
||||||
|
|
||||||
# Use curl to measure time-to-first-byte
|
|
||||||
curl -s -w "\nTTFB:%{time_starttransfer}\nTOTAL:%{time_total}" \
|
|
||||||
"${GW}/healthz" > "${METRICS_FILE}.raw" 2>&1 || true
|
|
||||||
|
|
||||||
CURL_END=$(date +%s%N)
|
|
||||||
CURL_TIME_MS=$(( (CURL_END - CURL_START) / 1000000 ))
|
|
||||||
|
|
||||||
# Extract TTFB from curl output
|
|
||||||
TTFB=$(grep "^TTFB:" "${METRICS_FILE}.raw" | cut -d: -f2 | awk '{print int($1 * 1000)}' || echo "0")
|
|
||||||
TOTAL_TIME=$(grep "^TOTAL:" "${METRICS_FILE}.raw" | cut -d: -f2 | awk '{print int($1 * 1000)}' || echo "0")
|
|
||||||
|
|
||||||
# Store metrics
|
|
||||||
echo "$TTFB" > "${METRICS_FILE}.ttfb"
|
|
||||||
echo "$TOTAL_TIME" > "${METRICS_FILE}.total"
|
|
||||||
|
|
||||||
if [ "$TTFB" -gt 0 ]; then
|
|
||||||
if [ "$TTFB" -lt "$MIN_TTFT_MS" ]; then
|
|
||||||
echo "$TTFB" > "${TEMP_DIR}/min_ttft"
|
|
||||||
fi
|
|
||||||
if [ "$TTFB" -gt "$MAX_TTFT_MS" ]; then
|
|
||||||
echo "$TTFB" > "${TEMP_DIR}/max_ttft"
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
rm -f "${METRICS_FILE}.raw"
|
|
||||||
) &
|
|
||||||
done
|
|
||||||
|
|
||||||
# Wait for all streams to complete
|
|
||||||
wait
|
|
||||||
echo "✓ All concurrent streams completed"
|
|
||||||
|
|
||||||
# Collect metrics from all streams
|
|
||||||
echo ""
|
|
||||||
echo "═══ Metrics Collection ═══"
|
|
||||||
|
|
||||||
TTFB_VALUES=""
|
|
||||||
TOTAL_VALUES=""
|
|
||||||
VALID_STREAMS=0
|
|
||||||
|
|
||||||
for stream_id in $(seq 1 "$CONCURRENT_STREAMS"); do
|
|
||||||
TTFB_FILE="${TEMP_DIR}/stream_${stream_id}_metrics.txt.ttfb"
|
|
||||||
TOTAL_FILE="${TEMP_DIR}/stream_${stream_id}_metrics.txt.total"
|
|
||||||
|
|
||||||
if [ -f "$TTFB_FILE" ] && [ -f "$TOTAL_FILE" ]; then
|
|
||||||
TTFB=$(cat "$TTFB_FILE" 2>/dev/null || echo "0")
|
|
||||||
TOTAL=$(cat "$TOTAL_FILE" 2>/dev/null || echo "0")
|
|
||||||
|
|
||||||
if [ "$TTFB" -gt 0 ]; then
|
|
||||||
TTFB_VALUES="${TTFB_VALUES}${TTFB} "
|
|
||||||
TOTAL_VALUES="${TOTAL_VALUES}${TOTAL} "
|
|
||||||
VALID_STREAMS=$((VALID_STREAMS + 1))
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
done
|
|
||||||
|
|
||||||
# Calculate statistics (sort and pick percentiles)
|
|
||||||
if [ "$VALID_STREAMS" -gt 0 ]; then
|
|
||||||
# Sort TTFB values
|
|
||||||
SORTED_TTFB=$(echo "$TTFB_VALUES" | tr ' ' '\n' | sort -n | grep -v '^$')
|
|
||||||
|
|
||||||
# Calculate percentiles
|
|
||||||
P50_TTFB=$(echo "$SORTED_TTFB" | awk '{arr[NR]=$0} END {print arr[int(NR*0.5)]}')
|
|
||||||
P99_TTFB=$(echo "$SORTED_TTFB" | awk '{arr[NR]=$0} END {print arr[int(NR*0.99)]}')
|
|
||||||
MIN_TTFB=$(echo "$SORTED_TTFB" | head -1)
|
|
||||||
MAX_TTFB=$(echo "$SORTED_TTFB" | tail -1)
|
|
||||||
|
|
||||||
# Calculate average
|
|
||||||
AVG_TTFB=$(echo "$SORTED_TTFB" | awk '{sum+=$0; n++} END {if(n>0) print int(sum/n); else print 0}')
|
|
||||||
|
|
||||||
# Throughput: events/sec (simplified: using successful streams)
|
|
||||||
THROUGHPUT=$(echo "scale=2; $VALID_STREAMS * 1000 / $MAX_TTFB" | bc 2>/dev/null || echo "0")
|
|
||||||
|
|
||||||
echo "✓ Streams completed: $VALID_STREAMS/$CONCURRENT_STREAMS"
|
|
||||||
echo "✓ TTFB (Time-To-First-Byte):"
|
|
||||||
echo " Min: ${MIN_TTFB}ms"
|
|
||||||
echo " P50: ${P50_TTFB}ms"
|
|
||||||
echo " P99: ${P99_TTFB}ms"
|
|
||||||
echo " Max: ${MAX_TTFB}ms"
|
|
||||||
echo " Avg: ${AVG_TTFB}ms"
|
|
||||||
echo "✓ Throughput: ~${THROUGHPUT} streams/sec"
|
|
||||||
|
|
||||||
# Check pass/fail criteria
|
|
||||||
# TTFB should be < 1000ms for health checks, < 5000ms for SSE streams
|
|
||||||
FAIL=0
|
|
||||||
if [ "$P99_TTFB" -gt 5000 ]; then
|
|
||||||
echo "✗ P99 TTFB exceeds 5000ms threshold"
|
|
||||||
FAIL=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [ "$VALID_STREAMS" -lt "$((CONCURRENT_STREAMS / 2))" ]; then
|
|
||||||
echo "✗ Less than 50% of streams completed successfully"
|
|
||||||
FAIL=1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Write results
|
|
||||||
if [ "$FAIL" -eq 0 ]; then
|
|
||||||
echo "pass" > "${RESULTS_DIR}/result"
|
|
||||||
SUMMARY="${VALID_STREAMS}/${CONCURRENT_STREAMS} streams OK | P50 TTFB: ${P50_TTFB}ms | P99 TTFB: ${P99_TTFB}ms | Throughput: ${THROUGHPUT} streams/sec"
|
|
||||||
else
|
|
||||||
echo "fail" > "${RESULTS_DIR}/result"
|
|
||||||
SUMMARY="FAILED: ${VALID_STREAMS}/${CONCURRENT_STREAMS} streams completed | P99 TTFB: ${P99_TTFB}ms (threshold: 5000ms)"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Write detailed metrics
|
|
||||||
cat > "${RESULTS_DIR}/metrics" <<EOF
|
|
||||||
{
|
|
||||||
"test_type": "sse_streaming_load_test",
|
|
||||||
"timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
|
|
||||||
"configuration": {
|
|
||||||
"concurrent_streams": $CONCURRENT_STREAMS,
|
|
||||||
"events_per_stream": $EVENTS_PER_STREAM,
|
|
||||||
"event_interval_ms": $EVENT_INTERVAL_MS
|
|
||||||
},
|
|
||||||
"results": {
|
|
||||||
"streams_completed": $VALID_STREAMS,
|
|
||||||
"streams_total": $CONCURRENT_STREAMS,
|
|
||||||
"ttfb_ms": {
|
|
||||||
"min": $MIN_TTFB,
|
|
||||||
"p50": $P50_TTFB,
|
|
||||||
"p99": $P99_TTFB,
|
|
||||||
"max": $MAX_TTFB,
|
|
||||||
"avg": $AVG_TTFB
|
|
||||||
},
|
|
||||||
"throughput_streams_per_sec": $THROUGHPUT
|
|
||||||
},
|
|
||||||
"issues_tested": [
|
|
||||||
"#31: TCP backpressure for streaming LLM responses",
|
|
||||||
"#32: HTTP/2 multiplexing for concurrent streams",
|
|
||||||
"#33: Disable proxy buffering for SSE"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
EOF
|
|
||||||
|
|
||||||
else
|
|
||||||
echo "✗ No valid streams collected"
|
|
||||||
echo "fail" > "${RESULTS_DIR}/result"
|
|
||||||
echo "no_valid_streams" > "${RESULTS_DIR}/summary"
|
|
||||||
echo '{"error":"no_valid_streams"}' > "${RESULTS_DIR}/metrics"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "═══ Summary ═══"
|
|
||||||
echo "$SUMMARY"
|
|
||||||
echo "$SUMMARY" > "${RESULTS_DIR}/summary"
|
|
||||||
|
|
||||||
exit "$FAIL"
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
#!/bin/sh
|
|
||||||
set -e
|
|
||||||
|
|
||||||
# Workflow visibility test for gateway.
|
|
||||||
# Verifies that the WorkflowAdapter provides visibility into terminated workflows
|
|
||||||
# in the poimen-harness namespace via X-Service: workflow routing.
|
|
||||||
#
|
|
||||||
# Expected: 4 terminated workflows in poimen-harness namespace
|
|
||||||
#
|
|
||||||
# Required env:
|
|
||||||
# GW — gateway base URL (e.g. http://localhost:8080)
|
|
||||||
# RESULTS_DIR — directory to write Tekton results
|
|
||||||
|
|
||||||
: "${RESULTS_DIR:=/tekton/results}"
|
|
||||||
|
|
||||||
PASS=0
|
|
||||||
FAIL=0
|
|
||||||
TOTAL=0
|
|
||||||
|
|
||||||
echo "═══ Workflow Visibility Test ═══"
|
|
||||||
echo ""
|
|
||||||
echo "Testing WorkflowAdapter namespace pass-down"
|
|
||||||
echo "Expected: 4 terminated workflows in poimen-harness namespace"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ── Wait for gateway ──
|
|
||||||
echo "⏳ Waiting for gateway..."
|
|
||||||
READY=false
|
|
||||||
for i in $(seq 1 60); do
|
|
||||||
if curl -s -f "${GW}/healthz" > /dev/null 2>&1; then
|
|
||||||
echo "✓ Gateway ready"
|
|
||||||
READY=true
|
|
||||||
break
|
|
||||||
fi
|
|
||||||
sleep 2
|
|
||||||
done
|
|
||||||
|
|
||||||
if [ "$READY" = "false" ]; then
|
|
||||||
echo "✗ Gateway timeout"
|
|
||||||
echo "fail" > "${RESULTS_DIR}/result"
|
|
||||||
echo "Gateway did not become ready" > "${RESULTS_DIR}/summary"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Test 1: List workflows in poimen-harness ──
|
|
||||||
TOTAL=$((TOTAL + 1))
|
|
||||||
echo "Test 1: List workflows in poimen-harness namespace"
|
|
||||||
|
|
||||||
WF_RESPONSE=$(curl -s -X POST \
|
|
||||||
-H "X-Service: workflow" \
|
|
||||||
-H "X-Resource: list" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"namespace": "poimen-harness"}' \
|
|
||||||
"${GW}/" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if [ -z "$WF_RESPONSE" ]; then
|
|
||||||
echo " ✗ No response from workflow list endpoint"
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
else
|
|
||||||
echo " ✓ Received workflow list response"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
|
|
||||||
# Extract workflow count (if available)
|
|
||||||
WF_COUNT=$(echo "$WF_RESPONSE" | grep -o '"execution_time"' | wc -l || echo "0")
|
|
||||||
echo " Found workflows: $WF_COUNT"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Test 2: Verify namespace is required ──
|
|
||||||
TOTAL=$((TOTAL + 1))
|
|
||||||
echo "Test 2: Namespace validation (missing namespace should fail)"
|
|
||||||
|
|
||||||
NO_NS_RESPONSE=$(curl -s -w "\n%{http_code}" -X POST \
|
|
||||||
-H "X-Service: workflow" \
|
|
||||||
-H "X-Resource: list" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{}' \
|
|
||||||
"${GW}/" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
NO_NS_CODE=$(echo "$NO_NS_RESPONSE" | tail -1)
|
|
||||||
|
|
||||||
if [ "$NO_NS_CODE" = "400" ]; then
|
|
||||||
echo " ✓ Correctly rejected missing namespace (HTTP 400)"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
elif [ "$NO_NS_CODE" = "401" ]; then
|
|
||||||
echo " ⚠ Got 401 (auth required) - namespace validation happens after auth check"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
else
|
|
||||||
echo " ✗ Expected 400/401, got $NO_NS_CODE"
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Test 3: Query specific terminated workflow ──
|
|
||||||
TOTAL=$((TOTAL + 1))
|
|
||||||
echo "Test 3: Describe specific workflow (if available)"
|
|
||||||
|
|
||||||
# Try to describe a workflow - this will fail if no workflows exist, but shows the feature works
|
|
||||||
DESCRIBE_RESPONSE=$(curl -s -X POST \
|
|
||||||
-H "X-Service: workflow" \
|
|
||||||
-H "X-Resource: describe" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"namespace": "poimen-harness", "workflow_id": "test-workflow"}' \
|
|
||||||
"${GW}/" 2>/dev/null || echo "")
|
|
||||||
|
|
||||||
if [ -n "$DESCRIBE_RESPONSE" ]; then
|
|
||||||
echo " ✓ Describe endpoint responded"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
else
|
|
||||||
echo " ⚠ Describe endpoint no response (may indicate workflow doesn't exist)"
|
|
||||||
# Not a failure - endpoint exists but workflow may not
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Test 4: Verify auth requirement ──
|
|
||||||
TOTAL=$((TOTAL + 1))
|
|
||||||
echo "Test 4: Auth requirement (workflow service requires Authorization)"
|
|
||||||
|
|
||||||
NO_AUTH_CODE=$(curl -s -w '%{http_code}' -o /dev/null -X POST \
|
|
||||||
-H "X-Service: workflow" \
|
|
||||||
-H "X-Resource: list" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"namespace": "poimen-harness"}' \
|
|
||||||
"${GW}/" 2>/dev/null || echo "000")
|
|
||||||
|
|
||||||
if [ "$NO_AUTH_CODE" = "401" ]; then
|
|
||||||
echo " ✓ Correctly requires auth (HTTP 401)"
|
|
||||||
PASS=$((PASS + 1))
|
|
||||||
else
|
|
||||||
echo " ✗ Expected 401, got $NO_AUTH_CODE"
|
|
||||||
echo " (Auth may be disabled in test environment)"
|
|
||||||
FAIL=$((FAIL + 1))
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ── Summary ──
|
|
||||||
echo ""
|
|
||||||
echo "═══ Results ═══"
|
|
||||||
echo "Passed: $PASS/$TOTAL"
|
|
||||||
echo "Failed: $FAIL/$TOTAL"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
if [ "$FAIL" -eq 0 ]; then
|
|
||||||
echo "pass" > "${RESULTS_DIR}/result"
|
|
||||||
SUMMARY="Workflow visibility test passed. WorkflowAdapter can list/describe workflows in poimen-harness namespace with namespace pass-down support."
|
|
||||||
echo "✓ All tests passed"
|
|
||||||
else
|
|
||||||
echo "fail" > "${RESULTS_DIR}/result"
|
|
||||||
SUMMARY="$FAIL tests failed. Check WorkflowAdapter implementation and namespace validation."
|
|
||||||
echo "✗ Some tests failed"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "$SUMMARY" > "${RESULTS_DIR}/summary"
|
|
||||||
echo "" >> "${RESULTS_DIR}/summary"
|
|
||||||
echo "Passed: $PASS/$TOTAL" >> "${RESULTS_DIR}/summary"
|
|
||||||
echo "Failed: $FAIL/$TOTAL" >> "${RESULTS_DIR}/summary"
|
|
||||||
|
|
||||||
[ "$FAIL" -eq 0 ]
|
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
apiVersion: tekton.dev/v1
|
|
||||||
kind: Task
|
|
||||||
metadata:
|
|
||||||
name: integration-test
|
|
||||||
namespace: api
|
|
||||||
labels:
|
|
||||||
app: api-gateway
|
|
||||||
component: testing
|
|
||||||
spec:
|
|
||||||
description: >
|
|
||||||
Spin up a gateway pod from the given image as a sidecar,
|
|
||||||
run curl-based integration tests, report pass/fail.
|
|
||||||
params:
|
|
||||||
- name: image
|
|
||||||
type: string
|
|
||||||
description: "Container image to test (repo:tag)"
|
|
||||||
- name: gateway-port
|
|
||||||
type: string
|
|
||||||
default: "8080"
|
|
||||||
results:
|
|
||||||
- name: result
|
|
||||||
type: string
|
|
||||||
- name: summary
|
|
||||||
type: string
|
|
||||||
|
|
||||||
sidecars:
|
|
||||||
- name: gateway
|
|
||||||
image: $(params.image)
|
|
||||||
env:
|
|
||||||
- name: LISTEN_ADDR
|
|
||||||
value: "0.0.0.0:$(params.gateway-port)"
|
|
||||||
- name: CONFIG_PATH
|
|
||||||
value: /etc/gateway/config.yaml
|
|
||||||
- name: LOG_LEVEL
|
|
||||||
value: info
|
|
||||||
- name: AUTH_CLIENT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: api-gw-client-secret
|
|
||||||
key: client-secret
|
|
||||||
optional: true
|
|
||||||
volumeMounts:
|
|
||||||
- name: gateway-config
|
|
||||||
mountPath: /etc/gateway
|
|
||||||
readOnly: true
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: run-tests
|
|
||||||
image: curlimages/curl:8.13.0
|
|
||||||
env:
|
|
||||||
- name: GW
|
|
||||||
value: "http://localhost:$(params.gateway-port)"
|
|
||||||
- name: RESULTS_DIR
|
|
||||||
value: /tekton/results
|
|
||||||
command: ["sh", "/scripts/integration-test.sh"]
|
|
||||||
volumeMounts:
|
|
||||||
- name: test-script
|
|
||||||
mountPath: /scripts
|
|
||||||
readOnly: true
|
|
||||||
computeResources:
|
|
||||||
requests:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 64Mi
|
|
||||||
limits:
|
|
||||||
cpu: 200m
|
|
||||||
memory: 128Mi
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- name: gateway-config
|
|
||||||
secret:
|
|
||||||
secretName: api-gateway-config
|
|
||||||
- name: test-script
|
|
||||||
configMap:
|
|
||||||
name: integration-test-script
|
|
||||||
defaultMode: 0755
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
apiVersion: tekton.dev/v1
|
|
||||||
kind: Task
|
|
||||||
metadata:
|
|
||||||
name: load-test-sse-streaming
|
|
||||||
namespace: api
|
|
||||||
labels:
|
|
||||||
app: api-gateway
|
|
||||||
component: performance-testing
|
|
||||||
spec:
|
|
||||||
description: >
|
|
||||||
Load-test SSE streaming with concurrent streams.
|
|
||||||
Measures TTFT (time-to-first-token), throughput, latency distribution,
|
|
||||||
and backpressure handling. Tests issues #31, #32, #33.
|
|
||||||
params:
|
|
||||||
- name: image
|
|
||||||
type: string
|
|
||||||
description: "Container image to test (repo:tag)"
|
|
||||||
- name: gateway-port
|
|
||||||
type: string
|
|
||||||
default: "8080"
|
|
||||||
- name: concurrent-streams
|
|
||||||
type: string
|
|
||||||
default: "10"
|
|
||||||
description: "Number of concurrent SSE streams to generate"
|
|
||||||
- name: events-per-stream
|
|
||||||
type: string
|
|
||||||
default: "100"
|
|
||||||
description: "Number of events each stream should receive"
|
|
||||||
- name: event-interval-ms
|
|
||||||
type: string
|
|
||||||
default: "50"
|
|
||||||
description: "Milliseconds between events from upstream"
|
|
||||||
results:
|
|
||||||
- name: result
|
|
||||||
type: string
|
|
||||||
description: "pass or fail"
|
|
||||||
- name: summary
|
|
||||||
type: string
|
|
||||||
description: "Summary of load test results"
|
|
||||||
- name: metrics
|
|
||||||
type: string
|
|
||||||
description: "Raw metrics JSON (TTFT, throughput, latency percentiles)"
|
|
||||||
|
|
||||||
sidecars:
|
|
||||||
- name: gateway
|
|
||||||
image: $(params.image)
|
|
||||||
env:
|
|
||||||
- name: LISTEN_ADDR
|
|
||||||
value: "0.0.0.0:$(params.gateway-port)"
|
|
||||||
- name: CONFIG_PATH
|
|
||||||
value: /etc/gateway/config.yaml
|
|
||||||
- name: LOG_LEVEL
|
|
||||||
value: info
|
|
||||||
- name: AUTH_CLIENT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: api-gw-client-secret
|
|
||||||
key: client-secret
|
|
||||||
optional: true
|
|
||||||
volumeMounts:
|
|
||||||
- name: gateway-config
|
|
||||||
mountPath: /etc/gateway
|
|
||||||
readOnly: true
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: run-load-test
|
|
||||||
image: curlimages/curl:8.13.0
|
|
||||||
env:
|
|
||||||
- name: GW
|
|
||||||
value: "http://localhost:$(params.gateway-port)"
|
|
||||||
- name: CONCURRENT_STREAMS
|
|
||||||
value: $(params.concurrent-streams)
|
|
||||||
- name: EVENTS_PER_STREAM
|
|
||||||
value: $(params.events-per-stream)
|
|
||||||
- name: EVENT_INTERVAL_MS
|
|
||||||
value: $(params.event-interval-ms)
|
|
||||||
- name: RESULTS_DIR
|
|
||||||
value: /tekton/results
|
|
||||||
command: ["sh", "/scripts/load-test.sh"]
|
|
||||||
volumeMounts:
|
|
||||||
- name: test-script
|
|
||||||
mountPath: /scripts
|
|
||||||
readOnly: true
|
|
||||||
computeResources:
|
|
||||||
requests:
|
|
||||||
cpu: 500m
|
|
||||||
memory: 256Mi
|
|
||||||
limits:
|
|
||||||
cpu: 1000m
|
|
||||||
memory: 512Mi
|
|
||||||
# Load test needs more time than unit tests
|
|
||||||
timeout: 10m
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- name: gateway-config
|
|
||||||
secret:
|
|
||||||
secretName: api-gateway-config
|
|
||||||
- name: test-script
|
|
||||||
configMap:
|
|
||||||
name: load-test-script
|
|
||||||
defaultMode: 0755
|
|
||||||
@@ -1,84 +0,0 @@
|
|||||||
apiVersion: tekton.dev/v1
|
|
||||||
kind: Task
|
|
||||||
metadata:
|
|
||||||
name: workflow-visibility-test
|
|
||||||
namespace: api
|
|
||||||
labels:
|
|
||||||
app: api-gateway
|
|
||||||
component: testing
|
|
||||||
spec:
|
|
||||||
description: >
|
|
||||||
Test workflow visibility via WorkflowAdapter.
|
|
||||||
Verifies that the gateway provides visibility into terminated workflows
|
|
||||||
in the poimen-harness namespace via X-Service: workflow routing.
|
|
||||||
This ensures namespace pass-down is working correctly.
|
|
||||||
|
|
||||||
params:
|
|
||||||
- name: image
|
|
||||||
type: string
|
|
||||||
description: "Container image to test (repo:tag)"
|
|
||||||
- name: gateway-port
|
|
||||||
type: string
|
|
||||||
default: "8080"
|
|
||||||
|
|
||||||
results:
|
|
||||||
- name: result
|
|
||||||
type: string
|
|
||||||
description: "pass or fail"
|
|
||||||
- name: summary
|
|
||||||
type: string
|
|
||||||
description: "Test summary"
|
|
||||||
- name: workflow-count
|
|
||||||
type: string
|
|
||||||
description: "Number of workflows found in poimen-harness"
|
|
||||||
|
|
||||||
sidecars:
|
|
||||||
- name: gateway
|
|
||||||
image: $(params.image)
|
|
||||||
env:
|
|
||||||
- name: LISTEN_ADDR
|
|
||||||
value: "0.0.0.0:$(params.gateway-port)"
|
|
||||||
- name: CONFIG_PATH
|
|
||||||
value: /etc/gateway/config.yaml
|
|
||||||
- name: LOG_LEVEL
|
|
||||||
value: info
|
|
||||||
- name: AUTH_CLIENT_SECRET
|
|
||||||
valueFrom:
|
|
||||||
secretKeyRef:
|
|
||||||
name: api-gw-client-secret
|
|
||||||
key: client-secret
|
|
||||||
optional: true
|
|
||||||
volumeMounts:
|
|
||||||
- name: gateway-config
|
|
||||||
mountPath: /etc/gateway
|
|
||||||
readOnly: true
|
|
||||||
|
|
||||||
steps:
|
|
||||||
- name: run-workflow-visibility-test
|
|
||||||
image: curlimages/curl:8.13.0
|
|
||||||
env:
|
|
||||||
- name: GW
|
|
||||||
value: "http://localhost:$(params.gateway-port)"
|
|
||||||
- name: RESULTS_DIR
|
|
||||||
value: /tekton/results
|
|
||||||
command: ["sh", "/scripts/workflow-visibility-test.sh"]
|
|
||||||
volumeMounts:
|
|
||||||
- name: test-script
|
|
||||||
mountPath: /scripts
|
|
||||||
readOnly: true
|
|
||||||
computeResources:
|
|
||||||
requests:
|
|
||||||
cpu: 100m
|
|
||||||
memory: 64Mi
|
|
||||||
limits:
|
|
||||||
cpu: 200m
|
|
||||||
memory: 128Mi
|
|
||||||
|
|
||||||
volumes:
|
|
||||||
- name: gateway-config
|
|
||||||
secret:
|
|
||||||
secretName: api-gateway-config
|
|
||||||
- name: test-script
|
|
||||||
configMap:
|
|
||||||
name: workflow-visibility-test-script
|
|
||||||
defaultMode: 0755
|
|
||||||
Reference in New Issue
Block a user